@surdeddd/bottom-sheet 0.23.2 → 0.23.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +18 -16
  2. package/README.ru.md +697 -0
  3. package/dist/{BottomSheetCore-CQStdVy2.d.cts → BottomSheetCore-C5tJYVMY.d.cts} +4 -1
  4. package/dist/{BottomSheetCore-9i-Uh6T3.d.ts → BottomSheetCore-DxSrC4QC.d.ts} +4 -1
  5. package/dist/{BottomSheetEngine-CsQWHLNb.d.cts → BottomSheetEngine-B3F9qukX.d.cts} +1 -1
  6. package/dist/{BottomSheetEngine-Dn6h-7gF.d.ts → BottomSheetEngine-BchzgqGG.d.ts} +1 -1
  7. package/dist/core.cjs +43 -13
  8. package/dist/core.d.cts +2 -2
  9. package/dist/core.d.ts +2 -2
  10. package/dist/core.js +43 -13
  11. package/dist/debug.d.cts +3 -2
  12. package/dist/debug.d.ts +3 -2
  13. package/dist/{default-features-DkG9aho0.d.cts → default-features-BGiCSmV1.d.cts} +1 -1
  14. package/dist/{default-features-C3cilcW9.d.ts → default-features-DwVTzDwP.d.ts} +1 -1
  15. package/dist/element.cjs +107 -21
  16. package/dist/element.d.cts +5 -3
  17. package/dist/element.d.ts +5 -3
  18. package/dist/element.iife.global.js +2 -2
  19. package/dist/element.js +107 -21
  20. package/dist/features.cjs +56 -3
  21. package/dist/features.d.cts +3 -3
  22. package/dist/features.d.ts +3 -3
  23. package/dist/features.js +56 -3
  24. package/dist/{index-CnsJQZp2.d.ts → index-C7TSQkVK.d.cts} +3 -2
  25. package/dist/{index-CVGC5B5D.d.cts → index-CdM1tke5.d.ts} +3 -2
  26. package/dist/index.cjs +96 -16
  27. package/dist/index.d.cts +7 -6
  28. package/dist/index.d.ts +7 -6
  29. package/dist/index.js +96 -16
  30. package/dist/integrations/formik.d.cts +3 -2
  31. package/dist/integrations/formik.d.ts +3 -2
  32. package/dist/integrations/react-hook-form.d.cts +3 -2
  33. package/dist/integrations/react-hook-form.d.ts +3 -2
  34. package/dist/overlay.d.cts +2 -1
  35. package/dist/overlay.d.ts +2 -1
  36. package/dist/preact.d.cts +2 -2
  37. package/dist/preact.d.ts +2 -2
  38. package/dist/qwik-lib/index.qwik.cjs +87 -16
  39. package/dist/qwik-lib/index.qwik.mjs +87 -16
  40. package/dist/qwik.cjs +96 -16
  41. package/dist/qwik.d.cts +5 -3
  42. package/dist/qwik.d.ts +5 -3
  43. package/dist/qwik.js +96 -16
  44. package/dist/react.cjs +96 -16
  45. package/dist/react.d.cts +8 -5
  46. package/dist/react.d.ts +8 -5
  47. package/dist/react.js +96 -16
  48. package/dist/solid.cjs +96 -16
  49. package/dist/solid.d.cts +4 -1
  50. package/dist/solid.d.ts +4 -1
  51. package/dist/solid.js +96 -16
  52. package/dist/solid.source.jsx +96 -16
  53. package/dist/svelte-core.cjs +98 -16
  54. package/dist/svelte-core.js +97 -17
  55. package/dist/svelte-src/BottomSheet.svelte +1 -1
  56. package/dist/svelte.cjs +97 -18
  57. package/dist/svelte.js +97 -18
  58. package/dist/vue.cjs +96 -16
  59. package/dist/vue.js +96 -16
  60. package/dist/webgl.d.cts +3 -2
  61. package/dist/webgl.d.ts +3 -2
  62. package/package.json +32 -34
package/README.ru.md ADDED
@@ -0,0 +1,697 @@
1
+ # @surdeddd/bottom-sheet
2
+
3
+ English: [README.md](./README.md)
4
+
5
+ > Универсальный headless-движок bottom sheet. **Одно ядро, восемь адаптеров, GPU-ускорение, полное покрытие тестами.**
6
+
7
+ ![bundle](https://img.shields.io/badge/core-%E2%89%A424%20KB%20gzip%20budget-1a1614?style=flat-square)
8
+ ![slim](https://img.shields.io/badge/slim%20core-%E2%89%A419.5%20KB%20gzip-1a1614?style=flat-square)
9
+ ![tests](https://img.shields.io/badge/tests-500%2B%20unit-2ea44f?style=flat-square)
10
+ ![ts](https://img.shields.io/badge/TypeScript-strict-3178c6?style=flat-square&logo=typescript&logoColor=white)
11
+ ![license](https://img.shields.io/badge/license-MIT-dc3522?style=flat-square)
12
+
13
+ Фреймворк-независимый примитив bottom sheet. Spring-физика, Pointer Events,
14
+ анимации только на GPU, полноценная клавиатурная a11y, перехват аппаратной
15
+ кнопки «Назад», стек из нескольких sheet — и один и тот же движок под каждым
16
+ адаптером.
17
+
18
+ ## 🚀 [Живое демо → bottom-sheet-demo-ashy.vercel.app](https://bottom-sheet-demo-ashy.vercel.app)
19
+
20
+ 📚 [Справочник API (typedoc) → surdeddd.github.io/BottomSheet](https://surdeddd.github.io/BottomSheet/)
21
+
22
+ <p align="center">
23
+ <picture>
24
+ <source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/sheet-dark.png" />
25
+ <img src="docs/screenshots/sheet-light.png" alt="bottom sheet на средней snap point поверх затемнённой страницы" width="420" />
26
+ </picture>
27
+ </p>
28
+
29
+ <p align="center">
30
+ <img src="https://raw.githubusercontent.com/Surdeddd/BottomSheet/main/docs/gifs/01-drag-to-close.gif" alt="перетаскивание для закрытия — spring-физика" width="380" />
31
+ </p>
32
+
33
+ <p align="center">
34
+ <img src="docs/assets/architecture.svg" alt="Одно ядро движка питает восемь адаптеров — React, Preact, Vue 3, Svelte, Solid, Qwik, Web Component и Vanilla — плюс отдельный subpath /overlay" width="720" />
35
+ </p>
36
+
37
+ ## Возможности
38
+
39
+ Всё перечисленное ниже можно проверить в `src/` этого репозитория:
40
+
41
+ - **8 целей сборки** — React, Preact, Vue 3, Svelte 5, Solid, Qwik, Web
42
+ Component (`<bottom-sheet>`, совместимый с Lit) и vanilla-ядро, плюс
43
+ subpath `/overlay` (отдельная выезжающая панель).
44
+ - **4 оси** — `bottom`, `top`, `left`, `right` (через `mode`).
45
+ - **Spring-физика с переносом скорости**, плюс настройка под тип указателя
46
+ (отдельные пороги drag для touch / mouse / pen).
47
+ - **Перехват аппаратной кнопки «Назад»** на Android — включается опцией
48
+ `closeOnBack`.
49
+ - **Отступы с учётом `env(safe-area-inset-*)`**, чтобы ряды кнопок не
50
+ попадали под индикатор «Домой».
51
+ - **Клавиатурный слайдер по WCAG 2.1 AA** — шаги Arrow/Home/End/Esc, focus
52
+ trap с возвратом фокуса.
53
+ - **Стек из нескольких sheet** и типизированный реестр sheet manager.
54
+ - **Отменяемые `before-snap` / `before-close`** — переход можно
55
+ синхронно заблокировать.
56
+ - Под капотом 500+ unit-тестов и ~200 e2e-прогонов Playwright на трёх
57
+ браузерных движках (`mobile-chrome` / `mobile-safari` / `firefox`).
58
+
59
+ ## Установка
60
+
61
+ ```bash
62
+ npm i @surdeddd/bottom-sheet
63
+ ```
64
+
65
+ React / Vue / Svelte указаны как **опциональные peer deps** — ставьте только
66
+ тот фреймворк, который используете: в бандл попадает только его адаптер.
67
+
68
+ ## Быстрый старт
69
+
70
+ ### React
71
+
72
+ ```tsx
73
+ import { BottomSheet } from '@surdeddd/bottom-sheet/react';
74
+ import '@surdeddd/bottom-sheet/styles';
75
+
76
+ <BottomSheet
77
+ snapPoints={[
78
+ { id: 'minimized', size: 96 },
79
+ { id: 'half', size: '45dvh' },
80
+ { id: 'full', size: '85%' },
81
+ ]}
82
+ initial='minimized'
83
+ animation='spring'
84
+ spring={{ stiffness: 260, damping: 28 }}
85
+ focusTrap
86
+ closeOnEscape
87
+ header={<h2>Search vehicles</h2>}
88
+ >
89
+ <YourList />
90
+ </BottomSheet>;
91
+ ```
92
+
93
+ [Полная документация по React →](docs/react.md)
94
+
95
+ ### Vue 3
96
+
97
+ ```vue
98
+ <script setup>
99
+ import { BottomSheet } from '@surdeddd/bottom-sheet/vue';
100
+ import '@surdeddd/bottom-sheet/styles';
101
+ </script>
102
+
103
+ <template>
104
+ <BottomSheet
105
+ :snap-points="snaps"
106
+ initial="minimized"
107
+ focus-trap
108
+ @snap="onSnap"
109
+ >
110
+ <template #header><h2>Search</h2></template>
111
+ <YourList />
112
+ </BottomSheet>
113
+ </template>
114
+ ```
115
+
116
+ [Полная документация по Vue →](docs/vue.md)
117
+
118
+ ### Svelte 5
119
+
120
+ ```svelte
121
+ <script lang="ts">
122
+ import { createBottomSheet } from "@surdeddd/bottom-sheet/svelte";
123
+ import "@surdeddd/bottom-sheet/styles";
124
+
125
+ let sheetEl: HTMLElement | undefined = $state();
126
+ let handleEl: HTMLElement | undefined = $state();
127
+ let contentEl: HTMLElement | undefined = $state();
128
+
129
+ const ctrl = createBottomSheet({
130
+ snapPoints: [{ id: "min", size: 96 }, { id: "full", size: "85%" }],
131
+ initial: "min",
132
+ });
133
+
134
+ $effect(() => {
135
+ if (!sheetEl) return;
136
+ return ctrl.attach({ element: sheetEl, handle: handleEl, scrollContainer: contentEl });
137
+ });
138
+ </script>
139
+
140
+ <section class="bs-sheet" bind:this={sheetEl} data-mode="bottom">
141
+ <div class="bs-handle" bind:this={handleEl}><h2>Search</h2></div>
142
+ <div class="bs-content" bind:this={contentEl}><!-- list --></div>
143
+ </section>
144
+ ```
145
+
146
+ [Полная документация по Svelte →](docs/svelte.md)
147
+
148
+ ### Web Component (любой фреймворк)
149
+
150
+ ```html
151
+ <link rel="stylesheet" href="/styles.css" />
152
+ <script type="module" src="/element.js"></script>
153
+
154
+ <bottom-sheet
155
+ snap-points='[{"id":"min","size":96},{"id":"full","size":"85%"}]'
156
+ initial="min"
157
+ animation="spring"
158
+ focus-trap="true"
159
+ >
160
+ <h2 slot="header">Search</h2>
161
+ <ul>
162
+ ...
163
+ </ul>
164
+ </bottom-sheet>
165
+ ```
166
+
167
+ [Полная документация по Web Component →](docs/web-component.md)
168
+
169
+ ### CDN — без сборщика и установки
170
+
171
+ Готовое подключение из статической HTML-страницы. IIFE-бандл регистрирует
172
+ `<bottom-sheet>` как побочный эффект — JS-обвязка не нужна.
173
+
174
+ ```html
175
+ <!-- CDN, no bundler -->
176
+ <link
177
+ rel="stylesheet"
178
+ href="https://unpkg.com/@surdeddd/bottom-sheet/dist/styles.css"
179
+ />
180
+ <script src="https://unpkg.com/@surdeddd/bottom-sheet/dist/element.iife.global.js"></script>
181
+ <bottom-sheet
182
+ snap-points='[{"id":"min","size":96},{"id":"full","size":"85%"}]'
183
+ initial="min"
184
+ >
185
+ <h2 slot="header">Title</h2>
186
+ <p>Content</p>
187
+ </bottom-sheet>
188
+ ```
189
+
190
+ ### Vanilla-ядро (без фреймворка)
191
+
192
+ ```ts
193
+ import { BottomSheetEngine } from '@surdeddd/bottom-sheet';
194
+ import '@surdeddd/bottom-sheet/styles';
195
+
196
+ const engine = new BottomSheetEngine({
197
+ element: document.querySelector('.bs-sheet'),
198
+ handle: document.querySelector('.bs-handle'),
199
+ scrollContainer: document.querySelector('.bs-content'),
200
+ backdrop: document.querySelector('.bs-backdrop'),
201
+ snapPoints: [
202
+ { id: 'min', size: 96 },
203
+ { id: 'full', size: '85%' },
204
+ ],
205
+ animation: 'spring',
206
+ });
207
+ engine.snapTo('full');
208
+ ```
209
+
210
+ [Полная документация по vanilla →](docs/vanilla.md)
211
+
212
+ ## API кратко
213
+
214
+ ### Snap points
215
+
216
+ ```ts
217
+ type SnapPoint =
218
+ | number // pixels
219
+ | `${number}%` // percent of viewport along the axis
220
+ | 'fit' // natural height of the whole sheet (handle + header + content + footer)
221
+ | 'content' // alias of 'fit'
222
+ | 'full' // 100 % of axis
223
+ | string; // any CSS length — "50dvh", "clamp(200px, 60%, 800px)"
224
+ ```
225
+
226
+ ### Опции движка
227
+
228
+ | Опция | По умолчанию | Что делает |
229
+ | -------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
230
+ | `snapPoints` | обязательная | Упорядоченный список `{ id, size }` |
231
+ | `allowed` | все id | Подмножество snap point, на которых sheet может остановиться сейчас |
232
+ | `initial` | первая из allowed | Id snap point, с которой стартовать |
233
+ | `mode` | `"bottom"` | `bottom`, `top`, `left`, `right` или логические `start` / `end`; в адаптерах React и Vue реактивна |
234
+ | `animation` | `"spring"` | `spring`, `tween`, `ios-spring`, `material-bounce`, `linear`, `snappy` |
235
+ | `settleAnimation` | выкл. | `"waapi"` — финальная доводка идёт как Web Animation на compositor (заранее насэмплированные spring-кейфреймы); при необходимости автоматически откатывается на rAF |
236
+ | `features` | все по умолчанию | `EngineFeature[]` — переопределить/расширить набор composable-фич (см. «Slim core» ниже) |
237
+ | `spring` | snappy | `{ stiffness, damping, mass }` |
238
+ | `flickVelocity` | `0.65 px/ms` | Порог флика на мобильных |
239
+ | `dragThreshold` | `18 px` | Если drag короче, sheet возвращается назад |
240
+ | `rubberBand` | `true` | Мягкое «резиновое» перетягивание в стиле iOS |
241
+ | `backdropRange` | `[0, 1]` | Диапазон progress, в котором backdrop проявляется |
242
+ | `screenRange` | `[0, 1]` | Диапазон progress, в котором проявляется контент `screen`/scrim |
243
+ | `focusTrap` | `false` | Удерживать Tab-фокус внутри открытого sheet |
244
+ | `initialFocus` | первое поле | Элемент / селектор, получающий фокус при открытии; `false` фокусирует контейнер sheet (`tabindex=-1`) — так не открывается клавиатура iOS |
245
+ | `closeOnEscape` | `true` | Слушать Escape и закрывать |
246
+ | `closeOnBack` | `false` | Перехватывать кнопку «Назад» на Android (history.popstate) |
247
+ | `closeOnRouteChange` | `false` | Закрывать при смене URL (патчит `pushState`/`replaceState` + popstate) |
248
+ | `lockBodyScroll` | `true` | Блокировка прокрутки body, безопасная для iOS (`position: fixed`) |
249
+ | `stackEffect` | `false` | Стек карточек как в iOS: нижние sheet уменьшаются с глубиной |
250
+ | `inertSiblings` | `false` | Помечать соседние элементы страницы `inert`, пока sheet открыт (полноценная модалка) |
251
+ | `persistent` | `false` | Запретить закрытие пользователем (backdrop / Escape / «Назад»); программный `close()` работает |
252
+ | `disableClose` | `false` | Запретить любое закрытие, включая программное |
253
+ | `disableDrag` | `false` | Отключить жест drag (императивные snap продолжают работать) |
254
+ | `dragFrom` | `"handle"` | Какие области запускают drag: `"handle"`, `"sheet"` (весь sheet) или `"zones"` (только поддеревья `[data-bs-drag]`). Если handle не задан, по умолчанию `"sheet"`. `[data-bs-no-drag]` исключает поддерево в любом режиме. Также `setDragFrom()` / `getDragFrom()` — переключение перемонтирует жест, без перемонтирования компонента |
255
+ | `dragFromContent` | `true` | Тянет ли sheet touch-жест на scroll-контейнере; переопределяется для отдельной snap point через `{ id, size, dragFromContent: false }`. Также `setDragFromContent()` |
256
+ | `fitContentToSnap` | `false` | Делает весь scroll-контейнер достижимым, а footer видимым на каждой snap point, а не только на самой большой. По умолчанию выключено; см. [Контент достижим на каждой snap point](#контент-достижим-на-каждой-snap-point) |
257
+ | `radius` | токен по умолчанию | Радиус скругления (`string` — CSS-длина или `number` — px); также `setRadius()` |
258
+ | `maxHeight` | нет | Ограничение высоты sheet. `number` (px) или строка (`"92dvh"`, `"50%"`), пересчитывается при смене viewport / ориентации |
259
+ | `returnFocusTo` | opener | Куда вернуть фокус при закрытии — `HTMLElement`, строка-селектор или фабрика `() => HTMLElement` |
260
+
261
+ ### Anchored-элементы, docked-бары и сцены scrim
262
+
263
+ Закрепляйте UI вокруг sheet — он едет вместе с ним на compositor (ноль JS на
264
+ кадр) и показывается/скрывается по состоянию с настраиваемой анимацией:
265
+
266
+ ```ts
267
+ engine.addAnchor({
268
+ element: closeBtn,
269
+ position: 'sheet-top-right', // rides the sheet edge; 9 screen positions too
270
+ showOn: ['half', 'full'], // snap ids or a predicate
271
+ animation: 'pop', // fade | scale | slide | pop | custom keyframes
272
+ });
273
+
274
+ engine.addAnchor({
275
+ element: tabBar,
276
+ position: 'dock-bottom', // full-width bar; the sheet slides under it
277
+ });
278
+
279
+ engine.setScrimStages({
280
+ stages: [
281
+ { for: 'peek', element: teaser },
282
+ { forRange: [0.5, 1], element: expanded },
283
+ ],
284
+ animation: 'fade',
285
+ });
286
+ ```
287
+
288
+ [Полная документация по anchors и stages →](docs/anchors.md)
289
+
290
+ ### События
291
+
292
+ ```ts
293
+ engine.on('snap', ({ id, size, progress }) => {}); // settled state change
294
+ engine.on('open', ({ id }) => {}); // 0 → >0 (enter starts)
295
+ engine.on('opened', ({ id }) => {}); // enter settled
296
+ engine.on('close', () => {}); // → 0 (exit starts)
297
+ engine.on('closed', () => {}); // exit settled
298
+ engine.on('dragstart', ({ size }) => {});
299
+ engine.on('drag', ({ size, delta }) => {}); // ~60 fps — payload is pooled, don't retain
300
+ engine.on('dragend', ({ size, velocity }) => {});
301
+ engine.on('progress', ({ value, size }) => {}); // ~60 fps
302
+
303
+ // Cancelable — call cancel() synchronously to veto the transition:
304
+ engine.on('before-snap', ({ id, size, previousId, cancel }) => {});
305
+ engine.on('before-close', ({ reason, cancel }) => {}); // reason: programmatic | backdrop | escape | back
306
+ ```
307
+
308
+ ### Императивный API
309
+
310
+ ```ts
311
+ engine.snapTo('half');
312
+ engine.open(); // → first allowed snap with resolved size > 0
313
+ engine.close(reason?); // → first snap that resolves to size 0, else first allowed
314
+ engine.expand(); // → largest allowed snap
315
+ engine.collapse(); // → smallest allowed snap with size > 0
316
+ engine.setAllowed(ids, snapId); // dynamic allowlist
317
+ engine.setSnapPoints(points); // re-measure
318
+ engine.recompute(); // re-measure a 'fit' / 'content' snap on demand
319
+ engine.getResolvedSnaps(); // → [{ id, size }] resolved to pixels right now
320
+ engine.setPersistent(bool); // toggle persistence (block dismissal) at runtime
321
+ engine.setDisableClose(bool); // toggle close-blocking at runtime
322
+ engine.setDisableDrag(bool); // toggle the drag gesture at runtime
323
+ engine.setRadius('28px'); // corner radius
324
+ engine.setMaxHeight('92dvh'); // height cap (re-resolved on viewport change)
325
+ engine.canDismiss(); // false when persistent / disableClose
326
+ engine.isTop(); // top of the multi-sheet stack?
327
+ engine.depth(); // open sheets above this one
328
+ engine.destroy(); // remove listeners + cancel animation
329
+ ```
330
+
331
+ `initial` запускает sheet **сразу открытым** на любой snap point, размер
332
+ которой разрешается в `> 0` (события `open` / `opened` не срабатывают — перехода
333
+ для анонса нет). Вызывать `open()` / `snapTo()` **синхронно в тике монтирования
334
+ безопасно** — задержка через `requestAnimationFrame` / `nextTick` не нужна, в том
335
+ числе для `fit` / `content` и телепортированных sheet. Sheet, смонтированный
336
+ скрытым (предок с `display:none`), «лечится» и один раз испускает свою
337
+ последовательность открытия при появлении. См.
338
+ [Architecture → Opening at construction](docs/ARCHITECTURE.md).
339
+
340
+ ### Runtime API scrim
341
+
342
+ Scrim — это затемняющий слой за sheet или вокруг него (`screenComponent`). Все
343
+ свойства scrim можно менять в рантайме — перемонтирование не нужно.
344
+
345
+ | Метод | Назначение |
346
+ | --- | --- |
347
+ | `setScrimMode('full' \| 'above-sheet' \| 'off')` | Переключает позиционирование в рантайме. `'off'` полностью отключает слой scrim. |
348
+ | `setScrimEnabled(boolean)` | Удобный переключатель — сохраняет/восстанавливает диапазоны opacity. |
349
+ | `setScrimTapToClose(boolean)` | Устанавливает/снимает закрытие по клику на scrim. |
350
+ | `setScrimColor(color \| null)` / `setScrimBlur(blur \| null)` | Живое изменение цвета и blur через backdrop-filter. `null` сбрасывает. |
351
+ | `setScrimInteractive(boolean)` | Переключает `pointer-events` на слое scrim. |
352
+ | `setBackdropRange([s, e])` / `setScreenRange([s, e])` | Отображение диапазона на opacity (progress 0..1 → opacity 0..1). |
353
+ | `setScrim({ ... })` | Пакетное обновление — применяет preset и отдельные поля за один проход с единственным `applySize`. |
354
+ | `setScrimOverlay({ children, position, interactive })` | Вставляет позиционированный плавающий элемент в область scrim. Возвращает функцию снятия. Полезно для мониторинговых UX вида «затемнить всё, кроме этого бейджа». |
355
+ | `getScrimState()` | Read-only снимок `{ mode, enabled }`. Стабильный публичный API для интроспекции без привязки к внутренностям. |
356
+
357
+ **Пресеты**: `'subtle' | 'standard' | 'monitoring' | 'cinematic'` — задаются опцией конструктора `scrimPreset` или через `setScrim({ preset })`.
358
+
359
+ ### Runtime API overlay
360
+
361
+ Overlay-движок (`@surdeddd/bottom-sheet/overlay`) — это отдельная выезжающая
362
+ панель, которая повторяет для контента ту же runtime-изменяемость, что и scrim.
363
+
364
+ | Метод | Назначение |
365
+ | --- | --- |
366
+ | `setOverlayChildren(node \| fragment \| factory)` | Заменяет содержимое overlay в рантайме. Принимает `Node`, `DocumentFragment` или фабрику `() => Node`. |
367
+ | `clearOverlayChildren()` | Удаляет вставленный контент. |
368
+ | `setOverlay({ children, ... })` | Пакетное обновление — `children` входит в число допустимых полей наряду с позиционированием и переопределениями preset. |
369
+
370
+ **Пресеты**: `OverlayPreset` — это `'sheet' | 'dialog' | 'sidebar' | 'toast'`;
371
+ доступны как константа-словарь `OVERLAY_PRESETS` и принимаются в `setOverlay({ preset })`.
372
+
373
+ ### Sheet manager (реестр по маршрутам)
374
+
375
+ ```ts
376
+ import { createSheetManager } from "@surdeddd/bottom-sheet";
377
+
378
+ const sheets = createSheetManager<"home" | "marker" | "panorama">({
379
+ home: { snapPoints: [...], allowed: ["min", "half", "full"], onOpen: loadHome },
380
+ marker: { snapPoints: [...], allowed: ["half", "full"], onOpen: focusMarker, onClose: blur },
381
+ });
382
+
383
+ // In your route watcher:
384
+ sheets.transition(prev, next);
385
+ const cfg = sheets.resolve(currentRoute); // typed!
386
+ ```
387
+
388
+ ## Производительность
389
+
390
+ Три осознанных решения держат 60 fps даже на Android среднего уровня:
391
+
392
+ 1. **Позиционирование через transform.** `height` sheet выставляется один раз
393
+ по самой большой snap point; изменения размера применяются через
394
+ `transform: translate3d(...)`. Всё остаётся на compositor — ноль layout на
395
+ кадр.
396
+ 2. **Sub-stepping spring с единственной записью в DOM.** Жёсткие пружины
397
+ интегрируются внутри на 240 Гц ради стабильности, но движок пишет в DOM
398
+ ровно один раз за `requestAnimationFrame`.
399
+ 3. **React-снимок только установившегося состояния.** `useSyncExternalStore`
400
+ обновляется лишь на `snap` / `dragstart` / `dragend`. Пиксели drag и кадры
401
+ анимации передаются через CSS-переменные (`--bs-progress`, `--bs-size`) и
402
+ прямые императивные подписки — никаких ре-рендеров React на каждом кадре.
403
+
404
+ ## Slim core и composable-фичи
405
+
406
+ Стандартный `BottomSheetEngine` поставляется со всеми опциональными
407
+ поведениями. Если нужно только необходимое, импортируйте slim core (~17.5 KB
408
+ gzip против ~23 KB) и подключайте только нужные фичи:
409
+
410
+ ```ts
411
+ import { BottomSheetCore } from '@surdeddd/bottom-sheet/core';
412
+ import { routeFeature, contentSwipeFeature } from '@surdeddd/bottom-sheet/features';
413
+
414
+ const engine = new BottomSheetCore({
415
+ element, handle, snapPoints,
416
+ closeOnBack: true,
417
+ features: [routeFeature(), contentSwipeFeature()],
418
+ });
419
+ ```
420
+
421
+ Опциональные фичи: `routeFeature()` (`closeOnBack` / `routedTo` /
422
+ `closeOnRouteChange`), `visualViewportFeature()` (ограничение под экранную
423
+ клавиатуру), `contentSwipeFeature()`, `persistFeature()`, `autoCollapseFeature()`.
424
+ Всё остальное (жесты, математика snap, самовосстановление при resize, a11y-слайдер,
425
+ focus trap, scroll lock, scrim, стек) — часть ядра. Если задать опцию, фича
426
+ которой не подключена, в dev-режиме будет одно предупреждение, а в production
427
+ опция игнорируется. Собственные фичи реализуют `{ name, install(ctx) }` — тот же
428
+ шов, что используют встроенные.
429
+
430
+ ### WebGL-рендерер (опционально)
431
+
432
+ ```ts
433
+ import { webglRenderer } from '@surdeddd/bottom-sheet/webgl';
434
+
435
+ const engine = new BottomSheetEngine({
436
+ element, handle, snapPoints,
437
+ features: [webglRenderer({ jelly: 0.6 })],
438
+ });
439
+ ```
440
+
441
+ Рисует поверхность sheet — панель, скругления, тень, блик по краю — на GPU. В
442
+ момент начала drag текст sheet переносится в текстуру, так что весь sheet
443
+ изгибается как единая поверхность, а после остановки возвращается обратно в
444
+ DOM. Layout, прокрутка, жесты и дерево доступности из DOM никуда не уходят.
445
+ 4.2 KB gzip, и только если вы его импортируете.
446
+ Откатывается на DOM-рендерер — молча и даже посреди сессии, если потребуется, —
447
+ когда WebGL недоступен, при `prefers-reduced-motion` или если браузер сбросил
448
+ GL-контекст.
449
+
450
+ [Полная документация по WebGL →](docs/webgl.md)
451
+
452
+ ### Debug overlay
453
+
454
+ ```ts
455
+ import { attachDebugOverlay } from '@surdeddd/bottom-sheet/debug';
456
+
457
+ const detach = attachDebugOverlay(engine);
458
+ ```
459
+
460
+ Фиксированная dev-панель с активной snap point, текущими size/progress,
461
+ флагами drag/animate и списком разрешённых snap — работает исключительно на
462
+ публичных событиях движка и поставляется отдельным entry, так что в
463
+ продакшн-бандлы не попадает.
464
+
465
+ ### Qwik SSR
466
+
467
+ Пакет объявляет поле `qwik`, указывающее на
468
+ `dist/qwik-lib/index.qwik.mjs`. Приложения на Qwik подхватывают адаптер
469
+ автоматически как vendor root: оптимизатор на стороне потребителя режет его на
470
+ QRL-чанки, а `renderToString` выдаёт возобновляемый (`q:container="paused"`)
471
+ sheet. Дополнительная конфигурация не нужна —
472
+ `import { BottomSheet } from '@surdeddd/bottom-sheet/qwik'` по-прежнему
473
+ работает для CSR, а Qwik City SSR теперь работает из коробки.
474
+
475
+ ## Темизация
476
+
477
+ Весь внешний вид управляется CSS custom properties. Переопределяйте где угодно:
478
+
479
+ ```css
480
+ .bs-root {
481
+ --bs-surface: #18181b;
482
+ --bs-handle-color: #71717a;
483
+ --bs-radius: 28px;
484
+ --bs-shadow: 0 -8px 30px rgba(0, 0, 0, 0.6);
485
+ }
486
+ ```
487
+
488
+ Движок записывает в элемент sheet `--bs-progress` (0..1) и `--bs-size` (px) —
489
+ управляйте любой CSS-анимацией от drag без единой строки JS:
490
+
491
+ ```css
492
+ .fab {
493
+ transform: scale(calc(1 - var(--bs-progress) * 0.3));
494
+ }
495
+ .map-canvas {
496
+ filter: blur(calc(var(--bs-progress) * 8px));
497
+ }
498
+ .search-input {
499
+ opacity: calc(1 - var(--bs-progress));
500
+ }
501
+ ```
502
+
503
+ > **z-index принадлежит движку.** Контроллер стека при каждом изменении стека
504
+ > перезаписывает inline-`z-index` у `.bs-sheet`, `.bs-backdrop` и обёрток
505
+ > anchor (база `100`, шаг `10` на уровень глубины), поэтому заданное вами
506
+ > значение будет перезаписано и начнёт конфликтовать со стеком. Чтобы
507
+ > расположить слой sheet относительно UI приложения, переместите контейнер
508
+ > (цель teleport / родителя `.bs-root`) или измените порядок открытия — не
509
+ > стилизуйте `z-index` у элементов, которыми управляет bs. См.
510
+ > [Architecture → z-index & the sheet stack](docs/ARCHITECTURE.md).
511
+
512
+ **У каждого sheet свой backdrop.** Откройте список, затем поверх него sheet с
513
+ деталями — оба backdrop остаются активными: затемнение усиливается с каждым
514
+ уровнем, а нижний sheet остаётся замаскированным всё время, пока закрывается
515
+ верхний, так что касания не доходят до страницы посреди анимации. Каждый
516
+ backdrop следует за progress собственного sheet, поэтому закрывающийся
517
+ «погребённый» sheet убирает свой scrim. Стек сохраняет порядок, в котором
518
+ sheet начали открываться: sheet, открытый, пока другой ещё анимируется, остаётся
519
+ сверху. Если нужен единый уровень затемнения, передайте `backdrop={false}`
520
+ (`:backdrop="false"` во Vue) тем sheet, которые открываете поверх.
521
+
522
+ ### Контент достижим на каждой snap point
523
+
524
+ Sheet всегда имеет высоту своей самой большой snap point и двигается через
525
+ transform — именно это держит drag на compositor. Цена: ниже самой большой
526
+ snap point низ scroll-контейнера оказывается под краем экрана ровно на
527
+ `largest snap − current size`. На половинной snap point прокручивать можно, но
528
+ последние строки не появятся, пока sheet не вытянут выше.
529
+
530
+ `fitContentToSnap` закрывает этот зазор:
531
+
532
+ ```tsx
533
+ <BottomSheet snapPoints={points} fitContentToSnap>
534
+ <LongList />
535
+ </BottomSheet>
536
+ ```
537
+
538
+ Когда sheet останавливается, движок записывает скрытую величину в
539
+ `--bs-content-inset`, а стили уменьшают `.bs-content` ровно на неё, так что
540
+ scroll-контейнер заканчивается у края экрана: последняя строка, поле в фокусе
541
+ и полоса прокрутки остаются в видимой части, а ползунок рассчитан на то, что
542
+ реально видно. В момент начала движения sheet переключается на контейнер
543
+ полной высоты со спейсером такого же размера в конце списка, поэтому строки
544
+ ниже края уже разложены, когда drag или анимация их открывает. Переключение
545
+ меняет и бокс, и диапазон прокрутки на одну и ту же величину, так что на
546
+ экране ничего не сдвигается. Это один layout при старте движения и один при
547
+ остановке, ни одного на кадр, поэтому движение остаётся чисто transform-овым.
548
+ Пока sheet движется, список, прокрученный до самого конца, удерживается на
549
+ месте на экране, а не уезжает вверх вместе с sheet и не откатывается обратно
550
+ в конце.
551
+
552
+ У footer та же проблема, и лечится она так же. Он последний в sheet, поэтому
553
+ ниже самой большой snap point он оказывается под краем экрана вместе с концом
554
+ списка. При включённом `fitContentToSnap` уменьшенный контейнер ставит footer
555
+ у низа видимой части sheet в покое, а пока sheet движется, стили поднимают
556
+ `.bs-footer` на `--bs-size − --bs-max-size`, так что он едет по этому краю на
557
+ каждом кадре drag или анимации — опять же без layout. Список заканчивается над
558
+ ним, а не под ним. На snap point, слишком короткой для обоих, footer уходит под
559
+ край экрана, а не закрывает handle. WebGL-рендерер получает ту же раскладку;
560
+ пока sheet движется, его footer закрашивается цветом поверхности, а drag
561
+ переносит список в текстуру, которая следует за позицией прокрутки списка, так
562
+ что список, удерживаемый в конце, остаётся там же.
563
+
564
+ #### Прокрутка списка на каждой snap point на таче
565
+
566
+ Сделать конец списка *достижимым* и дать пальцу *прокрутить* до него — две
567
+ разные вещи. По умолчанию свайп, начатый на контенте, тянет sheet, пока он не
568
+ дойдёт до самой большой snap point, и только потом прокручивает список — так
569
+ ведут себя нативные sheet. На мыши или трекпаде колесо прокручивает на любой
570
+ snap point, на таче — нет. Чтобы прокручивать пальцем на половинной snap point,
571
+ отдайте свайпы по контенту списку, а handle (и header) оставьте для движения
572
+ sheet:
573
+
574
+ ```tsx
575
+ <BottomSheet snapPoints={points} fitContentToSnap dragFromContent={false}>
576
+ <LongList />
577
+ </BottomSheet>
578
+ ```
579
+
580
+ `dragFromContent` можно задать и для отдельной snap point: это сохраняет
581
+ нативное ощущение pull-to-expand на маленьком peek и освобождает список начиная
582
+ с половины:
583
+
584
+ ```ts
585
+ const points = [
586
+ { id: "peek", size: 120 },
587
+ { id: "half", size: "50%", dragFromContent: false },
588
+ { id: "full", size: "90%", dragFromContent: false },
589
+ ];
590
+ ```
591
+
592
+ Работает для `bottom`-sheet и ожидает, что scroll-контейнер — растущий
593
+ потомок flex-колонки sheet, как `.bs-content`. Для любого другого контейнера
594
+ атрибут `data-bs-fit-content` на sheet принимает значения `rest` или `moving`,
595
+ а `--bs-content-inset` хранит скрытую величину: в покое уменьшайте контейнер на
596
+ неё, а при движении добавляйте её как отступ в конец содержимого. На slim core
597
+ добавьте `contentFitFeature()` из `@surdeddd/bottom-sheet/features`.
598
+
599
+ ### Scrim, blur и высокий контраст
600
+
601
+ | Токен | По умолчанию | Область |
602
+ | ------------------------ | ------------ | -------------------------------------------------------- |
603
+ | `--bs-scrim-color` | transparent | Заливка слоя scrim (`.bs-screen`) |
604
+ | `--bs-scrim-blur` | `0px` | `backdrop-filter: blur()`, применяемый к слою scrim |
605
+ | `--bs-header-min-height` | `0px` | Минимальная высота области `.bs-header` |
606
+
607
+ `--bs-scrim-blur` включает настоящий blur через `backdrop-filter` на scrim —
608
+ задайте его для эффекта матового стекла (или используйте пресеты scrim
609
+ `monitoring` / `cinematic`, которые задают и цвет, и blur). В режиме Windows
610
+ High Contrast / `forced-colors` таблица стилей содержит блок
611
+ `@media (forced-colors: active)`, чтобы поверхность sheet, handle и кольцо
612
+ фокуса оставались видимыми.
613
+
614
+ ### Отступы и раскладка
615
+
616
+ Отступы каждой области — это токен: переопределите или обнулите его, чтобы
617
+ контент шёл от края до края. Значения по умолчанию соответствуют встроенному
618
+ виду. Задавайте переменную на хосте / `.bs-root` / `:root`; custom properties
619
+ наследуются через shadow-границу Web Component, поэтому одно и то же
620
+ переопределение работает для всех адаптеров:
621
+
622
+ | Токен | По умолчанию | Область |
623
+ | ---------------------- | --------------------- | ----------------------- |
624
+ | `--bs-content-padding` | `0 16px 16px` | прокручиваемое тело |
625
+ | `--bs-handle-padding` | `8px 16px 4px` | полоса захвата (сверху) |
626
+ | `--bs-header-padding` | `0 16px 8px` | слот header |
627
+ | `--bs-footer-padding` | `12px 16px (+ inset)` | слот footer |
628
+
629
+ (Значение footer по умолчанию — `12px 16px calc(12px + env(safe-area-inset-bottom))`;
630
+ оно не даёт кнопкам действий оказаться под индикатором «Домой».)
631
+
632
+ ```css
633
+ .bs-root { --bs-content-padding: 0; } /* full-bleed content */
634
+ .bs-root { --bs-content-padding: 0 24px 24px; } /* custom insets */
635
+ bottom-sheet { --bs-handle-padding: 0; } /* Web Component, from the host */
636
+ ```
637
+
638
+ `maxHeight` (проп или атрибут `max-height` у элемента) ограничивает sheet:
639
+ snap point `'content'` / `'fit'`, превышающая ограничение, останавливается
640
+ вплотную к краю, а тело прокручивается внутри — sheet никогда не отрывается от
641
+ своей опоры, а `--bs-size` всегда равен отрисованной высоте.
642
+
643
+ ## Демо
644
+
645
+ ```bash
646
+ npm run demo
647
+ # http://localhost:5173
648
+ ```
649
+
650
+ Редакторское демо показывает адаптеры, каждый жест, каждый режим
651
+ (`bottom · top · left · right · overlay`) и живые показания движка (активная
652
+ snap point, progress, скорость, FPS). В углу переключатели EN/RU и светлой/тёмной темы.
653
+
654
+ ## Справа налево (RTL)
655
+
656
+ `left` и `right` — физические края и остаются физическими. Для интерфейсов,
657
+ которые зеркалятся вместе с направлением письма, используйте логические
658
+ режимы — `start` и `end` разрешаются относительно направления, действующего
659
+ для sheet, так что `start` — левый край в английском и правый в арабском:
660
+
661
+ ```ts
662
+ new BottomSheetEngine({ element, handle, mode: 'start', snapPoints });
663
+ ```
664
+
665
+ [Полная документация по RTL →](docs/rtl.md)
666
+
667
+ ## Поддержка браузеров
668
+
669
+ Базовый уровень: Chrome/Edge 80, Safari 13.1 (iOS 13.4), Firefox 72 — его
670
+ задают целевая сборка ES2020 и API, без которых движок работать не может
671
+ (Pointer Events, CSS custom properties, `ResizeObserver`). Всё остальное, от
672
+ `inert` до `backdrop-filter` и WebGL, проверяется на наличие и деградирует
673
+ мягко.
674
+
675
+ [Полная матрица поддержки →](docs/browser-support.md)
676
+
677
+ ## Тестирование
678
+
679
+ ```bash
680
+ npm test # 500+ unit tests via vitest + happy-dom
681
+ npx playwright test # e2e via Playwright on mobile-Chrome / mobile-Safari / Firefox
682
+ npm run typecheck # TypeScript --noEmit
683
+ ```
684
+
685
+ CI гоняет все три набора на Node 20.19 / 22 при каждом PR и теге.
686
+
687
+ ## Участие в разработке
688
+
689
+ 1. `npm install`
690
+ 2. `npm run dev` — tsup в watch-режиме
691
+ 3. `npm run demo` — dev-сервер Vite с живым демо
692
+ 4. `npm test` — vitest в watch-режиме
693
+ 5. Откройте PR с сфокусированным диффом. Слияние закрыто матрицей CI.
694
+
695
+ ## Лицензия
696
+
697
+ MIT — см. [LICENSE](LICENSE).