@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.
- package/README.md +5 -5
- package/package.json +19 -3
- package/src/components/layout/SSideMenuButton.vue +5 -4
- package/src/components/layout/SSideMenuRail.vue +3 -6
- package/src/components/layout/SStack.css +9 -3
- package/src/components/layout/SStack.vue +10 -2
- package/src/components/shared/_internal/STreeNode.vue +10 -4
- package/src/components/shared/_internal/sidebarGroupContext.ts +3 -1
- package/src/components/shared/_internal/tabsContext.ts +1 -0
- package/src/components/shared/containers/SLeftSidebar.vue +2 -2
- package/src/components/shared/containers/SPageHeader.css +11 -0
- package/src/components/shared/containers/SPageHeader.vue +7 -1
- package/src/components/shared/containers/SPanel.css +7 -1
- package/src/components/shared/containers/SPanel.vue +5 -1
- package/src/components/shared/containers/SSidebarGroup.css +1 -1
- package/src/components/shared/containers/SSidebarGroup.vue +113 -94
- package/src/components/shared/containers/SSidebarSection.css +16 -4
- package/src/components/shared/containers/SSidebarSection.vue +15 -16
- package/src/components/shared/controls/SButton.css +39 -0
- package/src/components/shared/controls/SButton.vue +146 -29
- package/src/components/shared/controls/SCombobox.css +9 -0
- package/src/components/shared/controls/SCombobox.vue +322 -0
- package/src/components/shared/controls/SContextToggleButton.vue +1 -1
- package/src/components/shared/controls/SInputText.vue +15 -0
- package/src/components/shared/controls/SInteractiveSurface.css +4 -0
- package/src/components/shared/controls/SInteractiveSurface.vue +4 -0
- package/src/components/shared/controls/SListbox.vue +99 -1
- package/src/components/shared/controls/SListboxOption.vue +15 -2
- package/src/components/shared/controls/SRange.css +35 -0
- package/src/components/shared/controls/SRange.vue +129 -0
- package/src/components/shared/controls/SSwitch.css +48 -0
- package/src/components/shared/controls/SSwitch.vue +109 -0
- package/src/components/shared/controls/_internal/SInlineTokenSurface.vue +6 -3
- package/src/components/shared/data-display/SChip.css +59 -0
- package/src/components/shared/data-display/SChip.vue +92 -0
- package/src/components/shared/data-display/SCodeBlock.vue +1 -1
- package/src/components/shared/data-display/SCodeEditor.css +15 -0
- package/src/components/shared/data-display/SCodeEditor.vue +14 -0
- package/src/components/shared/data-display/STable.vue +107 -3
- package/src/components/shared/data-display/table.ts +9 -0
- package/src/components/shared/navigation/SBottomNav.vue +3 -0
- package/src/components/shared/navigation/STab.vue +31 -6
- package/src/components/shared/navigation/STabs.vue +8 -2
- package/src/internal/codeEditorContract.ts +1 -1
- 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
|
+
}
|