@pgcorp/ui-kit 0.4.0 → 0.6.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.
Files changed (31) hide show
  1. package/README.md +21 -0
  2. package/package.json +1 -1
  3. package/src/components/shared/_internal/SHeaderSurface.css +98 -0
  4. package/src/components/shared/_internal/SHeaderSurface.vue +80 -0
  5. package/src/components/shared/_internal/useRadioGroup.ts +106 -0
  6. package/src/components/shared/containers/SPageHeader.vue +13 -20
  7. package/src/components/shared/controls/SCheckbox.vue +14 -43
  8. package/src/components/shared/controls/SChoiceCards.vue +16 -39
  9. package/src/components/shared/controls/SField.css +0 -12
  10. package/src/components/shared/controls/SField.vue +14 -10
  11. package/src/components/shared/controls/SFieldGroup.css +1 -19
  12. package/src/components/shared/controls/SFieldGroup.vue +13 -10
  13. package/src/components/shared/controls/SFieldLabel.css +0 -1
  14. package/src/components/shared/controls/SFieldLabel.vue +2 -1
  15. package/src/components/shared/controls/SSegmentedControl.vue +15 -43
  16. package/src/components/shared/controls/SSwitch.vue +14 -32
  17. package/src/components/shared/data-display/SBadge.vue +41 -18
  18. package/src/components/shared/data-display/SChip.vue +22 -10
  19. package/src/components/shared/data-display/SSectionHeader.vue +17 -21
  20. package/src/components/shared/data-display/STable.vue +131 -18
  21. package/src/components/shared/data-display/table.ts +1 -0
  22. package/src/components/shared/database/SDataGrid.vue +13 -0
  23. package/src/internal/fieldPresentation.ts +28 -0
  24. package/src/internal/pillSurface.ts +92 -0
  25. package/src/internal/useBinaryInput.ts +76 -0
  26. package/src/styles/style.css +114 -0
  27. package/src/styles/tokens.css +31 -0
  28. package/src/components/shared/containers/SPageHeader.css +0 -82
  29. package/src/components/shared/data-display/SBadge.css +0 -73
  30. package/src/components/shared/data-display/SChip.css +0 -59
  31. package/src/components/shared/data-display/SSectionHeader.css +0 -84
@@ -58,6 +58,8 @@
58
58
  width="full"
59
59
  :content-align="column.align === 'right' ? 'end' : column.align === 'center' ? 'center' : 'start'"
60
60
  :trailing-icon="sortIcon(column)"
61
+ :badge="sortPriority(column) ?? undefined"
62
+ badge-severity="primary"
61
63
  @click="requestSort(column)"
62
64
  >
63
65
  <span
@@ -193,6 +195,7 @@ import type {
193
195
  STableRowState,
194
196
  STableSelectionMode,
195
197
  STableSort,
198
+ STableSortMode,
196
199
  STableSurface,
197
200
  } from './table'
198
201
 
@@ -209,6 +212,7 @@ export type {
209
212
  STableSelectionMode,
210
213
  STableSort,
211
214
  STableSortDirection,
215
+ STableSortMode,
212
216
  STableSurface,
213
217
  } from './table'
214
218
 
@@ -232,7 +236,9 @@ export interface STableProps<
232
236
  expandedRowKeys?: readonly RowKey[]
233
237
  isRowExpandable?: (row: Row, index: number) => boolean
234
238
  getRowLabel?: (row: Row, index: number) => string
239
+ sortMode?: STableSortMode
235
240
  sort?: STableSort<Field> | null
241
+ sorts?: readonly STableSort<Field>[]
236
242
  maxHeight?: ScrollableViewportMaxBlockSize
237
243
  density?: STableDensity
238
244
  surface?: STableSurface
@@ -282,7 +288,9 @@ const props = withDefaults(defineProps<STableProps<Row, RowKey, Field>>(), {
282
288
  expandedRowKeys: () => [],
283
289
  isRowExpandable: undefined,
284
290
  getRowLabel: undefined,
291
+ sortMode: 'single',
285
292
  sort: null,
293
+ sorts: () => [],
286
294
  maxHeight: 'none',
287
295
  density: 'default',
288
296
  surface: 'bordered',
@@ -301,6 +309,8 @@ const emit = defineEmits<{
301
309
  'row-expand': [row: Row, index: number, expanded: boolean]
302
310
  'update:sort': [value: STableSort<Field> | null]
303
311
  sort: [value: STableSort<Field> | null]
312
+ 'update:sorts': [value: readonly STableSort<Field>[]]
313
+ 'multi-sort': [value: readonly STableSort<Field>[]]
304
314
  }>()
305
315
  const slots = useSlots()
306
316
 
@@ -313,6 +323,8 @@ const TABLE_CELL_STATES = ['default', 'pending'] as const
313
323
  const TABLE_COLUMN_CONTENT = ['field', 'value', 'slot'] as const
314
324
  const TABLE_HEADER_PRESENTATIONS = ['visible', 'assistive'] as const
315
325
  const TABLE_SELECTION_MODES = ['none', 'multiple'] as const
326
+ const TABLE_SORT_MODES = ['single', 'multiple'] as const
327
+ const TABLE_SORT_DIRECTIONS = ['ascending', 'descending'] as const
316
328
 
317
329
  const ownedAttrs = useOwnedAttrs({ component: 'STable', owner: 'table scroll surface' })
318
330
  useInteractiveLeafRegistration({ owner: 'STable' })
@@ -389,6 +401,12 @@ const validatedSelectionMode = computed(() => validateExactString(
389
401
  props.selectionMode,
390
402
  TABLE_SELECTION_MODES,
391
403
  ))
404
+ const validatedSortMode = computed(() => validateExactString(
405
+ 'STable',
406
+ 'sortMode',
407
+ props.sortMode,
408
+ TABLE_SORT_MODES,
409
+ ))
392
410
 
393
411
  const validatedColumns = computed<readonly STableColumn<Row, Field>[]>(() => {
394
412
  const fields = new Set<string>()
@@ -454,9 +472,15 @@ const validatedColumns = computed<readonly STableColumn<Row, Field>[]>(() => {
454
472
  return props.columns
455
473
  })
456
474
 
457
- const validatedSort = computed<STableSort<Field> | null>(() => {
458
- if (props.sort === null || props.sort === undefined) return null
459
- const key = validateNonEmptyString('STable', 'sort.key', props.sort.key) as Field
475
+ function validateSortEntry(value: unknown, coordinate: string): STableSort<Field> {
476
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
477
+ throw new TypeError(
478
+ `STable: ${coordinate} должен быть STableSort object. `
479
+ + `/ STable: ${coordinate} must be an STableSort object.`,
480
+ )
481
+ }
482
+ const entry = value as Record<string, unknown>
483
+ const key = validateNonEmptyString('STable', `${coordinate}.key`, entry.key) as Field
460
484
  const column = validatedColumns.value.find((candidate) => candidate.field === key)
461
485
  if (!column?.sortable) {
462
486
  throw new TypeError(`STable: sort key ${JSON.stringify(key)} должен ссылаться на sortable column. / STable: sort key ${JSON.stringify(key)} must reference a sortable column.`)
@@ -465,25 +489,100 @@ const validatedSort = computed<STableSort<Field> | null>(() => {
465
489
  key,
466
490
  direction: validateExactString(
467
491
  'STable',
468
- 'sort.direction',
469
- props.sort.direction,
470
- ['ascending', 'descending'] as const,
492
+ `${coordinate}.direction`,
493
+ entry.direction,
494
+ TABLE_SORT_DIRECTIONS,
471
495
  ),
472
496
  }
497
+ }
498
+
499
+ const validatedSingleSort = computed<STableSort<Field> | null>(() => {
500
+ if (validatedSortMode.value === 'multiple') {
501
+ if (props.sort !== null && props.sort !== undefined) {
502
+ throw new TypeError(
503
+ 'STable: sortMode="multiple" несовместим с sort; используйте v-model:sorts. '
504
+ + '/ STable: sortMode="multiple" is incompatible with sort; use v-model:sorts.',
505
+ )
506
+ }
507
+ return null
508
+ }
509
+ if (props.sort === null || props.sort === undefined) return null
510
+ return validateSortEntry(props.sort, 'sort')
511
+ })
512
+
513
+ const validatedMultipleSort = computed<readonly STableSort<Field>[]>(() => {
514
+ const sorts: unknown = props.sorts
515
+ if (!Array.isArray(sorts)) {
516
+ throw new TypeError(
517
+ 'STable: sorts должен быть ordered STableSort array. '
518
+ + '/ STable: sorts must be an ordered STableSort array.',
519
+ )
520
+ }
521
+ if (validatedSortMode.value === 'single') {
522
+ if (sorts.length > 0) {
523
+ throw new TypeError(
524
+ 'STable: sortMode="single" несовместим с непустым sorts; используйте v-model:sort. '
525
+ + '/ STable: sortMode="single" is incompatible with non-empty sorts; use v-model:sort.',
526
+ )
527
+ }
528
+ return []
529
+ }
530
+ const keys = new Set<Field>()
531
+ return sorts.map((entry, index) => {
532
+ const validated = validateSortEntry(entry, `sorts[${index}]`)
533
+ if (keys.has(validated.key)) {
534
+ throw new TypeError(
535
+ `STable: sorts содержит duplicate key ${JSON.stringify(validated.key)}. `
536
+ + `/ STable: sorts contains duplicate key ${JSON.stringify(validated.key)}.`,
537
+ )
538
+ }
539
+ keys.add(validated.key)
540
+ return validated
541
+ })
473
542
  })
474
543
 
544
+ const activeSorts = computed<readonly STableSort<Field>[]>(() => {
545
+ const singleSort = validatedSingleSort.value
546
+ const multipleSort = validatedMultipleSort.value
547
+ if (validatedSortMode.value === 'multiple') return multipleSort
548
+ return singleSort === null ? [] : [singleSort]
549
+ })
550
+
551
+ function sortIndex(column: STableColumn<Row, Field>): number {
552
+ return activeSorts.value.findIndex((candidate) => candidate.key === column.field)
553
+ }
554
+
555
+ function sortPriority(column: STableColumn<Row, Field>): number | null {
556
+ if (validatedSortMode.value !== 'multiple') return null
557
+ const index = sortIndex(column)
558
+ return index === -1 ? null : index + 1
559
+ }
560
+
475
561
  const columnAriaSort = (column: STableColumn<Row, Field>): 'none' | 'ascending' | 'descending' | undefined => {
476
562
  if (!column.sortable) return undefined
477
- return validatedSort.value?.key === column.field ? validatedSort.value.direction : 'none'
563
+ if (validatedSortMode.value === 'multiple') {
564
+ const primary = activeSorts.value[0]
565
+ return primary?.key === column.field ? primary.direction : undefined
566
+ }
567
+ const current = activeSorts.value[0]
568
+ return current?.key === column.field ? current.direction : 'none'
478
569
  }
479
570
 
480
571
  const sortIcon = (column: STableColumn<Row, Field>) => {
481
- const direction = columnAriaSort(column)
572
+ const direction = activeSorts.value.find((candidate) => candidate.key === column.field)?.direction
482
573
  return direction === 'ascending' ? ChevronUp : direction === 'descending' ? ChevronDown : ChevronsUpDown
483
574
  }
484
575
 
485
576
  const sortActionLabel = (column: STableColumn<Row, Field>): string => {
486
- const direction = columnAriaSort(column)
577
+ const index = sortIndex(column)
578
+ const current = index === -1 ? undefined : activeSorts.value[index]
579
+ if (validatedSortMode.value === 'multiple') {
580
+ if (!current) return `${column.header}: добавить в сортировку по возрастанию`
581
+ const state = current.direction === 'ascending' ? 'по возрастанию' : 'по убыванию'
582
+ const action = current.direction === 'ascending' ? 'сортировать по убыванию' : 'убрать из сортировки'
583
+ return `${column.header}: приоритет ${index + 1} из ${activeSorts.value.length}, ${state}; ${action}`
584
+ }
585
+ const direction = current?.direction
487
586
  if (direction === 'ascending') return `${column.header}: сортировать по убыванию`
488
587
  if (direction === 'descending') return `${column.header}: сбросить сортировку`
489
588
  return `${column.header}: сортировать по возрастанию`
@@ -491,15 +590,29 @@ const sortActionLabel = (column: STableColumn<Row, Field>): string => {
491
590
 
492
591
  function requestSort(column: STableColumn<Row, Field>): void {
493
592
  if (!column.sortable) return
494
- const current = validatedSort.value
495
593
  const key = column.field as Field
496
- const next: STableSort<Field> | null = current?.key !== key
497
- ? { key, direction: 'ascending' }
498
- : current.direction === 'ascending'
499
- ? { key, direction: 'descending' }
500
- : null
501
- emit('update:sort', next)
502
- emit('sort', next)
594
+ if (validatedSortMode.value === 'multiple') {
595
+ const current = activeSorts.value
596
+ const index = sortIndex(column)
597
+ let next: readonly STableSort<Field>[]
598
+ if (index === -1) next = [...current, { key, direction: 'ascending' }]
599
+ else if (current[index]!.direction === 'ascending') {
600
+ next = current.map((entry, candidateIndex) => candidateIndex === index
601
+ ? { key: entry.key, direction: 'descending' }
602
+ : entry)
603
+ } else next = current.filter((_entry, candidateIndex) => candidateIndex !== index)
604
+ emit('update:sorts', next)
605
+ emit('multi-sort', next)
606
+ } else {
607
+ const current = activeSorts.value[0]
608
+ const next: STableSort<Field> | null = current?.key !== key
609
+ ? { key, direction: 'ascending' }
610
+ : current.direction === 'ascending'
611
+ ? { key, direction: 'descending' }
612
+ : null
613
+ emit('update:sort', next)
614
+ emit('sort', next)
615
+ }
503
616
  }
504
617
 
505
618
  function describeRowKey(key: STableRowKey): string {
@@ -671,7 +784,7 @@ const rowExpandableByRow = computed<ReadonlyMap<Row, boolean>>(() => {
671
784
 
672
785
  const validatedRows = computed<readonly Row[]>(() => {
673
786
  const columns = validatedColumns.value
674
- void validatedSort.value
787
+ void activeSorts.value
675
788
  void resolvedRowKeys.value
676
789
  void validatedSelectedRowKey.value
677
790
  void validatedSelectedRowKeys.value
@@ -14,6 +14,7 @@ export type STableColumnContent = 'field' | 'value' | 'slot'
14
14
  export type STableColumnHeaderPresentation = 'visible' | 'assistive'
15
15
  export type STableSelectionMode = 'none' | 'multiple'
16
16
  export type STableSortDirection = 'ascending' | 'descending'
17
+ export type STableSortMode = 'single' | 'multiple'
17
18
 
18
19
  /** Controlled sort state; consumer owns data ordering. / Управляемое состояние сортировки; порядок данных принадлежит consumer. */
19
20
  export interface STableSort<Field extends string = string> {
@@ -8,6 +8,9 @@
8
8
  no-content-padding
9
9
  content-layout="block"
10
10
  surface-overflow="clip"
11
+ :surface-tone="validatedPresentation === 'embedded' ? 'transparent' : 'default'"
12
+ :surface-border="validatedPresentation === 'embedded' ? 'none' : 'default'"
13
+ :surface-radius="validatedPresentation === 'embedded' ? 'none' : 'default'"
11
14
  >
12
15
  <template v-if="$slots.actions" #actions>
13
16
  <slot
@@ -113,6 +116,8 @@ export interface SDataGridProps<Key extends SDataGridColumnKey = SDataGridColumn
113
116
  emptyMessage?: string
114
117
  selectedRowId?: string
115
118
  maxHeight?: ScrollableViewportMaxBlockSize
119
+ /** Surface chrome для самостоятельного или вложенного grid. / Surface chrome for a standalone or embedded grid. */
120
+ presentation?: 'panel' | 'embedded'
116
121
  /** Accessible name без visible title. / Accessible name when there is no visible title. */
117
122
  ariaLabel?: string
118
123
  /** DOM id external visible title. / DOM id of an external visible title. */
@@ -168,6 +173,7 @@ const props = withDefaults(defineProps<SDataGridProps<ColumnKey>>(), {
168
173
  emptyMessage: 'Нет строк для отображения',
169
174
  selectedRowId: undefined,
170
175
  maxHeight: 'none',
176
+ presentation: 'panel',
171
177
  ariaLabel: undefined,
172
178
  ariaLabelledby: undefined,
173
179
  })
@@ -185,6 +191,7 @@ const CELL_KINDS = ['text', 'number', 'boolean', 'json', 'datetime', 'binary'] a
185
191
  const COLUMN_ALIGNMENTS = ['left', 'center', 'right'] as const
186
192
  const ROW_STATES = ['clean', 'inserted', 'updated', 'deleted'] as const
187
193
  const COMMIT_SOURCES = ['draft', 'value'] as const
194
+ const PRESENTATIONS = ['panel', 'embedded'] as const
188
195
 
189
196
  function assertReadonlyArray(value: unknown, coordinate: string): asserts value is readonly unknown[] {
190
197
  if (Array.isArray(value)) return
@@ -335,6 +342,12 @@ const resolvedAccessibleName = computed(() => {
335
342
  const validatedLoading = computed(() => validateBoolean('SDataGrid', 'loading', props.loading))
336
343
  const validatedDisabled = computed(() => validateBoolean('SDataGrid', 'disabled', props.disabled))
337
344
  const validatedReadOnly = computed(() => validateBoolean('SDataGrid', 'readOnly', props.readOnly))
345
+ const validatedPresentation = computed(() => validateExactString(
346
+ 'SDataGrid',
347
+ 'presentation',
348
+ props.presentation,
349
+ PRESENTATIONS,
350
+ ))
338
351
  const validatedMaxHeight = computed(() => validateScrollableViewportMaxBlockSize(
339
352
  'SDataGrid',
340
353
  'maxHeight',
@@ -0,0 +1,28 @@
1
+ import { computed, type ComputedRef } from 'vue'
2
+
3
+ export const FIELD_REQUIRED_CLASS = 'ml-0.5 text-danger-600 dark:text-danger-400'
4
+ export const FIELD_SUPPORT_CLASS = 'mt-1 min-w-0 whitespace-normal text-xs [overflow-wrap:anywhere]'
5
+ export const FIELD_HELP_CLASS = `${FIELD_SUPPORT_CLASS} text-surface-500 dark:text-surface-400`
6
+ export const FIELD_ERROR_CLASS = `${FIELD_SUPPORT_CLASS} text-danger-600 dark:text-danger-400`
7
+
8
+ export interface FieldSupportingTextOptions {
9
+ readonly baseId: ComputedRef<string>
10
+ readonly hasHelp: () => boolean
11
+ readonly hasError: () => boolean
12
+ }
13
+
14
+ /**
15
+ * Владеет exact ID и aria-describedby contract для help/error у field и fieldset.
16
+ * Owns the exact help/error ID and aria-describedby contract for fields and fieldsets.
17
+ */
18
+ export function useFieldSupportingText(options: FieldSupportingTextOptions) {
19
+ const helpId = computed(() => `${options.baseId.value}-help`)
20
+ const errorId = computed(() => `${options.baseId.value}-error`)
21
+ const describedBy = computed(() => {
22
+ const ids: string[] = []
23
+ if (options.hasHelp()) ids.push(helpId.value)
24
+ if (options.hasError()) ids.push(errorId.value)
25
+ return ids.length > 0 ? ids.join(' ') : undefined
26
+ })
27
+ return { describedBy, errorId, helpId } as const
28
+ }
@@ -0,0 +1,92 @@
1
+ import {
2
+ validateBoolean,
3
+ validateExactString,
4
+ } from './runtimeContract'
5
+
6
+ const PILL_KINDS = ['badge', 'chip'] as const
7
+ const PILL_SEVERITIES = [
8
+ 'primary',
9
+ 'secondary',
10
+ 'success',
11
+ 'info',
12
+ 'warn',
13
+ 'danger',
14
+ 'contrast',
15
+ ] as const
16
+ const PILL_SIZES = ['xs', 'sm', 'md'] as const
17
+ const PILL_MAX_WIDTHS = ['none', 'sm', 'md'] as const
18
+ const PILL_ICON_MOTIONS = ['static', 'spin'] as const
19
+
20
+ export type PillSurfaceKind = typeof PILL_KINDS[number]
21
+ export type PillSurfaceSeverity = typeof PILL_SEVERITIES[number]
22
+ export type PillSurfaceSize = typeof PILL_SIZES[number]
23
+ export type PillSurfaceMaxWidth = typeof PILL_MAX_WIDTHS[number]
24
+ export type PillSurfaceIconMotion = typeof PILL_ICON_MOTIONS[number]
25
+
26
+ export const PILL_ICON_CLASS = 's-pill-icon inline-flex h-[var(--s-pill-icon-size)] w-[var(--s-pill-icon-size)] shrink-0 items-center justify-center'
27
+ export const PILL_ICON_GRAPHIC_CLASS = 's-pill-icon-graphic h-full w-full'
28
+ export const PILL_ICON_SPIN_CLASS = 'animate-spin'
29
+ export const PILL_LABEL_CLASS = 's-pill-label min-w-0'
30
+ export const PILL_LABEL_TRUNCATE_CLASS = 'overflow-hidden text-ellipsis whitespace-nowrap'
31
+
32
+ export interface PillSurfaceContract {
33
+ kind: PillSurfaceKind
34
+ severity: PillSurfaceSeverity
35
+ size: PillSurfaceSize
36
+ dot: boolean
37
+ closable: boolean
38
+ disabled: boolean
39
+ truncate: boolean
40
+ maxWidth: PillSurfaceMaxWidth
41
+ iconMotion: PillSurfaceIconMotion
42
+ }
43
+
44
+ export interface PillSurfaceBindings {
45
+ 'data-s-pill-surface': PillSurfaceKind
46
+ 'data-s-feedback-tone': PillSurfaceSeverity
47
+ 'data-s-pill-size': PillSurfaceSize
48
+ 'data-s-pill-dot'?: 'true'
49
+ 'data-s-pill-closable'?: 'true'
50
+ 'data-s-pill-disabled'?: 'true'
51
+ 'data-s-pill-truncate'?: 'true'
52
+ 'data-s-pill-max-width'?: Exclude<PillSurfaceMaxWidth, 'none'>
53
+ 'data-s-pill-icon-motion'?: Exclude<PillSurfaceIconMotion, 'static'>
54
+ }
55
+
56
+ /**
57
+ * Создаёт exact bindings единого pill/feedback presentation owner.
58
+ * Creates exact bindings for the canonical pill/feedback presentation owner.
59
+ */
60
+ export function resolvePillSurfaceBindings(
61
+ owner: 'SBadge' | 'SChip',
62
+ contract: PillSurfaceContract,
63
+ ): PillSurfaceBindings {
64
+ const kind = validateExactString(owner, 'pill kind', contract.kind, PILL_KINDS)
65
+ const severity = validateExactString(owner, 'severity', contract.severity, PILL_SEVERITIES)
66
+ const size = validateExactString(owner, 'size', contract.size, PILL_SIZES)
67
+ const maxWidth = validateExactString(owner, 'maxWidth', contract.maxWidth, PILL_MAX_WIDTHS)
68
+ const iconMotion = validateExactString(owner, 'iconMotion', contract.iconMotion, PILL_ICON_MOTIONS)
69
+ const dot = validateBoolean(owner, 'dot', contract.dot)
70
+ const closable = validateBoolean(owner, 'closable', contract.closable)
71
+ const disabled = validateBoolean(owner, 'disabled', contract.disabled)
72
+ const truncate = validateBoolean(owner, 'truncate', contract.truncate)
73
+
74
+ if (kind === 'chip' && dot) {
75
+ throw new TypeError('SChip: dot presentation не поддерживается. / SChip: dot presentation is not supported.')
76
+ }
77
+ if (kind === 'badge' && closable) {
78
+ throw new TypeError('SBadge: closable presentation не поддерживается. / SBadge: closable presentation is not supported.')
79
+ }
80
+
81
+ return {
82
+ 'data-s-pill-surface': kind,
83
+ 'data-s-feedback-tone': severity,
84
+ 'data-s-pill-size': size,
85
+ ...(dot ? { 'data-s-pill-dot': 'true' as const } : {}),
86
+ ...(closable ? { 'data-s-pill-closable': 'true' as const } : {}),
87
+ ...(disabled ? { 'data-s-pill-disabled': 'true' as const } : {}),
88
+ ...(truncate ? { 'data-s-pill-truncate': 'true' as const } : {}),
89
+ ...(maxWidth === 'none' ? {} : { 'data-s-pill-max-width': maxWidth }),
90
+ ...(iconMotion === 'static' ? {} : { 'data-s-pill-icon-motion': iconMotion }),
91
+ }
92
+ }
@@ -0,0 +1,76 @@
1
+ import { computed, onMounted, ref, useId, watch } from 'vue'
2
+
3
+ import { resolveOptionalDomId } from './runtimeContract'
4
+
5
+ export type BinaryInputOwner = 'SCheckbox' | 'SSwitch'
6
+
7
+ export interface BinaryInputModelContract {
8
+ readonly modelValue: boolean
9
+ readonly readonly: boolean
10
+ readonly id?: string
11
+ }
12
+
13
+ export interface BinaryInputOptions {
14
+ readonly owner: BinaryInputOwner
15
+ readonly inputName: 'checkbox' | 'switch'
16
+ readonly props: BinaryInputModelContract
17
+ readonly indeterminate?: () => boolean
18
+ readonly emitUpdate: (value: boolean) => void
19
+ readonly emitClick: (event: MouseEvent) => void
20
+ }
21
+
22
+ /**
23
+ * Единый runtime owner controlled binary input: identity, value, readonly и native events.
24
+ * Canonical runtime owner for controlled binary input identity, value, readonly, and native events.
25
+ */
26
+ export function useBinaryInput(options: BinaryInputOptions) {
27
+ const generatedInputId = useId()
28
+ const inputId = computed(() => resolveOptionalDomId(
29
+ options.owner,
30
+ 'id',
31
+ options.props.id,
32
+ `s-${options.inputName}-${generatedInputId}`,
33
+ ))
34
+ const inputEl = ref<HTMLInputElement | null>(null)
35
+ const checkedValue = computed(() => {
36
+ if (typeof options.props.modelValue !== 'boolean') {
37
+ throw new TypeError(`${options.owner}: modelValue must be boolean`)
38
+ }
39
+ return options.props.modelValue
40
+ })
41
+
42
+ const applyIndeterminate = (): void => {
43
+ if (inputEl.value) {
44
+ inputEl.value.indeterminate = (options.indeterminate?.() ?? false) && !checkedValue.value
45
+ }
46
+ }
47
+ onMounted(applyIndeterminate)
48
+ watch([options.indeterminate ?? (() => false), checkedValue], applyIndeterminate)
49
+
50
+ const onChange = (event: Event): void => {
51
+ if (options.props.readonly) return
52
+ const target = event.currentTarget
53
+ if (!(target instanceof HTMLInputElement)) {
54
+ throw new TypeError(
55
+ `${options.owner}: change event owner must be the native ${options.inputName} input`,
56
+ )
57
+ }
58
+ options.emitUpdate(target.checked)
59
+ }
60
+
61
+ const onClick = (event: MouseEvent): void => {
62
+ if (options.props.readonly) {
63
+ event.preventDefault()
64
+ return
65
+ }
66
+ options.emitClick(event)
67
+ }
68
+
69
+ return {
70
+ checkedValue,
71
+ inputEl,
72
+ inputId,
73
+ onChange,
74
+ onClick,
75
+ } as const
76
+ }
@@ -26,6 +26,120 @@ body {
26
26
  }
27
27
 
28
28
  @layer components {
29
+ :where([data-s-feedback-tone="primary"]) {
30
+ --s-feedback-tone-background: var(--s-feedback-primary-background);
31
+ --s-feedback-tone-foreground: var(--s-feedback-primary-foreground);
32
+ }
33
+
34
+ :where([data-s-feedback-tone="secondary"]) {
35
+ --s-feedback-tone-background: var(--s-feedback-secondary-background);
36
+ --s-feedback-tone-foreground: var(--s-feedback-secondary-foreground);
37
+ }
38
+
39
+ :where([data-s-feedback-tone="success"]) {
40
+ --s-feedback-tone-background: var(--s-feedback-success-background);
41
+ --s-feedback-tone-foreground: var(--s-feedback-success-foreground);
42
+ }
43
+
44
+ :where([data-s-feedback-tone="info"]) {
45
+ --s-feedback-tone-background: var(--s-feedback-info-background);
46
+ --s-feedback-tone-foreground: var(--s-feedback-info-foreground);
47
+ }
48
+
49
+ :where([data-s-feedback-tone="warn"]) {
50
+ --s-feedback-tone-background: var(--s-feedback-warn-background);
51
+ --s-feedback-tone-foreground: var(--s-feedback-warn-foreground);
52
+ }
53
+
54
+ :where([data-s-feedback-tone="danger"]) {
55
+ --s-feedback-tone-background: var(--s-feedback-danger-background);
56
+ --s-feedback-tone-foreground: var(--s-feedback-danger-foreground);
57
+ }
58
+
59
+ :where([data-s-feedback-tone="contrast"]) {
60
+ --s-feedback-tone-background: var(--s-feedback-contrast-background);
61
+ --s-feedback-tone-foreground: var(--s-feedback-contrast-foreground);
62
+ }
63
+
64
+ :where([data-s-pill-surface]) {
65
+ display: inline-flex;
66
+ min-width: 0;
67
+ align-items: center;
68
+ border-radius: var(--s-feedback-pill-radius);
69
+ background-color: var(--s-feedback-tone-background);
70
+ color: var(--s-feedback-tone-foreground);
71
+ }
72
+
73
+ :where([data-s-pill-surface="badge"]) {
74
+ --s-pill-icon-size: 0.75rem;
75
+ gap: 0.25rem;
76
+ font-weight: 600;
77
+ }
78
+
79
+ :where([data-s-pill-surface="chip"]) {
80
+ --s-pill-icon-size: 0.875rem;
81
+ font-weight: 500;
82
+ }
83
+
84
+ :where([data-s-pill-surface="badge"][data-s-pill-size="md"]) {
85
+ padding: 0.125rem 0.625rem;
86
+ font-size: 0.75rem;
87
+ line-height: 1rem;
88
+ }
89
+
90
+ :where([data-s-pill-surface="badge"][data-s-pill-size="sm"]) {
91
+ padding: 0.125rem 0.5rem;
92
+ font-size: 0.6875rem;
93
+ line-height: 1rem;
94
+ }
95
+
96
+ :where([data-s-pill-surface="badge"][data-s-pill-size="xs"]) {
97
+ padding: 0 0.375rem;
98
+ font-size: 0.625rem;
99
+ line-height: 1rem;
100
+ }
101
+
102
+ :where([data-s-pill-surface="chip"][data-s-pill-size="md"]) {
103
+ min-height: 1.75rem;
104
+ gap: 0.375rem;
105
+ padding-inline: 0.625rem;
106
+ font-size: 0.75rem;
107
+ }
108
+
109
+ :where([data-s-pill-surface="chip"][data-s-pill-size="sm"]) {
110
+ min-height: 1.5rem;
111
+ gap: 0.25rem;
112
+ padding-inline: 0.5rem;
113
+ font-size: 0.6875rem;
114
+ line-height: 1rem;
115
+ }
116
+
117
+ :where([data-s-pill-surface="chip"][data-s-pill-size="md"][data-s-pill-closable="true"]) {
118
+ padding-right: 0.25rem;
119
+ }
120
+
121
+ :where([data-s-pill-surface="chip"][data-s-pill-size="sm"][data-s-pill-closable="true"]) {
122
+ padding-right: 0.125rem;
123
+ }
124
+
125
+ :where([data-s-pill-surface][data-s-pill-disabled="true"]) {
126
+ opacity: 0.6;
127
+ }
128
+
129
+ :where([data-s-pill-surface][data-s-pill-dot="true"]) {
130
+ width: 0.625rem;
131
+ height: 0.625rem;
132
+ padding: 0;
133
+ }
134
+
135
+ :where([data-s-pill-surface][data-s-pill-max-width="sm"]) {
136
+ max-width: 9.5rem;
137
+ }
138
+
139
+ :where([data-s-pill-surface][data-s-pill-max-width="md"]) {
140
+ max-width: 14rem;
141
+ }
142
+
29
143
  :where([data-s-field-surface]) {
30
144
  transition-property: color, background-color, border-color, box-shadow;
31
145
  transition-duration: var(--s-motion-standard);
@@ -231,6 +231,7 @@
231
231
  /* Semantic feedback surfaces */
232
232
  --s-feedback-surface: color-mix(in srgb, var(--color-surface) 88%, transparent);
233
233
  --s-feedback-backdrop-blur: 1.5rem;
234
+ --s-feedback-pill-radius: var(--s-radius-pill);
234
235
  --s-floating-surface: var(--color-surface);
235
236
  --s-floating-foreground: var(--color-text);
236
237
  --s-floating-outline: color-mix(in srgb, var(--color-border) 44%, transparent);
@@ -607,6 +608,21 @@
607
608
  --s-progress-indicator-foreground-warn: var(--color-warn-600);
608
609
  --s-progress-indicator-foreground-danger: var(--color-danger-600);
609
610
 
611
+ --s-feedback-primary-background: var(--color-primary-100);
612
+ --s-feedback-primary-foreground: var(--color-primary-800);
613
+ --s-feedback-secondary-background: var(--color-surface-100);
614
+ --s-feedback-secondary-foreground: var(--color-surface-800);
615
+ --s-feedback-success-background: var(--color-success-100);
616
+ --s-feedback-success-foreground: var(--color-success-800);
617
+ --s-feedback-info-background: var(--color-info-100);
618
+ --s-feedback-info-foreground: var(--color-info-800);
619
+ --s-feedback-warn-background: var(--color-warn-100);
620
+ --s-feedback-warn-foreground: var(--color-warn-800);
621
+ --s-feedback-danger-background: var(--color-danger-100);
622
+ --s-feedback-danger-foreground: var(--color-danger-800);
623
+ --s-feedback-contrast-background: var(--color-surface-800);
624
+ --s-feedback-contrast-foreground: var(--color-surface-100);
625
+
610
626
  --s-graph-node-tone-neutral-fill: var(--color-surface-50);
611
627
  --s-graph-node-tone-neutral-border: var(--color-surface-500);
612
628
  --s-graph-node-tone-neutral-label: var(--color-surface-900);
@@ -742,6 +758,21 @@
742
758
  --s-progress-indicator-foreground-warn: var(--color-warn-300);
743
759
  --s-progress-indicator-foreground-danger: var(--color-danger-300);
744
760
 
761
+ --s-feedback-primary-background: var(--color-primary-950);
762
+ --s-feedback-primary-foreground: var(--color-primary-50);
763
+ --s-feedback-secondary-background: var(--color-surface-800);
764
+ --s-feedback-secondary-foreground: var(--color-surface-50);
765
+ --s-feedback-success-background: var(--color-success-950);
766
+ --s-feedback-success-foreground: var(--color-success-50);
767
+ --s-feedback-info-background: var(--color-info-950);
768
+ --s-feedback-info-foreground: var(--color-info-50);
769
+ --s-feedback-warn-background: var(--color-warn-950);
770
+ --s-feedback-warn-foreground: var(--color-warn-50);
771
+ --s-feedback-danger-background: var(--color-danger-950);
772
+ --s-feedback-danger-foreground: var(--color-danger-50);
773
+ --s-feedback-contrast-background: var(--color-surface-200);
774
+ --s-feedback-contrast-foreground: var(--color-surface-800);
775
+
745
776
  --s-graph-node-tone-neutral-fill: var(--color-surface-700);
746
777
  --s-graph-node-tone-neutral-border: var(--color-surface-300);
747
778
  --s-graph-node-tone-neutral-label: var(--color-surface-50);