@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.
Files changed (141) hide show
  1. package/dist/SvAutoComplete.svelte +23 -34
  2. package/dist/SvCalendar.svelte +74 -334
  3. package/dist/SvCalendar.svelte.d.ts +5 -23
  4. package/dist/SvCheckBox.svelte +11 -10
  5. package/dist/SvColorInput.svelte +31 -58
  6. package/dist/SvComboBox.svelte +32 -54
  7. package/dist/SvCountryInput.svelte +31 -29
  8. package/dist/SvDateTimePicker.svelte +53 -110
  9. package/dist/SvDateTimePicker.svelte.d.ts +4 -3
  10. package/dist/SvDropDownList.svelte +31 -54
  11. package/dist/SvGauge.svelte +21 -23
  12. package/dist/SvGauge.svelte.d.ts +0 -5
  13. package/dist/SvListBox.svelte +25 -58
  14. package/dist/SvListBox.svelte.d.ts +3 -0
  15. package/dist/SvMaskedInput.svelte +0 -0
  16. package/dist/SvNumberInput.svelte +0 -0
  17. package/dist/SvNumberInput.svelte.d.ts +2 -6
  18. package/dist/SvPasswordInput.svelte +18 -28
  19. package/dist/SvPasswordInput.svelte.d.ts +0 -4
  20. package/dist/SvPhoneInput.svelte +0 -0
  21. package/dist/SvRadioGroup.svelte +14 -37
  22. package/dist/SvRadioGroup.svelte.d.ts +6 -9
  23. package/dist/SvRating.svelte +22 -42
  24. package/dist/SvRating.svelte.d.ts +3 -5
  25. package/dist/SvSlider.svelte +30 -57
  26. package/dist/SvSlider.svelte.d.ts +0 -5
  27. package/dist/SvSwitchButton.svelte +10 -17
  28. package/dist/SvSwitchButton.svelte.d.ts +0 -5
  29. package/dist/SvTabs.svelte +26 -39
  30. package/dist/SvTabs.svelte.d.ts +5 -5
  31. package/dist/SvTagsInput.svelte +17 -28
  32. package/dist/SvTagsInput.svelte.d.ts +0 -5
  33. package/dist/SvTimePicker.svelte +52 -210
  34. package/dist/SvTimePicker.svelte.d.ts +11 -8
  35. package/dist/SvToggleButton.svelte +9 -9
  36. package/dist/SvTree.svelte +30 -109
  37. package/dist/SvTree.svelte.d.ts +9 -11
  38. package/dist/cdn/svgrid.js +7488 -5839
  39. package/dist/cdn/svgrid.svelte-external.js +4691 -3028
  40. package/dist/createAutocomplete.svelte.d.ts +77 -0
  41. package/dist/createAutocomplete.svelte.js +100 -0
  42. package/dist/createCalendar.svelte.d.ts +192 -0
  43. package/dist/createCalendar.svelte.js +402 -0
  44. package/dist/createCheckbox.svelte.d.ts +45 -0
  45. package/dist/createCheckbox.svelte.js +26 -0
  46. package/dist/createColorInput.svelte.d.ts +67 -0
  47. package/dist/createColorInput.svelte.js +100 -0
  48. package/dist/createCombobox.svelte.d.ts +102 -0
  49. package/dist/createCombobox.svelte.js +171 -0
  50. package/dist/createCountryInput.svelte.d.ts +86 -0
  51. package/dist/createCountryInput.svelte.js +126 -0
  52. package/dist/createDateTimePicker.svelte.d.ts +83 -0
  53. package/dist/createDateTimePicker.svelte.js +202 -0
  54. package/dist/createDropdownList.svelte.d.ts +78 -0
  55. package/dist/createDropdownList.svelte.js +117 -0
  56. package/dist/createGauge.svelte.d.ts +56 -0
  57. package/dist/createGauge.svelte.js +49 -0
  58. package/dist/createListbox.svelte.d.ts +79 -0
  59. package/dist/createListbox.svelte.js +117 -0
  60. package/dist/createMaskedInput.svelte.d.ts +30 -0
  61. package/dist/createMaskedInput.svelte.js +62 -0
  62. package/dist/createNumberInput.svelte.d.ts +72 -0
  63. package/dist/createNumberInput.svelte.js +167 -0
  64. package/dist/createPasswordInput.svelte.d.ts +60 -0
  65. package/dist/createPasswordInput.svelte.js +72 -0
  66. package/dist/createPhoneInput.svelte.d.ts +46 -0
  67. package/dist/createPhoneInput.svelte.js +105 -0
  68. package/dist/createRadioGroup.svelte.d.ts +64 -0
  69. package/dist/createRadioGroup.svelte.js +73 -0
  70. package/dist/createRating.svelte.d.ts +70 -0
  71. package/dist/createRating.svelte.js +85 -0
  72. package/dist/createSlider.svelte.d.ts +71 -0
  73. package/dist/createSlider.svelte.js +123 -0
  74. package/dist/createSwitch.svelte.d.ts +43 -0
  75. package/dist/createSwitch.svelte.js +41 -0
  76. package/dist/createTabs.svelte.d.ts +78 -0
  77. package/dist/createTabs.svelte.js +92 -0
  78. package/dist/createTagsInput.svelte.d.ts +65 -0
  79. package/dist/createTagsInput.svelte.js +93 -0
  80. package/dist/createTimePicker.svelte.d.ts +106 -0
  81. package/dist/createTimePicker.svelte.js +248 -0
  82. package/dist/createToggle.svelte.d.ts +43 -0
  83. package/dist/createToggle.svelte.js +39 -0
  84. package/dist/createTree.svelte.d.ts +96 -0
  85. package/dist/createTree.svelte.js +194 -0
  86. package/dist/editor-contract.d.ts +48 -0
  87. package/dist/editor-contract.js +26 -0
  88. package/dist/index.d.ts +24 -0
  89. package/dist/index.js +31 -0
  90. package/package.json +1 -1
  91. package/src/SvAutoComplete.svelte +23 -34
  92. package/src/SvCalendar.svelte +74 -334
  93. package/src/SvCheckBox.svelte +11 -10
  94. package/src/SvColorInput.svelte +31 -58
  95. package/src/SvComboBox.svelte +32 -54
  96. package/src/SvCountryInput.svelte +31 -29
  97. package/src/SvDateTimePicker.svelte +53 -110
  98. package/src/SvDropDownList.svelte +31 -54
  99. package/src/SvGauge.svelte +21 -23
  100. package/src/SvListBox.svelte +25 -58
  101. package/src/SvMaskedInput.svelte +0 -0
  102. package/src/SvNumberInput.svelte +0 -0
  103. package/src/SvPasswordInput.svelte +18 -28
  104. package/src/SvPhoneInput.svelte +0 -0
  105. package/src/SvRadioGroup.svelte +14 -37
  106. package/src/SvRating.svelte +22 -42
  107. package/src/SvSlider.svelte +30 -57
  108. package/src/SvSwitchButton.svelte +10 -17
  109. package/src/SvTabs.svelte +26 -39
  110. package/src/SvTagsInput.svelte +17 -28
  111. package/src/SvTimePicker.svelte +52 -210
  112. package/src/SvToggleButton.svelte +9 -9
  113. package/src/SvTree.svelte +30 -109
  114. package/src/createAutocomplete.svelte.ts +104 -0
  115. package/src/createCalendar.svelte.ts +488 -0
  116. package/src/createCheckbox.svelte.ts +74 -0
  117. package/src/createColorInput.svelte.ts +115 -0
  118. package/src/createCombobox.svelte.ts +158 -0
  119. package/src/createCountryInput.svelte.ts +126 -0
  120. package/src/createDateTimePicker.svelte.ts +216 -0
  121. package/src/createDropdownList.svelte.ts +125 -0
  122. package/src/createGauge.svelte.ts +86 -0
  123. package/src/createListbox.svelte.ts +186 -0
  124. package/src/createMaskedInput.svelte.ts +81 -0
  125. package/src/createNumberInput.svelte.ts +193 -0
  126. package/src/createPasswordInput.svelte.ts +87 -0
  127. package/src/createPhoneInput.svelte.ts +119 -0
  128. package/src/createRadioGroup.svelte.ts +132 -0
  129. package/src/createRating.svelte.ts +145 -0
  130. package/src/createSlider.svelte.ts +160 -0
  131. package/src/createSwitch.svelte.ts +78 -0
  132. package/src/createTabs.svelte.ts +129 -0
  133. package/src/createTagsInput.svelte.ts +98 -0
  134. package/src/createTimePicker.svelte.ts +289 -0
  135. package/src/createToggle.svelte.ts +69 -0
  136. package/src/createTree.svelte.ts +216 -0
  137. package/src/editor-contract.test.ts +32 -0
  138. package/src/editor-contract.ts +60 -0
  139. package/src/index.ts +39 -0
  140. package/src/ui-inputs.test.ts +26 -0
  141. 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
+ }