@svgrid/grid 1.2.23 → 1.2.25

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,158 @@
1
+ /**
2
+ * createCombobox - the HEADLESS core behind <SvComboBox>, in the same spirit as
3
+ * `createListbox` / `createSvGrid`: a runes-based state machine (type-to-filter,
4
+ * roving active index, full keyboard, unmatched-text revert) exposed as
5
+ * **prop-getters** you spread onto YOUR OWN markup. No styles, no DOM, no portal
6
+ * or measurement - those render concerns stay in the styled component.
7
+ *
8
+ * ```svelte
9
+ * <script lang="ts">
10
+ * import { createCombobox } from '@svgrid/grid'
11
+ * let value = $state<string | null>(null)
12
+ * const cb = createCombobox({ options: () => options, value: () => value, onChange: (v) => (value = v) })
13
+ * </script>
14
+ * <input {...cb.inputProps()} />
15
+ * {#if cb.open}
16
+ * <ul {...cb.listboxProps()}>
17
+ * {#each cb.filtered as opt, i (opt.value)}<li {...cb.optionProps(i)}>{opt.label}</li>{/each}
18
+ * </ul>
19
+ * {/if}
20
+ * ```
21
+ */
22
+ import { filterOptions, type ListOption } from './list-option'
23
+
24
+ export type ComboboxValue = string | number | null
25
+
26
+ /** Reactive inputs are passed as getters so the core tracks live prop changes. */
27
+ export type ComboboxConfig = {
28
+ options: () => ReadonlyArray<ListOption>
29
+ value: () => ComboboxValue
30
+ onChange?: (value: ComboboxValue) => void
31
+ disabled?: () => boolean
32
+ ariaLabel?: () => string | undefined
33
+ /** DOM focus hooks provided by the renderer; the core itself never touches the DOM. */
34
+ focusInput?: () => void
35
+ blurInput?: () => void
36
+ }
37
+
38
+ let uid = 0
39
+
40
+ export function createCombobox(config: ComboboxConfig) {
41
+ const id = `sv-combo-${uid++}`
42
+ const listId = `${id}-list`
43
+ const opts = () => config.options()
44
+ const disabled = () => config.disabled?.() ?? false
45
+
46
+ let open = $state(false)
47
+ let query = $state('')
48
+ let active = $state(0)
49
+ let editing = $state(false)
50
+
51
+ const selected = $derived(opts().find((o) => o.value === config.value()) ?? null)
52
+ const filtered = $derived(editing ? filterOptions(opts(), query) : [...opts()])
53
+ const shownText = $derived(editing ? query : selected?.label ?? '')
54
+
55
+ // While not actively editing, keep the shown text in sync with the selection.
56
+ $effect(() => { if (!editing) query = selected?.label ?? '' })
57
+
58
+ const optionId = (i: number) => `${listId}-opt-${i}`
59
+
60
+ function openPanel() { if (disabled() || open) return; open = true; active = 0 }
61
+ function close(revert = true) {
62
+ open = false; editing = false
63
+ if (revert) query = selected?.label ?? ''
64
+ }
65
+ function toggle() { if (open) { close() } else { config.focusInput?.(); openPanel() } }
66
+ function pick(o: ListOption | undefined) {
67
+ if (!o || o.disabled) return
68
+ config.onChange?.(o.value); query = o.label; editing = false; open = false; config.blurInput?.()
69
+ }
70
+ function setActive(i: number) { const o = filtered[i]; if (o && !o.disabled) active = i }
71
+ function onInput(e: Event) {
72
+ query = (e.currentTarget as HTMLInputElement).value
73
+ editing = true; active = 0
74
+ if (!open) openPanel()
75
+ }
76
+ function onFocus() { editing = true; query = selected?.label ?? ''; openPanel() }
77
+ function onKeydown(e: KeyboardEvent) {
78
+ if (disabled()) return
79
+ if (!open && e.key === 'ArrowDown') { e.preventDefault(); editing = true; openPanel(); return }
80
+ if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1) }
81
+ else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) }
82
+ else if (e.key === 'Enter') { e.preventDefault(); pick(filtered[active]) }
83
+ else if (e.key === 'Escape') { e.preventDefault(); close() }
84
+ }
85
+
86
+ return {
87
+ /** Panel open state. */
88
+ get open() { return open },
89
+ /** Highlighted option index into `filtered`. */
90
+ get activeIndex() { return active },
91
+ /** True while the user is typing a filter query. */
92
+ get editing() { return editing },
93
+ /** Current filter query text. */
94
+ get query() { return query },
95
+ /** Options after the live substring filter. */
96
+ get filtered() { return filtered },
97
+ /** The option matching the controlled value, if any. */
98
+ get selected() { return selected },
99
+ /** Text shown in the field (query while editing, else the selected label). */
100
+ get shownText() { return shownText },
101
+ /** Controlled value. */
102
+ get value() { return config.value() },
103
+ /** id of the listbox element (matches inputProps `aria-controls`). */
104
+ listId,
105
+ isActive: (i: number) => i === active,
106
+ isSelected: (o: ListOption) => o.value === config.value(),
107
+ setActive,
108
+ openPanel,
109
+ close,
110
+ toggle,
111
+ pick,
112
+ onInput,
113
+ onFocus,
114
+ onKeydown,
115
+ /** Spread onto the editable <input>. */
116
+ inputProps: () => ({
117
+ role: 'combobox' as const,
118
+ 'aria-expanded': open,
119
+ 'aria-controls': listId,
120
+ 'aria-autocomplete': 'list' as const,
121
+ 'aria-label': config.ariaLabel?.(),
122
+ value: shownText,
123
+ disabled: disabled(),
124
+ oninput: onInput,
125
+ onfocus: onFocus,
126
+ onkeydown: onKeydown,
127
+ }),
128
+ /** Spread onto the chevron/toggle <button>. */
129
+ triggerProps: () => ({
130
+ type: 'button' as const,
131
+ tabindex: -1,
132
+ 'aria-label': 'Toggle',
133
+ disabled: disabled(),
134
+ onclick: toggle,
135
+ }),
136
+ /** Spread onto the listbox container. */
137
+ listboxProps: () => ({
138
+ id: listId,
139
+ role: 'listbox' as const,
140
+ }),
141
+ /** Spread onto the option at `index` (index into `filtered`). */
142
+ optionProps: (index: number) => {
143
+ const o = filtered[index]
144
+ return {
145
+ role: 'option' as const,
146
+ id: optionId(index),
147
+ tabindex: -1,
148
+ 'aria-selected': o ? o.value === config.value() : false,
149
+ 'aria-disabled': o?.disabled,
150
+ 'data-idx': index,
151
+ onclick: () => pick(o),
152
+ onpointermove: () => { if (o && !o.disabled) active = index },
153
+ }
154
+ },
155
+ }
156
+ }
157
+
158
+ export type Combobox = ReturnType<typeof createCombobox>
@@ -0,0 +1,126 @@
1
+ /**
2
+ * createCountryInput - the HEADLESS core behind <SvCountryInput>: a searchable
3
+ * country picker (search by name, dial code, or ISO code; roving active index +
4
+ * keyboard) that emits the ISO 3166-1 alpha-2 code. Exposed as **prop-getters**
5
+ * you spread onto YOUR OWN markup. No styles/DOM/portal - those stay in the
6
+ * styled component.
7
+ *
8
+ * ```svelte
9
+ * <script lang="ts">
10
+ * import { createCountryInput, flagEmoji } from '@svgrid/grid'
11
+ * let value = $state<string | null>(null)
12
+ * const ci = createCountryInput({ value: () => value, onChange: (c) => (value = c) })
13
+ * </script>
14
+ * <button {...ci.triggerProps()}>{ci.selected?.name ?? 'Select'}</button>
15
+ * {#if ci.open}
16
+ * <input {...ci.searchProps()} />
17
+ * <ul {...ci.listboxProps()}>
18
+ * {#each ci.filtered as c, i (c.code)}<li {...ci.optionProps(i)}>{c.name}</li>{/each}
19
+ * </ul>
20
+ * {/if}
21
+ * ```
22
+ */
23
+ import { COUNTRIES, COUNTRY_BY_CODE, type Country } from './countries'
24
+
25
+ /** Reactive inputs are passed as getters so the core tracks live prop changes. */
26
+ export type CountryInputConfig = {
27
+ value: () => string | null
28
+ onChange?: (code: string) => void
29
+ disabled?: () => boolean
30
+ ariaLabel?: () => string | undefined
31
+ /** DOM focus hook provided by the renderer; the core never touches the DOM. */
32
+ focusTrigger?: () => void
33
+ }
34
+
35
+ export function createCountryInput(config: CountryInputConfig) {
36
+ const disabled = () => config.disabled?.() ?? false
37
+
38
+ let open = $state(false)
39
+ let query = $state('')
40
+ let active = $state(0)
41
+
42
+ const selected = $derived(config.value() ? COUNTRY_BY_CODE.get(config.value()!) ?? null : null)
43
+ const filtered = $derived<Country[]>(
44
+ query.trim() === ''
45
+ ? COUNTRIES
46
+ : COUNTRIES.filter(
47
+ (c) =>
48
+ c.name.toLowerCase().includes(query.toLowerCase()) ||
49
+ c.dial.includes(query) ||
50
+ c.code.toLowerCase() === query.toLowerCase(),
51
+ ),
52
+ )
53
+
54
+ function openPanel() { if (disabled() || open) return; open = true; query = ''; active = 0 }
55
+ function close() { open = false; config.focusTrigger?.() }
56
+ function toggle() { if (open) { open = false } else { openPanel() } }
57
+ function pick(code: string) { config.onChange?.(code); open = false; config.focusTrigger?.() }
58
+ function setQuery(v: string) { query = v; active = 0 }
59
+ function onSearchInput(e: Event) { setQuery((e.currentTarget as HTMLInputElement).value) }
60
+ function onKeydown(e: KeyboardEvent) {
61
+ if (!open) return
62
+ if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1) }
63
+ else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) }
64
+ else if (e.key === 'Enter') { e.preventDefault(); const c = filtered[active]; if (c) pick(c.code) }
65
+ else if (e.key === 'Escape') { e.preventDefault(); close() }
66
+ }
67
+
68
+ return {
69
+ /** Panel open state. */
70
+ get open() { return open },
71
+ /** Highlighted country index into `filtered`. */
72
+ get activeIndex() { return active },
73
+ /** Current search query. */
74
+ get query() { return query },
75
+ /** Countries after the current search filter. */
76
+ get filtered() { return filtered },
77
+ /** The selected country, if any. */
78
+ get selected() { return selected },
79
+ /** Controlled value (ISO alpha-2 code). */
80
+ get value() { return config.value() },
81
+ isActive: (i: number) => i === active,
82
+ isSelected: (code: string) => code === config.value(),
83
+ setActive: (i: number) => { active = i },
84
+ setQuery,
85
+ openPanel,
86
+ close,
87
+ toggle,
88
+ pick,
89
+ onKeydown,
90
+ /** Spread onto the trigger <button>. */
91
+ triggerProps: () => ({
92
+ type: 'button' as const,
93
+ 'aria-haspopup': 'listbox' as const,
94
+ 'aria-expanded': open,
95
+ 'aria-label': config.ariaLabel?.(),
96
+ disabled: disabled(),
97
+ onclick: toggle,
98
+ }),
99
+ /** Spread onto the search <input> inside the panel. */
100
+ searchProps: () => ({
101
+ type: 'text' as const,
102
+ value: query,
103
+ 'aria-label': 'Search countries',
104
+ oninput: onSearchInput,
105
+ onkeydown: onKeydown,
106
+ }),
107
+ /** Spread onto the listbox container. */
108
+ listboxProps: () => ({
109
+ role: 'listbox' as const,
110
+ }),
111
+ /** Spread onto the country option at `index` (index into `filtered`). */
112
+ optionProps: (index: number) => {
113
+ const c = filtered[index]
114
+ return {
115
+ role: 'option' as const,
116
+ tabindex: -1,
117
+ 'aria-selected': c ? c.code === config.value() : false,
118
+ 'data-idx': index,
119
+ onclick: () => { if (c) pick(c.code) },
120
+ onpointermove: () => { active = index },
121
+ }
122
+ },
123
+ }
124
+ }
125
+
126
+ export type CountryInput = ReturnType<typeof createCountryInput>
@@ -0,0 +1,216 @@
1
+ /**
2
+ * createDateTimePicker - the HEADLESS core behind <SvDateTimePicker>: a
3
+ * runes-based state machine for a masked date-time field (format/parse via the
4
+ * ./datetime token engine, min/max clamping, nullable clear, spin-button
5
+ * stepping) plus the dropdown's open/tab state and the calendar/time-picker
6
+ * wiring. The render-only concerns - the portalled popover positioning, DOM
7
+ * refs, outside-click + reposition listeners - stay in the styled component.
8
+ *
9
+ * ```svelte
10
+ * <script lang="ts">
11
+ * import { createDateTimePicker } from '@svgrid/grid'
12
+ * let value = $state<Date | null>(new Date())
13
+ * const dtp = createDateTimePicker({ value: () => value, onChange: (d) => (value = d) })
14
+ * </script>
15
+ * <input bind:value={dtp.text} {...dtp.inputProps()} />
16
+ * ```
17
+ */
18
+ import { formatDate, parseDate } from './datetime/date-format'
19
+ import { toDate, clampDate, withTime, startOfDay, type DateLike } from './datetime/date-core'
20
+
21
+ export type DateTimeValue = Date | string | number | null
22
+ export type DropDownDisplayMode = 'both' | 'calendar' | 'time'
23
+ export type DateTimeTab = 'date' | 'time'
24
+
25
+ /** Reactive inputs are getters; callbacks are closures. */
26
+ export type DateTimePickerConfig = {
27
+ value: () => DateTimeValue
28
+ onChange?: (value: Date | null) => void
29
+ /** Fired when the value is finalized (Enter, blur, or a single-date pick). */
30
+ onCommit?: (value: Date | null) => void
31
+ /** Fired on Escape / dismiss without committing. */
32
+ onCancel?: () => void
33
+ formatString?: () => string
34
+ min?: () => DateLike | null
35
+ max?: () => DateLike | null
36
+ nullable?: () => boolean
37
+ locale?: () => string
38
+ dropDownDisplayMode?: () => DropDownDisplayMode
39
+ spinButtons?: () => boolean
40
+ stepMinutes?: () => number
41
+ disabled?: () => boolean
42
+ readonly?: () => boolean
43
+ }
44
+
45
+ const defaultLocale = () => (typeof navigator !== 'undefined' ? navigator.language : 'en-US')
46
+
47
+ export function createDateTimePicker(config: DateTimePickerConfig) {
48
+ const formatString = () => config.formatString?.() ?? 'yyyy-MM-dd HH:mm'
49
+ const nullable = () => config.nullable?.() ?? true
50
+ const locale = () => config.locale?.() ?? defaultLocale()
51
+ const dropDownDisplayMode = () => config.dropDownDisplayMode?.() ?? 'both'
52
+ const spinButtons = () => config.spinButtons?.() ?? false
53
+ const stepMinutes = () => config.stepMinutes?.() ?? 1
54
+ const isDisabled = () => config.disabled?.() ?? false
55
+ const isReadonly = () => config.readonly?.() ?? false
56
+
57
+ const isInteractive = $derived(!isDisabled() && !isReadonly())
58
+ const minD = $derived(toDate(config.min?.() ?? null))
59
+ const maxD = $derived(toDate(config.max?.() ?? null))
60
+
61
+ let current = $state<Date | null>(null)
62
+ let text = $state('')
63
+ let lastKey = '￿'
64
+ $effect(() => {
65
+ const d = toDate(config.value() as DateLike)
66
+ const key = d ? String(d.getTime()) : 'null'
67
+ if (key !== lastKey) {
68
+ lastKey = key
69
+ current = d
70
+ text = d ? formatDate(d, formatString(), locale()) : ''
71
+ }
72
+ })
73
+
74
+ // --- Dropdown (open/tab) state; positioning stays in the component ----------
75
+ let open = $state(false)
76
+ let tab = $state<DateTimeTab>('date')
77
+
78
+ const showDateTab = $derived(dropDownDisplayMode() !== 'time')
79
+ const showTimeTab = $derived(dropDownDisplayMode() !== 'calendar')
80
+
81
+ // --- Commit helpers --------------------------------------------------------
82
+ function commit(next: Date | null) {
83
+ const clamped = next ? clampDate(next, minD, maxD) : null
84
+ current = clamped
85
+ lastKey = clamped ? String(clamped.getTime()) : 'null'
86
+ text = clamped ? formatDate(clamped, formatString(), locale()) : ''
87
+ config.onChange?.(clamped)
88
+ }
89
+
90
+ function commitFromText() {
91
+ const raw = text.trim()
92
+ if (!raw) {
93
+ if (nullable()) commit(null)
94
+ else text = current ? formatDate(current, formatString(), locale()) : ''
95
+ return
96
+ }
97
+ const parsed = parseDate(raw, formatString(), locale(), 2029, current ?? new Date())
98
+ if (parsed) commit(parsed)
99
+ else text = current ? formatDate(current, formatString(), locale()) : '' // revert on bad input
100
+ }
101
+
102
+ function bump(deltaMin: number) {
103
+ const base = current ?? new Date()
104
+ commit(new Date(base.getTime() + deltaMin * 60000))
105
+ }
106
+
107
+ // --- Sub-picker wiring -----------------------------------------------------
108
+ function onCalendarChange(dates: Date[]) {
109
+ const day = dates[0]
110
+ if (!day) return
111
+ commit(withTime(day, current ?? startOfDay(day)))
112
+ // Date-only picker: a single pick finalizes the value.
113
+ if (dropDownDisplayMode() === 'calendar') { open = false; config.onCommit?.(current) }
114
+ else if (dropDownDisplayMode() === 'both') tab = 'time'
115
+ }
116
+ function onTimeChange(t: Date) {
117
+ const base = current ?? new Date()
118
+ commit(withTime(base, t))
119
+ }
120
+
121
+ // --- Dropdown control ------------------------------------------------------
122
+ function openPanel() {
123
+ if (!isInteractive || open) return
124
+ tab = dropDownDisplayMode() === 'time' ? 'time' : 'date'
125
+ open = true
126
+ }
127
+ function closePanel() { open = false }
128
+ function toggle() { (open ? closePanel : openPanel)() }
129
+ function setTab(t: DateTimeTab) { tab = t }
130
+
131
+ function onInputKeydown(e: KeyboardEvent) {
132
+ if (e.key === 'Enter') { e.preventDefault(); commitFromText(); closePanel(); config.onCommit?.(current) }
133
+ else if (e.key === 'Escape') { closePanel(); config.onCancel?.() }
134
+ else if (e.key === 'ArrowDown' && (e.altKey || !text)) { e.preventDefault(); openPanel() }
135
+ else if (spinButtons() && e.key === 'ArrowUp') { e.preventDefault(); bump(stepMinutes()) }
136
+ else if (spinButtons() && e.key === 'ArrowDown') { e.preventDefault(); bump(-stepMinutes()) }
137
+ }
138
+
139
+ return {
140
+ // --- Reactive state -------------------------------------------------------
141
+ get current() { return current },
142
+ get text() { return text },
143
+ set text(v: string) { text = v },
144
+ get open() { return open },
145
+ get tab() { return tab },
146
+ get isInteractive() { return isInteractive },
147
+ get showDateTab() { return showDateTab },
148
+ get showTimeTab() { return showTimeTab },
149
+
150
+ // --- Actions --------------------------------------------------------------
151
+ commit,
152
+ commitFromText,
153
+ bump,
154
+ onCalendarChange,
155
+ onTimeChange,
156
+ openPanel,
157
+ closePanel,
158
+ toggle,
159
+ setTab,
160
+ onInputKeydown,
161
+
162
+ // --- Prop-getters ---------------------------------------------------------
163
+ /** The text field: masked value round-trips through the token engine. Spread
164
+ * alongside `bind:value={dtp.text}`. */
165
+ inputProps() {
166
+ return {
167
+ type: 'text' as const,
168
+ disabled: isDisabled(),
169
+ readonly: isReadonly(),
170
+ onblur: commitFromText,
171
+ onkeydown: onInputKeydown,
172
+ }
173
+ },
174
+ /** The calendar-toggle button. */
175
+ toggleProps() {
176
+ return {
177
+ type: 'button' as const,
178
+ 'aria-label': 'Open picker',
179
+ 'aria-haspopup': 'dialog' as const,
180
+ 'aria-expanded': open,
181
+ disabled: !isInteractive,
182
+ tabindex: -1,
183
+ onclick: toggle,
184
+ }
185
+ },
186
+ /** The inline clear (x) button (nullable values). */
187
+ clearProps() {
188
+ return {
189
+ type: 'button' as const,
190
+ 'aria-label': 'Clear',
191
+ onclick: () => commit(null),
192
+ }
193
+ },
194
+ /** A spin (increment/decrement) button that steps by `stepMinutes`. */
195
+ spinProps(dir: 1 | -1) {
196
+ return {
197
+ type: 'button' as const,
198
+ tabindex: -1,
199
+ 'aria-label': dir === 1 ? 'Increment' : 'Decrement',
200
+ disabled: !isInteractive,
201
+ onclick: () => bump(dir * stepMinutes()),
202
+ }
203
+ },
204
+ /** A DATE / TIME tab button. */
205
+ tabProps(which: DateTimeTab) {
206
+ return {
207
+ type: 'button' as const,
208
+ role: 'tab' as const,
209
+ 'aria-selected': tab === which,
210
+ onclick: () => setTab(which),
211
+ }
212
+ },
213
+ }
214
+ }
215
+
216
+ export type DateTimePicker = ReturnType<typeof createDateTimePicker>
@@ -0,0 +1,125 @@
1
+ /**
2
+ * createDropdownList - the HEADLESS core behind <SvDropDownList>: a single-select
3
+ * dropdown state machine (trigger open/close, roving active index over enabled
4
+ * options, full keyboard) exposed as **prop-getters** you spread onto YOUR OWN
5
+ * markup. No styles, no DOM, no portal/measurement - those stay in the styled
6
+ * component.
7
+ *
8
+ * ```svelte
9
+ * <script lang="ts">
10
+ * import { createDropdownList } from '@svgrid/grid'
11
+ * let value = $state<string | null>(null)
12
+ * const dd = createDropdownList({ options: () => options, value: () => value, onChange: (v) => (value = v) })
13
+ * </script>
14
+ * <button {...dd.triggerProps()}>{dd.selected?.label ?? 'Select'}</button>
15
+ * {#if dd.open}
16
+ * <ul {...dd.listboxProps()}>
17
+ * {#each options as opt, i (opt.value)}<li {...dd.optionProps(i)}>{opt.label}</li>{/each}
18
+ * </ul>
19
+ * {/if}
20
+ * ```
21
+ */
22
+ import type { ListOption } from './list-option'
23
+
24
+ export type DropdownValue = string | number | null
25
+
26
+ /** Reactive inputs are passed as getters so the core tracks live prop changes. */
27
+ export type DropdownListConfig = {
28
+ options: () => ReadonlyArray<ListOption>
29
+ value: () => DropdownValue
30
+ onChange?: (value: string | number) => void
31
+ disabled?: () => boolean
32
+ ariaLabel?: () => string | undefined
33
+ /** DOM focus hook provided by the renderer; the core never touches the DOM. */
34
+ focusTrigger?: () => void
35
+ }
36
+
37
+ export function createDropdownList(config: DropdownListConfig) {
38
+ const opts = () => config.options()
39
+ const disabled = () => config.disabled?.() ?? false
40
+
41
+ let open = $state(false)
42
+ let active = $state(-1)
43
+
44
+ const selected = $derived(opts().find((o) => o.value === config.value()) ?? null)
45
+ const enabledIdx = $derived(opts().map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0))
46
+
47
+ function openPanel() {
48
+ if (disabled() || open) return
49
+ open = true
50
+ active = Math.max(0, opts().findIndex((o) => o.value === config.value()))
51
+ }
52
+ function close() { open = false }
53
+ function toggle() { if (open) { close() } else { openPanel() } }
54
+ function pick(i: number) {
55
+ const o = opts()[i]
56
+ if (!o || o.disabled) return
57
+ config.onChange?.(o.value); close(); config.focusTrigger?.()
58
+ }
59
+ function setActive(i: number) { const o = opts()[i]; if (o && !o.disabled) active = i }
60
+ function move(d: number) {
61
+ const pos = enabledIdx.indexOf(active)
62
+ active = enabledIdx[(pos + d + enabledIdx.length) % enabledIdx.length] ?? active
63
+ }
64
+ function onKeydown(e: KeyboardEvent) {
65
+ if (disabled()) return
66
+ if (!open && (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); openPanel(); return }
67
+ if (!open) return
68
+ if (e.key === 'ArrowDown') { e.preventDefault(); move(1) }
69
+ else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1) }
70
+ else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(active) }
71
+ else if (e.key === 'Escape') { e.preventDefault(); close(); config.focusTrigger?.() }
72
+ else if (e.key === 'Home') { active = enabledIdx[0] ?? 0 }
73
+ else if (e.key === 'End') { active = enabledIdx.at(-1) ?? 0 }
74
+ }
75
+
76
+ return {
77
+ /** Panel open state. */
78
+ get open() { return open },
79
+ /** Highlighted option index into `options`. */
80
+ get activeIndex() { return active },
81
+ /** The option matching the controlled value, if any. */
82
+ get selected() { return selected },
83
+ /** Controlled value. */
84
+ get value() { return config.value() },
85
+ isActive: (i: number) => i === active,
86
+ isSelected: (o: ListOption) => o.value === config.value(),
87
+ setActive,
88
+ openPanel,
89
+ close,
90
+ toggle,
91
+ pick,
92
+ move,
93
+ onKeydown,
94
+ /** Spread onto the trigger <button>. */
95
+ triggerProps: () => ({
96
+ type: 'button' as const,
97
+ 'aria-haspopup': 'listbox' as const,
98
+ 'aria-expanded': open,
99
+ 'aria-label': config.ariaLabel?.(),
100
+ disabled: disabled(),
101
+ onclick: toggle,
102
+ onkeydown: onKeydown,
103
+ }),
104
+ /** Spread onto the listbox container. */
105
+ listboxProps: () => ({
106
+ role: 'listbox' as const,
107
+ tabindex: -1,
108
+ }),
109
+ /** Spread onto the option at `index` (index into `options`). */
110
+ optionProps: (index: number) => {
111
+ const o = opts()[index]
112
+ return {
113
+ role: 'option' as const,
114
+ tabindex: -1,
115
+ 'aria-selected': o ? o.value === config.value() : false,
116
+ 'aria-disabled': o?.disabled,
117
+ 'data-idx': index,
118
+ onclick: () => pick(index),
119
+ onpointermove: () => { if (o && !o.disabled) active = index },
120
+ }
121
+ },
122
+ }
123
+ }
124
+
125
+ export type DropdownList = ReturnType<typeof createDropdownList>