@pgcorp/ui-kit 0.8.1 → 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 +144 -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/STextarea.ts +13 -0
- package/src/components/shared/controls/STextarea.vue +391 -37
- 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/styles/tokens.css +3 -0
package/README.md
CHANGED
|
@@ -348,6 +348,139 @@ button and owns ARIA/focus, while `pointerTarget` is applied to a passive
|
|
|
348
348
|
</STooltip>
|
|
349
349
|
```
|
|
350
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
|
+
|
|
351
484
|
## Темы и стили / Themes and styles
|
|
352
485
|
|
|
353
486
|
Для приложения на Tailwind 4 подключайте integration entrypoint после собственного
|
|
@@ -433,6 +566,17 @@ CodeMirror autocomplete in `SCodeEditor` and the overlay `SResizeHandle` stay
|
|
|
433
566
|
inside their positioned component owners. They intentionally use the local
|
|
434
567
|
`--s-layer-floating` anchor and are not shifted by `--s-layer-host-offset`.
|
|
435
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
|
+
|
|
436
580
|
## Публичный контракт / Public contract
|
|
437
581
|
|
|
438
582
|
`package.json#exports` — источник истины для импортов. Публичный контракт
|
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>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
Props as STextareaProps,
|
|
3
|
+
STextareaAppearance,
|
|
4
|
+
STextareaAutocomplete,
|
|
5
|
+
STextareaSuggestionPopupRole,
|
|
6
|
+
STextareaSuggestions,
|
|
7
|
+
STextareaReplaceOptions,
|
|
8
|
+
STextareaResizeContract,
|
|
9
|
+
STextareaSelection,
|
|
10
|
+
STextareaSelectionDirection,
|
|
11
|
+
STextareaSize,
|
|
12
|
+
STextareaState,
|
|
13
|
+
} from './STextarea.vue'
|