@pgcorp/ui-kit 0.8.0 → 0.9.0
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.
- package/README.md +174 -0
- package/docs/public-api.md +8 -0
- package/docs/theming.md +17 -0
- package/package.json +10 -5
- package/src/components/layout/SResizeHandle.vue +45 -5
- package/src/components/shared/_internal/SDialogSurface.css +18 -0
- package/src/components/shared/_internal/SDialogSurface.vue +6 -1
- package/src/components/shared/_internal/dialogSurface.ts +7 -1
- package/src/components/shared/containers/SDrawer.css +11 -2
- package/src/components/shared/containers/SDrawer.vue +43 -7
- package/src/components/shared/controls/SInteractiveSurface.css +50 -4
- package/src/components/shared/controls/SInteractiveSurface.vue +44 -30
- package/src/components/shared/controls/STextarea.ts +13 -0
- package/src/components/shared/controls/STextarea.vue +391 -37
- package/src/components/shared/data-display/SActionCard.css +6 -2
- package/src/components/shared/data-display/SActionCard.vue +13 -3
- package/src/components/shared/data-display/SActionListItem.css +7 -8
- package/src/components/shared/data-display/SActionListItem.vue +21 -18
- package/src/components/shared/data-display/SChart.vue +2 -2
- package/src/components/shared/data-display/SChip.css +7 -2
- package/src/components/shared/data-display/SCodeBlock.vue +4 -2
- package/src/components/shared/data-display/SVirtualList.ts +33 -0
- package/src/components/shared/data-display/SVirtualList.vue +321 -36
- package/src/internal/chartGeometry.ts +126 -22
- package/src/styles/tokens.css +3 -0
package/README.md
CHANGED
|
@@ -219,6 +219,18 @@ axes, grid, legend, markers, labels, auto/fixed domains, and the data table. The
|
|
|
219
219
|
component recomputes SVG geometry for its actual container, preserves readable
|
|
220
220
|
typography, and wraps long category labels at narrow widths.
|
|
221
221
|
|
|
222
|
+
Форматированные подписи шкалы Y участвуют в расчёте поля графика. Крайние
|
|
223
|
+
подписи X привязаны к границам plot-area, поэтому `x-labels="auto"` сохраняет
|
|
224
|
+
полные начальное и конечное значения и не обрезает их краем SVG. Если ширины
|
|
225
|
+
контейнера физически недостаточно, визуальная подпись сокращается, а полное
|
|
226
|
+
значение остаётся в tooltip и доступной таблице данных.
|
|
227
|
+
|
|
228
|
+
Formatted Y-axis labels participate in plot-gutter calculation. Endpoint
|
|
229
|
+
X labels are anchored to the plot boundaries, so `x-labels="auto"` preserves
|
|
230
|
+
the full first and last values instead of clipping them at the SVG edge. When
|
|
231
|
+
the container is physically too narrow, the visual label is shortened while
|
|
232
|
+
the full value remains available through the tooltip and accessible data table.
|
|
233
|
+
|
|
222
234
|
```ts
|
|
223
235
|
import type {
|
|
224
236
|
SChartCategory,
|
|
@@ -266,6 +278,24 @@ nested button. `SPanel` uses `header-layout="auto"` to move actions below the
|
|
|
266
278
|
title based on container width; `inline` and `stacked` remain explicit layout
|
|
267
279
|
options.
|
|
268
280
|
|
|
281
|
+
Action/link chip всегда сохраняет видимый интерактивный контур, включая
|
|
282
|
+
`severity="secondary"` на subtle-поверхности; static chip не получает ложного
|
|
283
|
+
affordance. Для строк и карточек с дополнительными controls используйте
|
|
284
|
+
`SActionCard#actions` или `SActionListItem#actions`. `actions-layout`/
|
|
285
|
+
`trailing-layout` задаёт `inline`, `stacked` или container-responsive
|
|
286
|
+
композицию. Основное действие и trailing controls остаются sibling-элементами
|
|
287
|
+
внутри одной визуальной поверхности, без вложенных интерактивных элементов и
|
|
288
|
+
дублирования рамки.
|
|
289
|
+
|
|
290
|
+
Action and link chips always retain a visible interactive outline, including a
|
|
291
|
+
`secondary` chip on a subtle surface; static chips do not gain a false action
|
|
292
|
+
affordance. For rows and cards with additional controls, use
|
|
293
|
+
`SActionCard#actions` or `SActionListItem#actions`. The `actions-layout` and
|
|
294
|
+
`trailing-layout` props select `inline`, `stacked`, or container-responsive
|
|
295
|
+
composition. The primary action and trailing controls remain siblings inside a
|
|
296
|
+
single visual surface, with no nested interactive elements or duplicated
|
|
297
|
+
chrome.
|
|
298
|
+
|
|
269
299
|
### Лёгкий и редактируемый код / Lightweight and editable code
|
|
270
300
|
|
|
271
301
|
`SCodeBlock` загружает CodeMirror отдельным async chunk только для
|
|
@@ -318,6 +348,139 @@ button and owns ARIA/focus, while `pointerTarget` is applied to a passive
|
|
|
318
348
|
</STooltip>
|
|
319
349
|
```
|
|
320
350
|
|
|
351
|
+
### Composer textarea
|
|
352
|
+
|
|
353
|
+
`STextarea` передаёт исходный `ClipboardEvent` синхронно. Обработчик может
|
|
354
|
+
отменить default-вставку файлов и изображений; текстовая вставка без
|
|
355
|
+
`preventDefault()` остаётся native. Typed `suggestions` связывает multiline
|
|
356
|
+
textbox с consumer-owned popup без private attributes. Нативная роль textarea
|
|
357
|
+
сохраняется: `role="combobox"` для этого HTML-элемента не используется. Pixel
|
|
358
|
+
bounds принадлежат auto-resize contract.
|
|
359
|
+
|
|
360
|
+
`STextarea` emits the original `ClipboardEvent` synchronously. A handler may
|
|
361
|
+
cancel the default insertion for files and images; text paste remains native
|
|
362
|
+
unless `preventDefault()` is called. The typed `suggestions` prop associates
|
|
363
|
+
the multiline textbox with a consumer-owned popup without private attributes.
|
|
364
|
+
The textarea keeps its native role; `role="combobox"` is not assigned to this
|
|
365
|
+
HTML element. Pixel bounds belong to the auto-resize contract.
|
|
366
|
+
|
|
367
|
+
```vue
|
|
368
|
+
<STextarea
|
|
369
|
+
ref="composer"
|
|
370
|
+
v-model="message"
|
|
371
|
+
label="Сообщение"
|
|
372
|
+
:auto-resize="true"
|
|
373
|
+
:min-height="64"
|
|
374
|
+
:max-height="240"
|
|
375
|
+
:suggestions="{
|
|
376
|
+
controls: 'mention-listbox',
|
|
377
|
+
activeDescendant: activeMentionId,
|
|
378
|
+
autocomplete: 'list',
|
|
379
|
+
popupRole: 'listbox',
|
|
380
|
+
}"
|
|
381
|
+
@paste="handlePaste"
|
|
382
|
+
/>
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
function handlePaste(event: ClipboardEvent): void {
|
|
387
|
+
if ((event.clipboardData?.files.length ?? 0) === 0) return
|
|
388
|
+
event.preventDefault()
|
|
389
|
+
attachFiles([...(event.clipboardData?.files ?? [])])
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
composer.value?.setSelection({ start, end, direction: 'forward' })
|
|
393
|
+
composer.value?.replaceSelection('@file README.md')
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
`getSelection()` возвращает `start`, `end` и `direction`. `setSelection()` и
|
|
397
|
+
`replaceSelection()` владеют focus/caret contract; replacement во время active
|
|
398
|
+
IME composition завершается диагностируемой ошибкой. События `selection-change`,
|
|
399
|
+
`compositionstart`, `compositionupdate`, `compositionend` и `state-change`
|
|
400
|
+
позволяют consumer синхронизировать popup без чтения внутреннего DOM.
|
|
401
|
+
|
|
402
|
+
`getSelection()` returns `start`, `end`, and `direction`. `setSelection()` and
|
|
403
|
+
`replaceSelection()` own the focus/caret contract; replacement during active
|
|
404
|
+
IME composition fails with an explicit diagnostic. The `selection-change`,
|
|
405
|
+
`compositionstart`, `compositionupdate`, `compositionend`, and `state-change`
|
|
406
|
+
events synchronize a popup without reading internal DOM.
|
|
407
|
+
|
|
408
|
+
### Drawer placement
|
|
409
|
+
|
|
410
|
+
`SDrawer` использует `placement="right"` по умолчанию. Нижняя панель задаётся
|
|
411
|
+
`placement="bottom"`; semantic sizes соответствуют 50, 70 и 90 процентам
|
|
412
|
+
dynamic viewport height. Body остаётся единственным scroll owner, а overlay,
|
|
413
|
+
focus trap/restore, Escape, backdrop и nested layer stack совпадают с боковой
|
|
414
|
+
панелью.
|
|
415
|
+
|
|
416
|
+
`SDrawer` uses `placement="right"` by default. Set `placement="bottom"` for a
|
|
417
|
+
bottom sheet; semantic sizes map to 50, 70, and 90 percent of the dynamic
|
|
418
|
+
viewport height. The body remains the sole scroll owner, while overlay,
|
|
419
|
+
focus trap/restore, Escape, backdrop, and the nested layer stack are shared
|
|
420
|
+
with the side drawer.
|
|
421
|
+
|
|
422
|
+
```vue
|
|
423
|
+
<SDrawer
|
|
424
|
+
v-model:open="activityOpen"
|
|
425
|
+
title="Активность агента"
|
|
426
|
+
placement="bottom"
|
|
427
|
+
size="lg"
|
|
428
|
+
:mobile-full-screen="false"
|
|
429
|
+
>
|
|
430
|
+
<AgentActivity />
|
|
431
|
+
</SDrawer>
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Virtual conversation history
|
|
435
|
+
|
|
436
|
+
`SVirtualList` сохраняет viewport по domain key и смещению внутри первой
|
|
437
|
+
видимой строки. Prepend, keyed replace и пакетные variable-height measurements
|
|
438
|
+
не требуют ручной коррекции `scrollTop`. Снимок переносится между экземплярами
|
|
439
|
+
или сохраняется на время переключения чата.
|
|
440
|
+
|
|
441
|
+
`SVirtualList` preserves its viewport by domain key and the offset inside the
|
|
442
|
+
first visible row. Prepend, keyed replacement, and batched variable-height
|
|
443
|
+
measurements do not require manual `scrollTop` correction. A snapshot can move
|
|
444
|
+
between instances or be retained while switching chats.
|
|
445
|
+
|
|
446
|
+
```vue
|
|
447
|
+
<SVirtualList
|
|
448
|
+
ref="history"
|
|
449
|
+
v-model:follow-tail="followTail"
|
|
450
|
+
:items="messages"
|
|
451
|
+
:item-size="48"
|
|
452
|
+
:height="480"
|
|
453
|
+
:item-key="(message) => message.id"
|
|
454
|
+
:item-label="(message) => message.accessibleLabel"
|
|
455
|
+
:near-start-threshold="96"
|
|
456
|
+
semantic-role="log"
|
|
457
|
+
live="polite"
|
|
458
|
+
accessible-label="История сообщений"
|
|
459
|
+
@near-start="loadPreviousPage"
|
|
460
|
+
@viewport-change="viewport = $event"
|
|
461
|
+
/>
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
```ts
|
|
465
|
+
const snapshot = history.value?.captureViewport()
|
|
466
|
+
await history.value?.restoreViewport(snapshot)
|
|
467
|
+
history.value?.scrollToKey(messageId, 'center')
|
|
468
|
+
history.value?.scrollToEnd({ follow: true })
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
`viewport-change` сообщает keyed anchor, visible range, `nearStart`, `atEnd` и
|
|
472
|
+
`pinnedToEnd`. Wheel, keyboard и scrollbar gesture вверх снимают follow-tail до
|
|
473
|
+
явного `scrollToEnd()` или пользовательского возврата в конец. Append, stream,
|
|
474
|
+
resize и shrink не включают follow-tail самостоятельно. `role=log`, live mode и
|
|
475
|
+
busy state задаются typed props `semanticRole`, `live` и `busy`.
|
|
476
|
+
|
|
477
|
+
`viewport-change` reports the keyed anchor, visible range, `nearStart`, `atEnd`,
|
|
478
|
+
and `pinnedToEnd`. An upward wheel, keyboard, or scrollbar gesture detaches
|
|
479
|
+
follow-tail until an explicit `scrollToEnd()` or a user return to the end.
|
|
480
|
+
Append, stream, resize, and shrink do not re-enable follow-tail. `role=log`, the
|
|
481
|
+
live mode, and busy state are declared through the typed `semanticRole`, `live`,
|
|
482
|
+
and `busy` props.
|
|
483
|
+
|
|
321
484
|
## Темы и стили / Themes and styles
|
|
322
485
|
|
|
323
486
|
Для приложения на Tailwind 4 подключайте integration entrypoint после собственного
|
|
@@ -403,6 +566,17 @@ CodeMirror autocomplete in `SCodeEditor` and the overlay `SResizeHandle` stay
|
|
|
403
566
|
inside their positioned component owners. They intentionally use the local
|
|
404
567
|
`--s-layer-floating` anchor and are not shifted by `--s-layer-host-offset`.
|
|
405
568
|
|
|
569
|
+
`SResizeHandle` owns separator geometry through `hitbox="compact | standard |
|
|
570
|
+
spacious"`. The semantic steps are 6, 12, and 32 px; the default is `compact`
|
|
571
|
+
for inline separators and `spacious` for overlay handles. Use `standard` for a
|
|
572
|
+
12 px workbench panel separator instead of styling the component internals.
|
|
573
|
+
|
|
574
|
+
`SResizeHandle` управляет геометрией separator через `hitbox="compact |
|
|
575
|
+
standard | spacious"`. Семантические ступени равны 6, 12 и 32 px; inline
|
|
576
|
+
separator по умолчанию использует `compact`, overlay handle — `spacious`.
|
|
577
|
+
Для 12-пиксельной границы workbench-панели используйте `standard`, не изменяя
|
|
578
|
+
внутренние стили компонента.
|
|
579
|
+
|
|
406
580
|
## Публичный контракт / Public contract
|
|
407
581
|
|
|
408
582
|
`package.json#exports` — источник истины для импортов. Публичный контракт
|
package/docs/public-api.md
CHANGED
|
@@ -33,6 +33,10 @@ responsive geometry, semantic palette и доступную таблицу да
|
|
|
33
33
|
async boundary; `presentation="plain"` является lightweight read-only контрактом
|
|
34
34
|
без загрузки editor runtime. `SChip.surface` владеет action/link семантикой, а
|
|
35
35
|
`SPanel.headerLayout` — inline/stacked/auto компоновкой заголовка и действий.
|
|
36
|
+
`SInteractiveSurface#actions` и использующие его `SActionCard`/
|
|
37
|
+
`SActionListItem` держат primary activation и дополнительные controls
|
|
38
|
+
sibling-элементами внутри одного chrome; `actionsLayout`/`trailingLayout`
|
|
39
|
+
управляют inline, stacked и container-responsive раскладкой.
|
|
36
40
|
|
|
37
41
|
## English
|
|
38
42
|
|
|
@@ -68,3 +72,7 @@ an accessible data table. `SMetricCard` accepts a compact chart through
|
|
|
68
72
|
`presentation="plain"` is the lightweight read-only contract that does not load
|
|
69
73
|
the editor runtime. `SChip.surface` owns action/link semantics, while
|
|
70
74
|
`SPanel.headerLayout` owns inline/stacked/auto title and action composition.
|
|
75
|
+
`SInteractiveSurface#actions` and its `SActionCard`/`SActionListItem`
|
|
76
|
+
compositions keep primary activation and additional controls as siblings inside
|
|
77
|
+
one chrome; `actionsLayout`/`trailingLayout` control inline, stacked, and
|
|
78
|
+
container-responsive layout.
|
package/docs/theming.md
CHANGED
|
@@ -17,6 +17,14 @@ applyThemeIdToDocument(document, 'classic-light')
|
|
|
17
17
|
theme-контейнере. Не стилизуйте внутренний DOM компонентов через `:deep`,
|
|
18
18
|
private selectors или зависимость от структуры template.
|
|
19
19
|
|
|
20
|
+
### Геометрия separator
|
|
21
|
+
|
|
22
|
+
`SResizeHandle` использует `--s-resize-handle-hitbox-compact` (6 px),
|
|
23
|
+
`--s-resize-handle-hitbox-standard` (12 px) и
|
|
24
|
+
`--s-resize-handle-hitbox-spacious` (32 px). Выбирайте ступень через публичный
|
|
25
|
+
prop `hitbox`; переопределяйте токены только как часть общей темы, а не для
|
|
26
|
+
отдельного экземпляра компонента. Толщина визуальной линии от hitbox не зависит.
|
|
27
|
+
|
|
20
28
|
### Интеграция шкалы слоёв host-приложения
|
|
21
29
|
|
|
22
30
|
Floating surfaces (`STooltip`, menu, select, combobox и popover), modal и
|
|
@@ -80,6 +88,15 @@ Override documented CSS custom properties on the root theme container only. Do
|
|
|
80
88
|
not style component internals through `:deep`, private selectors, or template
|
|
81
89
|
structure dependencies.
|
|
82
90
|
|
|
91
|
+
### Separator geometry
|
|
92
|
+
|
|
93
|
+
`SResizeHandle` uses `--s-resize-handle-hitbox-compact` (6 px),
|
|
94
|
+
`--s-resize-handle-hitbox-standard` (12 px), and
|
|
95
|
+
`--s-resize-handle-hitbox-spacious` (32 px). Select a step with the public
|
|
96
|
+
`hitbox` prop. Override the tokens only as part of the host theme, not for an
|
|
97
|
+
individual component instance. The visual divider thickness is independent of
|
|
98
|
+
the hitbox.
|
|
99
|
+
|
|
83
100
|
### Host application layer-scale integration
|
|
84
101
|
|
|
85
102
|
Floating surfaces (`STooltip`, menus, selects, comboboxes, and popovers), modals,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pgcorp/ui-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Typed Vue 3 design system with accessible components, semantic themes, and workbench patterns.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"types": "./index.ts",
|
|
@@ -267,6 +267,9 @@
|
|
|
267
267
|
"shared/controls/SSwitch.vue": [
|
|
268
268
|
"src/components/shared/controls/SSwitch.vue"
|
|
269
269
|
],
|
|
270
|
+
"shared/controls/STextarea": [
|
|
271
|
+
"src/components/shared/controls/STextarea.ts"
|
|
272
|
+
],
|
|
270
273
|
"shared/controls/STextarea.vue": [
|
|
271
274
|
"src/components/shared/controls/STextarea.vue"
|
|
272
275
|
],
|
|
@@ -510,6 +513,7 @@
|
|
|
510
513
|
"./shared/controls/SSegmentedControl.vue": "./src/components/shared/controls/SSegmentedControl.vue",
|
|
511
514
|
"./shared/controls/SSelect.vue": "./src/components/shared/controls/SSelect.vue",
|
|
512
515
|
"./shared/controls/SSwitch.vue": "./src/components/shared/controls/SSwitch.vue",
|
|
516
|
+
"./shared/controls/STextarea": "./src/components/shared/controls/STextarea.ts",
|
|
513
517
|
"./shared/controls/STextarea.vue": "./src/components/shared/controls/STextarea.vue",
|
|
514
518
|
"./shared/data-display/chart": "./src/components/shared/data-display/chart.ts",
|
|
515
519
|
"./shared/data-display/json": "./src/components/shared/data-display/json.ts",
|
|
@@ -585,7 +589,7 @@
|
|
|
585
589
|
"@uiw/codemirror-extensions-langs": "^4.25.8",
|
|
586
590
|
"codemirror": "6.0.2",
|
|
587
591
|
"codemirror-lang-makefile": "^0.1.1",
|
|
588
|
-
"dompurify": "^3.4.
|
|
592
|
+
"dompurify": "^3.4.16",
|
|
589
593
|
"graphology": "^0.26.0",
|
|
590
594
|
"highlight.js": "^11.11.1",
|
|
591
595
|
"@lucide/vue": "^1.16.0",
|
|
@@ -601,12 +605,11 @@
|
|
|
601
605
|
"@tailwindcss/postcss": "^4.2.0",
|
|
602
606
|
"@types/node": "^25.3.0",
|
|
603
607
|
"@vitejs/plugin-vue": "^6.0.4",
|
|
604
|
-
"@vitest/coverage-v8": "^4.
|
|
608
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
605
609
|
"@vitest/eslint-plugin": "1.6.20",
|
|
606
610
|
"@vue/compiler-dom": "3.5.39",
|
|
607
611
|
"@vue/compiler-sfc": "3.5.39",
|
|
608
612
|
"@vue/eslint-config-prettier": "^10.2.0",
|
|
609
|
-
"@vue/eslint-config-typescript": "^14.7.0",
|
|
610
613
|
"@vue/test-utils": "^2.4.6",
|
|
611
614
|
"date-fns": "^4.1.0",
|
|
612
615
|
"eslint": "^10.0.0",
|
|
@@ -617,10 +620,12 @@
|
|
|
617
620
|
"postcss-selector-parser": "7.1.4",
|
|
618
621
|
"storybook": "10.5.3",
|
|
619
622
|
"typescript": "^5.9.3",
|
|
623
|
+
"typescript-eslint": "^8.60.0",
|
|
620
624
|
"vite": "^7.3.1",
|
|
621
625
|
"vite-plugin-dts": "^4.5.4",
|
|
622
|
-
"vitest": "^4.
|
|
626
|
+
"vitest": "^4.1.11",
|
|
623
627
|
"vue": "^3.5.28",
|
|
628
|
+
"vue-eslint-parser": "^10.4.0",
|
|
624
629
|
"vue-router": "^5.0.3",
|
|
625
630
|
"vue-tsc": "^3.2.4"
|
|
626
631
|
},
|
|
@@ -14,6 +14,9 @@
|
|
|
14
14
|
:data-testid="dataTestId"
|
|
15
15
|
:data-resizing="effectiveResizing || undefined"
|
|
16
16
|
:data-hidden-on-narrow="hiddenOnNarrow || undefined"
|
|
17
|
+
:data-hitbox="validatedHitbox"
|
|
18
|
+
:data-orientation="orientation"
|
|
19
|
+
:data-variant="variant"
|
|
17
20
|
:class="handleClasses"
|
|
18
21
|
@pointerdown="handlePointerDown"
|
|
19
22
|
@keydown="handleKeydown"
|
|
@@ -30,10 +33,14 @@ import {
|
|
|
30
33
|
acquirePointerInteractionLease,
|
|
31
34
|
type PointerInteractionLease,
|
|
32
35
|
} from '../../internal/pointerInteractionLease'
|
|
33
|
-
import { validateNonEmptyString } from '../../internal/runtimeContract'
|
|
36
|
+
import { validateExactString, validateNonEmptyString } from '../../internal/runtimeContract'
|
|
34
37
|
|
|
35
38
|
defineOptions({ inheritAttrs: false })
|
|
36
39
|
|
|
40
|
+
export type SResizeHandleHitbox = 'compact' | 'standard' | 'spacious'
|
|
41
|
+
|
|
42
|
+
const RESIZE_HANDLE_HITBOXES = ['compact', 'standard', 'spacious'] as const satisfies readonly SResizeHandleHitbox[]
|
|
43
|
+
|
|
37
44
|
const props = withDefaults(defineProps<{
|
|
38
45
|
orientation: 'vertical' | 'horizontal'
|
|
39
46
|
variant?: 'inline' | 'overlay'
|
|
@@ -48,6 +55,8 @@ const props = withDefaults(defineProps<{
|
|
|
48
55
|
max?: number
|
|
49
56
|
step?: number
|
|
50
57
|
pointerScale?: number
|
|
58
|
+
/** Семантическая толщина pointer/focus hitbox; visual divider остаётся однопиксельным. */
|
|
59
|
+
hitbox?: SResizeHandleHitbox
|
|
51
60
|
}>(), {
|
|
52
61
|
orientation: 'vertical',
|
|
53
62
|
variant: 'inline',
|
|
@@ -61,6 +70,7 @@ const props = withDefaults(defineProps<{
|
|
|
61
70
|
max: 100,
|
|
62
71
|
step: 10,
|
|
63
72
|
pointerScale: 1,
|
|
73
|
+
hitbox: undefined,
|
|
64
74
|
})
|
|
65
75
|
|
|
66
76
|
const emit = defineEmits<{
|
|
@@ -83,6 +93,12 @@ const ownedAttrs = useOwnedAttrs({ component: 'SResizeHandle', owner: 'native se
|
|
|
83
93
|
useInteractiveLeafRegistration({ owner: 'SResizeHandle' })
|
|
84
94
|
const effectiveResizing = computed(() => props.resizing || pointerResizeState.value !== null)
|
|
85
95
|
const validatedLabel = computed(() => validateNonEmptyString('SResizeHandle', 'label', props.label))
|
|
96
|
+
const validatedHitbox = computed(() => validateExactString(
|
|
97
|
+
'SResizeHandle',
|
|
98
|
+
'hitbox',
|
|
99
|
+
props.hitbox ?? (props.variant === 'overlay' ? 'spacious' : 'compact'),
|
|
100
|
+
RESIZE_HANDLE_HITBOXES,
|
|
101
|
+
))
|
|
86
102
|
const validatedRange = computed(() => {
|
|
87
103
|
assertRangeContract()
|
|
88
104
|
return { value: props.value, min: props.min, max: props.max }
|
|
@@ -225,11 +241,11 @@ const handleClasses = computed(() => [
|
|
|
225
241
|
: 'pointer-events-auto absolute z-[var(--s-layer-floating)] items-center justify-center',
|
|
226
242
|
props.orientation === 'vertical'
|
|
227
243
|
? props.variant === 'inline'
|
|
228
|
-
? '
|
|
229
|
-
: 'inset-y-3 left-2
|
|
244
|
+
? 'self-stretch cursor-col-resize items-stretch justify-center'
|
|
245
|
+
: 'inset-y-3 left-2 -translate-x-1/2 cursor-col-resize'
|
|
230
246
|
: props.variant === 'inline'
|
|
231
|
-
? '
|
|
232
|
-
: 'inset-x-5 top-0
|
|
247
|
+
? 'w-full cursor-row-resize items-center justify-stretch'
|
|
248
|
+
: 'inset-x-5 top-0 -translate-y-1/2 cursor-row-resize',
|
|
233
249
|
])
|
|
234
250
|
|
|
235
251
|
const lineClasses = computed(() =>
|
|
@@ -244,6 +260,30 @@ const lineClasses = computed(() =>
|
|
|
244
260
|
</script>
|
|
245
261
|
|
|
246
262
|
<style scoped>
|
|
263
|
+
.s-resize-handle[data-orientation='vertical'][data-hitbox='compact'] {
|
|
264
|
+
width: var(--s-resize-handle-hitbox-compact);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.s-resize-handle[data-orientation='vertical'][data-hitbox='standard'] {
|
|
268
|
+
width: var(--s-resize-handle-hitbox-standard);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.s-resize-handle[data-orientation='vertical'][data-hitbox='spacious'] {
|
|
272
|
+
width: var(--s-resize-handle-hitbox-spacious);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.s-resize-handle[data-orientation='horizontal'][data-hitbox='compact'] {
|
|
276
|
+
height: var(--s-resize-handle-hitbox-compact);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.s-resize-handle[data-orientation='horizontal'][data-hitbox='standard'] {
|
|
280
|
+
height: var(--s-resize-handle-hitbox-standard);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.s-resize-handle[data-orientation='horizontal'][data-hitbox='spacious'] {
|
|
284
|
+
height: var(--s-resize-handle-hitbox-spacious);
|
|
285
|
+
}
|
|
286
|
+
|
|
247
287
|
.s-resize-handle:focus-visible {
|
|
248
288
|
outline: var(--s-focus-ring-width) solid var(--s-focus-ring-color);
|
|
249
289
|
outline-offset: var(--s-focus-ring-offset);
|
|
@@ -104,6 +104,24 @@
|
|
|
104
104
|
max-width: 44rem;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
.s-dialog-surface[data-presentation='drawer'][data-placement='bottom'] {
|
|
108
|
+
@apply max-w-none rounded-t-2xl border-l-0 border-t border-surface-200 dark:border-surface-700;
|
|
109
|
+
width: 100%;
|
|
110
|
+
max-height: calc(100dvh - 1rem);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
.s-dialog-surface[data-presentation='drawer'][data-placement='bottom'][data-size='sm'] {
|
|
114
|
+
height: 50dvh;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.s-dialog-surface[data-presentation='drawer'][data-placement='bottom'][data-size='md'] {
|
|
118
|
+
height: 70dvh;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.s-dialog-surface[data-presentation='drawer'][data-placement='bottom'][data-size='lg'] {
|
|
122
|
+
height: 90dvh;
|
|
123
|
+
}
|
|
124
|
+
|
|
107
125
|
.s-dialog-surface[data-presentation='drawer'] .s-dialog-surface__header {
|
|
108
126
|
@apply flex items-start gap-3 border-b border-surface-200 px-5 py-4 dark:border-surface-700 md:px-6;
|
|
109
127
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
:data-presentation="presentation.kind"
|
|
8
8
|
:data-layout="surfaceLayout"
|
|
9
9
|
:data-size="presentation.size"
|
|
10
|
+
:data-placement="drawerPresentation?.placement"
|
|
10
11
|
:data-surface="modalPresentation?.surface"
|
|
11
12
|
:data-scroll="modalPresentation?.scrollBehavior"
|
|
12
13
|
tabindex="-1"
|
|
@@ -95,17 +96,21 @@ const isModal = computed(() => props.presentation.kind === 'modal')
|
|
|
95
96
|
const modalPresentation = computed<ModalDialogSurfacePresentation | null>(() => (
|
|
96
97
|
props.presentation.kind === 'modal' ? props.presentation : null
|
|
97
98
|
))
|
|
99
|
+
const drawerPresentation = computed(() => (
|
|
100
|
+
props.presentation.kind === 'drawer' ? props.presentation : null
|
|
101
|
+
))
|
|
98
102
|
const hasHeader = computed(() => Boolean(props.title || slots.header))
|
|
99
103
|
const hasSplitFooter = computed(() => isModal.value && Boolean(slots['footer-start'] || slots['footer-end']))
|
|
100
104
|
const hasFooter = computed(() => hasSplitFooter.value || Boolean(slots.footer))
|
|
101
105
|
const rootTag = 'div'
|
|
102
106
|
const headerTag = computed(() => (isModal.value ? 'div' : 'header'))
|
|
103
107
|
const headingTag = computed(() => (isModal.value ? 'h3' : 'h2'))
|
|
104
|
-
type DialogSurfaceLayout = 'contained' | 'fullscreen' | 'side-panel' | 'mobile-fullscreen'
|
|
108
|
+
type DialogSurfaceLayout = 'contained' | 'fullscreen' | 'side-panel' | 'mobile-fullscreen' | 'bottom-sheet'
|
|
105
109
|
|
|
106
110
|
const surfaceLayout = computed<DialogSurfaceLayout>(() => {
|
|
107
111
|
const presentation = props.presentation
|
|
108
112
|
if (presentation.kind === 'drawer') {
|
|
113
|
+
if (presentation.placement === 'bottom') return 'bottom-sheet'
|
|
109
114
|
return presentation.mobileFullScreen ? 'mobile-fullscreen' : 'side-panel'
|
|
110
115
|
}
|
|
111
116
|
return presentation.fullscreen || presentation.size === 'full' ? 'fullscreen' : 'contained'
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type DialogSurfaceModalSize = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'wide' | 'full'
|
|
2
2
|
export type DialogSurfaceDrawerSize = 'sm' | 'md' | 'lg'
|
|
3
|
+
export type DialogSurfaceDrawerPlacement = 'right' | 'bottom'
|
|
3
4
|
export type DialogSurfaceTone = 'default' | 'subtle'
|
|
4
5
|
export type DialogSurfaceScrollBehavior = 'page' | 'body'
|
|
5
6
|
|
|
@@ -14,6 +15,7 @@ export interface ModalDialogSurfacePresentation {
|
|
|
14
15
|
export interface DrawerDialogSurfacePresentation {
|
|
15
16
|
kind: 'drawer'
|
|
16
17
|
size: DialogSurfaceDrawerSize
|
|
18
|
+
placement: DialogSurfaceDrawerPlacement
|
|
17
19
|
mobileFullScreen: boolean
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -38,9 +40,10 @@ export class DialogSurfaceContractError extends Error {
|
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
const modalKeys = new Set(['kind', 'size', 'fullscreen', 'surface', 'scrollBehavior'])
|
|
41
|
-
const drawerKeys = new Set(['kind', 'size', 'mobileFullScreen'])
|
|
43
|
+
const drawerKeys = new Set(['kind', 'size', 'placement', 'mobileFullScreen'])
|
|
42
44
|
const modalSizes = new Set<DialogSurfaceModalSize>(['sm', 'md', 'lg', 'xl', '2xl', '3xl', 'wide', 'full'])
|
|
43
45
|
const drawerSizes = new Set<DialogSurfaceDrawerSize>(['sm', 'md', 'lg'])
|
|
46
|
+
const drawerPlacements = new Set<DialogSurfaceDrawerPlacement>(['right', 'bottom'])
|
|
44
47
|
const surfaceTones = new Set<DialogSurfaceTone>(['default', 'subtle'])
|
|
45
48
|
const scrollBehaviors = new Set<DialogSurfaceScrollBehavior>(['page', 'body'])
|
|
46
49
|
|
|
@@ -92,6 +95,9 @@ export function assertDialogSurfacePresentation(
|
|
|
92
95
|
if (!drawerSizes.has(candidate.size as DialogSurfaceDrawerSize)) {
|
|
93
96
|
throw new DialogSurfaceContractError('invalid-size', `Недопустимый размер SDrawer: ${String(candidate.size)}`)
|
|
94
97
|
}
|
|
98
|
+
if (!drawerPlacements.has(candidate.placement as DialogSurfaceDrawerPlacement)) {
|
|
99
|
+
throw new DialogSurfaceContractError('invalid-presentation', `Недопустимое размещение SDrawer: ${String(candidate.placement)}`)
|
|
100
|
+
}
|
|
95
101
|
if (typeof candidate.mobileFullScreen !== 'boolean') {
|
|
96
102
|
throw new DialogSurfaceContractError('invalid-presentation', 'SDrawer presentation требует boolean mobileFullScreen')
|
|
97
103
|
}
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
@apply absolute inset-0 flex items-stretch justify-end;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
.s-drawer-positioner[data-placement='bottom'] {
|
|
19
|
+
@apply items-end justify-stretch;
|
|
20
|
+
}
|
|
21
|
+
|
|
18
22
|
.s-drawer-backdrop-enter-active,
|
|
19
23
|
.s-drawer-backdrop-leave-active {
|
|
20
24
|
transition: opacity var(--s-motion-standard) ease;
|
|
@@ -38,9 +42,14 @@
|
|
|
38
42
|
transform: translateX(1.5rem);
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
.s-drawer-shell[data-placement='bottom'].s-drawer-panel-enter-from,
|
|
46
|
+
.s-drawer-shell[data-placement='bottom'].s-drawer-panel-leave-to {
|
|
47
|
+
transform: translateY(1.5rem);
|
|
48
|
+
}
|
|
49
|
+
|
|
41
50
|
@media (max-width: 767px) {
|
|
42
|
-
.s-drawer-panel-enter-from,
|
|
43
|
-
.s-drawer-panel-leave-to {
|
|
51
|
+
.s-drawer-shell[data-placement='right'].s-drawer-panel-enter-from,
|
|
52
|
+
.s-drawer-shell[data-placement='right'].s-drawer-panel-leave-to {
|
|
44
53
|
transform: translateY(1.5rem);
|
|
45
54
|
}
|
|
46
55
|
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
</transition>
|
|
6
6
|
|
|
7
7
|
<transition name="s-drawer-panel">
|
|
8
|
-
<div v-if="open" class="s-drawer-shell" :style="surfaceStyle">
|
|
9
|
-
<div class="s-drawer-positioner">
|
|
8
|
+
<div v-if="open" class="s-drawer-shell" :data-placement="validatedPlacement" :style="surfaceStyle">
|
|
9
|
+
<div class="s-drawer-positioner" :data-placement="validatedPlacement">
|
|
10
10
|
<SDialogSurface
|
|
11
11
|
v-bind="ownedAttrs.bindings()"
|
|
12
12
|
:presentation="dialogPresentation"
|
|
@@ -31,11 +31,13 @@
|
|
|
31
31
|
</template>
|
|
32
32
|
|
|
33
33
|
<script setup lang="ts">
|
|
34
|
-
import { computed
|
|
34
|
+
import { computed } from 'vue'
|
|
35
35
|
import { useOwnedAttrs } from '../../../internal/ownedAttrs'
|
|
36
36
|
import { useDialogOverlay } from '../../../composables/useDialogOverlay'
|
|
37
|
+
import { validateBoolean, validateExactString } from '../../../internal/runtimeContract'
|
|
37
38
|
import SDialogSurface from '../_internal/SDialogSurface.vue'
|
|
38
39
|
import type {
|
|
40
|
+
DialogSurfaceDrawerPlacement,
|
|
39
41
|
DialogSurfaceDrawerSize,
|
|
40
42
|
DrawerDialogSurfacePresentation,
|
|
41
43
|
} from '../_internal/dialogSurface'
|
|
@@ -43,12 +45,14 @@ import type {
|
|
|
43
45
|
defineOptions({ inheritAttrs: false })
|
|
44
46
|
|
|
45
47
|
export type DrawerSize = DialogSurfaceDrawerSize
|
|
48
|
+
export type DrawerPlacement = DialogSurfaceDrawerPlacement
|
|
46
49
|
|
|
47
50
|
export interface Props {
|
|
48
51
|
open: boolean
|
|
49
52
|
title?: string
|
|
50
53
|
closable?: boolean
|
|
51
54
|
size?: DrawerSize
|
|
55
|
+
placement?: DrawerPlacement
|
|
52
56
|
mobileFullScreen?: boolean
|
|
53
57
|
ariaLabel?: string
|
|
54
58
|
}
|
|
@@ -57,6 +61,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
57
61
|
title: undefined,
|
|
58
62
|
closable: true,
|
|
59
63
|
size: 'md',
|
|
64
|
+
placement: 'right',
|
|
60
65
|
mobileFullScreen: true,
|
|
61
66
|
ariaLabel: 'Боковая панель',
|
|
62
67
|
})
|
|
@@ -66,19 +71,50 @@ const emit = defineEmits<{
|
|
|
66
71
|
}>()
|
|
67
72
|
|
|
68
73
|
const ownedAttrs = useOwnedAttrs({ component: 'SDrawer', owner: 'drawer dialog surface' })
|
|
74
|
+
const validatedPlacement = computed<DrawerPlacement>(() => validateExactString(
|
|
75
|
+
'SDrawer',
|
|
76
|
+
'placement',
|
|
77
|
+
props.placement,
|
|
78
|
+
['right', 'bottom'] as const,
|
|
79
|
+
))
|
|
80
|
+
const validatedSize = computed<DrawerSize>(() => validateExactString(
|
|
81
|
+
'SDrawer',
|
|
82
|
+
'size',
|
|
83
|
+
props.size,
|
|
84
|
+
['sm', 'md', 'lg'] as const,
|
|
85
|
+
))
|
|
86
|
+
const validatedOpen = computed(() => validateBoolean('SDrawer', 'open', props.open))
|
|
87
|
+
const validatedClosable = computed(() => validateBoolean('SDrawer', 'closable', props.closable))
|
|
88
|
+
const validatedMobileFullScreen = computed(() => validateBoolean(
|
|
89
|
+
'SDrawer',
|
|
90
|
+
'mobileFullScreen',
|
|
91
|
+
props.mobileFullScreen,
|
|
92
|
+
))
|
|
69
93
|
const dialogPresentation = computed<DrawerDialogSurfacePresentation>(() => ({
|
|
70
94
|
kind: 'drawer',
|
|
71
|
-
size:
|
|
72
|
-
|
|
95
|
+
size: validatedSize.value,
|
|
96
|
+
placement: validatedPlacement.value,
|
|
97
|
+
mobileFullScreen: validatedMobileFullScreen.value,
|
|
73
98
|
}))
|
|
74
99
|
|
|
100
|
+
function assertInitialDrawerContract(): void {
|
|
101
|
+
void validatedPlacement.value
|
|
102
|
+
void validatedSize.value
|
|
103
|
+
void validatedOpen.value
|
|
104
|
+
void validatedClosable.value
|
|
105
|
+
void validatedMobileFullScreen.value
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Validate before the overlay owner acquires global layer and focus resources.
|
|
109
|
+
assertInitialDrawerContract()
|
|
110
|
+
|
|
75
111
|
function closeDrawer() {
|
|
76
112
|
emit('update:open', false)
|
|
77
113
|
}
|
|
78
114
|
|
|
79
115
|
const { setPanelElement, backdropStyle, surfaceStyle } = useDialogOverlay({
|
|
80
|
-
open:
|
|
81
|
-
closable:
|
|
116
|
+
open: validatedOpen,
|
|
117
|
+
closable: validatedClosable,
|
|
82
118
|
requestClose: closeDrawer,
|
|
83
119
|
})
|
|
84
120
|
</script>
|