@pgcorp/ui-kit 0.1.2 → 0.3.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 (45) hide show
  1. package/README.md +5 -5
  2. package/package.json +19 -3
  3. package/src/components/layout/SSideMenuButton.vue +5 -4
  4. package/src/components/layout/SSideMenuRail.vue +3 -6
  5. package/src/components/layout/SStack.css +9 -3
  6. package/src/components/layout/SStack.vue +10 -2
  7. package/src/components/shared/_internal/STreeNode.vue +10 -4
  8. package/src/components/shared/_internal/sidebarGroupContext.ts +3 -1
  9. package/src/components/shared/_internal/tabsContext.ts +1 -0
  10. package/src/components/shared/containers/SLeftSidebar.vue +2 -2
  11. package/src/components/shared/containers/SPageHeader.css +11 -0
  12. package/src/components/shared/containers/SPageHeader.vue +7 -1
  13. package/src/components/shared/containers/SPanel.css +7 -1
  14. package/src/components/shared/containers/SPanel.vue +5 -1
  15. package/src/components/shared/containers/SSidebarGroup.css +1 -1
  16. package/src/components/shared/containers/SSidebarGroup.vue +113 -94
  17. package/src/components/shared/containers/SSidebarSection.css +16 -4
  18. package/src/components/shared/containers/SSidebarSection.vue +15 -16
  19. package/src/components/shared/controls/SButton.css +39 -0
  20. package/src/components/shared/controls/SButton.vue +146 -29
  21. package/src/components/shared/controls/SCombobox.css +9 -0
  22. package/src/components/shared/controls/SCombobox.vue +322 -0
  23. package/src/components/shared/controls/SContextToggleButton.vue +1 -1
  24. package/src/components/shared/controls/SInputText.vue +15 -0
  25. package/src/components/shared/controls/SInteractiveSurface.css +4 -0
  26. package/src/components/shared/controls/SInteractiveSurface.vue +4 -0
  27. package/src/components/shared/controls/SListbox.vue +99 -1
  28. package/src/components/shared/controls/SListboxOption.vue +15 -2
  29. package/src/components/shared/controls/SRange.css +35 -0
  30. package/src/components/shared/controls/SRange.vue +129 -0
  31. package/src/components/shared/controls/SSwitch.css +48 -0
  32. package/src/components/shared/controls/SSwitch.vue +109 -0
  33. package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +6 -3
  34. package/src/components/shared/data-display/SChip.css +59 -0
  35. package/src/components/shared/data-display/SChip.vue +92 -0
  36. package/src/components/shared/data-display/SCodeBlock.vue +1 -1
  37. package/src/components/shared/data-display/SCodeEditor.css +15 -0
  38. package/src/components/shared/data-display/SCodeEditor.vue +14 -0
  39. package/src/components/shared/data-display/STable.vue +107 -3
  40. package/src/components/shared/data-display/table.ts +9 -0
  41. package/src/components/shared/navigation/SBottomNav.vue +3 -0
  42. package/src/components/shared/navigation/STab.vue +31 -6
  43. package/src/components/shared/navigation/STabs.vue +8 -2
  44. package/src/internal/codeEditorContract.ts +1 -1
  45. package/src/internal/ownedAttrs.ts +31 -2
@@ -40,6 +40,9 @@
40
40
  :aria-describedby="mergeIdReferences(ownedAttrs.string('aria-describedby'), field.describedBy)"
41
41
  @input="onInput"
42
42
  @change="onChange"
43
+ @focus="emit('focus', $event)"
44
+ @click="emit('click', $event)"
45
+ @keydown="emit('keydown', $event)"
43
46
  />
44
47
  <div v-if="trailingKind" class="s-input-trailing" :data-kind="trailingKind">
45
48
  <span v-if="trailingIcon" class="s-input-trailing__passive" aria-hidden="true">
@@ -72,6 +75,7 @@
72
75
 
73
76
  <script setup lang="ts">
74
77
  import { computed, ref, useId, watchEffect, type Component } from 'vue'
78
+ import type { InteractiveElementApi } from '../../../internal/interactiveElement'
75
79
  import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs'
76
80
  import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
77
81
  import { resolveFieldSurfaceBindings, type FieldSurfaceBindings } from '../../../internal/fieldSurface'
@@ -177,6 +181,9 @@ export interface SInputTextTrailingAction {
177
181
  loading?: boolean
178
182
  }
179
183
 
184
+ /** Native input owner API for focus-managed compound controls. */
185
+ export type SInputTextApi = InteractiveElementApi<HTMLInputElement>
186
+
180
187
  const props = withDefaults(defineProps<Props>(), {
181
188
  modelValue: '',
182
189
  modelModifiers: () => ({}),
@@ -206,6 +213,9 @@ const props = withDefaults(defineProps<Props>(), {
206
213
  const emit = defineEmits<{
207
214
  'update:modelValue': [value: string]
208
215
  'trailing-action': [event: MouseEvent]
216
+ focus: [event: FocusEvent]
217
+ click: [event: MouseEvent]
218
+ keydown: [event: KeyboardEvent]
209
219
  }>()
210
220
  const ownedAttrs = useOwnedAttrs({ component: 'SInputText', owner: 'native input' })
211
221
  useInteractiveLeafRegistration({ owner: 'SInputText' })
@@ -213,6 +223,11 @@ useInteractiveLeafRegistration({ owner: 'SInputText' })
213
223
  const inputRef = ref<HTMLInputElement | null>(null)
214
224
  defineExpose({
215
225
  focus: () => inputRef.value?.focus(),
226
+ getElement: () => {
227
+ const element = inputRef.value
228
+ if (!element) throw new Error('SInputText: native input element is unavailable')
229
+ return element
230
+ },
216
231
  })
217
232
 
218
233
  const generatedInputId = useId()
@@ -14,6 +14,10 @@
14
14
  color var(--s-motion-standard) ease;
15
15
  }
16
16
 
17
+ .s-interactive-surface[data-appearance='plain'] {
18
+ @apply border-transparent bg-transparent dark:border-transparent dark:bg-transparent;
19
+ }
20
+
17
21
  .s-interactive-surface__activation {
18
22
  @apply absolute inset-0 z-10 block h-full w-full cursor-pointer border-0 bg-transparent p-0;
19
23
  border-radius: inherit;
@@ -17,6 +17,7 @@ export type InteractiveSurfaceDensity = 'compact' | 'comfortable'
17
17
  export type InteractiveSurfacePadding = 'none' | 'sm' | 'md'
18
18
  export type InteractiveSurfaceRadius = 'md' | 'lg' | 'xl'
19
19
  export type InteractiveSurfaceFill = 'content' | 'container'
20
+ export type InteractiveSurfaceAppearance = 'card' | 'plain'
20
21
 
21
22
  /**
22
23
  * Единственный semantic-контракт поверхности: passive, action или ровно одна link-target.
@@ -55,6 +56,7 @@ export interface Props {
55
56
  padding?: InteractiveSurfacePadding
56
57
  radius?: InteractiveSurfaceRadius
57
58
  fill?: InteractiveSurfaceFill
59
+ appearance?: InteractiveSurfaceAppearance
58
60
  contentAlign?: 'start' | 'center'
59
61
  /** Полный binding явного STooltip trigger, направляемый на activation owner. / Complete explicit STooltip trigger binding routed to the activation owner. */
60
62
  tooltipTrigger?: STooltipTriggerBinding
@@ -67,6 +69,7 @@ const props = withDefaults(defineProps<Props>(), {
67
69
  padding: 'md',
68
70
  radius: 'lg',
69
71
  fill: 'content',
72
+ appearance: 'card',
70
73
  contentAlign: 'start',
71
74
  tooltipTrigger: undefined,
72
75
  })
@@ -285,6 +288,7 @@ function activateAction(event: MouseEvent): void {
285
288
  :data-padding="padding"
286
289
  :data-radius="radius"
287
290
  :data-fill="fill"
291
+ :data-appearance="appearance"
288
292
  :data-selected="selected || undefined"
289
293
  :data-pressed="surfacePressed"
290
294
  :data-disabled="surfaceDisabled || undefined"
@@ -6,6 +6,7 @@
6
6
  :data-variant="resolvedVariant"
7
7
  :data-invalid="resolvedInvalid || undefined"
8
8
  :data-readonly="resolvedReadonly || undefined"
9
+ :data-focus-mode="resolvedFocusMode"
9
10
  role="listbox"
10
11
  :aria-label="resolvedLabel"
11
12
  :aria-multiselectable="resolvedSelectionMode === 'multiple' || undefined"
@@ -17,23 +18,31 @@
17
18
  <SListboxOption
18
19
  v-if="resolvedClearOptionLabel"
19
20
  :registration-token="CLEAR_OPTION_TOKEN"
21
+ :id="optionId(CLEAR_OPTION_TOKEN)"
20
22
  :label="resolvedClearOptionLabel"
21
23
  :title="resolvedClearOptionLabel"
22
24
  :selected="modelValue === null"
23
25
  :disabled="resolvedDisabled"
24
26
  :appearance="optionAppearance"
27
+ :active="activeToken === CLEAR_OPTION_TOKEN"
28
+ :focus-on-select="resolvedFocusMode === 'roving'"
29
+ @hover="setActiveToken(CLEAR_OPTION_TOKEN)"
25
30
  @select="clearSelection"
26
31
  />
27
32
  <template v-for="entry in inventory.entries" :key="entry.token">
28
33
  <SListboxOption
29
34
  v-if="entry.kind === 'option'"
30
35
  :registration-token="entry.record.token"
36
+ :id="optionId(entry.record.token)"
31
37
  :label="entry.record.option.label"
32
38
  :title="entry.record.option.title ?? entry.record.option.label"
33
39
  :selected="selectedTokens.has(entry.record.token)"
34
40
  :disabled="resolvedDisabled || entry.record.option.disabled"
35
41
  :marker="entry.record.option.marker"
36
42
  :appearance="optionAppearance"
43
+ :active="activeToken === entry.record.token"
44
+ :focus-on-select="resolvedFocusMode === 'roving'"
45
+ @hover="setActiveToken(entry.record.token)"
37
46
  @select="selectOption(entry.record.token)"
38
47
  />
39
48
  <SListboxGroup
@@ -46,12 +55,16 @@
46
55
  v-for="record in entry.records"
47
56
  :key="record.token"
48
57
  :registration-token="record.token"
58
+ :id="optionId(record.token)"
49
59
  :label="record.option.label"
50
60
  :title="record.option.title ?? record.option.label"
51
61
  :selected="selectedTokens.has(record.token)"
52
62
  :disabled="resolvedDisabled || record.option.disabled"
53
63
  :marker="record.option.marker"
54
64
  :appearance="optionAppearance"
65
+ :active="activeToken === record.token"
66
+ :focus-on-select="resolvedFocusMode === 'roving'"
67
+ @hover="setActiveToken(record.token)"
55
68
  @select="selectOption(record.token)"
56
69
  />
57
70
  </SListboxGroup>
@@ -73,6 +86,8 @@ import type {
73
86
  } from '../_internal/selectionContract'
74
87
 
75
88
  export type SListboxVariant = 'select' | 'color'
89
+ export type SListboxFocusMode = 'roving' | 'activedescendant'
90
+ export type SListboxMoveIntent = 'next' | 'previous' | 'first' | 'last'
76
91
  export type SListboxOption<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionOption<TKey>
77
92
  export type SListboxOptionGroup<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionOptionGroup<TKey>
78
93
  export type SListboxEntry<TKey extends PublicSelectionKey = PublicSelectionKey> = PublicSelectionEntry<TKey>
@@ -106,6 +121,8 @@ interface SharedProps<TKey extends PublicSelectionKey> {
106
121
  emptyLabel?: string
107
122
  clearOptionLabel?: string
108
123
  id?: string
124
+ focusMode?: SListboxFocusMode
125
+ activeValue?: TKey | null
109
126
  }
110
127
 
111
128
  export type SListboxProps<
@@ -154,9 +171,12 @@ const props = withDefaults(defineProps<ComponentProps<TKey>>(), {
154
171
  emptyLabel: 'Нет доступных вариантов',
155
172
  clearOptionLabel: undefined,
156
173
  id: undefined,
174
+ focusMode: 'roving',
175
+ activeValue: undefined,
157
176
  })
158
177
  const emit = defineEmits<{
159
178
  'update:modelValue': [value: TKey | null] | [value: readonly TKey[]]
179
+ 'update:activeValue': [value: TKey | null]
160
180
  'option-keydown': [payload: SListboxOptionKeydownPayload<TKey>]
161
181
  escape: []
162
182
  tab: [event: KeyboardEvent]
@@ -187,6 +207,9 @@ const contract = computed(() => {
187
207
  disabled: validateBoolean('SListbox', 'disabled', props.disabled),
188
208
  readonly: validateBoolean('SListbox', 'readonly', props.readonly),
189
209
  invalid: validateBoolean('SListbox', 'invalid', props.invalid),
210
+ focusMode: validateExactString(
211
+ 'SListbox', 'focusMode', props.focusMode, ['roving', 'activedescendant'] as const,
212
+ ),
190
213
  selectionMode,
191
214
  clearOptionLabel,
192
215
  }
@@ -199,6 +222,7 @@ const resolvedDisabled = computed(() => contract.value.disabled)
199
222
  const resolvedReadonly = computed(() => contract.value.readonly)
200
223
  const resolvedInvalid = computed(() => contract.value.invalid)
201
224
  const resolvedSelectionMode = computed(() => contract.value.selectionMode)
225
+ const resolvedFocusMode = computed(() => contract.value.focusMode)
202
226
  const resolvedClearOptionLabel = computed(() => contract.value.clearOptionLabel)
203
227
  const selectionState = computed(() => {
204
228
  const resolvedInventory = resolveSelectionInventory<TKey>('SListbox', props.options)
@@ -213,6 +237,25 @@ const inventory = computed(() => selectionState.value.inventory)
213
237
  const selectedTokens = computed(() => selectionState.value.selectedTokens)
214
238
  const optionAppearance = computed(() => resolvedVariant.value === 'color' ? 'color' : 'select')
215
239
  const CLEAR_OPTION_TOKEN = 'clear-option'
240
+ const activeToken = computed(() => {
241
+ if (resolvedFocusMode.value === 'roving') return undefined
242
+ if (resolvedSelectionMode.value !== 'single') {
243
+ throw new Error('SListbox: activedescendant focus mode supports single selection only')
244
+ }
245
+ if (props.activeValue === undefined) {
246
+ throw new TypeError('SListbox: activeValue is required in activedescendant focus mode')
247
+ }
248
+ if (props.activeValue === null) return null
249
+ const token = selectionKeyToken('SListbox', props.activeValue, 'activeValue')
250
+ if (!inventory.value.optionsByToken.has(token)) {
251
+ throw new Error('SListbox: activeValue does not match any option')
252
+ }
253
+ return token
254
+ })
255
+
256
+ function optionId(token: string): string {
257
+ return `${resolvedId.value}-option-${encodeURIComponent(token)}`
258
+ }
216
259
 
217
260
  function emitModelValue(value: TKey | null | readonly TKey[]): void {
218
261
  if (contract.value.selectionMode === 'multiple') {
@@ -281,6 +324,7 @@ function enabledRegistrations(): ListboxOptionRegistration[] {
281
324
  function initializeTabstop(): void {
282
325
  const options = enabledRegistrations()
283
326
  for (const option of registrations) option.element.tabIndex = -1
327
+ if (resolvedFocusMode.value === 'activedescendant') return
284
328
  const selected = options.find((option) => option.selected())
285
329
  const target = selected ?? options[0]
286
330
  if (target) target.element.tabIndex = 0
@@ -300,6 +344,56 @@ function focusOption(target: ListboxOptionRegistration, options = enabledRegistr
300
344
  target.element.focus()
301
345
  }
302
346
 
347
+ function setActiveToken(token: string): void {
348
+ if (resolvedFocusMode.value !== 'activedescendant') return
349
+ const target = enabledRegistrations().find((option) => option.token === token)
350
+ if (!target) return
351
+ emit('update:activeValue', optionValue(target))
352
+ }
353
+
354
+ function activeRegistration(options: ListboxOptionRegistration[]): ListboxOptionRegistration | undefined {
355
+ const token = activeToken.value
356
+ return token === undefined || token === null
357
+ ? undefined
358
+ : options.find((option) => option.token === token)
359
+ }
360
+
361
+ function initializeActive(): void {
362
+ if (resolvedFocusMode.value !== 'activedescendant') {
363
+ throw new Error('SListbox: initializeActive is available only in activedescendant focus mode')
364
+ }
365
+ const options = enabledRegistrations()
366
+ const target = activeRegistration(options) ?? options.find((option) => option.selected()) ?? options[0]
367
+ if (target) emit('update:activeValue', optionValue(target))
368
+ }
369
+
370
+ function moveActive(intent: SListboxMoveIntent): void {
371
+ if (resolvedFocusMode.value !== 'activedescendant') {
372
+ throw new Error('SListbox: moveActive is available only in activedescendant focus mode')
373
+ }
374
+ const options = enabledRegistrations()
375
+ if (options.length === 0) return
376
+ const current = activeRegistration(options)
377
+ const currentIndex = current === undefined ? -1 : options.indexOf(current)
378
+ const nextIndex = intent === 'first'
379
+ ? 0
380
+ : intent === 'last'
381
+ ? options.length - 1
382
+ : intent === 'next'
383
+ ? (Math.max(currentIndex, -1) + 1) % options.length
384
+ : (currentIndex <= 0 ? options.length : currentIndex) - 1
385
+ const target = options[nextIndex]
386
+ if (target) emit('update:activeValue', optionValue(target))
387
+ }
388
+
389
+ function selectActive(): void {
390
+ if (resolvedFocusMode.value !== 'activedescendant') {
391
+ throw new Error('SListbox: selectActive is available only in activedescendant focus mode')
392
+ }
393
+ if (resolvedReadonly.value || resolvedDisabled.value) return
394
+ activeRegistration(enabledRegistrations())?.activate()
395
+ }
396
+
303
397
  function currentOption(
304
398
  options: ListboxOptionRegistration[],
305
399
  event: KeyboardEvent,
@@ -319,6 +413,7 @@ function optionValue(registration: ListboxOptionRegistration): TKey | null {
319
413
  }
320
414
 
321
415
  function handleKeydown(event: KeyboardEvent): void {
416
+ if (resolvedFocusMode.value === 'activedescendant') return
322
417
  if (event.key === 'Tab') {
323
418
  emit('tab', event)
324
419
  return
@@ -376,13 +471,16 @@ function handleKeydown(event: KeyboardEvent): void {
376
471
  }
377
472
 
378
473
  function focusSelectedOrFirst(): void {
474
+ if (resolvedFocusMode.value !== 'roving') {
475
+ throw new Error('SListbox: focusSelectedOrFirst is available only in roving focus mode')
476
+ }
379
477
  const options = enabledRegistrations()
380
478
  const target = options.find((option) => option.selected()) ?? options[0]
381
479
  if (target) focusOption(target, options)
382
480
  }
383
481
 
384
482
  onBeforeUnmount(() => { if (typeaheadTimer) clearTimeout(typeaheadTimer) })
385
- defineExpose({ focusSelectedOrFirst })
483
+ defineExpose({ focusSelectedOrFirst, initializeActive, moveActive, selectActive })
386
484
  </script>
387
485
 
388
486
  <style lang="postcss" src="./SListbox.css" scoped></style>
@@ -1,9 +1,10 @@
1
1
  <template>
2
2
  <li
3
3
  ref="optionRef"
4
+ :id="id"
4
5
  class="s-listbox-option__control"
5
6
  :data-appearance="appearance"
6
- :data-focused="focused || undefined"
7
+ :data-focused="focused || active || undefined"
7
8
  :title="title ?? label"
8
9
  :aria-label="appearance === 'color' ? label : undefined"
9
10
  role="option"
@@ -12,6 +13,8 @@
12
13
  :tabindex="-1"
13
14
  @focus="focused = true"
14
15
  @blur="focused = false"
16
+ @mousedown="onMousedown"
17
+ @mouseenter="emit('hover')"
15
18
  @click.stop="select"
16
19
  >
17
20
  <span class="s-listbox-option__main" :data-appearance="appearance">
@@ -42,6 +45,9 @@ export interface Props {
42
45
  appearance?: 'select' | 'color'
43
46
  marker?: MarkerContract
44
47
  title?: string
48
+ id?: string
49
+ active?: boolean
50
+ focusOnSelect?: boolean
45
51
  }
46
52
 
47
53
  defineOptions({ inheritAttrs: false })
@@ -51,10 +57,14 @@ const props = withDefaults(defineProps<Props>(), {
51
57
  appearance: 'select',
52
58
  marker: undefined,
53
59
  title: undefined,
60
+ id: undefined,
61
+ active: false,
62
+ focusOnSelect: true,
54
63
  })
55
64
 
56
65
  const emit = defineEmits<{
57
66
  select: []
67
+ hover: []
58
68
  }>()
59
69
  useInteractiveLeafRegistration({ owner: 'SListboxOption' })
60
70
  const context = inject(listboxContextKey)
@@ -64,9 +74,12 @@ const optionRef = ref<HTMLLIElement | null>(null)
64
74
  let unregister: (() => void) | undefined
65
75
  const select = (): void => {
66
76
  if (props.disabled) return
67
- optionRef.value?.focus()
77
+ if (props.focusOnSelect) optionRef.value?.focus()
68
78
  emit('select')
69
79
  }
80
+ const onMousedown = (event: MouseEvent): void => {
81
+ if (!props.focusOnSelect) event.preventDefault()
82
+ }
70
83
  onMounted(() => {
71
84
  const element = optionRef.value
72
85
  if (!(element instanceof HTMLLIElement)) throw new Error('SListboxOption: option element is unavailable')
@@ -0,0 +1,35 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-range {
4
+ @apply flex min-w-0 items-center gap-3;
5
+ }
6
+
7
+ .s-range__input {
8
+ width: 100%;
9
+ min-height: var(--s-control-hitbox-sm);
10
+ cursor: pointer;
11
+ accent-color: var(--color-primary-500);
12
+ }
13
+
14
+ .s-range__input:focus-visible {
15
+ outline: var(--s-focus-ring-width) solid var(--s-focus-ring-color);
16
+ outline-offset: var(--s-focus-ring-offset);
17
+ }
18
+
19
+ .s-range__input:disabled {
20
+ cursor: not-allowed;
21
+ opacity: 0.6;
22
+ }
23
+
24
+ .s-range__input[aria-readonly="true"] {
25
+ cursor: default;
26
+ opacity: 0.8;
27
+ }
28
+
29
+ .s-range__input[aria-invalid="true"] {
30
+ accent-color: var(--color-danger-500);
31
+ }
32
+
33
+ .s-range__value {
34
+ @apply min-w-12 text-right font-mono text-xs text-surface-600 dark:text-surface-300;
35
+ }
@@ -0,0 +1,129 @@
1
+ <template>
2
+ <SField
3
+ :control-id="inputId"
4
+ :label="label"
5
+ :disabled="disabled"
6
+ :readonly="readonly"
7
+ :required="required"
8
+ :help-text="helpText"
9
+ :error-message="errorMessage"
10
+ :invalid="invalid"
11
+ >
12
+ <template #default="field">
13
+ <div class="s-range">
14
+ <input
15
+ v-bind="ownedAttrs.bindings()"
16
+ :id="field.controlId"
17
+ class="s-range__input"
18
+ type="range"
19
+ :min="validatedMin"
20
+ :max="validatedMax"
21
+ :step="validatedStep"
22
+ :value="validatedValue"
23
+ :disabled="field.disabled"
24
+ :aria-invalid="field.invalid || undefined"
25
+ :aria-readonly="field.readonly || undefined"
26
+ :aria-required="field.required || undefined"
27
+ :aria-describedby="mergeIdReferences(ownedAttrs.string('aria-describedby'), field.describedBy)"
28
+ @input="onInput"
29
+ />
30
+ <output v-if="showValue" class="s-range__value" :for="field.controlId">
31
+ {{ validatedValue }}
32
+ </output>
33
+ </div>
34
+ </template>
35
+ </SField>
36
+ </template>
37
+
38
+ <script setup lang="ts">
39
+ import { computed, useId } from 'vue'
40
+ import { mergeIdReferences, useOwnedAttrs } from '../../../internal/ownedAttrs'
41
+ import { useInteractiveLeafRegistration } from '../../../internal/passiveContentContract'
42
+ import { resolveOptionalDomId } from '../../../internal/runtimeContract'
43
+ import SField from './SField.vue'
44
+
45
+ defineOptions({ inheritAttrs: false })
46
+
47
+ export interface Props {
48
+ modelValue: number
49
+ min: number
50
+ max: number
51
+ step?: number
52
+ label: string
53
+ id?: string
54
+ disabled?: boolean
55
+ readonly?: boolean
56
+ required?: boolean
57
+ invalid?: boolean
58
+ helpText?: string
59
+ errorMessage?: string
60
+ showValue?: boolean
61
+ }
62
+
63
+ const props = withDefaults(defineProps<Props>(), {
64
+ step: 1,
65
+ id: undefined,
66
+ disabled: false,
67
+ readonly: false,
68
+ required: false,
69
+ invalid: false,
70
+ helpText: undefined,
71
+ errorMessage: undefined,
72
+ showValue: false,
73
+ })
74
+
75
+ const emit = defineEmits<{
76
+ 'update:modelValue': [value: number]
77
+ }>()
78
+
79
+ function finiteNumber(field: string, value: number): number {
80
+ if (!Number.isFinite(value)) {
81
+ throw new TypeError(`SRange: ${field} должен быть конечным числом / must be a finite number`)
82
+ }
83
+ return value
84
+ }
85
+
86
+ const validatedMin = computed(() => finiteNumber('min', props.min))
87
+ const validatedMax = computed(() => {
88
+ const value = finiteNumber('max', props.max)
89
+ if (value <= validatedMin.value) {
90
+ throw new RangeError('SRange: max должен быть больше min / max must be greater than min')
91
+ }
92
+ return value
93
+ })
94
+ const validatedStep = computed(() => {
95
+ const value = finiteNumber('step', props.step)
96
+ if (value <= 0) {
97
+ throw new RangeError('SRange: step должен быть больше нуля / step must be greater than zero')
98
+ }
99
+ return value
100
+ })
101
+ const validatedValue = computed(() => {
102
+ const value = finiteNumber('modelValue', props.modelValue)
103
+ if (value < validatedMin.value || value > validatedMax.value) {
104
+ throw new RangeError('SRange: modelValue должен находиться внутри [min, max] / must be within [min, max]')
105
+ }
106
+ return value
107
+ })
108
+
109
+ const generatedId = useId()
110
+ const inputId = computed(() => resolveOptionalDomId('SRange', 'id', props.id, `s-range-${generatedId}`))
111
+ const ownedAttrs = useOwnedAttrs({ component: 'SRange', owner: 'native range input' })
112
+ useInteractiveLeafRegistration({ owner: 'SRange' })
113
+
114
+ function onInput(event: Event): void {
115
+ const target = event.currentTarget
116
+ if (!(target instanceof HTMLInputElement)) {
117
+ throw new TypeError('SRange: input event owner must be the native range input')
118
+ }
119
+ if (props.readonly) {
120
+ target.value = String(validatedValue.value)
121
+ return
122
+ }
123
+ const value = Number(target.value)
124
+ finiteNumber('input value', value)
125
+ emit('update:modelValue', value)
126
+ }
127
+ </script>
128
+
129
+ <style lang="postcss" src="./SRange.css" scoped></style>
@@ -0,0 +1,48 @@
1
+ @reference "../../../styles/reference.css";
2
+
3
+ .s-switch-control {
4
+ @apply relative inline-flex shrink-0 items-center;
5
+ width: 2.5rem;
6
+ height: 1.5rem;
7
+ }
8
+
9
+ .s-switch-input {
10
+ @apply absolute inset-0 z-10 m-0 cursor-pointer opacity-0;
11
+ }
12
+
13
+ .s-switch-track {
14
+ @apply flex h-5 w-9 items-center rounded-full border border-surface-300 bg-surface-200 p-0.5 transition-colors;
15
+ @apply dark:border-surface-600 dark:bg-surface-700;
16
+ transition-duration: var(--s-motion-standard);
17
+ }
18
+
19
+ .s-switch-thumb {
20
+ @apply block h-3.5 w-3.5 rounded-full bg-surface-0 shadow-sm transition-transform;
21
+ @apply dark:bg-surface-100;
22
+ transition-duration: var(--s-motion-standard);
23
+ }
24
+
25
+ .s-switch-input:checked + .s-switch-track {
26
+ @apply border-primary-500 bg-primary-500 dark:border-primary-400 dark:bg-primary-400;
27
+ }
28
+
29
+ .s-switch-input:checked + .s-switch-track > .s-switch-thumb {
30
+ transform: translateX(1rem);
31
+ }
32
+
33
+ .s-switch-input:not(:disabled):hover + .s-switch-track {
34
+ @apply border-primary-500 dark:border-primary-400;
35
+ }
36
+
37
+ .s-switch-input:focus-visible + .s-switch-track {
38
+ outline: var(--s-focus-ring-width) solid var(--s-focus-ring-strong-color);
39
+ outline-offset: var(--s-focus-ring-offset);
40
+ }
41
+
42
+ .s-switch-input:disabled {
43
+ @apply cursor-not-allowed;
44
+ }
45
+
46
+ .s-switch-input:disabled + .s-switch-track {
47
+ @apply border-surface-200 bg-surface-100 opacity-70 dark:border-surface-700 dark:bg-surface-800;
48
+ }