@pgcorp/ui-kit 0.5.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 (28) hide show
  1. package/package.json +1 -1
  2. package/src/components/shared/_internal/SHeaderSurface.css +98 -0
  3. package/src/components/shared/_internal/SHeaderSurface.vue +80 -0
  4. package/src/components/shared/_internal/useRadioGroup.ts +106 -0
  5. package/src/components/shared/containers/SPageHeader.vue +13 -20
  6. package/src/components/shared/controls/SCheckbox.vue +14 -43
  7. package/src/components/shared/controls/SChoiceCards.vue +16 -39
  8. package/src/components/shared/controls/SField.css +0 -12
  9. package/src/components/shared/controls/SField.vue +14 -10
  10. package/src/components/shared/controls/SFieldGroup.css +1 -19
  11. package/src/components/shared/controls/SFieldGroup.vue +13 -10
  12. package/src/components/shared/controls/SFieldLabel.css +0 -1
  13. package/src/components/shared/controls/SFieldLabel.vue +2 -1
  14. package/src/components/shared/controls/SSegmentedControl.vue +15 -43
  15. package/src/components/shared/controls/SSwitch.vue +14 -32
  16. package/src/components/shared/data-display/SBadge.vue +41 -18
  17. package/src/components/shared/data-display/SChip.vue +22 -10
  18. package/src/components/shared/data-display/SSectionHeader.vue +17 -21
  19. package/src/components/shared/database/SDataGrid.vue +13 -0
  20. package/src/internal/fieldPresentation.ts +28 -0
  21. package/src/internal/pillSurface.ts +92 -0
  22. package/src/internal/useBinaryInput.ts +76 -0
  23. package/src/styles/style.css +114 -0
  24. package/src/styles/tokens.css +31 -0
  25. package/src/components/shared/containers/SPageHeader.css +0 -82
  26. package/src/components/shared/data-display/SBadge.css +0 -73
  27. package/src/components/shared/data-display/SChip.css +0 -59
  28. package/src/components/shared/data-display/SSectionHeader.css +0 -84
@@ -9,4 +9,3 @@
9
9
  .s-field-label[data-size='md'] { @apply text-sm; }
10
10
  .s-field-label[data-align='end'] { @apply text-right; }
11
11
  .s-field-label[data-disabled='true'] { @apply text-surface-400 dark:text-surface-500; }
12
- .s-field-label__required { @apply ml-0.5 text-danger-600 dark:text-danger-400; }
@@ -8,12 +8,13 @@
8
8
  :data-disabled="disabled || undefined"
9
9
  >
10
10
  <slot />
11
- <span v-if="required" class="s-field-label__required" aria-hidden="true">*</span>
11
+ <span v-if="required" :class="['s-field-label__required', FIELD_REQUIRED_CLASS]" aria-hidden="true">*</span>
12
12
  </label>
13
13
  </template>
14
14
 
15
15
  <script setup lang="ts">
16
16
  import type { VNodeChild } from 'vue'
17
+ import { FIELD_REQUIRED_CLASS } from '../../../internal/fieldPresentation'
17
18
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
18
19
 
19
20
  defineOptions({ inheritAttrs: false })
@@ -17,27 +17,21 @@
17
17
  :data-option-wrap="optionWrap"
18
18
  :data-invalid="group.invalid || undefined"
19
19
  :data-readonly="group.readonly || undefined"
20
- role="radiogroup"
21
- :aria-labelledby="group.labelId"
22
- :aria-describedby="group.describedBy"
23
- :aria-invalid="group.invalid || undefined"
24
- :aria-required="group.required || undefined"
25
- :aria-readonly="group.readonly || undefined"
20
+ v-bind="radioGroup.groupBindings({
21
+ labelledBy: group.labelId,
22
+ describedBy: group.describedBy,
23
+ required: group.required,
24
+ invalid: group.invalid,
25
+ })"
26
26
  >
27
27
  <button
28
28
  v-for="option in options"
29
- :key="optionToken(option)"
30
- :ref="(element) => roving.register(option.value, resolveNativeElement(element))"
29
+ :key="radioGroup.optionToken(option.value)"
30
+ :ref="radioGroup.optionRef(option.value)"
31
31
  class="s-segmented-control__item"
32
32
  :data-size="size"
33
- type="button"
34
- :aria-checked="option.value === modelValue"
35
- :disabled="group.disabled || option.disabled"
36
- role="radio"
37
- :tabindex="option.value === focusableValue ? 0 : -1"
38
33
  :title="option.title"
39
- @click="selectOption(option)"
40
- @keydown="roving.handleKeydown($event, option.value)"
34
+ v-bind="radioGroup.optionBindings(option)"
41
35
  >{{ option.label }}</button>
42
36
  </div>
43
37
  </template>
@@ -74,10 +68,9 @@ export interface Props<T extends PublicSelectionKey = string> {
74
68
 
75
69
  <script setup lang="ts" generic="T extends SelectionKey = string">
76
70
  import { computed } from 'vue'
77
- import type { ComponentPublicInstance } from 'vue'
78
71
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
79
72
  import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
80
- import { selectionKeyToken, useSelectionRoving } from '../_internal/useSelectionRoving'
73
+ import { useRadioGroup } from '../_internal/useRadioGroup'
81
74
  import type { SelectionKey } from '../_internal/useSelectionRoving'
82
75
  import SFieldGroup from './SFieldGroup.vue'
83
76
 
@@ -100,35 +93,14 @@ const emit = defineEmits<{
100
93
  }>()
101
94
  const ownedAttrs = useOwnedAttrs({ component: 'SSegmentedControl', owner: 'field group root' })
102
95
  useInteractiveLeafRegistration({ owner: 'SSegmentedControl' })
103
-
104
- function selectOption(option: SegmentedControlOption<T>): void {
105
- if (props.disabled || props.readonly || option.disabled) {
106
- return
107
- }
108
- emit('update:modelValue', option.value)
109
- }
110
-
111
- const rovingOptions = computed(() => props.options.map((option) => ({
112
- value: option.value,
113
- disabled: props.disabled || option.disabled,
114
- })))
115
- const roving = useSelectionRoving({
116
- options: rovingOptions,
96
+ const radioGroup = useRadioGroup({
97
+ options: computed(() => props.options),
117
98
  selectedValue: computed(() => props.modelValue),
118
- select: (value) => {
119
- if (!props.disabled && !props.readonly) emit('update:modelValue', value)
120
- },
99
+ disabled: computed(() => props.disabled),
100
+ readonly: computed(() => props.readonly),
101
+ update: (value) => emit('update:modelValue', value),
121
102
  component: 'SSegmentedControl',
122
103
  })
123
- const focusableValue = roving.focusableValue
124
- const optionToken = (option: SegmentedControlOption<T>): string => selectionKeyToken(
125
- 'SSegmentedControl',
126
- option.value,
127
- 'option.value',
128
- )
129
- const resolveNativeElement = (element: Element | ComponentPublicInstance | null): HTMLElement | null => (
130
- element instanceof HTMLElement ? element : null
131
- )
132
104
  </script>
133
105
 
134
106
  <style lang="postcss" src="./SSegmentedControl.css" scoped></style>
@@ -13,6 +13,7 @@
13
13
  <template #default="field">
14
14
  <span class="s-switch-control">
15
15
  <input
16
+ ref="inputEl"
16
17
  v-bind="ownedAttrs.bindings()"
17
18
  :id="field.controlId"
18
19
  type="checkbox"
@@ -37,10 +38,9 @@
37
38
  </template>
38
39
 
39
40
  <script setup lang="ts">
40
- import { computed, useId } from 'vue'
41
41
  import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs'
42
42
  import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
43
- import { resolveOptionalDomId } from '../../../internal/runtimeContract'
43
+ import { useBinaryInput } from '../../../internal/useBinaryInput'
44
44
  import SField from './SField.vue'
45
45
 
46
46
  defineOptions({ inheritAttrs: false })
@@ -73,37 +73,19 @@ const emit = defineEmits<{
73
73
  }>()
74
74
  const ownedAttrs = useOwnedAttrs({ component: 'SSwitch', owner: 'native switch input' })
75
75
  useInteractiveLeafRegistration({ owner: 'SSwitch' })
76
-
77
- const generatedInputId = useId()
78
- const inputId = computed(() => resolveOptionalDomId(
79
- 'SSwitch',
80
- 'id',
81
- props.id,
82
- `s-switch-${generatedInputId}`,
83
- ))
84
- const checkedValue = computed(() => {
85
- if (typeof props.modelValue !== 'boolean') {
86
- throw new TypeError('SSwitch: modelValue must be boolean')
87
- }
88
- return props.modelValue
76
+ const {
77
+ checkedValue,
78
+ inputEl,
79
+ inputId,
80
+ onChange,
81
+ onClick,
82
+ } = useBinaryInput({
83
+ owner: 'SSwitch',
84
+ inputName: 'switch',
85
+ props,
86
+ emitUpdate: (value) => emit('update:modelValue', value),
87
+ emitClick: (event) => emit('click', event),
89
88
  })
90
-
91
- function onChange(event: Event): void {
92
- if (props.readonly) return
93
- const target = event.currentTarget
94
- if (!(target instanceof HTMLInputElement)) {
95
- throw new TypeError('SSwitch: change event owner must be the native switch input')
96
- }
97
- emit('update:modelValue', target.checked)
98
- }
99
-
100
- function onClick(event: MouseEvent): void {
101
- if (props.readonly) {
102
- event.preventDefault()
103
- return
104
- }
105
- emit('click', event)
106
- }
107
89
  </script>
108
90
 
109
91
  <style lang="postcss" src="./SSwitch.css" scoped></style>
@@ -1,42 +1,56 @@
1
1
  <template>
2
2
  <span
3
- v-bind="ownedAttrs.bindings()"
3
+ v-bind="{ ...ownedAttrs.bindings(), ...surfaceBindings }"
4
4
  class="s-badge"
5
- :class="{ 's-badge--dot': dot }"
6
- :data-severity="severity"
7
- :data-size="size"
8
- :data-max-width="maxWidth"
9
- :data-truncate="truncate || undefined"
10
- :data-icon-motion="iconMotion"
11
5
  >
12
6
  <template v-if="!dot">
13
- <span v-if="leadingIcon" class="s-badge__icon" aria-hidden="true">
14
- <component :is="leadingIcon" class="s-badge__icon-graphic" />
7
+ <span v-if="leadingIcon" :class="PILL_ICON_CLASS" aria-hidden="true">
8
+ <component
9
+ :is="leadingIcon"
10
+ :class="[
11
+ PILL_ICON_GRAPHIC_CLASS,
12
+ { [PILL_ICON_SPIN_CLASS]: iconMotion === 'spin' },
13
+ ]"
14
+ />
15
+ </span>
16
+ <span :class="[PILL_LABEL_CLASS, { [PILL_LABEL_TRUNCATE_CLASS]: truncate }]">
17
+ <slot />
15
18
  </span>
16
- <span class="s-badge__label"><slot /></span>
17
19
  </template>
18
20
  </span>
19
21
  </template>
20
22
 
21
23
  <script setup lang="ts">
22
- import type { Component } from 'vue';
24
+ import { computed, type Component } from 'vue';
23
25
  import { useOwnedAttrs } from '../../../internal/ownedAttrs';
26
+ import {
27
+ PILL_ICON_CLASS,
28
+ PILL_ICON_GRAPHIC_CLASS,
29
+ PILL_ICON_SPIN_CLASS,
30
+ PILL_LABEL_CLASS,
31
+ PILL_LABEL_TRUNCATE_CLASS,
32
+ resolvePillSurfaceBindings,
33
+ type PillSurfaceIconMotion,
34
+ type PillSurfaceMaxWidth,
35
+ type PillSurfaceSeverity,
36
+ type PillSurfaceSize,
37
+ } from '../../../internal/pillSurface';
24
38
 
25
39
  defineOptions({ inheritAttrs: false });
26
40
 
27
- export type BadgeSeverity = 'primary' | 'secondary' | 'success' | 'info' | 'warn' | 'danger' | 'contrast';
41
+ export type BadgeSeverity = PillSurfaceSeverity;
28
42
 
29
43
  export interface Props {
30
44
  severity?: BadgeSeverity;
31
45
  dot?: boolean;
32
- size?: 'xs' | 'sm' | 'md';
46
+ size?: PillSurfaceSize;
33
47
  leadingIcon?: Component;
34
- iconMotion?: 'static' | 'spin';
48
+ iconMotion?: PillSurfaceIconMotion;
35
49
  truncate?: boolean;
36
- maxWidth?: 'none' | 'sm' | 'md';
50
+ maxWidth?: PillSurfaceMaxWidth;
37
51
  }
38
52
 
39
- withDefaults(defineProps<Props>(), {
53
+ const props = withDefaults(defineProps<Props>(), {
40
54
  severity: 'secondary',
41
55
  dot: false,
42
56
  size: 'md',
@@ -47,6 +61,15 @@ withDefaults(defineProps<Props>(), {
47
61
  });
48
62
 
49
63
  const ownedAttrs = useOwnedAttrs({ component: 'SBadge', owner: 'badge root' });
64
+ const surfaceBindings = computed(() => resolvePillSurfaceBindings('SBadge', {
65
+ kind: 'badge',
66
+ severity: props.severity,
67
+ size: props.size,
68
+ dot: props.dot,
69
+ closable: false,
70
+ disabled: false,
71
+ truncate: props.truncate,
72
+ maxWidth: props.maxWidth,
73
+ iconMotion: props.iconMotion,
74
+ }));
50
75
  </script>
51
-
52
- <style lang="postcss" src="./SBadge.css" scoped></style>
@@ -1,16 +1,12 @@
1
1
  <template>
2
2
  <span
3
- v-bind="ownedAttrs.bindings()"
3
+ v-bind="{ ...ownedAttrs.bindings(), ...surfaceBindings }"
4
4
  class="s-chip"
5
- :data-severity="validatedSeverity"
6
- :data-size="validatedSize"
7
- :data-closable="closable || undefined"
8
- :data-disabled="disabled || undefined"
9
5
  >
10
- <span v-if="leadingIcon" class="s-chip__icon" aria-hidden="true">
11
- <component :is="leadingIcon" class="s-chip__icon-graphic" />
6
+ <span v-if="leadingIcon" :class="PILL_ICON_CLASS" aria-hidden="true">
7
+ <component :is="leadingIcon" :class="PILL_ICON_GRAPHIC_CLASS" />
12
8
  </span>
13
- <span class="s-chip__label">{{ validatedLabel }}</span>
9
+ <span :class="[PILL_LABEL_CLASS, PILL_LABEL_TRUNCATE_CLASS]">{{ validatedLabel }}</span>
14
10
  <SButton
15
11
  v-if="closable"
16
12
  label=""
@@ -34,6 +30,13 @@
34
30
  import { computed, type Component } from 'vue'
35
31
  import { X } from '../../icons/sputnigUiIcons'
36
32
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
33
+ import {
34
+ PILL_ICON_CLASS,
35
+ PILL_ICON_GRAPHIC_CLASS,
36
+ PILL_LABEL_CLASS,
37
+ PILL_LABEL_TRUNCATE_CLASS,
38
+ resolvePillSurfaceBindings,
39
+ } from '../../../internal/pillSurface'
37
40
  import {
38
41
  validateBoolean,
39
42
  validateExactString,
@@ -80,6 +83,17 @@ const validatedSeverity = computed(() => validateExactString(
80
83
  const validatedSize = computed(() => validateExactString(
81
84
  'SChip', 'size', props.size, ['sm', 'md'] as const,
82
85
  ))
86
+ const surfaceBindings = computed(() => resolvePillSurfaceBindings('SChip', {
87
+ kind: 'chip',
88
+ severity: validatedSeverity.value,
89
+ size: validatedSize.value,
90
+ dot: false,
91
+ closable: props.closable,
92
+ disabled: props.disabled,
93
+ truncate: true,
94
+ maxWidth: 'none',
95
+ iconMotion: 'static',
96
+ }))
83
97
  const resolvedCloseLabel = computed(() => {
84
98
  validateBoolean('SChip', 'closable', props.closable)
85
99
  validateBoolean('SChip', 'disabled', props.disabled)
@@ -88,5 +102,3 @@ const resolvedCloseLabel = computed(() => {
88
102
  : validateNonEmptyString('SChip', 'closeLabel', props.closeLabel)
89
103
  })
90
104
  </script>
91
-
92
- <style lang="postcss" src="./SChip.css" scoped></style>
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { useOwnedAttrs } from '../../../internal/ownedAttrs'
3
+ import SHeaderSurface from '../_internal/SHeaderSurface.vue'
3
4
 
4
5
  defineOptions({ inheritAttrs: false })
5
6
 
@@ -32,27 +33,22 @@ const ownedAttrs = useOwnedAttrs({ component: 'SSectionHeader', owner: 'section
32
33
  </script>
33
34
 
34
35
  <template>
35
- <header
36
+ <SHeaderSurface
36
37
  v-bind="ownedAttrs.bindings()"
37
- class="s-section-header"
38
- :data-size="size"
39
- :data-variant="variant"
40
- :data-divider="divider || undefined"
41
- :data-padding="padding"
42
- :data-spacing-after="spacingAfter"
43
- :data-trailing-layout="trailingLayout"
38
+ kind="section"
39
+ :title="title"
40
+ :eyebrow="eyebrow"
41
+ :description="description"
42
+ :meta="meta"
43
+ :heading-level="headingLevel"
44
+ :size="size"
45
+ :variant="variant"
46
+ :divider="divider"
47
+ :padding="padding"
48
+ :spacing-after="spacingAfter"
49
+ :trailing-layout="trailingLayout"
44
50
  >
45
- <div class="s-section-header__copy">
46
- <div v-if="eyebrow" class="s-section-header__eyebrow">{{ eyebrow }}</div>
47
- <component :is="`h${headingLevel}`" class="s-section-header__title">{{ title }}</component>
48
- <p v-if="description" class="s-section-header__description">{{ description }}</p>
49
- <p v-if="meta" class="s-section-header__meta">{{ meta }}</p>
50
- </div>
51
- <div v-if="$slots.badges || $slots.actions" class="s-section-header__trailing">
52
- <div v-if="$slots.badges" class="s-section-header__badges"><slot name="badges" /></div>
53
- <div v-if="$slots.actions" class="s-section-header__actions"><slot name="actions" /></div>
54
- </div>
55
- </header>
51
+ <template v-if="$slots.badges" #badges><slot name="badges" /></template>
52
+ <template v-if="$slots.actions" #actions><slot name="actions" /></template>
53
+ </SHeaderSurface>
56
54
  </template>
57
-
58
- <style lang="postcss" src="./SSectionHeader.css" scoped></style>
@@ -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
+ }