npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@casperui/core

v0.0.22

Published

> Версия `@casperui/core` 0.0.19 > Фреймворк для построения браузерных приложений с архитектурой, вдохновлённой Android SDK.

Readme

CasperUI Core — Полное руководство (RU)

Версия @casperui/core 0.0.19
Фреймворк для построения браузерных приложений с архитектурой, вдохновлённой Android SDK.


Содержание

  1. Концепция и архитектура
  2. Структура проекта
  3. Ресурсы и R.ts
  4. Application — точка входа
  5. Activity — главный экран
  6. View — базовый UI-компонент
  7. Лейауты и биндинги (BXML)
  8. Fragments — составные экраны
  9. LiveData и ViewModel — реактивное состояние
  10. Пользовательские виджеты
  11. Навигация и FragmentManager
  12. Context и Resources
  13. Canvas и графика
  14. Паттерны и best practices

1. Концепция и архитектура

CasperUI — фреймворк, переносящий архитектурные паттерны Android на веб.

Ключевые параллели с Android

| Android | CasperUI | |--------------------|-----------------------| | Application | Application | | Activity | Activity | | Fragment | JFragment | | View | View | | ViewModel | ViewModel | | LiveData | LiveData | | FragmentManager | FragmentManager | | R.id, R.layout | R.id, R.layout | | XML-лейауты | HTML-лейауты → BXML |

Жизненный цикл приложения

main.ts
  └── new Application()          ← загружает res.html (иконки, шрифты, строки)
  └── application.startActivity(new MainActivity())
        └── activity.onCreate()
              └── setContentView(layout)
                    └── replaceFragment(id, fragment)
                          └── fragment.onCreateView()
                          └── fragment.onCreated()

2. Структура проекта

my-app/
├── src/
│   ├── main.ts              ← точка входа
│   ├── MainActivity.ts      ← главный Activity
│   ├── R.ts                 ← ГЕНЕРИРУЕТСЯ (не редактировать)
│   ├── bind.ts              ← ГЕНЕРИРУЕТСЯ (не редактировать)
│   ├── models/              ← ViewModel-классы
│   ├── view/fragments/      ← JFragment-классы
│   └── widgets/             ← кастомные View-компоненты
├── res/
│   ├── layout/              ← HTML-лейауты
│   ├── icons/               ← SVG-иконки
│   ├── fonts/               ← шрифты (.ttf, .woff)
│   ├── string/              ← строки локализации (.tsv)
│   └── style/               ← CSS-файлы
├── assets/                  ← ГЕНЕРИРУЕТСЯ: app.js, res.html, app.css
├── resconfig.json
├── tsconfig.json
└── webpack.config.js

resconfig.json

{
  "resourceDir": "./res",
  "output": {
    "R": "src/R.ts",
    "bind": "src/bind.ts",
    "res": "assets/res.html",
    "css": "assets/app.css"
  },
  "subResources": []
}

Команды

npm run resources_dev      # собрать ресурсы (dev)
npm run resources_release  # собрать ресурсы (prod)
npm run start              # dev-сервер
npm run build              # сборка

tsconfig.json

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "experimentalDecorators": true,
    "lib": ["ESNext", "DOM"],
    "paths": { "@app/*": ["src/*"] }
  }
}

3. Ресурсы и R.ts

resmaker компилирует всё содержимое res/ в бинарный пакет res.html и генерирует типизированный файл R.ts.

Структура res/

res/
├── layout/
│   ├── root.html              → R.layout.root(ctx)
│   └── auth/
│       └── auth.html          → R.layout.auth.auth(ctx)
├── icons/
│   ├── check.svg              → R.icons.check
│   └── arrow_down.svg         → R.icons.arrow_down
├── fonts/
│   └── regular.ttf            → R.fonts.regular
├── string/
│   └── strings.tsv            → R.lang.hello
└── style/
    └── main.css               ← включается в assets/app.css
  • Папки в res/layout/ становятся вложенными объектами: res/layout/widgets/checkbox/main.htmlR.layout.widgets.checkbox.main(ctx)
  • Атрибут id в HTML становится полем биндинга и записью в R.id

Использование R.ts

import {R} from "@app/R"

const bind = R.layout.auth.auth(this.ctx())  // создаёт DOM, возвращает биндинг
bind.root                                     // корневой View

myView.setSVGById(R.icons.check)             // вставить SVG

ctx.getString(R.lang.hello)                  // строка локализации

activity.byId(R.id.email)                    // найти View по id

4. Application — точка входа

src/main.ts

import {Application} from "@casperui/core/app/Application"
import {WidgetRegistrar} from "@casperui/core/view/inflater/WidgetRegistrar"
import {CheckBoxBlock} from "@app/widgets/CheckBoxBlock"
import {MainActivity} from "@app/MainActivity"

WidgetRegistrar.register("CheckBoxBlock", (ctx, tag, attr) => new CheckBoxBlock(ctx, tag, attr))
WidgetRegistrar.register("RadioGroup",    (ctx, tag, attr) => new RadioGroup(ctx, tag, attr))

const application = new Application()

window.onload = () => {
    application.startActivity(new MainActivity())
}

Application скачивает res.html и инициализирует ресурсы; startActivity дожидается загрузки перед вызовом onCreate.

Загрузка шрифтов

// В MainActivity.onCreate():
const appCtx = this.getApplicationContext()
const res = appCtx.getResources()
appCtx.addFontFace("ui", res.getData(R.fonts.regular))
appCtx.addFontFace("ui", res.getData(R.fonts.bold), { weight: "700" })

5. Activity — главный экран

Activity — единственный для приложения, реализует ILiveManager.

import {Activity} from "@casperui/core/app/Activity"
import {R} from "@app/R"
import {UsersModel} from "@app/models/UsersModel"

export class MainActivity extends Activity {

    usersModel     = new UsersModel()
    analyticsModel = new AnalyticsModel()

    async onCreate() {
        const bind = R.layout.root(this)
        this.setContentView(bind.root)

        await this.usersModel.ping()

        if (this.usersModel.self.getValue()) {
            this.showMainContent()
        } else {
            this.showAuthFragment()
        }
    }

    showAuthFragment() {
        this.replaceFragment(R.id.app_content, new AuthFragment())
    }

    showMainContent() {
        this.replaceFragment(R.id.app_content, new MainFragment())
    }
}

Методы Activity

| Метод | Описание | |---|---| | onCreate() | Инициализация UI. | | setContentView(view) | Установить корневой View (заменяет document.body). | | replaceFragment(id, fragment) | Поместить фрагмент в контейнер с R.id. | | byId(R.id.foo) | Найти View по ID во всём дереве. | | byPath([0, 1]) | Найти View по пути индексов. | | getWindowView() | View, обёртывающий document.body. | | getApplicationContext() | Возвращает Application. | | getFragmentManager() | Менеджер фрагментов. |


6. View — базовый UI-компонент

View — обёртка над DOM-элементом.

Создание

import {View} from "@casperui/core/view/View"

const div   = new View(context)
const span  = new View(context, "span")
const input = new View(context, "input")

// Обернуть существующий DOM-элемент
const wrap  = new View(context, document.getElementById("my-el"))

// С атрибутами
const btn   = new View(context, "button", { class: "btn btn-primary" })

Текст и значения

view.setText("Привет")        // textContent
view.setValue("text")         // .value (для <input>)
view.getValue()
view.setChecked(true)
view.isChecked()
view.setDefaultValue("def")
view.resetValue()

Видимость

view.show()
view.hide()
view.toggle()
view.toggle(true)
view.setVisibility(false)
view.getVisibility()          // boolean
view.setOpacity(0.5)

CSS-классы

view.addClass("active")
view.removeClass("active")
view.hasClass("active")       // boolean
view.swapClass("inactive", "active")
view.addClassList("a b c")    // несколько сразу

view.activate()               // добавляет "active"
view.deactivate()             // убирает "active"
view.isActive()

Размеры и позиция

view.getWidth()               // clientWidth
view.getHeight()              // clientHeight
view.setWidth(200)
view.setHeight(100)
view.setLeft(50)
view.getLeft()
view.setTop(20)
view.getTop()                 // кэшированное
view.getTopReal()             // из style
view.setTranslateY(30)
view.getTranslateY()
view.getViewportRect()        // Rect с экранными координатами

Прокрутка

view.getScrollY()
view.setScrollY(100)
view.getScrollX()
view.setScrollX(0)

Стили и атрибуты

view.getStyle()                           // CSSStyleDeclaration
view.setStyle("background-color", "red")
view.setParameter("data-id", "42")
view.getParameter("data-id")
view.appendAttributes({ "data-x": "1", class: "foo" })

Иконки

view.setSVGById(R.icons.arrow_down)       // SVG из ресурсов
view.setImageSrc("/img.png")              // src для <img>

События

view.setOnClickListener((e) => { ... })
view.setOnFastClickListener((e) => { ... })  // mousedown
view.onMouseDownListener((e) => { ... })
view.onMouseOverListener((e) => { ... })
view.onMouseOutListener((e) => { ... })
view.onMouseMoveListener((e) => { ... })
view.onMouseDoubleClickListener((e) => { ... })

view.vEvent("scroll", (e) => { ... })
view.vEvent("input", handler, 300)        // с дебаунсом (ms)

view.mNode.addEventListener("keydown", handler, { passive: true })  // DOM напрямую

Все методы setOn*/vEvent внутри используют makeSafeEvent — повторный вызов заменяет предыдущий обработчик того же типа, не добавляет новый.

Дочерние View

view.addView(child)
view.addView(child, 0)       // по индексу
view.removeView(child)
view.removeAllViews()
view.hasView(child)
view.indexView(child)
view.getChildren()           // View[]
view.x([a, b, c])           // добавить несколько цепочкой

Поиск в дереве

view.byId(R.id.email)
view.byIds([R.id.title, R.id.body])
view.byPath([0, 2, 1])

Прочее

view.setRef(obj)
view.getRef<MyType>()

view.waitingSelf(() => {         // выполнит callback когда View попадёт в document.body
    console.log(view.getWidth())
})

view.handleViewError()           // добавляет класс "snake-anim" (анимация ошибки)

7. Лейауты и биндинги (BXML)

Лейауты — HTML-файлы в res/layout/. resmaker компилирует их в бинарный формат и генерирует TypeScript-биндинги. Никакого рантаймового парсинга нет — inflate это вызов сгенерированной функции.

Создание лейаута

<!-- res/layout/fragments/profile.html -->
<div class="profile-card" id="root">
    <img id="avatar" class="avatar" />
    <span id="name" class="profile-name"></span>
    <span id="email" class="profile-email"></span>
    <button id="logout_btn" class="btn">@{logout}</button>
</div>

После npm run resources_dev генерируется биндинг с интерфейсом:

// R.layout.fragments.profile(ctx) возвращает:
{
    root:       View
    avatar:     View
    name:       View
    email:      View
    logout_btn: View
}

Использование биндинга

const bind = R.layout.fragments.profile(this.ctx())

bind.name.setText("Иван Петров")
bind.email.setText("[email protected]")
bind.avatar.setImageSrc("/avatars/ivan.png")
bind.logout_btn.setOnClickListener(() => this.onLogout())

this.setContentView(bind.root)
// или
container.addView(bind.root)

Кастомные виджеты в HTML

Зарегистрированный виджет используется как тег:

<CheckBoxBlock id="enable_notifications" title="Уведомления" />
<RadioGroup id="theme_selector" />
<DateRangePicker id="date_range" />

При inflate WidgetRegistrar.createInstance находит конструктор по тегу и передаёт атрибуты из бинарных данных.


@{locale_key} — строки локализации в HTML

<label>@{rule_name}</label>
<input placeholder="@{enter_value}" />
<button>@{save_button}</button>

resmaker заменяет @{key} на текстовый узел с соответствующей строкой из res/string/strings.tsv.

При смене локали во время выполнения текст не обновляется — нужен re-inflate.


{{id}} — именованные текстовые узлы

{{id}} создаёт именованный текстовый узел. Находится через byId(R.id.id) и обновляется через setText().

<div class="split_block_text">Имя: {{name}}</div>
<span class="status-line">Статус: {{status_text}} (код {{status_code}})</span>
const bind = R.layout.widgets.split_item(ctx)
bind.name.setText("Иван")
bind.status_text.setText("Активен")
bind.status_code.setText("200")

Разница от id на теге:

  • id="foo" на теге → bind.foo — полноценный View (элемент)
  • {{foo}} — текстовый узел; полезен когда нужно менять часть текста среди статического

<template> — фабричные функции

<template id="name"> компилируется в функцию-фабрику. Каждый вызов создаёт новый независимый экземпляр DOM.

<div id="root">
    <div id="list_container"></div>

    <template id="list_item">
        <div class="list-item">
            <i id="icon" class="item-icon"></i>
            <span>{{title}}</span>
            <span class="item-sub">{{subtitle}}</span>
            <button id="delete_btn" class="btn-icon"></button>
        </div>
    </template>
</div>
export interface IMyList {
    root:           View
    list_container: View
    list_item: () => {        // ← функция, а не View
        root:       View
        title:      View
        subtitle:   View
        delete_btn: View
    }
}
const layout = R.layout.my_list(this.ctx())

function addItem(title: string, sub: string) {
    const item = layout.list_item()   // новый экземпляр каждый раз
    item.title.setText(title)
    item.subtitle.setText(sub)
    item.delete_btn.setOnClickListener(() => {
        layout.list_container.removeView(item.root)
    })
    layout.list_container.addView(item.root)
}

Переключатель шаблонов:

<div id="root">
    <RadioGroup id="selector_type"></RadioGroup>
    <div id="selector_content"></div>

    <template id="rule_weekday" class="rule_child">
        <SelectGroup id="days" class="vs_flex_50">
            <div>@{abb_monday}</div>
            <div>@{abb_tuesday}</div>
            <!-- ... -->
        </SelectGroup>
    </template>

    <template id="rule_dates" class="rule_child">
        <textarea id="dates_area" rows="3"></textarea>
    </template>
</div>
export class RuleEditor extends JFragment {
    private layout = R.layout.profiles.rules.rule_editor(this.ctx())
    private currentRule: View | null = null

    onCreateView() { return this.layout.root }

    onCreated() {
        const selector = this.layout.selector_type as RadioGroup
        selector.setOnItemSelected((index) => this.showTemplate(index))
        selector.setActive(0, true)
    }

    private showTemplate(index: number) {
        const container = this.layout.selector_content
        if (this.currentRule) container.removeView(this.currentRule)

        const factories = [
            () => this.layout.rule_weekday(),
            () => this.layout.rule_dates(),
        ]
        const bind = factories[index]()
        this.currentRule = bind.root
        container.addView(bind.root)
    }
}

<style> внутри лейаута

<style> в HTML-лейауте вставляется в DOM вместе с разметкой при inflate.

<div id="root" class="date-picker">
    <style>
        .date-picker { display: flex; gap: 8px; align-items: center; }
        .date-picker-input { background: #1e1e1e; border-radius: 6px; padding: 6px 10px; }
    </style>
    <input id="start" class="date-picker-input" />
    <span>—</span>
    <input id="end" class="date-picker-input" />
</div>

Стили глобальные — используйте уникальные имена классов (BEM или префикс компонента).

Работает и внутри <template>:

<template id="alert_card">
    <style>
        .alert-card { border-left: 3px solid var(--accent); padding: 10px; }
    </style>
    <div class="alert-card" id="root">
        <span id="icon"></span>
        <span>{{message}}</span>
    </div>
</template>

Как работает inflate (под капотом)

Каждая функция биндинга использует _getLayCTX, который предоставляет четыре фабрики:

let [c, t, l, i] = _getLayCTX(ctx, layoutId)
//   │  │  │  └─ i()            → пустой текстовый узел ({{id}})
//   │  │  └──── l(langId)      → текстовый узел из локали (@{key})
//   │  └─────── t(valueId)     → текстовый узел из бинарных данных (<style>, статика)
//   └────────── c(tag, attrId) → View или виджет с атрибутами из бинарных данных

c ищет тег в WidgetRegistrar — именно так кастомные виджеты создаются при inflate.


8. Fragments — составные экраны

Создание Fragment

import {JFragment} from "@casperui/core/app/JFragment"
import {View} from "@casperui/core/view/View"
import {R} from "@app/R"
import {MainActivity} from "@app/MainActivity"

export class ProfileFragment extends JFragment {

    private bind = R.layout.fragments.profile(this.ctx())
    private get main() { return this.getActivity() as MainActivity }

    onCreateView(): View {
        return this.bind.root
    }

    onCreated() {
        const user = this.main.usersModel.self.getValue()
        this.bind.name.setText(user?.name ?? "")

        this.bind.logout_btn.setOnClickListener(async () => {
            await this.main.usersModel.logout()
            this.main.showAuthFragment()
        })

        this.main.usersModel.self.observe(this, (user) => {
            this.bind.name.setText(user?.name ?? "")
        })
    }

    onAttachSingle() { /* первый раз в DOM — DOM-зависимая инициализация */ }
    onAttach()       { /* каждый раз в DOM */ }
    onDetach()       { /* уход с экрана — очистить ресурсы */ }
}

Жизненный цикл Fragment

replaceFragment(id, new MyFragment())
  └── onCreateView()      ← вернуть View (DOM ещё не вставлен)
  └── onCreated()         ← инициализировать логику
  └── [View вставляется в DOM]
  └── onAttachSingle()    ← только при первом attach
  └── onAttach()          ← при каждом attach

replaceFragment(id, другой)
  └── onDetach()          ← очистить ресурсы

Когда что использовать

| Хук | Когда | Что делать | |---|---|---| | onCreateView() | До DOM | Вернуть this.bind.root | | onCreated() | После onCreateView | Обработчики, подписки на LiveData | | onAttachSingle() | Первый раз в DOM | Размеры, Canvas, фокус — всё что требует DOM | | onAttach() | Каждый раз в DOM | Рестарт поллинга, обновление данных | | onDetach() | Уход | Очистить интервалы, анимации |

getWidth(), getHeight(), инициализация <canvas> вернут 0 в onCreated() — элемент ещё не в DOM. Используйте onAttachSingle().

Пример: DOM-зависимая инициализация

export class ChartFragment extends JFragment {
    private bind = R.layout.fragments.chart(this.ctx())
    private chart: Chart | null = null

    onCreateView() { return this.bind.root }

    onCreated() {
        this.bind.title.setText("Загрузка...")
    }

    onAttachSingle() {
        const canvas = this.bind.chart_canvas.mNode as HTMLCanvasElement
        this.chart = new Chart(canvas, { type: "line", data: { datasets: [] } })

        const width = this.bind.container.getWidth()  // корректный размер
        this.loadData()
    }

    onAttach() {
        this.main.analyticsModel.startPolling()
    }

    onDetach() {
        this.main.analyticsModel.stopPolling()
        this.chart?.destroy()
        this.chart = null
    }
}

Вспомогательные методы JFragment

this.ctx()
this.getActivity()
this.getFragmentManager()
this.getView()
this.replaceFragment(id, fragment)

Вложенные фрагменты

<!-- res/layout/fragments/main.html -->
<div class="main-layout" id="root">
    <nav id="sidebar">...</nav>
    <div id="content_area"></div>
</div>
export class MainFragment extends JFragment {
    private bind = R.layout.fragments.main(this.ctx())

    onCreateView() { return this.bind.root }

    onCreated() {
        this.replaceFragment(R.id.content_area, new DashboardFragment())
    }

    showSettings() {
        this.replaceFragment(R.id.content_area, new SettingsFragment())
    }
}

9. LiveData и ViewModel — реактивное состояние

LiveData

import {LiveData} from "@casperui/core/live/LiveData"

const counter = new LiveData<number>(0)

counter.setValue(42)          // установить → уведомит всех наблюдателей
counter.setIfChanged(42)      // установить только если изменилось
counter.getValue()            // 42
counter.update()              // уведомить без изменения (если мутировали объект)

const list = new LiveData<string[]>([])
list.pushValueToArray("item") // добавить в массив и уведомить

Наблюдение

Activity и JFragment реализуют ILiveManager — их можно передавать как observer напрямую.

model.counter.observe(this, (value, caller) => {
    this.bind.label.setText(String(value))
})
  • Callback вызывается сразу при подписке (если LiveManager активен)
  • caller — аргумент из setValue(value, caller), полезен для фильтрации источника

ViewModel

import {ViewModel} from "@casperui/core/live/ViewModel"
import {LiveData} from "@casperui/core/live/LiveData"
import {LiveDataObjectId} from "@casperui/core/live/LiveDataObjectId"

export class UsersModel extends ViewModel {
    isLoading = new LiveData<boolean>(false)
    self      = new LiveData<IUser | null>(null)
    users     = new LiveDataObjectId<IUser[]>([])  // оптимизирован для массивов объектов с id

    async loadUsers() {
        this.isLoading.setValue(true)
        try {
            this.users.setValue(await this.api.post("search"))
        } finally {
            this.isLoading.setValue(false)
        }
    }

    async auth(email: string, password: string) {
        this.self.setValue(await this.api.post("sign-in", { email, password }))
    }

    async logout() {
        await this.api.post("sign-out")
        this.self.setValue(null)
    }
}

Модели создаются в Activity один раз и доступны из любого Fragment:

// MainActivity
usersModel = new UsersModel()

// В Fragment
private get main() { return this.getActivity() as MainActivity }

onCreated() {
    this.main.usersModel.self.observe(this, (user) => {
        if (user) {
            this.bind.name.setText(user.name)
        } else {
            this.main.showAuthFragment()
        }
    })

    this.main.usersModel.isLoading.observe(this, (loading) => {
        this.bind.spinner.toggle(loading)
    })
}

MappedLiveData

import {MappedLiveData} from "@casperui/core/live/MappedLiveData"

const fullName = new MappedLiveData(
    userModel.self,
    (user) => user ? `${user.firstName} ${user.lastName}` : ""
)

fullName.observe(this, (name) => this.bind.title.setText(name))

10. Пользовательские виджеты

Виджет — это View с собственным лейаутом и логикой.

Минимальный виджет

import {View, ViewTag} from "@casperui/core/view/View"
import {Context} from "@casperui/core/content/Context"
import {ViewAttributes} from "@casperui/core/view/ViewAttributes"
import {R} from "@app/R"

export class Badge extends View {
    constructor(context: Context, tag?: ViewTag, attr?: ViewAttributes) {
        super(context, "span", attr)
        this.replaceNode(R.layout.widgets.badge(context).root)

        if (attr?.["text"]) {
            this.byId(R.id.badge_label).setText(attr["text"] as string)
        }
    }

    setLabel(text: string) {
        this.byId(R.id.badge_label).setText(text)
        return this
    }
}

Регистрация и использование в HTML

// main.ts
WidgetRegistrar.register("Badge", (ctx, tag, attr) => new Badge(ctx, tag, attr))
<Badge id="status_badge" text="Новый" />

Паттерн: виджет с callback

export class CheckBoxBlock extends View {
    private mIsChecked = false
    onChange?: (checked: boolean) => void

    constructor(context: Context, tag?: ViewTag, attr?: ViewAttributes) {
        super(context, "div", attr)
        const bind = R.layout.widgets.checkbox.main(context)
        this.replaceNode(bind.root)

        if (attr?.["title"]) bind.title.setText(attr["title"] as string)

        this.setOnClickListener(() => this.setBoolValue(!this.mIsChecked))
        this.setBoolValue(false)
    }

    setBoolValue(value: boolean) {
        this.mIsChecked = value
        this.byId(R.id.icon).setSVGById(value ? R.icons.switch_on : R.icons.switch_off)
        this.onChange?.(this.mIsChecked)
        return this
    }

    setOnChange(fn: (v: boolean) => void) { this.onChange = fn; return this }
    isChecked() { return this.mIsChecked }
}

Паттерн: RadioGroup (одиночный выбор)

export class RadioGroup extends View {
    private activeIndex = 0
    private mCallback?: (index: number) => void

    setOnItemSelected(fn: (index: number) => void) { this.mCallback = fn; return this }

    setActive(index: number, emit = true) {
        this.getChildren().forEach(c => c.deactivate())
        this.getChildren()[index]?.activate()
        this.activeIndex = index
        if (emit) this.mCallback?.(index)
        return this
    }

    getActive() { return this.activeIndex }

    addView(view: View, index?: number) {
        super.addView(view, index)
        view.setOnClickListener(() => this.setActive(this.getChildren().indexOf(view)))
        return this
    }
}

Паттерн: виджет с памятью (localStorage)

export class GroupBlock extends View {
    private memoryKey?: string
    private isOpen = true

    constructor(context: Context, tag?: ViewTag, attr?: ViewAttributes) {
        super(context, "div", attr)
        const bind = R.layout.widgets.group_block.main(context)
        this.replaceNode(bind.root)

        if (attr?.["memory"]) {
            this.memoryKey = "gb_" + attr["memory"]
            const saved = localStorage.getItem(this.memoryKey)
            if (saved !== null) this.isOpen = saved === "1"
        }

        bind.header.setOnClickListener(() => {
            this.isOpen = !this.isOpen
            this.updateVisibility()
            if (this.memoryKey) localStorage.setItem(this.memoryKey, this.isOpen ? "1" : "0")
        })

        this.updateVisibility()
    }

    private updateVisibility() {
        this.byId(R.id.content).toggle(this.isOpen)
        this.byId(R.id.open_icon).setSVGById(this.isOpen ? R.icons.arrow_down : R.icons.arrow_right)
    }

    addView(view: View, index?: number) {
        this.byId(R.id.content).addView(view, index)
        return this
    }
}

11. Навигация и FragmentManager

// В Activity
this.replaceFragment(R.id.app_content, new MainFragment())

// В Fragment (вложенные)
this.replaceFragment(R.id.content_area, new SettingsFragment())

// Через менеджер
this.getFragmentManager().replaceFragment(R.id.content_area, new SettingsFragment())

При replaceFragment: старый фрагмент получает onDetach(), его DOM удаляется; новый проходит onCreateViewonCreatedonAttachSingle (первый раз) → onAttach.

Паттерн: side-bar навигация

export class MainFragment extends JFragment {
    private bind = R.layout.fragments.main(this.ctx())

    onCreateView() { return this.bind.root }

    onCreated() {
        const navItems = [
            this.bind.nav_dashboard,
            this.bind.nav_stats,
            this.bind.nav_users,
        ]

        navItems.forEach((item, i) => {
            item.setOnClickListener(() => {
                navItems.forEach(n => n.deactivate())
                item.activate()
                const fragments = [new DashboardFragment(), new StatsFragment(), new UsersFragment()]
                this.replaceFragment(R.id.content_area, fragments[i])
            })
        })

        navItems[0].activate()
        this.replaceFragment(R.id.content_area, new DashboardFragment())
    }
}

12. Context и Resources

Activity и JFragment являются Context (через ContextWrapper).

const ctx = this.ctx()                        // в Fragment
const ctx = this                              // в Activity

ctx.getString(R.lang.welcome_message)         // строка локализации
ctx.getResources()                            // BinaryResources
ctx.getApplicationContext()                   // Application

const res = this.getApplicationContext().getResources()
const fontData = res.getData(R.fonts.regular) // DataView — для addFontFace

13. Canvas и графика

import {CanvasView} from "@casperui/core/view/widget/CanvasView"
import {Canvas} from "@casperui/core/graphics/Canvas"
import {Paint} from "@casperui/core/graphics/Paint"
import {Rect} from "@casperui/core/graphics/Rect"
import {Bitmap} from "@casperui/core/graphics/Bitmap"
import {Matrix} from "@casperui/core/graphics/Matrix"

const canvasView = new CanvasView(context)
container.addView(canvasView)

const canvas = canvasView.getCanvas()

const paint = new Paint()
paint.setColor("#3b82f6")
paint.setStrokeWidth(2)

canvas.drawRect(new Rect(10, 10, 100, 50), paint)
canvas.drawCircle(50, 50, 30, paint)
canvas.drawLine(0, 0, 100, 100, paint)
canvas.drawText("Hello", 20, 80, paint)

const bitmap = new Bitmap(200, 200)
new Canvas(bitmap).drawRect(new Rect(0, 0, 200, 200), paint)
canvas.drawBitmap(bitmap, 0, 0, null)

const matrix = new Matrix()
matrix.setScale(2, 2)
matrix.postTranslate(10, 10)
canvas.concat(matrix)

Canvas требует попадания в DOM — инициализируйте в onAttachSingle().


14. Паттерны и best practices

Структура Fragment

export class StatsFragment extends JFragment {

    private bind = R.layout.fragments.stats(this.ctx())
    private get main() { return this.getActivity() as MainActivity }

    onCreateView() { return this.bind.root }

    onCreated() {
        this.initFilters()
        this.subscribeToData()
        this.loadData()
    }

    onDetach() {
        // очистить интервалы, анимации
    }

    private initFilters() {
        const dr = this.bind.date_range as DateRangePicker
        dr.setOnChange(() => this.loadData())
    }

    private subscribeToData() {
        this.main.analyticsModel.tableData.observe(this, (data) => {
            this.renderTable(data)
        })
    }

    private async loadData() {
        const dr = this.bind.date_range as DateRangePicker
        await this.main.analyticsModel.load({
            from: formatDate(dr.getRange().start),
            to:   formatDate(dr.getRange().end),
        })
    }

    private renderTable(data: RowData[]) {
        const tbody = this.bind.table_body
        tbody.removeAllViews()
        for (const item of data) {
            const row = R.layout.table.stat_row(this.ctx())
            row.id_cell.setText(item.id)
            row.value_cell.setText(item.value.toString())
            tbody.addView(row.root)
        }
    }
}

Эффективное обновление списка

private rowCache = new Map<string, ReturnType<typeof R.layout.table.row>>()

updateList(data: Item[]) {
    // удалить исчезнувшие
    for (const [key, bind] of this.rowCache) {
        if (!data.find(d => d.id === key)) {
            this.container.removeView(bind.root)
            this.rowCache.delete(key)
        }
    }
    // обновить или создать
    for (const item of data) {
        if (!this.rowCache.has(item.id)) {
            const bind = R.layout.table.row(this.ctx())
            this.rowCache.set(item.id, bind)
            this.container.addView(bind.root)
        }
        const bind = this.rowCache.get(item.id)!
        bind.name.setText(item.name)
        bind.value.setText(item.value.toString())
    }
}

Анимация числовых значений

function animateValue(from: number, to: number, duration: number, onUpdate: (v: number) => void) {
    const start = performance.now()
    let raf: number
    function tick(now: number) {
        const t = Math.min((now - start) / duration, 1)
        onUpdate(from + (to - from) * (1 - Math.pow(1 - t, 3)))  // ease-out cubic
        if (t < 1) raf = requestAnimationFrame(tick)
    }
    raf = requestAnimationFrame(tick)
    return () => cancelAnimationFrame(raf)
}

const cancel = animateValue(0, 1500, 800, (v) => {
    this.bind.counter.setText(Math.round(v).toString())
})

Типичные ошибки

Биндинг — поле класса, не локальная переменная:

// Плохо
onCreateView() {
    const bind = R.layout.fragments.profile(this.ctx())
    return bind.root
}

// Хорошо
private bind = R.layout.fragments.profile(this.ctx())
onCreateView() { return this.bind.root }

Очистка в onDetach:

private intervals: ReturnType<typeof setInterval>[] = []

onCreated() {
    this.intervals.push(setInterval(() => this.tick(), 1000))
}

onDetach() {
    this.intervals.forEach(clearInterval)
    this.intervals = []
}

Быстрый старт

res/layout/root.html

<div class="app-root" id="root">
    <div id="app_content" style="height:100%"></div>
</div>

res/layout/main.html

<div class="main-page" id="root">
    <h1 id="greeting"></h1>
    <button id="btn" class="btn">Нажми меня</button>
    <span id="counter">0</span>
</div>

src/models/CounterModel.ts

import {ViewModel} from "@casperui/core/live/ViewModel"
import {LiveData} from "@casperui/core/live/LiveData"

export class CounterModel extends ViewModel {
    count = new LiveData<number>(0)
    increment() { this.count.setValue(this.count.getValue() + 1) }
}

src/view/fragments/MainFragment.ts

import {JFragment} from "@casperui/core/app/JFragment"
import {View} from "@casperui/core/view/View"
import {R} from "@app/R"
import {MainActivity} from "@app/MainActivity"

export class MainFragment extends JFragment {
    private bind = R.layout.main(this.ctx())

    onCreateView(): View { return this.bind.root }

    onCreated() {
        const main = this.getActivity() as MainActivity
        this.bind.greeting.setText("Привет, мир!")
        main.counter.count.observe(this, (n) => this.bind.counter.setText(String(n)))
        this.bind.btn.setOnClickListener(() => main.counter.increment())
    }
}

src/MainActivity.ts

import {Activity} from "@casperui/core/app/Activity"
import {R} from "@app/R"
import {CounterModel} from "@app/models/CounterModel"
import {MainFragment} from "@app/view/fragments/MainFragment"

export class MainActivity extends Activity {
    counter = new CounterModel()

    async onCreate() {
        this.setContentView(R.layout.root(this).root)
        this.replaceFragment(R.id.app_content, new MainFragment())
    }
}

src/main.ts

import {Application} from "@casperui/core/app/Application"
import {MainActivity} from "@app/MainActivity"

const app = new Application()
window.onload = () => app.startActivity(new MainActivity())