@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 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` — источник истины для импортов. Публичный контракт
@@ -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.8.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.13",
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.0.18",
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.0.18",
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
- ? 'w-1.5 self-stretch cursor-col-resize items-stretch justify-center'
229
- : 'inset-y-3 left-2 w-8 -translate-x-1/2 cursor-col-resize'
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
- ? 'h-1.5 w-full cursor-row-resize items-center justify-stretch'
232
- : 'inset-x-5 top-0 h-8 -translate-y-1/2 cursor-row-resize',
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, toRef } from 'vue'
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: props.size,
72
- mobileFullScreen: props.mobileFullScreen,
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: toRef(props, 'open'),
81
- closable: toRef(props, 'closable'),
116
+ open: validatedOpen,
117
+ closable: validatedClosable,
82
118
  requestClose: closeDrawer,
83
119
  })
84
120
  </script>