@svgrid/grid 1.2.23 → 1.2.24
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/dist/SvAutoComplete.svelte +23 -34
- package/dist/SvCalendar.svelte +74 -334
- package/dist/SvCalendar.svelte.d.ts +5 -23
- package/dist/SvCheckBox.svelte +11 -10
- package/dist/SvColorInput.svelte +31 -58
- package/dist/SvComboBox.svelte +32 -54
- package/dist/SvCountryInput.svelte +31 -29
- package/dist/SvDateTimePicker.svelte +53 -110
- package/dist/SvDateTimePicker.svelte.d.ts +4 -3
- package/dist/SvDropDownList.svelte +31 -54
- package/dist/SvGauge.svelte +21 -23
- package/dist/SvGauge.svelte.d.ts +0 -5
- package/dist/SvListBox.svelte +25 -58
- package/dist/SvListBox.svelte.d.ts +3 -0
- package/dist/SvMaskedInput.svelte +0 -0
- package/dist/SvNumberInput.svelte +0 -0
- package/dist/SvNumberInput.svelte.d.ts +2 -6
- package/dist/SvPasswordInput.svelte +18 -28
- package/dist/SvPasswordInput.svelte.d.ts +0 -4
- package/dist/SvPhoneInput.svelte +0 -0
- package/dist/SvRadioGroup.svelte +14 -37
- package/dist/SvRadioGroup.svelte.d.ts +6 -9
- package/dist/SvRating.svelte +22 -42
- package/dist/SvRating.svelte.d.ts +3 -5
- package/dist/SvSlider.svelte +30 -57
- package/dist/SvSlider.svelte.d.ts +0 -5
- package/dist/SvSwitchButton.svelte +10 -17
- package/dist/SvSwitchButton.svelte.d.ts +0 -5
- package/dist/SvTabs.svelte +26 -39
- package/dist/SvTabs.svelte.d.ts +5 -5
- package/dist/SvTagsInput.svelte +17 -28
- package/dist/SvTagsInput.svelte.d.ts +0 -5
- package/dist/SvTimePicker.svelte +52 -210
- package/dist/SvTimePicker.svelte.d.ts +11 -8
- package/dist/SvToggleButton.svelte +9 -9
- package/dist/SvTree.svelte +30 -109
- package/dist/SvTree.svelte.d.ts +9 -11
- package/dist/cdn/svgrid.js +7488 -5839
- package/dist/cdn/svgrid.svelte-external.js +4691 -3028
- package/dist/createAutocomplete.svelte.d.ts +77 -0
- package/dist/createAutocomplete.svelte.js +100 -0
- package/dist/createCalendar.svelte.d.ts +192 -0
- package/dist/createCalendar.svelte.js +402 -0
- package/dist/createCheckbox.svelte.d.ts +45 -0
- package/dist/createCheckbox.svelte.js +26 -0
- package/dist/createColorInput.svelte.d.ts +67 -0
- package/dist/createColorInput.svelte.js +100 -0
- package/dist/createCombobox.svelte.d.ts +102 -0
- package/dist/createCombobox.svelte.js +171 -0
- package/dist/createCountryInput.svelte.d.ts +86 -0
- package/dist/createCountryInput.svelte.js +126 -0
- package/dist/createDateTimePicker.svelte.d.ts +83 -0
- package/dist/createDateTimePicker.svelte.js +202 -0
- package/dist/createDropdownList.svelte.d.ts +78 -0
- package/dist/createDropdownList.svelte.js +117 -0
- package/dist/createGauge.svelte.d.ts +56 -0
- package/dist/createGauge.svelte.js +49 -0
- package/dist/createListbox.svelte.d.ts +79 -0
- package/dist/createListbox.svelte.js +117 -0
- package/dist/createMaskedInput.svelte.d.ts +30 -0
- package/dist/createMaskedInput.svelte.js +62 -0
- package/dist/createNumberInput.svelte.d.ts +72 -0
- package/dist/createNumberInput.svelte.js +167 -0
- package/dist/createPasswordInput.svelte.d.ts +60 -0
- package/dist/createPasswordInput.svelte.js +72 -0
- package/dist/createPhoneInput.svelte.d.ts +46 -0
- package/dist/createPhoneInput.svelte.js +105 -0
- package/dist/createRadioGroup.svelte.d.ts +64 -0
- package/dist/createRadioGroup.svelte.js +73 -0
- package/dist/createRating.svelte.d.ts +70 -0
- package/dist/createRating.svelte.js +85 -0
- package/dist/createSlider.svelte.d.ts +71 -0
- package/dist/createSlider.svelte.js +123 -0
- package/dist/createSwitch.svelte.d.ts +43 -0
- package/dist/createSwitch.svelte.js +41 -0
- package/dist/createTabs.svelte.d.ts +78 -0
- package/dist/createTabs.svelte.js +92 -0
- package/dist/createTagsInput.svelte.d.ts +65 -0
- package/dist/createTagsInput.svelte.js +93 -0
- package/dist/createTimePicker.svelte.d.ts +106 -0
- package/dist/createTimePicker.svelte.js +248 -0
- package/dist/createToggle.svelte.d.ts +43 -0
- package/dist/createToggle.svelte.js +39 -0
- package/dist/createTree.svelte.d.ts +96 -0
- package/dist/createTree.svelte.js +194 -0
- package/dist/editor-contract.d.ts +48 -0
- package/dist/editor-contract.js +26 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +31 -0
- package/package.json +1 -1
- package/src/SvAutoComplete.svelte +23 -34
- package/src/SvCalendar.svelte +74 -334
- package/src/SvCheckBox.svelte +11 -10
- package/src/SvColorInput.svelte +31 -58
- package/src/SvComboBox.svelte +32 -54
- package/src/SvCountryInput.svelte +31 -29
- package/src/SvDateTimePicker.svelte +53 -110
- package/src/SvDropDownList.svelte +31 -54
- package/src/SvGauge.svelte +21 -23
- package/src/SvListBox.svelte +25 -58
- package/src/SvMaskedInput.svelte +0 -0
- package/src/SvNumberInput.svelte +0 -0
- package/src/SvPasswordInput.svelte +18 -28
- package/src/SvPhoneInput.svelte +0 -0
- package/src/SvRadioGroup.svelte +14 -37
- package/src/SvRating.svelte +22 -42
- package/src/SvSlider.svelte +30 -57
- package/src/SvSwitchButton.svelte +10 -17
- package/src/SvTabs.svelte +26 -39
- package/src/SvTagsInput.svelte +17 -28
- package/src/SvTimePicker.svelte +52 -210
- package/src/SvToggleButton.svelte +9 -9
- package/src/SvTree.svelte +30 -109
- package/src/createAutocomplete.svelte.ts +104 -0
- package/src/createCalendar.svelte.ts +488 -0
- package/src/createCheckbox.svelte.ts +74 -0
- package/src/createColorInput.svelte.ts +115 -0
- package/src/createCombobox.svelte.ts +158 -0
- package/src/createCountryInput.svelte.ts +126 -0
- package/src/createDateTimePicker.svelte.ts +216 -0
- package/src/createDropdownList.svelte.ts +125 -0
- package/src/createGauge.svelte.ts +86 -0
- package/src/createListbox.svelte.ts +186 -0
- package/src/createMaskedInput.svelte.ts +81 -0
- package/src/createNumberInput.svelte.ts +193 -0
- package/src/createPasswordInput.svelte.ts +87 -0
- package/src/createPhoneInput.svelte.ts +119 -0
- package/src/createRadioGroup.svelte.ts +132 -0
- package/src/createRating.svelte.ts +145 -0
- package/src/createSlider.svelte.ts +160 -0
- package/src/createSwitch.svelte.ts +78 -0
- package/src/createTabs.svelte.ts +129 -0
- package/src/createTagsInput.svelte.ts +98 -0
- package/src/createTimePicker.svelte.ts +289 -0
- package/src/createToggle.svelte.ts +69 -0
- package/src/createTree.svelte.ts +216 -0
- package/src/editor-contract.test.ts +32 -0
- package/src/editor-contract.ts +60 -0
- package/src/index.ts +39 -0
- package/src/ui-inputs.test.ts +26 -0
- package/src/ui-selection.test.ts +13 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createPhoneInput - the HEADLESS core behind <SvPhoneInput>: a runes-based
|
|
3
|
+
* state machine that owns the country dial-code + national-number state, parsing
|
|
4
|
+
* an E.164-ish string (`+<dial><digits>`) and re-emitting it. Reuses the pure
|
|
5
|
+
* `./countries` reference and `./datetime/mask` engine. Exposed as **prop-getters**
|
|
6
|
+
* you spread onto YOUR OWN markup. No styles, no DOM.
|
|
7
|
+
*
|
|
8
|
+
* ```svelte
|
|
9
|
+
* <script lang="ts">
|
|
10
|
+
* import { createPhoneInput } from '@svgrid/grid'
|
|
11
|
+
* let value = $state('')
|
|
12
|
+
* const ph = createPhoneInput({ value: () => value, onChange: (v) => (value = v) })
|
|
13
|
+
* </script>
|
|
14
|
+
* <select {...ph.selectProps()}>...</select>
|
|
15
|
+
* <input {...ph.inputProps()} />
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* The styled <SvPhoneInput> is just one renderer over this core.
|
|
19
|
+
*/
|
|
20
|
+
import { COUNTRIES, COUNTRY_BY_CODE, flagEmoji } from './countries'
|
|
21
|
+
import { applyMask } from './datetime/mask'
|
|
22
|
+
|
|
23
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
24
|
+
export type PhoneInputConfig = {
|
|
25
|
+
value: () => string
|
|
26
|
+
onChange?: (value: string, parts: { country: string; dial: string; national: string }) => void
|
|
27
|
+
/** Default country ISO code. */
|
|
28
|
+
country?: () => string
|
|
29
|
+
disabled?: () => boolean
|
|
30
|
+
readonly?: () => boolean
|
|
31
|
+
placeholder?: () => string | undefined
|
|
32
|
+
ariaLabel?: () => string | undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Best-effort parse: strip to the longest matching dial code. */
|
|
36
|
+
function matchCountry(v: string) {
|
|
37
|
+
return [...COUNTRIES].sort((a, b) => b.dial.length - a.dial.length).find((c) => v.startsWith(c.dial))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createPhoneInput(config: PhoneInputConfig) {
|
|
41
|
+
const disabled = () => config.disabled?.() ?? false
|
|
42
|
+
const readonly = () => config.readonly?.() ?? false
|
|
43
|
+
|
|
44
|
+
let iso = $state('US')
|
|
45
|
+
let national = $state('')
|
|
46
|
+
let seeded = false
|
|
47
|
+
let lastVal = ''
|
|
48
|
+
|
|
49
|
+
// US/Canada get a friendly national mask; others are plain grouped digits.
|
|
50
|
+
const mask = $derived(COUNTRY_BY_CODE.get(iso)?.dial === '+1' ? '(###) ###-####' : '')
|
|
51
|
+
const dial = $derived(COUNTRY_BY_CODE.get(iso)?.dial ?? '+1')
|
|
52
|
+
const displayNational = $derived(mask ? applyMask(national, mask).formatted : national)
|
|
53
|
+
|
|
54
|
+
$effect(() => {
|
|
55
|
+
const v = config.value()
|
|
56
|
+
if (!seeded) {
|
|
57
|
+
seeded = true
|
|
58
|
+
iso = config.country?.() ?? 'US'
|
|
59
|
+
const match = matchCountry(v)
|
|
60
|
+
if (match) { iso = match.code; national = v.slice(match.dial.length) }
|
|
61
|
+
else if (v) national = v.replace(/^\+/, '')
|
|
62
|
+
lastVal = v
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (v !== lastVal) {
|
|
66
|
+
lastVal = v
|
|
67
|
+
const match = matchCountry(v)
|
|
68
|
+
if (match) { iso = match.code; national = v.slice(match.dial.length) }
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
function emit() {
|
|
73
|
+
config.onChange?.(`${dial}${national}`, { country: iso, dial, national })
|
|
74
|
+
}
|
|
75
|
+
function onNumberInput(e: Event) {
|
|
76
|
+
const raw = (e.currentTarget as HTMLInputElement).value.replace(/\D/g, '')
|
|
77
|
+
national = raw
|
|
78
|
+
lastVal = `${dial}${raw}`
|
|
79
|
+
emit()
|
|
80
|
+
}
|
|
81
|
+
function onCountryChange(e: Event) {
|
|
82
|
+
iso = (e.currentTarget as HTMLSelectElement).value
|
|
83
|
+
lastVal = `${dial}${national}`
|
|
84
|
+
emit()
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
/** Selected country ISO code. */
|
|
89
|
+
get iso() { return iso },
|
|
90
|
+
/** Dial code for the selected country (e.g. `+1`). */
|
|
91
|
+
get dial() { return dial },
|
|
92
|
+
/** Flag emoji for the selected country. */
|
|
93
|
+
get flag() { return flagEmoji(iso) },
|
|
94
|
+
/** National number formatted for display. */
|
|
95
|
+
get displayNational() { return displayNational },
|
|
96
|
+
/** The full emitted value (`+<dial><digits>`). */
|
|
97
|
+
get value() { return `${dial}${national}` },
|
|
98
|
+
/** Spread onto the country `<select>` element. */
|
|
99
|
+
selectProps: () => ({
|
|
100
|
+
value: iso,
|
|
101
|
+
disabled: disabled(),
|
|
102
|
+
'aria-label': 'Country',
|
|
103
|
+
onchange: onCountryChange,
|
|
104
|
+
}),
|
|
105
|
+
/** Spread onto the national number input element. */
|
|
106
|
+
inputProps: () => ({
|
|
107
|
+
value: displayNational,
|
|
108
|
+
type: 'tel' as const,
|
|
109
|
+
inputmode: 'tel' as const,
|
|
110
|
+
placeholder: config.placeholder?.() ?? 'Phone number',
|
|
111
|
+
disabled: disabled(),
|
|
112
|
+
readonly: readonly(),
|
|
113
|
+
'aria-label': config.ariaLabel?.() ?? 'Phone number',
|
|
114
|
+
oninput: onNumberInput,
|
|
115
|
+
}),
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type PhoneInputCore = ReturnType<typeof createPhoneInput>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createRadioGroup - the HEADLESS core behind <SvRadioGroup>: an accessible
|
|
3
|
+
* single-select radio group (WAI-ARIA `radiogroup`) with roving tabindex + full
|
|
4
|
+
* arrow-key navigation, exposed as **prop-getters** you spread onto YOUR OWN
|
|
5
|
+
* markup. Keyboard/ARIA/roving-focus come from the core, the DOM is yours.
|
|
6
|
+
*
|
|
7
|
+
* ```svelte
|
|
8
|
+
* <script lang="ts">
|
|
9
|
+
* import { createRadioGroup, type RadioOption } from '@svgrid/grid'
|
|
10
|
+
* let value = $state<string | number | null>('a')
|
|
11
|
+
* const rg = createRadioGroup({ options: () => options, value: () => value, onChange: (v) => (value = v) })
|
|
12
|
+
* </script>
|
|
13
|
+
* <div {...rg.groupProps()}>
|
|
14
|
+
* {#each options as opt (opt.value)}
|
|
15
|
+
* <button {...rg.radioProps(opt)}>{opt.label}</button>
|
|
16
|
+
* {/each}
|
|
17
|
+
* </div>
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* The styled <SvRadioGroup> is just one renderer over this core.
|
|
21
|
+
*/
|
|
22
|
+
export type RadioOption = { value: string | number; label: string; disabled?: boolean }
|
|
23
|
+
|
|
24
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
25
|
+
export type RadioGroupConfig = {
|
|
26
|
+
options: () => ReadonlyArray<RadioOption>
|
|
27
|
+
value: () => string | number | null
|
|
28
|
+
onChange?: (value: string | number) => void
|
|
29
|
+
disabled?: () => boolean
|
|
30
|
+
ariaLabel?: () => string | undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type RadioProps = {
|
|
34
|
+
type: 'button'
|
|
35
|
+
role: 'radio'
|
|
36
|
+
'aria-checked': boolean
|
|
37
|
+
'data-idx': number
|
|
38
|
+
'data-checked': '' | undefined
|
|
39
|
+
disabled: boolean
|
|
40
|
+
tabindex: number
|
|
41
|
+
onclick: () => void
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type RadioGroupProps = {
|
|
45
|
+
role: 'radiogroup'
|
|
46
|
+
'aria-label': string | undefined
|
|
47
|
+
'aria-disabled': boolean
|
|
48
|
+
onkeydown: (e: KeyboardEvent) => void
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type RadioGroup = {
|
|
52
|
+
/** Currently selected value. */
|
|
53
|
+
readonly value: string | number | null
|
|
54
|
+
readonly disabled: boolean
|
|
55
|
+
isChecked: (option: RadioOption) => boolean
|
|
56
|
+
/** Select by value (no-op when disabled / option disabled / unknown). */
|
|
57
|
+
select: (value: string | number) => void
|
|
58
|
+
onKeydown: (e: KeyboardEvent) => void
|
|
59
|
+
/** Spread onto the group container element. */
|
|
60
|
+
groupProps: () => RadioGroupProps
|
|
61
|
+
/** Spread onto each option element. */
|
|
62
|
+
radioProps: (option: RadioOption) => RadioProps
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createRadioGroup(config: RadioGroupConfig): RadioGroup {
|
|
66
|
+
const opts = () => config.options()
|
|
67
|
+
const disabled = () => config.disabled?.() ?? false
|
|
68
|
+
|
|
69
|
+
const enabledIdx = $derived(opts().map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0))
|
|
70
|
+
const selectedIdx = $derived(opts().findIndex((o) => o.value === config.value()))
|
|
71
|
+
// Roving-tabindex target: the selected option, else the first enabled one.
|
|
72
|
+
const focusIdx = $derived(selectedIdx >= 0 ? selectedIdx : enabledIdx[0] ?? -1)
|
|
73
|
+
|
|
74
|
+
function pickIndex(i: number) {
|
|
75
|
+
const o = opts()[i]
|
|
76
|
+
if (!o || o.disabled || disabled()) return
|
|
77
|
+
config.onChange?.(o.value)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function select(value: string | number) {
|
|
81
|
+
pickIndex(opts().findIndex((o) => o.value === value))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Arrow handling lives on the group container so it works for ANY markup:
|
|
85
|
+
// the originating radio is found by role + data-idx, so nothing is assumed
|
|
86
|
+
// about how you nest your elements.
|
|
87
|
+
function onKeydown(e: KeyboardEvent) {
|
|
88
|
+
if (disabled()) return
|
|
89
|
+
const container = e.currentTarget as HTMLElement
|
|
90
|
+
const origin = (e.target as HTMLElement | null)?.closest<HTMLElement>('[role="radio"]')
|
|
91
|
+
const i = origin ? Number(origin.getAttribute('data-idx')) : focusIdx
|
|
92
|
+
const pos = enabledIdx.indexOf(i)
|
|
93
|
+
if (pos < 0) return
|
|
94
|
+
if (e.key === ' ') { e.preventDefault(); pickIndex(i); return }
|
|
95
|
+
let nextPos = pos
|
|
96
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowRight') nextPos = (pos + 1) % enabledIdx.length
|
|
97
|
+
else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') nextPos = (pos - 1 + enabledIdx.length) % enabledIdx.length
|
|
98
|
+
else return
|
|
99
|
+
e.preventDefault()
|
|
100
|
+
const target = enabledIdx[nextPos]!
|
|
101
|
+
pickIndex(target)
|
|
102
|
+
container.querySelectorAll<HTMLElement>('[role="radio"]')[target]?.focus()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
get value() { return config.value() },
|
|
107
|
+
get disabled() { return disabled() },
|
|
108
|
+
isChecked: (option: RadioOption) => option.value === config.value(),
|
|
109
|
+
select,
|
|
110
|
+
onKeydown,
|
|
111
|
+
groupProps: () => ({
|
|
112
|
+
role: 'radiogroup',
|
|
113
|
+
'aria-label': config.ariaLabel?.(),
|
|
114
|
+
'aria-disabled': disabled(),
|
|
115
|
+
onkeydown: onKeydown,
|
|
116
|
+
}),
|
|
117
|
+
radioProps: (option: RadioOption) => {
|
|
118
|
+
const i = opts().findIndex((o) => o.value === option.value)
|
|
119
|
+
const checked = option.value === config.value()
|
|
120
|
+
return {
|
|
121
|
+
type: 'button',
|
|
122
|
+
role: 'radio',
|
|
123
|
+
'aria-checked': checked,
|
|
124
|
+
'data-idx': i,
|
|
125
|
+
'data-checked': checked ? '' : undefined,
|
|
126
|
+
disabled: disabled() || !!option.disabled,
|
|
127
|
+
tabindex: i === focusIdx ? 0 : -1,
|
|
128
|
+
onclick: () => pickIndex(i),
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createRating - the HEADLESS core behind <SvRating>: a controlled star-rating
|
|
3
|
+
* control (WAI-ARIA `slider`) with hover preview, optional half steps, keyboard
|
|
4
|
+
* (Arrows step, Home/End jump), and readonly/disabled - exposed as **prop-getters**
|
|
5
|
+
* you spread onto YOUR OWN markup.
|
|
6
|
+
*
|
|
7
|
+
* ```svelte
|
|
8
|
+
* <script lang="ts">
|
|
9
|
+
* import { createRating } from '@svgrid/grid'
|
|
10
|
+
* let value = $state(3)
|
|
11
|
+
* const r = createRating({ value: () => value, onChange: (v) => (value = v) })
|
|
12
|
+
* </script>
|
|
13
|
+
* <div {...r.rootProps()}>
|
|
14
|
+
* {#each Array(5) as _, i}<button {...r.starProps(i)}>{r.fillOf(i)}</button>{/each}
|
|
15
|
+
* </div>
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* The styled <SvRating> is just one renderer over this core.
|
|
19
|
+
*/
|
|
20
|
+
export type RatingFill = 'full' | 'half' | 'empty'
|
|
21
|
+
|
|
22
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
23
|
+
export type RatingConfig = {
|
|
24
|
+
value: () => number
|
|
25
|
+
onChange?: (value: number) => void
|
|
26
|
+
max?: () => number
|
|
27
|
+
allowHalf?: () => boolean
|
|
28
|
+
readonly?: () => boolean
|
|
29
|
+
disabled?: () => boolean
|
|
30
|
+
ariaLabel?: () => string | undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type RatingRootProps = {
|
|
34
|
+
role: 'slider'
|
|
35
|
+
tabindex: number
|
|
36
|
+
'aria-label': string
|
|
37
|
+
'aria-valuenow': number
|
|
38
|
+
'aria-valuemin': number
|
|
39
|
+
'aria-valuemax': number
|
|
40
|
+
'aria-readonly': boolean
|
|
41
|
+
onkeydown: (e: KeyboardEvent) => void
|
|
42
|
+
onpointerleave: () => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type RatingStarProps = {
|
|
46
|
+
type: 'button'
|
|
47
|
+
tabindex: number
|
|
48
|
+
disabled: boolean
|
|
49
|
+
'aria-label': string
|
|
50
|
+
'data-fill': RatingFill
|
|
51
|
+
onpointermove: (e: PointerEvent) => void
|
|
52
|
+
onclick: (e: MouseEvent) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type Rating = {
|
|
56
|
+
/** The committed value. */
|
|
57
|
+
readonly value: number
|
|
58
|
+
/** What the stars should paint right now: the hover preview, else `value`. */
|
|
59
|
+
readonly preview: number
|
|
60
|
+
readonly max: number
|
|
61
|
+
readonly interactive: boolean
|
|
62
|
+
/** Fill state of the star at `index` given the current preview. */
|
|
63
|
+
fillOf: (index: number) => RatingFill
|
|
64
|
+
/** Set the hover preview (pass null to clear). No-op when non-interactive. */
|
|
65
|
+
setHover: (value: number | null) => void
|
|
66
|
+
/** Commit a value. No-op when non-interactive. */
|
|
67
|
+
pick: (value: number) => void
|
|
68
|
+
onKeydown: (e: KeyboardEvent) => void
|
|
69
|
+
/** Spread onto the container element. */
|
|
70
|
+
rootProps: () => RatingRootProps
|
|
71
|
+
/** Spread onto the star element at `index`. */
|
|
72
|
+
starProps: (index: number) => RatingStarProps
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function createRating(config: RatingConfig): Rating {
|
|
76
|
+
const max = () => config.max?.() ?? 5
|
|
77
|
+
const allowHalf = () => config.allowHalf?.() ?? false
|
|
78
|
+
const ro = () => config.readonly?.() ?? false
|
|
79
|
+
const disabled = () => config.disabled?.() ?? false
|
|
80
|
+
const interactive = () => !ro() && !disabled()
|
|
81
|
+
|
|
82
|
+
let hover = $state<number | null>(null)
|
|
83
|
+
const preview = $derived(hover ?? config.value())
|
|
84
|
+
|
|
85
|
+
function valueAt(e: MouseEvent, index: number): number {
|
|
86
|
+
if (!allowHalf()) return index + 1
|
|
87
|
+
const el = e.currentTarget as HTMLElement
|
|
88
|
+
const rect = el.getBoundingClientRect()
|
|
89
|
+
return e.clientX - rect.left < rect.width / 2 ? index + 0.5 : index + 1
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function fillOf(index: number): RatingFill {
|
|
93
|
+
if (preview >= index + 1) return 'full'
|
|
94
|
+
if (preview >= index + 0.5) return 'half'
|
|
95
|
+
return 'empty'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function setHover(value: number | null) {
|
|
99
|
+
if (interactive()) hover = value
|
|
100
|
+
}
|
|
101
|
+
function pick(value: number) {
|
|
102
|
+
if (interactive()) config.onChange?.(value)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function onKeydown(e: KeyboardEvent) {
|
|
106
|
+
if (!interactive()) return
|
|
107
|
+
const step = allowHalf() ? 0.5 : 1
|
|
108
|
+
const v = config.value()
|
|
109
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); config.onChange?.(Math.min(max(), v + step)) }
|
|
110
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); config.onChange?.(Math.max(0, v - step)) }
|
|
111
|
+
else if (e.key === 'Home') { e.preventDefault(); config.onChange?.(0) }
|
|
112
|
+
else if (e.key === 'End') { e.preventDefault(); config.onChange?.(max()) }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
get value() { return config.value() },
|
|
117
|
+
get preview() { return preview },
|
|
118
|
+
get max() { return max() },
|
|
119
|
+
get interactive() { return interactive() },
|
|
120
|
+
fillOf,
|
|
121
|
+
setHover,
|
|
122
|
+
pick,
|
|
123
|
+
onKeydown,
|
|
124
|
+
rootProps: () => ({
|
|
125
|
+
role: 'slider',
|
|
126
|
+
tabindex: interactive() ? 0 : -1,
|
|
127
|
+
'aria-label': config.ariaLabel?.() ?? 'Rating',
|
|
128
|
+
'aria-valuenow': config.value(),
|
|
129
|
+
'aria-valuemin': 0,
|
|
130
|
+
'aria-valuemax': max(),
|
|
131
|
+
'aria-readonly': ro(),
|
|
132
|
+
onkeydown: onKeydown,
|
|
133
|
+
onpointerleave: () => { hover = null },
|
|
134
|
+
}),
|
|
135
|
+
starProps: (index: number) => ({
|
|
136
|
+
type: 'button',
|
|
137
|
+
tabindex: -1,
|
|
138
|
+
disabled: !interactive(),
|
|
139
|
+
'aria-label': `${index + 1} star${index ? 's' : ''}`,
|
|
140
|
+
'data-fill': fillOf(index),
|
|
141
|
+
onpointermove: (e: PointerEvent) => { if (interactive()) hover = valueAt(e, index) },
|
|
142
|
+
onclick: (e: MouseEvent) => { if (interactive()) config.onChange?.(valueAt(e, index)) },
|
|
143
|
+
}),
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createSlider - the HEADLESS core behind <SvSlider>, in the same spirit as
|
|
3
|
+
* `createSvGrid` for the grid: a runes-based state machine (single + dual-thumb,
|
|
4
|
+
* stepping, keyboard, drag state) that owns the value<->position math and the
|
|
5
|
+
* ARIA wiring, exposed as **prop-getters** you spread onto YOUR OWN markup.
|
|
6
|
+
*
|
|
7
|
+
* The core never touches the DOM: the styled renderer measures the track
|
|
8
|
+
* (`getBoundingClientRect`) and passes the rect + pointer position IN via
|
|
9
|
+
* `pointerDown` / `setFromPointer`. The core turns that into a stepped value.
|
|
10
|
+
*
|
|
11
|
+
* ```svelte
|
|
12
|
+
* <script lang="ts">
|
|
13
|
+
* import { createSlider } from '@svgrid/grid'
|
|
14
|
+
* let value = $state(50)
|
|
15
|
+
* const s = createSlider({ value: () => value, onChange: (v) => (value = v as number) })
|
|
16
|
+
* </script>
|
|
17
|
+
* <div bind:this={track} onpointerdown={(e) => s.pointerDown({ x: e.clientX, y: e.clientY }, track.getBoundingClientRect())}>
|
|
18
|
+
* <div {...s.thumbProps(0)} style:left={s.percentOf(s.hi) + '%'}></div>
|
|
19
|
+
* </div>
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export type SliderValue = number | [number, number]
|
|
23
|
+
export type SliderOrientation = 'horizontal' | 'vertical'
|
|
24
|
+
export type SliderThumb = 'single' | 'lo' | 'hi'
|
|
25
|
+
export type SliderPoint = { x: number; y: number }
|
|
26
|
+
|
|
27
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
28
|
+
export type SliderConfig = {
|
|
29
|
+
value: () => SliderValue
|
|
30
|
+
onChange?: (value: SliderValue) => void
|
|
31
|
+
min?: () => number
|
|
32
|
+
max?: () => number
|
|
33
|
+
step?: () => number
|
|
34
|
+
range?: () => boolean
|
|
35
|
+
orientation?: () => SliderOrientation
|
|
36
|
+
disabled?: () => boolean
|
|
37
|
+
ariaLabel?: () => string | undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createSlider(config: SliderConfig) {
|
|
41
|
+
const min = () => config.min?.() ?? 0
|
|
42
|
+
const max = () => config.max?.() ?? 100
|
|
43
|
+
const stepSize = () => config.step?.() ?? 1
|
|
44
|
+
const range = () => config.range?.() ?? false
|
|
45
|
+
const isVert = () => (config.orientation?.() ?? 'horizontal') === 'vertical'
|
|
46
|
+
const disabled = () => config.disabled?.() ?? false
|
|
47
|
+
|
|
48
|
+
const lo = $derived(range() ? (config.value() as [number, number])[0] : min())
|
|
49
|
+
const hi = $derived(range() ? (config.value() as [number, number])[1] : (config.value() as number))
|
|
50
|
+
|
|
51
|
+
function clampStep(v: number): number {
|
|
52
|
+
const stepped = Math.round((v - min()) / stepSize()) * stepSize() + min()
|
|
53
|
+
return Math.min(max(), Math.max(min(), Number(stepped.toFixed(10))))
|
|
54
|
+
}
|
|
55
|
+
/** Value -> percentage along the track (0..100). */
|
|
56
|
+
const percentOf = (v: number) => ((v - min()) / (max() - min())) * 100
|
|
57
|
+
|
|
58
|
+
let dragging = $state<SliderThumb | null>(null)
|
|
59
|
+
|
|
60
|
+
/** Turn a pointer position (client coords) + measured track rect into a value. */
|
|
61
|
+
function valueFromPointer(pos: SliderPoint, rect: DOMRect): number {
|
|
62
|
+
const frac = isVert()
|
|
63
|
+
? 1 - (pos.y - rect.top) / rect.height
|
|
64
|
+
: (pos.x - rect.left) / rect.width
|
|
65
|
+
return clampStep(min() + Math.min(1, Math.max(0, frac)) * (max() - min()))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const emitSingle = (v: number) => config.onChange?.(v)
|
|
69
|
+
const emitRange = (nlo: number, nhi: number) => config.onChange?.([Math.min(nlo, nhi), Math.max(nlo, nhi)])
|
|
70
|
+
|
|
71
|
+
function nudge(which: SliderThumb, next: number) {
|
|
72
|
+
if (which === 'single') emitSingle(next)
|
|
73
|
+
else if (which === 'lo') emitRange(next, hi)
|
|
74
|
+
else emitRange(lo, next)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Begin a drag: pick the nearest thumb (range) and emit the initial value. */
|
|
78
|
+
function pointerDown(pos: SliderPoint, rect: DOMRect) {
|
|
79
|
+
if (disabled()) return
|
|
80
|
+
const v = valueFromPointer(pos, rect)
|
|
81
|
+
if (range()) {
|
|
82
|
+
const which: SliderThumb = Math.abs(v - lo) <= Math.abs(v - hi) ? 'lo' : 'hi'
|
|
83
|
+
dragging = which
|
|
84
|
+
which === 'lo' ? emitRange(v, hi) : emitRange(lo, v)
|
|
85
|
+
} else {
|
|
86
|
+
dragging = 'single'
|
|
87
|
+
emitSingle(v)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Continue a drag: emit the value for the currently-dragged thumb. */
|
|
91
|
+
function setFromPointer(pos: SliderPoint, rect: DOMRect) {
|
|
92
|
+
if (!dragging) return
|
|
93
|
+
nudge(dragging, valueFromPointer(pos, rect))
|
|
94
|
+
}
|
|
95
|
+
function endDrag() { dragging = null }
|
|
96
|
+
|
|
97
|
+
/** Step a thumb by `delta` steps (used for custom controls). */
|
|
98
|
+
function step(which: SliderThumb, delta: number) {
|
|
99
|
+
if (disabled()) return
|
|
100
|
+
const cur = which === 'lo' ? lo : hi
|
|
101
|
+
nudge(which, clampStep(cur + delta * stepSize()))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function onThumbKey(e: KeyboardEvent, which: SliderThumb) {
|
|
105
|
+
if (disabled()) return
|
|
106
|
+
const cur = which === 'lo' ? lo : hi
|
|
107
|
+
let next = cur
|
|
108
|
+
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') next = clampStep(cur + stepSize())
|
|
109
|
+
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') next = clampStep(cur - stepSize())
|
|
110
|
+
else if (e.key === 'Home') next = min()
|
|
111
|
+
else if (e.key === 'End') next = max()
|
|
112
|
+
else if (e.key === 'PageUp') next = clampStep(cur + stepSize() * 10)
|
|
113
|
+
else if (e.key === 'PageDown') next = clampStep(cur - stepSize() * 10)
|
|
114
|
+
else return
|
|
115
|
+
e.preventDefault()
|
|
116
|
+
nudge(which, next)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const thumbOf = (i: number): SliderThumb => (range() ? (i === 0 ? 'lo' : 'hi') : 'single')
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
get value() { return config.value() },
|
|
123
|
+
get lo() { return lo },
|
|
124
|
+
get hi() { return hi },
|
|
125
|
+
/** Thumb currently being dragged, or null. */
|
|
126
|
+
get dragging() { return dragging },
|
|
127
|
+
percentOf,
|
|
128
|
+
clampStep,
|
|
129
|
+
valueFromPointer,
|
|
130
|
+
pointerDown,
|
|
131
|
+
setFromPointer,
|
|
132
|
+
endDrag,
|
|
133
|
+
step,
|
|
134
|
+
/** Spread onto the track container (static a11y/data hooks). */
|
|
135
|
+
trackProps: () => ({
|
|
136
|
+
'data-orientation': isVert() ? 'vertical' : 'horizontal',
|
|
137
|
+
}),
|
|
138
|
+
/** Spread onto the thumb at index `i` (0 = single/low, 1 = high). */
|
|
139
|
+
thumbProps: (i: number) => {
|
|
140
|
+
const which = thumbOf(i)
|
|
141
|
+
const cur = which === 'lo' ? lo : hi
|
|
142
|
+
const vmin = which === 'hi' ? lo : min()
|
|
143
|
+
const vmax = which === 'lo' ? hi : max()
|
|
144
|
+
const label = which === 'lo' ? 'Minimum' : which === 'hi' ? 'Maximum' : (config.ariaLabel?.() ?? 'Value')
|
|
145
|
+
return {
|
|
146
|
+
role: 'slider' as const,
|
|
147
|
+
tabindex: disabled() ? -1 : 0,
|
|
148
|
+
'aria-valuemin': vmin,
|
|
149
|
+
'aria-valuemax': vmax,
|
|
150
|
+
'aria-valuenow': cur,
|
|
151
|
+
'aria-label': label,
|
|
152
|
+
'aria-orientation': (isVert() ? 'vertical' : 'horizontal') as SliderOrientation,
|
|
153
|
+
'aria-disabled': disabled() || undefined,
|
|
154
|
+
onkeydown: (e: KeyboardEvent) => onThumbKey(e, which),
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type Slider = ReturnType<typeof createSlider>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createSwitch - the HEADLESS core behind <SvSwitchButton>: a controlled on/off
|
|
3
|
+
* switch (ARIA `switch`) with full keyboard (Space/Enter toggle, ArrowRight on,
|
|
4
|
+
* ArrowLeft off), exposed as **prop-getters** you spread onto YOUR OWN markup.
|
|
5
|
+
*
|
|
6
|
+
* ```svelte
|
|
7
|
+
* <script lang="ts">
|
|
8
|
+
* import { createSwitch } from '@svgrid/grid'
|
|
9
|
+
* let on = $state(false)
|
|
10
|
+
* const sw = createSwitch({ checked: () => on, onChange: (v) => (on = v) })
|
|
11
|
+
* </script>
|
|
12
|
+
* <button {...sw.switchProps()}><span class="thumb"></span></button>
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* The styled <SvSwitchButton> is just one renderer over this core.
|
|
16
|
+
*/
|
|
17
|
+
export type SwitchConfig = {
|
|
18
|
+
checked: () => boolean
|
|
19
|
+
onChange?: (checked: boolean) => void
|
|
20
|
+
disabled?: () => boolean
|
|
21
|
+
ariaLabel?: () => string | undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type SwitchProps = {
|
|
25
|
+
type: 'button'
|
|
26
|
+
role: 'switch'
|
|
27
|
+
'aria-checked': boolean
|
|
28
|
+
'aria-label': string | undefined
|
|
29
|
+
disabled: boolean
|
|
30
|
+
'data-checked': '' | undefined
|
|
31
|
+
onclick: () => void
|
|
32
|
+
onkeydown: (e: KeyboardEvent) => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type Switch = {
|
|
36
|
+
/** Current on/off state. */
|
|
37
|
+
readonly checked: boolean
|
|
38
|
+
readonly disabled: boolean
|
|
39
|
+
/** Flip on/off (no-op when disabled). */
|
|
40
|
+
toggle: () => void
|
|
41
|
+
onKeydown: (e: KeyboardEvent) => void
|
|
42
|
+
/** Spread onto the switch element. */
|
|
43
|
+
switchProps: () => SwitchProps
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createSwitch(config: SwitchConfig): Switch {
|
|
47
|
+
const checked = () => config.checked()
|
|
48
|
+
const disabled = () => config.disabled?.() ?? false
|
|
49
|
+
|
|
50
|
+
function toggle() {
|
|
51
|
+
if (disabled()) return
|
|
52
|
+
config.onChange?.(!checked())
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function onKeydown(e: KeyboardEvent) {
|
|
56
|
+
if (disabled()) return
|
|
57
|
+
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); toggle() }
|
|
58
|
+
else if (e.key === 'ArrowRight') { if (!checked()) config.onChange?.(true) }
|
|
59
|
+
else if (e.key === 'ArrowLeft') { if (checked()) config.onChange?.(false) }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
get checked() { return checked() },
|
|
64
|
+
get disabled() { return disabled() },
|
|
65
|
+
toggle,
|
|
66
|
+
onKeydown,
|
|
67
|
+
switchProps: () => ({
|
|
68
|
+
type: 'button',
|
|
69
|
+
role: 'switch',
|
|
70
|
+
'aria-checked': checked(),
|
|
71
|
+
'aria-label': config.ariaLabel?.(),
|
|
72
|
+
disabled: disabled(),
|
|
73
|
+
'data-checked': checked() ? '' : undefined,
|
|
74
|
+
onclick: toggle,
|
|
75
|
+
onkeydown: onKeydown,
|
|
76
|
+
}),
|
|
77
|
+
}
|
|
78
|
+
}
|