280 lines
8.6 KiB
Markdown
280 lines
8.6 KiB
Markdown
# dom.js — декларативное создание DOM с реактивностью на сигналах
|
||
|
||
Библиотека предоставляет текучий (fluent) API для создания DOM-элементов и реактивного управления ими через собственную систему сигналов.
|
||
|
||
## Установка
|
||
|
||
```html
|
||
<script src="path/to/dom.js"></script>
|
||
```
|
||
|
||
## Быстрый старт
|
||
|
||
```javascript
|
||
// Создать кнопку с реактивным текстом
|
||
const count = dom.signal(0);
|
||
|
||
const button = dom.button('counter')
|
||
.cls('btn btn-primary')
|
||
.text(count)
|
||
.on('click', () => count(count() + 1))
|
||
.done();
|
||
|
||
document.body.appendChild(button);
|
||
```
|
||
|
||
## API
|
||
|
||
### Сигналы
|
||
|
||
Сигнал — реактивный контейнер для значения. При изменении значения все подписчики автоматически уведомляются.
|
||
|
||
```javascript
|
||
// Создание сигнала
|
||
const name = dom.signal('John');
|
||
|
||
// Чтение значения
|
||
console.log(name()); // 'John'
|
||
console.log(name.value); // 'John'
|
||
|
||
// Изменение значения
|
||
name('Jane');
|
||
name.value = 'Jane';
|
||
|
||
// Подписка на изменения
|
||
const unsubscribe = name.on((newVal, oldVal) => {
|
||
console.log(`${oldVal} → ${newVal}`);
|
||
});
|
||
|
||
// Отписка
|
||
unsubscribe();
|
||
```
|
||
|
||
#### Производные сигналы `.map()`
|
||
|
||
Создаёт новый сигнал, значение которого автоматически обновляется при изменении родительского:
|
||
|
||
```javascript
|
||
const count = dom.signal(1);
|
||
const doubled = count.map(x => x * 2);
|
||
|
||
console.log(doubled()); // 2
|
||
count(5);
|
||
console.log(doubled()); // 10
|
||
```
|
||
|
||
#### Управление памятью `.destroy()`
|
||
|
||
Уничтожает сигнал, отписывая его от родителя и очищая всех слушателей:
|
||
|
||
```javascript
|
||
doubled.destroy();
|
||
```
|
||
|
||
#### Отладка `.chain()` и `.listenerCount()`
|
||
|
||
```javascript
|
||
console.log(doubled.chain()); // 'signal_a1b2c → signal_a1b2c_map_1'
|
||
console.log(count.listenerCount()); // количество подписчиков
|
||
```
|
||
|
||
### Создание элементов
|
||
|
||
Любой HTML-тег доступен как метод `dom`:
|
||
|
||
```javascript
|
||
dom.div() // <div></div>
|
||
dom.div('app') // <div id="app"></div>
|
||
dom.section() // <section></section>
|
||
dom.span() // <span></span>
|
||
```
|
||
|
||
CamelCase автоматически преобразуется в kebab-case:
|
||
|
||
```javascript
|
||
dom.viewBox() // <view-box></view-box>
|
||
```
|
||
|
||
### Инструкции
|
||
|
||
После создания элемента конфигурация задаётся цепочкой инструкций. Все инструкции возвращают прокси элемента для продолжения цепочки.
|
||
|
||
| Инструкция | Описание | Пример |
|
||
|---|---|---|
|
||
| `.id(id)` | Установить id | `.id('main')` |
|
||
| `.cls(classes)` | Добавить CSS-классы | `.cls('btn primary')` |
|
||
| `.atr(name, value)` | Установить атрибут | `.atr('href', '/home')` |
|
||
| `.atrs({...})` | Установить несколько атрибутов | `.atrs({ href: '/', title: 'Home' })` |
|
||
| `.css(styles)` | Добавить инлайн-стили | `.css('color: red;')` |
|
||
| `.pcss(selector, styles)` | Scoped-стили | `.pcss(':hover', 'color: blue;')` |
|
||
| `.mcss(query, styles)` | Media-запросы | `.mcss('(max-width: 768px)', 'font-size: 14px;')` |
|
||
| `.text(content)` | Установить текстовое содержимое | `.text('Hello')` |
|
||
| `.child(element)` | Добавить дочерний элемент | `.child(dom.span().done())` |
|
||
| `.children([...])` | Добавить массив дочерних | `.children([el1, el2])` |
|
||
|
||
**Важно:** внутри `.atrs()`, `.children()` и других методов, где значения могут быть сигналами, нужно передавать их явно:
|
||
|
||
```javascript
|
||
// Передача сигнала в атрибуты
|
||
const href = dom.signal('/home');
|
||
dom.a().atr('href', href).done();
|
||
```
|
||
|
||
#### Scoped-стили: `pcss`
|
||
|
||
Создаёт стили, привязанные только к этому элементу, с уникальным классом:
|
||
|
||
```javascript
|
||
dom.button()
|
||
.pcss(':hover', 'background: blue; color: white;')
|
||
.pcss('::after', 'content: "→";')
|
||
.done();
|
||
```
|
||
|
||
Сгенерирует `<style>` с уникальным селектором, действующим только на этот элемент.
|
||
|
||
#### Media-запросы: `mcss`
|
||
|
||
Создаёт медиа-запросы для конкретного элемента:
|
||
|
||
```javascript
|
||
dom.div()
|
||
.mcss('(max-width: 600px)', 'width: 100%;')
|
||
.mcss('(min-width: 1200px)', 'max-width: 800px; margin: 0 auto;')
|
||
.done();
|
||
```
|
||
|
||
### Условный рендеринг
|
||
|
||
```javascript
|
||
const isLoggedIn = dom.signal(false);
|
||
|
||
dom.div()
|
||
.if(isLoggedIn)
|
||
.text('Welcome back!')
|
||
.elif(dom.signal(false))
|
||
.text('Session expired')
|
||
.else()
|
||
.text('Please log in')
|
||
.endif()
|
||
.done();
|
||
```
|
||
|
||
При изменении сигнала DOM перерендеривается автоматически.
|
||
|
||
### Циклы
|
||
|
||
#### Неупорядоченный список: `ueach`
|
||
|
||
```javascript
|
||
const items = dom.signal(['Apple', 'Banana', 'Cherry']);
|
||
|
||
dom.div()
|
||
.ueach(items)
|
||
.done();
|
||
```
|
||
|
||
#### Упорядоченный список: `oeach`
|
||
|
||
```javascript
|
||
const steps = dom.signal(['Install', 'Configure', 'Run']);
|
||
|
||
dom.div()
|
||
.oeach(steps)
|
||
.done();
|
||
```
|
||
|
||
При изменении массива в сигнале элементы списка обновляются реактивно — добавляются/удаляются по мере необходимости, существующие элементы переиспользуются.
|
||
|
||
### События
|
||
|
||
Все нативные события доступны через camelCase-алиасы:
|
||
|
||
```javascript
|
||
dom.button()
|
||
.onClick(() => console.log('clicked'))
|
||
.onMouseEnter(() => console.log('hover'))
|
||
.onKeyDown(e => console.log(e.key))
|
||
.done();
|
||
```
|
||
|
||
Поддерживаемые алиасы событий:
|
||
|
||
| Категория | Алиасы |
|
||
|---|---|
|
||
| Keyboard | `keyDown`, `keyUp`, `keyPress` |
|
||
| Mouse | `mouseEnter`, `mouseLeave`, `mouseDown`, `mouseUp`, `mouseMove`, `mouseOver`, `mouseOut` |
|
||
| Touch | `touchStart`, `touchEnd`, `touchMove`, `touchCancel` |
|
||
| Focus | `focusIn`, `focusOut` |
|
||
| Animation | `animationStart`, `animationEnd`, `animationIteration` |
|
||
| Transition | `transitionEnd` |
|
||
| Drag | `dragStart`, `dragEnd`, `dragEnter`, `dragLeave`, `dragOver` |
|
||
| Pointer | `pointerDown`, `pointerUp`, `pointerMove`, `pointerEnter`, `pointerLeave`, `pointerOver`, `pointerOut`, `pointerCancel` |
|
||
| Wheel | `wheelStart`, `wheelEnd` |
|
||
| Composition | `compositionStart`, `compositionEnd`, `compositionUpdate` |
|
||
| Other | `contextMenu`, `formData`, `formChange`, `formInput` |
|
||
|
||
Для событий без алиаса используйте kebab-case в camelCase-нотации:
|
||
|
||
```javascript
|
||
dom.div().onCustomEvent(handler) // → 'custom-event'
|
||
```
|
||
|
||
### Завершение сборки: `.done()`
|
||
|
||
Подписывается на все сигналы, выполняет рендеринг и возвращает готовый DOM-элемент:
|
||
|
||
```javascript
|
||
const element = dom.div('app')
|
||
.cls('container')
|
||
.child(dom.span().text('Hello').done())
|
||
.done();
|
||
|
||
document.body.appendChild(element);
|
||
```
|
||
|
||
**Важно:** дочерние элементы, добавляемые через `.child()`, должны быть либо готовым `HTMLElement`, либо результатом `.done()`.
|
||
|
||
## Полный пример
|
||
|
||
```javascript
|
||
// Сигналы состояния
|
||
const count = dom.signal(0);
|
||
const theme = dom.signal('light');
|
||
|
||
// Приложение
|
||
const app = dom.div('app')
|
||
.cls('app-container')
|
||
.pcss(':hover', 'box-shadow: 0 0 10px rgba(0,0,0,0.1);')
|
||
.child(
|
||
dom.h1()
|
||
.text('Counter')
|
||
.done()
|
||
)
|
||
.child(
|
||
dom.p('counter-value')
|
||
.text(count.map(x => `Count: ${x}`))
|
||
.css('font-size: 24px;')
|
||
.done()
|
||
)
|
||
.child(
|
||
dom.div()
|
||
.cls('buttons')
|
||
.child(
|
||
dom.button('increment')
|
||
.text('+')
|
||
.onClick(() => count(count() + 1))
|
||
.done()
|
||
)
|
||
.child(
|
||
dom.button('decrement')
|
||
.text('-')
|
||
.onClick(() => count(count() - 1))
|
||
.done()
|
||
)
|
||
.done()
|
||
)
|
||
.done();
|
||
|
||
document.body.appendChild(app);
|
||
``` |