@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 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.8.1",
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>
@@ -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'