@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
@@ -1,20 +1,23 @@
1
1
  <script lang="ts" module>
2
- export type DateTimeValue = Date | string | number | null
2
+ export type { DateTimeValue } from './createDateTimePicker.svelte'
3
3
  </script>
4
4
 
5
5
  <script lang="ts">
6
6
  /**
7
7
  * SvDateTimePicker - a text input (formatted via the date-format token engine)
8
8
  * plus a dropdown that composes SvCalendar + SvTimePicker behind DATE / TIME
9
- * tabs. Parity target: Smart `smart-date-time-picker`. Works standalone and as
10
- * the SvGrid `datetime` cell editor. The dropdown portals to <body> (via
11
- * popover.ts) so it is never clipped by the grid's scroll container.
9
+ * tabs. Parity target: Smart `smart-date-time-picker`. It is a styled renderer
10
+ * over the headless `createDateTimePicker` core (value math, parse/format,
11
+ * clamp, dropdown open/tab state, sub-picker wiring); the component keeps the
12
+ * render-only concerns - the portalled popover positioning (via popover.ts), DOM
13
+ * refs, and outside-click / reposition listeners - so the dropdown is never
14
+ * clipped by the grid's scroll container.
12
15
  */
13
16
  import SvCalendar from './SvCalendar.svelte'
14
17
  import SvTimePicker from './SvTimePicker.svelte'
15
18
  import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
16
- import { formatDate, parseDate } from './datetime/date-format'
17
- import { toDate, clampDate, withTime, startOfDay, type DateLike } from './datetime/date-core'
19
+ import { createDateTimePicker, type DateTimeValue, type DropDownDisplayMode } from './createDateTimePicker.svelte'
20
+ import type { DateLike } from './datetime/date-core'
18
21
 
19
22
  type Props = {
20
23
  value?: DateTimeValue
@@ -39,7 +42,7 @@
39
42
  hourFormat?: '12-hour' | '24-hour'
40
43
  minuteInterval?: number
41
44
  /** Which tabs the dropdown shows. */
42
- dropDownDisplayMode?: 'both' | 'calendar' | 'time'
45
+ dropDownDisplayMode?: DropDownDisplayMode
43
46
  /** Up/down spinner buttons that bump the value by `stepMinutes`. */
44
47
  spinButtons?: boolean
45
48
  stepMinutes?: number
@@ -74,66 +77,27 @@
74
77
  animate = false,
75
78
  }: Props = $props()
76
79
 
77
- const isInteractive = $derived(!disabled && !readonly)
78
- const minD = $derived(toDate(min ?? null))
79
- const maxD = $derived(toDate(max ?? null))
80
-
81
- let current = $state<Date | null>(null)
82
- let text = $state('')
83
- let lastKey = '￿'
84
- $effect(() => {
85
- const d = toDate(value as DateLike)
86
- const key = d ? String(d.getTime()) : 'null'
87
- if (key !== lastKey) {
88
- lastKey = key
89
- current = d
90
- text = d ? formatDate(d, formatString, locale) : ''
91
- }
80
+ // The headless core owns value math, parse/format, clamping, the dropdown
81
+ // open/tab state and the sub-picker wiring. Reactive inputs are getters;
82
+ // callbacks are closures.
83
+ const dtp = createDateTimePicker({
84
+ value: () => value,
85
+ onChange: (d) => onChange?.(d),
86
+ onCommit: (d) => onCommit?.(d),
87
+ onCancel: () => onCancel?.(),
88
+ formatString: () => formatString,
89
+ min: () => min,
90
+ max: () => max,
91
+ nullable: () => nullable,
92
+ locale: () => locale,
93
+ dropDownDisplayMode: () => dropDownDisplayMode,
94
+ spinButtons: () => spinButtons,
95
+ stepMinutes: () => stepMinutes,
96
+ disabled: () => disabled,
97
+ readonly: () => readonly,
92
98
  })
93
99
 
94
- // --- Commit helpers ---
95
- function commit(next: Date | null) {
96
- const clamped = next ? clampDate(next, minD, maxD) : null
97
- current = clamped
98
- lastKey = clamped ? String(clamped.getTime()) : 'null'
99
- text = clamped ? formatDate(clamped, formatString, locale) : ''
100
- onChange?.(clamped)
101
- }
102
-
103
- function commitFromText() {
104
- const raw = text.trim()
105
- if (!raw) {
106
- if (nullable) commit(null)
107
- else text = current ? formatDate(current, formatString, locale) : ''
108
- return
109
- }
110
- const parsed = parseDate(raw, formatString, locale, 2029, current ?? new Date())
111
- if (parsed) commit(parsed)
112
- else text = current ? formatDate(current, formatString, locale) : '' // revert on bad input
113
- }
114
-
115
- function bump(deltaMin: number) {
116
- const base = current ?? new Date()
117
- commit(new Date(base.getTime() + deltaMin * 60000))
118
- }
119
-
120
- // --- Sub-picker wiring ---
121
- function onCalendarChange(dates: Date[]) {
122
- const day = dates[0]
123
- if (!day) return
124
- commit(withTime(day, current ?? startOfDay(day)))
125
- // Date-only picker: a single pick finalizes the value.
126
- if (dropDownDisplayMode === 'calendar') { open = false; onCommit?.(current) }
127
- else if (dropDownDisplayMode === 'both') tab = 'time'
128
- }
129
- function onTimeChange(t: Date) {
130
- const base = current ?? new Date()
131
- commit(withTime(base, t))
132
- }
133
-
134
- // --- Dropdown (portalled) ---
135
- let open = $state(false)
136
- let tab = $state<'date' | 'time'>('date')
100
+ // --- Portalled dropdown positioning (DOM-bound; stays in the component) -----
137
101
  let triggerEl = $state<HTMLDivElement | null>(null)
138
102
  let panelEl = $state<HTMLDivElement | null>(null)
139
103
  let panelRect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
@@ -142,17 +106,11 @@
142
106
  if (!triggerEl) return
143
107
  panelRect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: 360, minWidth: 300 })
144
108
  }
145
- function openPanel() {
146
- if (!isInteractive || open) return
147
- tab = dropDownDisplayMode === 'time' ? 'time' : 'date'
148
- open = true
149
- updatePos()
150
- }
151
- function closePanel() { open = false }
152
- function toggle() { (open ? closePanel : openPanel)() }
153
109
 
110
+ // Reposition on open + while open (scroll/resize), and dismiss on outside click.
154
111
  $effect(() => {
155
- if (!open) return
112
+ if (!dtp.open) return
113
+ updatePos()
156
114
  const reposition = () => updatePos()
157
115
  window.addEventListener('scroll', reposition, true)
158
116
  window.addEventListener('resize', reposition)
@@ -160,7 +118,7 @@
160
118
  const t = e.target as Node | null
161
119
  if (!t) return
162
120
  if (triggerEl?.contains(t) || panelEl?.contains(t)) return
163
- closePanel()
121
+ dtp.closePanel()
164
122
  }
165
123
  document.addEventListener('pointerdown', onDown, true)
166
124
  return () => {
@@ -170,59 +128,44 @@
170
128
  }
171
129
  })
172
130
 
173
- function onInputKeydown(e: KeyboardEvent) {
174
- if (e.key === 'Enter') { e.preventDefault(); commitFromText(); closePanel(); onCommit?.(current) }
175
- else if (e.key === 'Escape') { closePanel(); onCancel?.() }
176
- else if (e.key === 'ArrowDown' && (e.altKey || !text)) { e.preventDefault(); openPanel() }
177
- else if (spinButtons && e.key === 'ArrowUp') { e.preventDefault(); bump(stepMinutes) }
178
- else if (spinButtons && e.key === 'ArrowDown') { e.preventDefault(); bump(-stepMinutes) }
179
- }
180
-
181
131
  function focusOpen(node: HTMLInputElement) {
182
132
  if (!autoOpen) return
183
133
  node.focus()
184
- openPanel()
134
+ dtp.openPanel()
185
135
  }
186
136
 
187
137
  function onRootFocusOut(e: FocusEvent) {
188
138
  const next = e.relatedTarget as Node | null
189
139
  // Ignore focus moving within the field or into the portalled panel.
190
140
  if (next && (triggerEl?.contains(next) || panelEl?.contains(next))) return
191
- onCommit?.(current)
141
+ onCommit?.(dtp.current)
192
142
  }
193
-
194
- const showDateTab = $derived(dropDownDisplayMode !== 'time')
195
- const showTimeTab = $derived(dropDownDisplayMode !== 'calendar')
196
143
  </script>
197
144
 
198
145
  <div class="sv-dtp" class:sv-dtp--disabled={disabled} onfocusout={onRootFocusOut}>
199
146
  <div class="sv-dtp__field" bind:this={triggerEl}>
200
147
  {#if spinButtons}
201
148
  <div class="sv-dtp__spin">
202
- <button type="button" tabindex="-1" aria-label="Increment" onclick={() => bump(stepMinutes)} disabled={!isInteractive}>▲</button>
203
- <button type="button" tabindex="-1" aria-label="Decrement" onclick={() => bump(-stepMinutes)} disabled={!isInteractive}>▼</button>
149
+ <button {...dtp.spinProps(1)}>▲</button>
150
+ <button {...dtp.spinProps(-1)}>▼</button>
204
151
  </div>
205
152
  {/if}
206
153
  <input
207
154
  class="sv-dtp__input"
208
- type="text"
209
- bind:value={text}
155
+ bind:value={dtp.text}
210
156
  {placeholder}
211
- {disabled}
212
- {readonly}
213
- onblur={commitFromText}
214
- onkeydown={onInputKeydown}
157
+ {...dtp.inputProps()}
215
158
  use:focusOpen
216
159
  />
217
- {#if nullable && current && isInteractive}
218
- <button type="button" class="sv-dtp__clear" aria-label="Clear" onclick={() => commit(null)}>&times;</button>
160
+ {#if nullable && dtp.current && dtp.isInteractive}
161
+ <button class="sv-dtp__clear" {...dtp.clearProps()}>&times;</button>
219
162
  {/if}
220
- <button type="button" class="sv-dtp__toggle" aria-label="Open picker" aria-haspopup="dialog" aria-expanded={open} onclick={toggle} disabled={!isInteractive} tabindex="-1">
163
+ <button class="sv-dtp__toggle" {...dtp.toggleProps()}>
221
164
  <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></svg>
222
165
  </button>
223
166
  </div>
224
167
 
225
- {#if open}
168
+ {#if dtp.open}
226
169
  <div
227
170
  class="sv-dtp__panel"
228
171
  bind:this={panelEl}
@@ -233,16 +176,16 @@
233
176
  role="dialog"
234
177
  aria-label="Choose date and time"
235
178
  >
236
- {#if showDateTab && showTimeTab}
179
+ {#if dtp.showDateTab && dtp.showTimeTab}
237
180
  <div class="sv-dtp__tabs" role="tablist">
238
- <button type="button" role="tab" aria-selected={tab === 'date'} class:is-active={tab === 'date'} onclick={() => (tab = 'date')}>DATE</button>
239
- <button type="button" role="tab" aria-selected={tab === 'time'} class:is-active={tab === 'time'} onclick={() => (tab = 'time')}>TIME</button>
181
+ <button class:is-active={dtp.tab === 'date'} {...dtp.tabProps('date')}>DATE</button>
182
+ <button class:is-active={dtp.tab === 'time'} {...dtp.tabProps('time')}>TIME</button>
240
183
  </div>
241
184
  {/if}
242
185
  <div class="sv-dtp__body">
243
- {#if tab === 'date' && showDateTab}
186
+ {#if dtp.tab === 'date' && dtp.showDateTab}
244
187
  <SvCalendar
245
- value={current}
188
+ value={dtp.current}
246
189
  selectionMode="one"
247
190
  min={min}
248
191
  max={max}
@@ -250,22 +193,22 @@
250
193
  {weekNumbers}
251
194
  {locale}
252
195
  {animate}
253
- onChange={onCalendarChange}
196
+ onChange={dtp.onCalendarChange}
254
197
  />
255
- {:else if showTimeTab}
198
+ {:else if dtp.showTimeTab}
256
199
  <SvTimePicker
257
- value={current}
200
+ value={dtp.current}
258
201
  format={hourFormat}
259
202
  {minuteInterval}
260
203
  footer
261
- onChange={onTimeChange}
204
+ onChange={dtp.onTimeChange}
262
205
  />
263
206
  {/if}
264
207
  </div>
265
208
  </div>
266
209
  {/if}
267
210
 
268
- {#if name}<input type="hidden" {name} value={current ? current.toISOString() : ''} />{/if}
211
+ {#if name}<input type="hidden" {name} value={dtp.current ? dtp.current.toISOString() : ''} />{/if}
269
212
  </div>
270
213
 
271
214
  <style>
@@ -3,9 +3,13 @@
3
3
  * SvDropDownList - a single-select dropdown (trigger button + portalled list,
4
4
  * no typing). Parity: Smart `smart-drop-down-list`. Controlled via `value` +
5
5
  * `onChange`. Popover escapes scroll clipping via popover.ts.
6
+ *
7
+ * One styled renderer over the headless `createDropdownList` core (state +
8
+ * keyboard + ARIA); only portal/measure/scroll render concerns live here.
6
9
  */
7
10
  import { anchoredRect, portalToBody, type AnchoredRect } from './popover'
8
11
  import type { ListOption } from './list-option'
12
+ import { createDropdownList } from './createDropdownList.svelte'
9
13
 
10
14
  type Props = {
11
15
  options: ReadonlyArray<ListOption>
@@ -21,93 +25,66 @@
21
25
 
22
26
  let { options, value = null, onChange, placeholder = 'Select…', disabled = false, name, size = 'md', ariaLabel, autoOpen = false }: Props = $props()
23
27
 
24
- let open = $state(false)
25
- let active = $state(-1)
26
28
  let triggerEl = $state<HTMLButtonElement | null>(null)
27
29
  let panelEl = $state<HTMLDivElement | null>(null)
28
30
  let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
29
31
 
30
- const selected = $derived(options.find((o) => o.value === value) ?? null)
31
- const enabledIdx = $derived(options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0))
32
+ const ddl = createDropdownList({
33
+ options: () => options,
34
+ value: () => value,
35
+ onChange: (v) => onChange?.(v),
36
+ disabled: () => disabled,
37
+ ariaLabel: () => ariaLabel,
38
+ focusTrigger: () => triggerEl?.focus(),
39
+ })
40
+
41
+ const selected = $derived(ddl.selected)
32
42
 
33
43
  function updatePos() {
34
44
  if (!triggerEl) return
35
45
  rect = anchoredRect(triggerEl.getBoundingClientRect(), { estimatedHeight: Math.min(options.length, 8) * 34 + 8 })
36
46
  }
37
- function openPanel() {
38
- if (disabled || open) return
39
- open = true
40
- active = Math.max(0, options.findIndex((o) => o.value === value))
41
- updatePos()
42
- }
43
- function close() { open = false }
44
- function toggle() { open ? close() : openPanel() }
45
- function pick(i: number) {
46
- const o = options[i]
47
- if (!o || o.disabled) return
48
- onChange?.(o.value); close(); triggerEl?.focus()
49
- }
50
- function move(d: number) {
51
- const pos = enabledIdx.indexOf(active)
52
- active = enabledIdx[(pos + d + enabledIdx.length) % enabledIdx.length] ?? active
53
- queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${active}"]`)?.scrollIntoView({ block: 'nearest' }))
54
- }
55
- function onKeydown(e: KeyboardEvent) {
56
- if (disabled) return
57
- if (!open && (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); openPanel(); return }
58
- if (!open) return
59
- if (e.key === 'ArrowDown') { e.preventDefault(); move(1) }
60
- else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1) }
61
- else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(active) }
62
- else if (e.key === 'Escape') { e.preventDefault(); close(); triggerEl?.focus() }
63
- else if (e.key === 'Home') { active = enabledIdx[0] ?? 0 }
64
- else if (e.key === 'End') { active = enabledIdx.at(-1) ?? 0 }
65
- }
66
47
 
67
48
  $effect(() => {
68
- if (!open) return
49
+ if (!ddl.open) return
50
+ updatePos()
69
51
  const rp = () => updatePos()
70
52
  window.addEventListener('scroll', rp, true); window.addEventListener('resize', rp)
71
- const od = (e: PointerEvent) => { const t = e.target as Node | null; if (t && (triggerEl?.contains(t) || panelEl?.contains(t))) return; close() }
53
+ const od = (e: PointerEvent) => { const t = e.target as Node | null; if (t && (triggerEl?.contains(t) || panelEl?.contains(t))) return; ddl.close() }
72
54
  document.addEventListener('pointerdown', od, true)
73
55
  return () => { window.removeEventListener('scroll', rp, true); window.removeEventListener('resize', rp); document.removeEventListener('pointerdown', od, true) }
74
56
  })
75
- function focusOpen(node: HTMLButtonElement) { if (autoOpen) { node.focus(); openPanel() } }
57
+ // Keep the active option scrolled into view (render concern).
58
+ $effect(() => {
59
+ if (!ddl.open) return
60
+ const i = ddl.activeIndex
61
+ queueMicrotask(() => panelEl?.querySelector<HTMLElement>(`[data-idx="${i}"]`)?.scrollIntoView({ block: 'nearest' }))
62
+ })
63
+ function focusOpen(node: HTMLButtonElement) { if (autoOpen) { node.focus(); ddl.openPanel() } }
76
64
  </script>
77
65
 
78
66
  <button
79
67
  bind:this={triggerEl}
80
- type="button"
81
68
  class="sv-ddl sv-ddl--{size}"
82
- class:is-open={open}
69
+ class:is-open={ddl.open}
83
70
  class:is-disabled={disabled}
84
- aria-haspopup="listbox"
85
- aria-expanded={open}
86
- aria-label={ariaLabel}
87
- {disabled}
88
- onclick={toggle}
89
- onkeydown={onKeydown}
71
+ {...ddl.triggerProps()}
90
72
  use:focusOpen
91
73
  >
92
74
  <span class="sv-ddl__value" class:is-placeholder={!selected}>{selected?.label ?? placeholder}</span>
93
75
  <svg class="sv-ddl__chev" viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
94
76
  </button>
95
77
 
96
- {#if open}
97
- <div bind:this={panelEl} class="sv-ddl__panel" use:portalToBody style:position="fixed" style:top={`${rect.top}px`} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} role="listbox" tabindex="-1">
78
+ {#if ddl.open}
79
+ <div bind:this={panelEl} class="sv-ddl__panel" use:portalToBody style:position="fixed" style:top={`${rect.top}px`} style:left={`${rect.left}px`} style:min-width={`${rect.width}px`} {...ddl.listboxProps()}>
98
80
  {#each options as opt, i (opt.value)}
99
81
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
100
82
  <div
101
83
  class="sv-ddl__opt"
102
- class:is-active={i === active}
103
- class:is-selected={opt.value === value}
84
+ class:is-active={ddl.isActive(i)}
85
+ class:is-selected={ddl.isSelected(opt)}
104
86
  class:is-disabled={opt.disabled}
105
- role="option"
106
- tabindex="-1"
107
- aria-selected={opt.value === value}
108
- data-idx={i}
109
- onclick={() => pick(i)}
110
- onpointermove={() => { if (!opt.disabled) active = i }}
87
+ {...ddl.optionProps(i)}
111
88
  >{opt.label}</div>
112
89
  {/each}
113
90
  </div>
@@ -3,7 +3,12 @@
3
3
  * SvGauge - a radial arc gauge (SVG) that renders a value within [min, max]
4
4
  * with optional colored threshold bands, a needle, and a center label. Parity:
5
5
  * Smart `smart-gauge` (radial). Display control; theme-driven.
6
+ *
7
+ * The arc/needle geometry + `role="meter"` ARIA live in the headless
8
+ * `createGauge` core; this component is just one styled renderer over it.
6
9
  */
10
+ import { createGauge } from './createGauge.svelte'
11
+
7
12
  type Band = { from: number; to: number; color: string }
8
13
 
9
14
  type Props = {
@@ -40,32 +45,25 @@
40
45
  ariaLabel,
41
46
  }: Props = $props()
42
47
 
43
- const clamped = $derived(Math.min(max, Math.max(min, value)))
44
- const startAngle = $derived(90 + (360 - sweep) / 2) // symmetric around the bottom
45
- const cx = $derived(size / 2)
46
- const cy = $derived(size / 2)
47
- const r = $derived(size / 2 - thickness / 2 - 2)
48
-
49
- function frac(v: number) { return (v - min) / (max - min) }
50
- function angleOf(v: number) { return startAngle + frac(v) * sweep }
51
- function polar(angleDeg: number, radius: number) {
52
- const a = (angleDeg * Math.PI) / 180
53
- return { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) }
54
- }
55
- function arcPath(v0: number, v1: number, radius: number): string {
56
- const a0 = angleOf(v0)
57
- const a1 = angleOf(v1)
58
- const p0 = polar(a0, radius)
59
- const p1 = polar(a1, radius)
60
- const large = a1 - a0 > 180 ? 1 : 0
61
- return `M ${p0.x} ${p0.y} A ${radius} ${radius} 0 ${large} 1 ${p1.x} ${p1.y}`
62
- }
63
-
64
- const needleEnd = $derived(polar(angleOf(clamped), r - thickness / 2))
48
+ const gauge = createGauge({
49
+ value: () => value,
50
+ min: () => min,
51
+ max: () => max,
52
+ sweep: () => sweep,
53
+ size: () => size,
54
+ thickness: () => thickness,
55
+ ariaLabel: () => ariaLabel,
56
+ })
57
+ const clamped = $derived(gauge.clamped)
58
+ const cx = $derived(gauge.cx)
59
+ const cy = $derived(gauge.cy)
60
+ const r = $derived(gauge.r)
61
+ const arcPath = (v0: number, v1: number, radius: number) => gauge.arcPath(v0, v1, radius)
62
+ const needleEnd = $derived(gauge.needleEnd)
65
63
  const display = $derived(label ?? `${formatValue ? formatValue(clamped) : Math.round(clamped)}${unit}`)
66
64
  </script>
67
65
 
68
- <div class="sv-gauge" role="meter" aria-valuenow={clamped} aria-valuemin={min} aria-valuemax={max} aria-label={ariaLabel ?? 'Gauge'}>
66
+ <div class="sv-gauge" {...gauge.rootProps()}>
69
67
  <svg viewBox={`0 0 ${size} ${size}`} width={size} height={size} class="sv-gauge__svg">
70
68
  <!-- track -->
71
69
  <path d={arcPath(min, max, r)} class="sv-gauge__track" fill="none" stroke-width={thickness} stroke-linecap="round" />
@@ -5,6 +5,7 @@
5
5
  * via `value` (scalar or array) + `onChange`.
6
6
  */
7
7
  import type { ListOption } from './list-option'
8
+ import { createListbox } from './createListbox.svelte'
8
9
 
9
10
  type Props = {
10
11
  options: ReadonlyArray<ListOption>
@@ -15,89 +16,55 @@
15
16
  /** Visible height in rows before scrolling. */
16
17
  rows?: number
17
18
  ariaLabel?: string
19
+ /** Form field name; emits a hidden input per selected value so the listbox
20
+ * posts in a native <form> (multiple selection -> repeated name). */
21
+ name?: string
18
22
  }
19
23
 
20
- let { options, value = null, onChange, multiple = false, disabled = false, rows = 7, ariaLabel }: Props = $props()
24
+ let { options, value = null, onChange, multiple = false, disabled = false, rows = 7, ariaLabel, name }: Props = $props()
21
25
 
22
- const selected = $derived<Array<string | number>>(
23
- multiple ? (Array.isArray(value) ? value : value == null ? [] : [value]) : value == null ? [] : [value as string | number],
24
- )
25
- const enabledIdx = $derived(options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0))
26
- let active = $state(0)
27
- $effect(() => {
28
- // Keep the active index on an enabled option.
29
- if (!options[active] || options[active]!.disabled) active = enabledIdx[0] ?? 0
26
+ // The styled listbox is just a renderer over the headless core.
27
+ const lb = createListbox({
28
+ options: () => options,
29
+ value: () => value,
30
+ onChange: (v) => onChange?.(v),
31
+ multiple: () => multiple,
32
+ disabled: () => disabled,
33
+ ariaLabel: () => ariaLabel,
30
34
  })
31
-
32
- function isSel(o: ListOption) { return selected.includes(o.value) }
33
-
34
- function pick(i: number) {
35
- const o = options[i]
36
- if (!o || o.disabled || disabled) return
37
- active = i
38
- if (multiple) {
39
- const set = new Set(selected)
40
- set.has(o.value) ? set.delete(o.value) : set.add(o.value)
41
- onChange?.([...set])
42
- } else {
43
- onChange?.(o.value)
44
- }
45
- }
46
-
47
- function move(delta: number) {
48
- const pos = enabledIdx.indexOf(active)
49
- const next = enabledIdx[(pos + delta + enabledIdx.length) % enabledIdx.length]
50
- if (next != null) { active = next; scrollActive() }
51
- }
35
+ const isSel = (o: ListOption) => lb.isSelected(o.value)
52
36
 
53
37
  let listEl: HTMLUListElement | null = null
54
- function scrollActive() {
55
- queueMicrotask(() => listEl?.querySelector<HTMLElement>(`[data-idx="${active}"]`)?.scrollIntoView({ block: 'nearest' }))
56
- }
57
-
58
- function onKeydown(e: KeyboardEvent) {
59
- if (disabled) return
60
- switch (e.key) {
61
- case 'ArrowDown': e.preventDefault(); move(1); break
62
- case 'ArrowUp': e.preventDefault(); move(-1); break
63
- case 'Home': e.preventDefault(); active = enabledIdx[0] ?? 0; scrollActive(); break
64
- case 'End': e.preventDefault(); active = enabledIdx.at(-1) ?? 0; scrollActive(); break
65
- case ' ':
66
- case 'Enter': e.preventDefault(); pick(active); break
67
- }
68
- }
38
+ // Scroll-into-view is a render concern, so it lives here (not in the core).
39
+ $effect(() => {
40
+ const i = lb.activeIndex
41
+ queueMicrotask(() => listEl?.querySelector<HTMLElement>(`[data-idx="${i}"]`)?.scrollIntoView({ block: 'nearest' }))
42
+ })
69
43
  </script>
70
44
 
45
+ <div style="display: contents">
71
46
  <ul
72
47
  bind:this={listEl}
73
48
  class="sv-listbox"
74
- role="listbox"
75
- aria-multiselectable={multiple}
76
- aria-label={ariaLabel}
77
- aria-disabled={disabled}
78
- tabindex={disabled ? -1 : 0}
79
49
  style:--sv-rows={rows}
80
- onkeydown={onKeydown}
50
+ {...lb.rootProps()}
81
51
  >
82
52
  {#each options as opt, i (opt.value)}
83
53
  <!-- svelte-ignore a11y_click_events_have_key_events a11y_interactive_supports_focus -->
84
54
  <li
85
55
  class="sv-listbox__opt"
86
56
  class:is-selected={isSel(opt)}
87
- class:is-active={i === active}
57
+ class:is-active={lb.isActive(i)}
88
58
  class:is-disabled={opt.disabled}
89
- role="option"
90
- aria-selected={isSel(opt)}
91
- aria-disabled={opt.disabled}
92
- data-idx={i}
93
- onclick={() => pick(i)}
94
- onpointermove={() => { if (!opt.disabled) active = i }}
59
+ {...lb.optionProps(i)}
95
60
  >
96
61
  {#if multiple}<span class="sv-listbox__check" aria-hidden="true">{isSel(opt) ? '✓' : ''}</span>{/if}
97
62
  <span class="sv-listbox__label">{opt.label}</span>
98
63
  </li>
99
64
  {/each}
100
65
  </ul>
66
+ {#if name}{#each lb.selectedValues as v (v)}<input type="hidden" {name} value={v} />{/each}{/if}
67
+ </div>
101
68
 
102
69
  <style>
103
70
  .sv-listbox {
Binary file
Binary file