@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,488 @@
1
+ /**
2
+ * createCalendar - the HEADLESS core behind <SvCalendar>, in the same spirit as
3
+ * `createListbox` / `createSvGrid`: a runes-based state machine (view page +
4
+ * drill level, roving focus, every Smart `selectionMode`, range hover preview,
5
+ * full keyboard) with **prop-getters** you spread onto YOUR OWN markup. No
6
+ * styles, no DOM assumptions. It reuses the framework-free date engine in
7
+ * ./datetime for all the actual date math / selection / restriction, so this
8
+ * file only owns the reactive component-level state + interaction wiring.
9
+ *
10
+ * ```svelte
11
+ * <script lang="ts">
12
+ * import { createCalendar } from '@svgrid/grid'
13
+ * let value = $state<Date | null>(new Date())
14
+ * const cal = createCalendar({ value: () => value, onChange: (d) => (value = d[0] ?? null) })
15
+ * </script>
16
+ * <div {...cal.gridProps()}>
17
+ * {#each cal.panels[0].matrix as week}
18
+ * {#each week as cell}
19
+ * <button {...cal.dayProps(cell, cal.panels[0].month)}>{cell.date.getDate()}</button>
20
+ * {/each}
21
+ * {/each}
22
+ * </div>
23
+ * ```
24
+ *
25
+ * The styled <SvCalendar> is one renderer over this core; it keeps the render-only
26
+ * concerns (WAAPI view-change animation, mouse-wheel navigation, DOM refs) itself.
27
+ */
28
+ import {
29
+ addDays,
30
+ addMonths,
31
+ addYears,
32
+ monthMatrix,
33
+ weekdayOrder,
34
+ startOfDay,
35
+ isSameDay,
36
+ isSameMonth,
37
+ toDate,
38
+ decadeRange,
39
+ type DateLike,
40
+ type MonthMatrixCell,
41
+ } from './datetime/date-core'
42
+ import {
43
+ selectDate,
44
+ isSelected,
45
+ rangeDays,
46
+ emptySelection,
47
+ type SelectionMode,
48
+ type SelectionState,
49
+ } from './datetime/date-selection'
50
+ import { isDisabledDay, isImportant, type RestrictOptions } from './datetime/date-restrict'
51
+
52
+ export type DisplayMode = 'month' | 'year' | 'decade'
53
+ export type CalendarNameFormat = 'narrow' | 'short' | 'long'
54
+
55
+ /** Selected value(s). Single Date for one/zeroOrOne, array for multi modes. */
56
+ export type CalendarValue = Date | number | string | ReadonlyArray<Date | number | string> | null
57
+
58
+ /** A one-click shortcut shown in the presets rail. `value` is a single date
59
+ * (or an [start, end] range for range mode); pass a function for "today"-
60
+ * relative shortcuts that resolve at click time. */
61
+ export type CalendarPreset = {
62
+ label: string
63
+ value:
64
+ | Date | number | string
65
+ | readonly [Date | number | string, Date | number | string]
66
+ | (() => Date | number | string | readonly [Date | number | string, Date | number | string])
67
+ }
68
+
69
+ /** How the calendar animates a navigation / drill (a render concern the styled
70
+ * component acts on; the core only reports the direction). */
71
+ export type CalendarAnimation = 'slide' | 'fade' | 'none'
72
+
73
+ /** Why the visible view last changed - drives the styled component's animation. */
74
+ export type CalendarNavDir = 'next' | 'prev' | 'drillDown' | 'drillUp' | 'fade'
75
+
76
+ /** Per-day interaction/visual state (booleans the renderer maps to classes). */
77
+ export type CalendarDayState = {
78
+ disabled: boolean
79
+ selected: boolean
80
+ important: boolean
81
+ today: boolean
82
+ outside: boolean
83
+ focused: boolean
84
+ preview: boolean
85
+ }
86
+
87
+ /** Reactive inputs are passed as getters so the core tracks live prop changes;
88
+ * callbacks are plain closures. */
89
+ export type CalendarConfig = {
90
+ value: () => CalendarValue
91
+ onChange?: (dates: Date[]) => void
92
+ onNavigate?: (viewDate: Date, displayMode: DisplayMode) => void
93
+ selectionMode?: () => SelectionMode
94
+ min?: () => DateLike | null
95
+ max?: () => DateLike | null
96
+ restrictedDates?: () => RestrictOptions['restrictedDates']
97
+ importantDates?: () => ReadonlyArray<DateLike> | ((d: Date) => boolean) | null
98
+ firstDayOfWeek?: () => number
99
+ weeks?: () => number
100
+ months?: () => number
101
+ displayMode?: () => DisplayMode
102
+ disabled?: () => boolean
103
+ readonly?: () => boolean
104
+ locale?: () => string
105
+ dayNameFormat?: () => CalendarNameFormat
106
+ monthNameFormat?: () => CalendarNameFormat
107
+ dateTooltip?: () => ((date: Date) => string | null | undefined) | undefined
108
+ }
109
+
110
+ const defaultLocale = () => (typeof navigator !== 'undefined' ? navigator.language : 'en-US')
111
+
112
+ /** Normalize a calendar value to an ordered day-midnight list (pure). */
113
+ export function normalizeCalendarValue(v: CalendarValue): Date[] {
114
+ if (v == null) return []
115
+ const list = Array.isArray(v) ? v : [v]
116
+ const out: Date[] = []
117
+ for (const item of list) {
118
+ const d = toDate(item as DateLike)
119
+ if (d) out.push(startOfDay(d))
120
+ }
121
+ return out
122
+ }
123
+
124
+ export function createCalendar(config: CalendarConfig) {
125
+ // --- Config accessors (with the same defaults as the styled component) ------
126
+ const selectionMode = () => config.selectionMode?.() ?? 'one'
127
+ const firstDayOfWeek = () => config.firstDayOfWeek?.() ?? 0
128
+ const weeksCount = () => config.weeks?.() ?? 6
129
+ const monthsCount = () => Math.max(1, config.months?.() ?? 1)
130
+ const displayMode = () => config.displayMode?.() ?? 'month'
131
+ const isDisabled = () => config.disabled?.() ?? false
132
+ const isReadonly = () => config.readonly?.() ?? false
133
+ const locale = () => config.locale?.() ?? defaultLocale()
134
+ const dayNameFormat = () => config.dayNameFormat?.() ?? 'short'
135
+ const monthNameFormat = () => config.monthNameFormat?.() ?? 'long'
136
+
137
+ // --- Internal reactive state ------------------------------------------------
138
+ // Seeded from `value`; range mode also tracks a pending rangeStart between the
139
+ // two clicks, which `value` alone cannot hold.
140
+ let sel = $state<SelectionState>(emptySelection())
141
+ // The month currently shown (first panel) + the drill level.
142
+ let view = $state<Date>(startOfDay(new Date()))
143
+ let mode = $state<DisplayMode>('month')
144
+ // Roving focus + range hover preview.
145
+ let focusDate = $state<Date>(startOfDay(new Date()))
146
+ let hoverDate = $state<Date | null>(null)
147
+ // View-change animation is a render concern that lives in the styled
148
+ // component; the core only records why the view changed and bumps a token so
149
+ // the component can replay a keyframe on the fresh DOM.
150
+ let navDir: CalendarNavDir = 'fade'
151
+ let navToken = $state(0)
152
+
153
+ const restrict = $derived<RestrictOptions>({
154
+ min: config.min?.() ?? null,
155
+ max: config.max?.() ?? null,
156
+ restrictedDates: config.restrictedDates?.() ?? null,
157
+ })
158
+ const isInteractive = $derived(!isDisabled() && !isReadonly())
159
+
160
+ // --- Value sync + one-time seed of the visible page ------------------------
161
+ let lastSyncedKey = '￿' // sentinel so the first sync always fires (incl. null value)
162
+ let seeded = false
163
+ $effect(() => {
164
+ const dates = normalizeCalendarValue(config.value())
165
+ const key = dates.map((d) => d.getTime()).join(',')
166
+ if (key !== lastSyncedKey) {
167
+ lastSyncedKey = key
168
+ sel = { dates, anchor: dates[dates.length - 1] ?? null, rangeStart: null }
169
+ if (!seeded) {
170
+ seeded = true
171
+ const first = dates[0] ?? startOfDay(new Date())
172
+ view = startOfDay(first)
173
+ focusDate = startOfDay(first)
174
+ mode = displayMode()
175
+ }
176
+ }
177
+ })
178
+
179
+ // --- Intl-driven labels (cached by the browser) ----------------------------
180
+ const weekdayFmt = $derived(new Intl.DateTimeFormat(locale(), { weekday: dayNameFormat() }))
181
+ const monthTitleFmt = $derived(new Intl.DateTimeFormat(locale(), { month: monthNameFormat(), year: 'numeric' }))
182
+ const monthShortFmt = $derived(new Intl.DateTimeFormat(locale(), { month: 'short' }))
183
+ const fullDateFmt = $derived(new Intl.DateTimeFormat(locale(), { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }))
184
+
185
+ const weekdayHeaders = $derived(
186
+ weekdayOrder(firstDayOfWeek()).map((wd) => {
187
+ // 2021-08-01 is a Sunday; offset to the requested weekday.
188
+ const d = new Date(2021, 7, 1 + wd)
189
+ return { label: weekdayFmt.format(d), weekday: wd }
190
+ }),
191
+ )
192
+
193
+ /** The month grids to render (1..months panels), each with its own title. */
194
+ const panels = $derived(
195
+ Array.from({ length: monthsCount() }, (_, i) => {
196
+ const panelMonth = addMonths(view, i)
197
+ return { month: panelMonth, title: monthTitleFmt.format(panelMonth), matrix: monthMatrix(panelMonth, firstDayOfWeek(), weeksCount()) }
198
+ }),
199
+ )
200
+
201
+ // Range preview: while a range is pending (or being hovered), show the band.
202
+ const previewRange = $derived.by(() => {
203
+ if (selectionMode() !== 'range') return null
204
+ const start = sel.rangeStart
205
+ if (!start || !hoverDate) return null
206
+ return rangeDays(start, hoverDate)
207
+ })
208
+ const inPreview = (d: Date): boolean => !!previewRange && previewRange.some((p) => isSameDay(p, d))
209
+
210
+ function dayState(d: Date, panelMonth: Date): CalendarDayState {
211
+ return {
212
+ disabled: isDisabledDay(d, restrict),
213
+ selected: isSelected(sel.dates, d),
214
+ important: isImportant(d, config.importantDates?.() ?? null),
215
+ today: isSameDay(d, new Date()),
216
+ outside: !isSameMonth(d, panelMonth),
217
+ focused: isSameDay(d, focusDate),
218
+ preview: inPreview(d),
219
+ }
220
+ }
221
+
222
+ const title = $derived(
223
+ mode === 'month'
224
+ ? monthTitleFmt.format(view)
225
+ : mode === 'year'
226
+ ? String(view.getFullYear())
227
+ : (() => {
228
+ const { start, end } = decadeRange(view.getFullYear())
229
+ return `${start} - ${end}`
230
+ })(),
231
+ )
232
+
233
+ // Year-mode cells (12 months) and decade-mode cells (10 years + 2 padding).
234
+ const yearCells = $derived(
235
+ Array.from({ length: 12 }, (_, m) => ({
236
+ index: m,
237
+ label: monthShortFmt.format(new Date(view.getFullYear(), m, 1)),
238
+ current: m === new Date().getMonth() && view.getFullYear() === new Date().getFullYear(),
239
+ selected: sel.dates.some((d) => d.getMonth() === m && d.getFullYear() === view.getFullYear()),
240
+ })),
241
+ )
242
+ const decadeCells = $derived.by(() => {
243
+ const { start } = decadeRange(view.getFullYear())
244
+ return Array.from({ length: 12 }, (_, i) => {
245
+ const year = start - 1 + i
246
+ return {
247
+ year,
248
+ outside: i === 0 || i === 11,
249
+ current: year === new Date().getFullYear(),
250
+ selected: sel.dates.some((d) => d.getFullYear() === year),
251
+ }
252
+ })
253
+ })
254
+
255
+ const canClear = $derived(
256
+ selectionMode() === 'zeroOrOne' || selectionMode() === 'zeroOrMany' || selectionMode() === 'none',
257
+ )
258
+
259
+ // --- Actions ---------------------------------------------------------------
260
+ function commit(next: SelectionState) {
261
+ sel = next
262
+ lastSyncedKey = next.dates.map((x) => x.getTime()).join(',')
263
+ config.onChange?.(next.dates.map((x) => new Date(x.getTime())))
264
+ }
265
+
266
+ function pickDay(d: Date, e?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) {
267
+ if (!isInteractive || isDisabledDay(d, restrict)) return
268
+ focusDate = startOfDay(d)
269
+ commit(selectDate(sel, d, selectionMode(), { ctrl: e?.ctrlKey || e?.metaKey, shift: e?.shiftKey }, firstDayOfWeek()))
270
+ }
271
+
272
+ function setView(next: Date, nextMode: DisplayMode = mode) {
273
+ view = startOfDay(next)
274
+ mode = nextMode
275
+ navToken++ // signal the styled component to replay the view-change animation
276
+ config.onNavigate?.(view, mode)
277
+ }
278
+
279
+ function drillUp() {
280
+ navDir = 'drillUp'
281
+ if (mode === 'month') setView(view, 'year')
282
+ else if (mode === 'year') setView(view, 'decade')
283
+ }
284
+
285
+ function step(dir: -1 | 1) {
286
+ navDir = dir === 1 ? 'next' : 'prev'
287
+ if (mode === 'month') setView(addMonths(view, dir))
288
+ else if (mode === 'year') setView(addYears(view, dir))
289
+ else setView(addYears(view, dir * 10))
290
+ }
291
+
292
+ function pickMonth(monthIndex: number) {
293
+ navDir = 'drillDown'
294
+ setView(new Date(view.getFullYear(), monthIndex, 1), 'month')
295
+ focusDate = new Date(view.getFullYear(), monthIndex, Math.min(focusDate.getDate(), 28))
296
+ }
297
+
298
+ function pickYear(year: number) {
299
+ navDir = 'drillDown'
300
+ setView(new Date(year, view.getMonth(), 1), 'year')
301
+ }
302
+
303
+ function goToday() {
304
+ navDir = 'fade'
305
+ const t = new Date()
306
+ setView(t, 'month')
307
+ focusDate = startOfDay(t)
308
+ if (isInteractive) pickDay(t)
309
+ }
310
+
311
+ const startOfMonthSafe = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1)
312
+ function applyPreset(p: CalendarPreset) {
313
+ if (!isInteractive) return
314
+ const resolved = typeof p.value === 'function' ? p.value() : p.value
315
+ const isRange = Array.isArray(resolved)
316
+ const pair = (isRange ? resolved : [resolved, resolved]) as ReadonlyArray<DateLike>
317
+ const start = toDate(pair[0] as DateLike)
318
+ const end = toDate(pair[1] as DateLike)
319
+ if (!start) return
320
+ focusDate = startOfDay(start)
321
+ navDir = 'fade'
322
+ view = startOfMonthSafe(start)
323
+ navToken++
324
+ if (isRange && end) {
325
+ commit({ dates: rangeDays(start, end), anchor: startOfDay(end), rangeStart: null })
326
+ } else {
327
+ commit({ dates: [startOfDay(start)], anchor: startOfDay(start), rangeStart: null })
328
+ }
329
+ }
330
+
331
+ function clearSelection() {
332
+ if (!isInteractive) return
333
+ commit(emptySelection())
334
+ }
335
+
336
+ const setHover = (d: Date | null) => (hoverDate = d)
337
+
338
+ // --- Keyboard --------------------------------------------------------------
339
+ function onNonMonthKeydown(e: KeyboardEvent) {
340
+ if (e.key === 'Enter' || e.key === ' ') {
341
+ e.preventDefault()
342
+ if (mode === 'year') pickMonth(focusDate.getMonth())
343
+ else pickYear(focusDate.getFullYear())
344
+ }
345
+ }
346
+
347
+ function onKeydown(e: KeyboardEvent) {
348
+ if (mode !== 'month') return onNonMonthKeydown(e)
349
+ let next: Date | null = null
350
+ switch (e.key) {
351
+ case 'ArrowLeft': next = addDays(focusDate, -1); break
352
+ case 'ArrowRight': next = addDays(focusDate, 1); break
353
+ case 'ArrowUp': next = addDays(focusDate, -7); break
354
+ case 'ArrowDown': next = addDays(focusDate, 7); break
355
+ case 'Home': next = addDays(focusDate, -((focusDate.getDay() - firstDayOfWeek() + 7) % 7)); break
356
+ case 'End': next = addDays(focusDate, 6 - ((focusDate.getDay() - firstDayOfWeek() + 7) % 7)); break
357
+ case 'PageUp': next = e.shiftKey ? addYears(focusDate, -1) : addMonths(focusDate, -1); break
358
+ case 'PageDown': next = e.shiftKey ? addYears(focusDate, 1) : addMonths(focusDate, 1); break
359
+ case 'Enter':
360
+ case ' ':
361
+ e.preventDefault()
362
+ pickDay(focusDate, e)
363
+ return
364
+ default:
365
+ return
366
+ }
367
+ if (next) {
368
+ e.preventDefault()
369
+ focusDate = startOfDay(next)
370
+ if (!isSameMonth(focusDate, view)) setView(focusDate, 'month')
371
+ }
372
+ }
373
+
374
+ return {
375
+ // --- Reactive state -------------------------------------------------------
376
+ get view() { return view },
377
+ get mode() { return mode },
378
+ get title() { return title },
379
+ get panels() { return panels },
380
+ get weekdayHeaders() { return weekdayHeaders },
381
+ get yearCells() { return yearCells },
382
+ get decadeCells() { return decadeCells },
383
+ get selectedDates() { return sel.dates },
384
+ get previewRange() { return previewRange },
385
+ get focusDate() { return focusDate },
386
+ get hoverDate() { return hoverDate },
387
+ get canClear() { return canClear },
388
+ get isInteractive() { return isInteractive },
389
+ /** Increments on every view/drill change; the styled component watches it to
390
+ * replay its WAAPI animation on the updated DOM. */
391
+ get navToken() { return navToken },
392
+ /** Why the view last changed (read untracked from the animation effect). */
393
+ get navDir() { return navDir },
394
+
395
+ // --- Derived helpers ------------------------------------------------------
396
+ dayState,
397
+ inPreview,
398
+ formatFullDate: (d: Date) => fullDateFmt.format(d),
399
+ formatMonthTitle: (d: Date) => monthTitleFmt.format(d),
400
+
401
+ // --- Actions --------------------------------------------------------------
402
+ pickDay,
403
+ step,
404
+ drillUp,
405
+ pickMonth,
406
+ pickYear,
407
+ goToday,
408
+ applyPreset,
409
+ clearSelection,
410
+ setHover,
411
+ onKeydown,
412
+
413
+ // --- Prop-getters (spread onto YOUR markup) -------------------------------
414
+ /** Previous / Next navigation button. */
415
+ navProps(dir: -1 | 1) {
416
+ return {
417
+ type: 'button' as const,
418
+ disabled: isDisabled(),
419
+ 'aria-label': dir === 1 ? 'Next' : 'Previous',
420
+ onclick: () => step(dir),
421
+ }
422
+ },
423
+ /** The centered title button that drills up (month -> year -> decade). */
424
+ titleProps() {
425
+ return {
426
+ type: 'button' as const,
427
+ disabled: isDisabled() || mode === 'decade',
428
+ 'aria-live': 'polite' as const,
429
+ onclick: drillUp,
430
+ }
431
+ },
432
+ /** The grid container: keyboard + ARIA. Works for month, year and decade
433
+ * modes (the handler dispatches on the current mode). */
434
+ gridProps() {
435
+ return {
436
+ role: 'grid' as const,
437
+ 'aria-label': title,
438
+ tabindex: -1,
439
+ onkeydown: onKeydown,
440
+ }
441
+ },
442
+ /** A single day button in the month grid. Combine with `dayState` for the
443
+ * visual class flags. */
444
+ dayProps(cell: MonthMatrixCell, panelMonth: Date) {
445
+ const st = dayState(cell.date, panelMonth)
446
+ return {
447
+ type: 'button' as const,
448
+ role: 'gridcell' as const,
449
+ 'aria-selected': st.selected,
450
+ 'aria-disabled': st.disabled,
451
+ 'aria-current': st.today ? ('date' as const) : undefined,
452
+ 'aria-label': fullDateFmt.format(cell.date),
453
+ title: config.dateTooltip?.()?.(cell.date) ?? undefined,
454
+ tabindex: st.focused ? 0 : -1,
455
+ disabled: st.disabled || isDisabled(),
456
+ onclick: (e: MouseEvent) => pickDay(cell.date, e),
457
+ onpointerenter: () => setHover(cell.date),
458
+ onpointerleave: () => setHover(null),
459
+ }
460
+ },
461
+ /** A month cell in year mode. */
462
+ monthCellProps(monthIndex: number) {
463
+ return {
464
+ type: 'button' as const,
465
+ disabled: isDisabled(),
466
+ onclick: () => pickMonth(monthIndex),
467
+ }
468
+ },
469
+ /** A year cell in decade mode. */
470
+ yearCellProps(year: number) {
471
+ return {
472
+ type: 'button' as const,
473
+ disabled: isDisabled(),
474
+ onclick: () => pickYear(year),
475
+ }
476
+ },
477
+ /** A preset shortcut button. */
478
+ presetProps(preset: CalendarPreset) {
479
+ return {
480
+ type: 'button' as const,
481
+ disabled: !isInteractive,
482
+ onclick: () => applyPreset(preset),
483
+ }
484
+ },
485
+ }
486
+ }
487
+
488
+ export type Calendar = ReturnType<typeof createCalendar>
@@ -0,0 +1,74 @@
1
+ /**
2
+ * createCheckbox - the HEADLESS core behind <SvCheckBox>: a controlled tri-state
3
+ * checkbox (checked / unchecked / indeterminate, ARIA `checkbox` with
4
+ * `aria-checked="mixed"`), exposed as **prop-getters** you spread onto YOUR OWN
5
+ * markup. A native <button> supplies Space/Enter activation.
6
+ *
7
+ * ```svelte
8
+ * <script lang="ts">
9
+ * import { createCheckbox } from '@svgrid/grid'
10
+ * let checked = $state(false)
11
+ * const cb = createCheckbox({ checked: () => checked, onChange: (v) => (checked = v) })
12
+ * </script>
13
+ * <button {...cb.boxProps()}>{cb.checked ? '✓' : ''}</button>
14
+ * ```
15
+ *
16
+ * The styled <SvCheckBox> is just one renderer over this core.
17
+ */
18
+ export type CheckboxConfig = {
19
+ checked: () => boolean
20
+ indeterminate?: () => boolean
21
+ onChange?: (checked: boolean) => void
22
+ disabled?: () => boolean
23
+ ariaLabel?: () => string | undefined
24
+ }
25
+
26
+ export type CheckboxBoxProps = {
27
+ type: 'button'
28
+ role: 'checkbox'
29
+ 'aria-checked': boolean | 'mixed'
30
+ 'aria-label': string | undefined
31
+ disabled: boolean
32
+ 'data-checked': '' | undefined
33
+ 'data-indeterminate': '' | undefined
34
+ onclick: () => void
35
+ }
36
+
37
+ export type Checkbox = {
38
+ /** True only when fully checked (never in the indeterminate state). */
39
+ readonly checked: boolean
40
+ readonly indeterminate: boolean
41
+ readonly disabled: boolean
42
+ /** Indeterminate resolves to checked; otherwise flips checked. */
43
+ toggle: () => void
44
+ /** Spread onto the checkbox element. */
45
+ boxProps: () => CheckboxBoxProps
46
+ }
47
+
48
+ export function createCheckbox(config: CheckboxConfig): Checkbox {
49
+ const checked = () => config.checked()
50
+ const indeterminate = () => config.indeterminate?.() ?? false
51
+ const disabled = () => config.disabled?.() ?? false
52
+
53
+ function toggle() {
54
+ if (disabled()) return
55
+ config.onChange?.(indeterminate() ? true : !checked())
56
+ }
57
+
58
+ return {
59
+ get checked() { return checked() },
60
+ get indeterminate() { return indeterminate() },
61
+ get disabled() { return disabled() },
62
+ toggle,
63
+ boxProps: () => ({
64
+ type: 'button',
65
+ role: 'checkbox',
66
+ 'aria-checked': indeterminate() ? 'mixed' : checked(),
67
+ 'aria-label': config.ariaLabel?.(),
68
+ disabled: disabled(),
69
+ 'data-checked': checked() && !indeterminate() ? '' : undefined,
70
+ 'data-indeterminate': indeterminate() ? '' : undefined,
71
+ onclick: toggle,
72
+ }),
73
+ }
74
+ }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * createColorInput - the HEADLESS core behind <SvColorInput>: a runes-based
3
+ * state machine that owns hex normalization, the palette, the draft hex field
4
+ * and the open/close popover state, exposed as **prop-getters** you spread onto
5
+ * YOUR OWN markup. DOM measurement / portalling stays in the renderer, NOT here.
6
+ *
7
+ * ```svelte
8
+ * <script lang="ts">
9
+ * import { createColorInput } from '@svgrid/grid'
10
+ * let value = $state('#3b82f6')
11
+ * const col = createColorInput({ value: () => value, onChange: (v) => (value = v) })
12
+ * </script>
13
+ * <button {...col.swatchProps()}>{col.normalized}</button>
14
+ * {#if col.popover.open}<div>...palette...</div>{/if}
15
+ * ```
16
+ *
17
+ * The styled <SvColorInput> is just one renderer over this core (it adds the
18
+ * portal + anchored positioning).
19
+ */
20
+
21
+ export const DEFAULT_PALETTE = [
22
+ '#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e', '#10b981', '#14b8a6',
23
+ '#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1', '#8b5cf6', '#a855f7', '#d946ef', '#ec4899',
24
+ '#f43f5e', '#64748b', '#0f172a', '#ffffff',
25
+ ]
26
+
27
+ /** Normalize a hex string (#abc / #aabbcc / abc) to lower-case #aabbcc (pure). */
28
+ export function normalizeHex(h: string): string | null {
29
+ let s = h.trim()
30
+ if (!s.startsWith('#')) s = '#' + s
31
+ if (/^#[0-9a-fA-F]{3}$/.test(s)) s = '#' + s.slice(1).split('').map((c) => c + c).join('')
32
+ return /^#[0-9a-fA-F]{6}$/.test(s) ? s.toLowerCase() : null
33
+ }
34
+
35
+ /** Reactive inputs are passed as getters so the core tracks live prop changes. */
36
+ export type ColorInputConfig = {
37
+ value: () => string
38
+ onChange?: (hex: string) => void
39
+ disabled?: () => boolean
40
+ readonly?: () => boolean
41
+ palette?: () => string[]
42
+ ariaLabel?: () => string | undefined
43
+ }
44
+
45
+ export function createColorInput(config: ColorInputConfig) {
46
+ const disabled = () => config.disabled?.() ?? false
47
+ const readonly = () => config.readonly?.() ?? false
48
+ const isInteractive = $derived(!disabled() && !readonly())
49
+
50
+ let open = $state(false)
51
+ let hexDraft = $state('')
52
+
53
+ const normalized = $derived(normalizeHex(config.value()) ?? '#000000')
54
+
55
+ function openPanel() {
56
+ if (!isInteractive || open) return
57
+ hexDraft = normalized
58
+ open = true
59
+ }
60
+ function close() { open = false }
61
+ function toggle() { open ? (open = false) : openPanel() }
62
+
63
+ function pick(hex: string) {
64
+ const n = normalizeHex(hex)
65
+ if (n) config.onChange?.(n)
66
+ }
67
+ function commitHex() {
68
+ const n = normalizeHex(hexDraft)
69
+ if (n) config.onChange?.(n)
70
+ else hexDraft = normalized
71
+ }
72
+ const isActive = (c: string) => normalizeHex(c) === normalized
73
+
74
+ // Popover control surface (open state + show/close/toggle), no DOM measurement.
75
+ const popover = {
76
+ get open() { return open },
77
+ show: openPanel,
78
+ close,
79
+ toggle,
80
+ }
81
+
82
+ return {
83
+ /** The normalized current color (#aabbcc). */
84
+ get normalized() { return normalized },
85
+ /** Preset swatches. */
86
+ get palette() { return config.palette?.() ?? DEFAULT_PALETTE },
87
+ /** Draft hex text for the panel field (settable for `bind:value`). */
88
+ get hexDraft() { return hexDraft },
89
+ set hexDraft(v: string) { hexDraft = v },
90
+ /** Whether interaction is allowed. */
91
+ get isInteractive() { return isInteractive },
92
+ /** Popover open state + open/close/toggle controls. */
93
+ popover,
94
+ pick,
95
+ commitHex,
96
+ isActive,
97
+ /** Spread onto the swatch trigger button. */
98
+ swatchProps: () => ({
99
+ type: 'button' as const,
100
+ 'aria-haspopup': 'dialog' as const,
101
+ 'aria-expanded': open,
102
+ 'aria-label': config.ariaLabel?.() ?? `Color ${normalized}`,
103
+ disabled: !isInteractive,
104
+ onclick: toggle,
105
+ }),
106
+ /** Spread onto a palette chip button for color `c`. */
107
+ chipProps: (c: string) => ({
108
+ type: 'button' as const,
109
+ 'aria-label': c,
110
+ onclick: () => { pick(c); open = false },
111
+ }),
112
+ }
113
+ }
114
+
115
+ export type ColorInputCore = ReturnType<typeof createColorInput>