@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,54 +1,32 @@
1
1
  <script lang="ts" module>
2
- export type CalendarValue = Date | number | string | ReadonlyArray<Date | number | string> | null
3
-
4
- /** A one-click shortcut shown in the presets rail. `value` is a single date
5
- * (or an [start, end] range for range mode); pass a function for "today"-
6
- * relative shortcuts that resolve at click time. */
7
- export type CalendarPreset = {
8
- label: string
9
- value:
10
- | Date | number | string
11
- | readonly [Date | number | string, Date | number | string]
12
- | (() => Date | number | string | readonly [Date | number | string, Date | number | string])
13
- }
14
-
15
- /** How the calendar animates a navigation / drill. */
16
- export type CalendarAnimation = 'slide' | 'fade' | 'none'
2
+ // Public types now live with the headless core and are re-exported here so the
3
+ // component's module entry point (and @svgrid/grid) keeps the same surface.
4
+ export type { CalendarValue, CalendarPreset, CalendarAnimation, DisplayMode } from './createCalendar.svelte'
17
5
  </script>
18
6
 
19
7
  <script lang="ts">
20
8
  /**
21
- * SvCalendar - a themeable, accessible month/year/decade calendar built on the
22
- * framework-free date engine in ./datetime. It works standalone AND is the
23
- * date cell editor for SvGrid (rich-by-default). All visuals come from the
24
- * grid's `--sg-*` theme tokens, so every preset theme styles it for free.
9
+ * SvCalendar - a themeable, accessible month/year/decade calendar. It is a thin
10
+ * styled renderer over the headless `createCalendar` core (the same split as
11
+ * `createSvGrid` / <SvGrid>): the core owns the reactive state + interaction +
12
+ * ARIA (built on the framework-free ./datetime engine), while this component
13
+ * keeps the render-only concerns - the WAAPI view-change animation, mouse-wheel
14
+ * navigation, and DOM refs. All visuals come from the grid's `--sg-*` tokens.
25
15
  *
26
16
  * Parity target: Smart `smart-calendar` (selectionModes, min/max, restricted /
27
17
  * important dates, week numbers, firstDayOfWeek, drill navigation, keyboard).
28
18
  */
29
- import {
30
- addDays,
31
- addMonths,
32
- addYears,
33
- monthMatrix,
34
- weekdayOrder,
35
- startOfDay,
36
- isSameDay,
37
- isSameMonth,
38
- toDate,
39
- decadeRange,
40
- type DateLike,
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
19
  import { untrack } from 'svelte'
20
+ import {
21
+ createCalendar,
22
+ type CalendarValue,
23
+ type CalendarPreset,
24
+ type CalendarAnimation,
25
+ type DisplayMode,
26
+ } from './createCalendar.svelte'
27
+ import type { SelectionMode } from './datetime/date-selection'
28
+ import type { RestrictOptions } from './datetime/date-restrict'
29
+ import type { DateLike } from './datetime/date-core'
52
30
 
53
31
  type NameFormat = 'narrow' | 'short' | 'long'
54
32
 
@@ -96,8 +74,6 @@
96
74
  presets?: ReadonlyArray<CalendarPreset>
97
75
  }
98
76
 
99
- type DisplayMode = 'month' | 'year' | 'decade'
100
-
101
77
  let {
102
78
  value = null,
103
79
  onChange,
@@ -127,14 +103,37 @@
127
103
  presets,
128
104
  }: Props = $props()
129
105
 
106
+ // The headless core owns all reactive state, selection, navigation, keyboard
107
+ // and ARIA. Reactive inputs are passed as getters; callbacks as closures.
108
+ const cal = createCalendar({
109
+ value: () => value,
110
+ onChange: (d) => onChange?.(d),
111
+ onNavigate: (v, m) => onNavigate?.(v, m),
112
+ selectionMode: () => selectionMode,
113
+ min: () => min,
114
+ max: () => max,
115
+ restrictedDates: () => restrictedDates,
116
+ importantDates: () => importantDates,
117
+ firstDayOfWeek: () => firstDayOfWeek,
118
+ weeks: () => weeks,
119
+ months: () => months,
120
+ displayMode: () => displayMode,
121
+ disabled: () => disabled,
122
+ readonly: () => readonly,
123
+ locale: () => locale,
124
+ dayNameFormat: () => dayNameFormat,
125
+ monthNameFormat: () => monthNameFormat,
126
+ dateTooltip: () => dateTooltip,
127
+ })
128
+
130
129
  // --- View-change animation (framework-native Web Animations API) -----------
131
- // We animate a wrapper element instead of re-keying the grid, so day cells stay
132
- // mounted (keyboard focus + interactivity are preserved). `navDir` records why
133
- // the view changed; an effect replays a short keyframe on the new content.
130
+ // A render concern kept out of the core: we animate a wrapper element instead
131
+ // of re-keying the grid, so day cells stay mounted (keyboard focus +
132
+ // interactivity are preserved). The core exposes `navToken` (bumps on each
133
+ // navigation) + `navDir` (why it changed); an effect replays a short keyframe
134
+ // on the new content.
134
135
  const animKind = $derived<CalendarAnimation>(animate === true ? 'slide' : animate || 'none')
135
136
  let animEl = $state<HTMLElement | undefined>()
136
- let animToken = $state(0)
137
- let navDir: 'next' | 'prev' | 'drillDown' | 'drillUp' | 'fade' = 'fade'
138
137
  let animPrimed = false // skip the very first (mount) run
139
138
  // Note: `animate` is an explicit opt-in, so we honor it even under the OS
140
139
  // "reduce motion" setting. Pass animate="none" (or gate it yourself on
@@ -146,6 +145,7 @@
146
145
  // pronounced to read as a transition. Slide the new content a clear distance +
147
146
  // fade; drills zoom. A mid-keyframe holds the offset briefly for legibility.
148
147
  const D = 34
148
+ const navDir = cal.navDir
149
149
  const from =
150
150
  !slide ? { opacity: 0, transform: 'scale(0.98)' }
151
151
  : navDir === 'next' ? { transform: `translateX(${D}px)`, opacity: 0 }
@@ -159,260 +159,21 @@
159
159
  })
160
160
  }
161
161
  $effect(() => {
162
- animToken // re-run after each navigation's DOM update
162
+ cal.navToken // re-run after each navigation's DOM update
163
163
  if (!animPrimed) { animPrimed = true; return }
164
164
  untrack(playAnim)
165
165
  })
166
166
 
167
- const restrict = $derived<RestrictOptions>({ min, max, restrictedDates })
168
-
169
- /** Normalize the incoming value to an ordered day list. */
170
- function normalizeValue(v: CalendarValue): Date[] {
171
- if (v == null) return []
172
- const list = Array.isArray(v) ? v : [v]
173
- const out: Date[] = []
174
- for (const item of list) {
175
- const d = toDate(item as DateLike)
176
- if (d) out.push(startOfDay(d))
177
- }
178
- return out
179
- }
180
-
181
- // Internal selection state. Seeded from `value`; range mode also tracks a
182
- // pending rangeStart between the two clicks, which `value` alone cannot hold.
183
- let sel = $state<SelectionState>(emptySelection())
184
- // The month currently shown (first panel) + the drill level.
185
- let view = $state<Date>(startOfDay(new Date()))
186
- let mode = $state<DisplayMode>('month')
187
- // Roving focus + range hover preview.
188
- let focusDate = $state<Date>(startOfDay(new Date()))
189
- let hoverDate = $state<Date | null>(null)
190
-
191
- let lastSyncedKey = '￿' // sentinel so the first sync always fires (incl. null value)
192
- let seeded = false
193
- $effect(() => {
194
- const dates = normalizeValue(value)
195
- const key = dates.map((d) => d.getTime()).join(',')
196
- if (key !== lastSyncedKey) {
197
- lastSyncedKey = key
198
- sel = { dates, anchor: dates[dates.length - 1] ?? null, rangeStart: null }
199
- // Seed the visible page + roving focus once, from the initial value.
200
- if (!seeded) {
201
- seeded = true
202
- const first = dates[0] ?? startOfDay(new Date())
203
- view = startOfDay(first)
204
- focusDate = startOfDay(first)
205
- mode = displayMode
206
- }
207
- }
208
- })
209
-
210
- const isInteractive = $derived(!disabled && !readonly)
211
-
212
- // --- Intl-driven labels (cached by the browser) ---
213
- const weekdayFmt = $derived(new Intl.DateTimeFormat(locale, { weekday: dayNameFormat }))
214
- const monthTitleFmt = $derived(new Intl.DateTimeFormat(locale, { month: monthNameFormat, year: 'numeric' }))
215
- const monthShortFmt = $derived(new Intl.DateTimeFormat(locale, { month: 'short' }))
216
- const fullDateFmt = $derived(new Intl.DateTimeFormat(locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }))
217
-
218
- const weekdayHeaders = $derived(
219
- weekdayOrder(firstDayOfWeek).map((wd) => {
220
- // 2021-08-01 is a Sunday; offset to the requested weekday.
221
- const d = new Date(2021, 7, 1 + wd)
222
- return { label: weekdayFmt.format(d), weekday: wd }
223
- }),
224
- )
225
-
226
- /** The month grids to render (1..months panels). */
227
- const panels = $derived(
228
- Array.from({ length: Math.max(1, months) }, (_, i) => {
229
- const panelMonth = addMonths(view, i)
230
- return { month: panelMonth, matrix: monthMatrix(panelMonth, firstDayOfWeek, weeks) }
231
- }),
232
- )
233
-
234
- // Range preview: while a range is pending (or being hovered), show the band.
235
- const previewRange = $derived.by(() => {
236
- if (selectionMode !== 'range') return null
237
- const start = sel.rangeStart
238
- if (!start || !hoverDate) return null
239
- return rangeDays(start, hoverDate)
240
- })
241
-
242
- function inPreview(d: Date): boolean {
243
- return !!previewRange && previewRange.some((p) => isSameDay(p, d))
244
- }
245
-
246
- function dayState(d: Date, panelMonth: Date) {
247
- const disabledDay = isDisabledDay(d, restrict)
248
- return {
249
- disabled: disabledDay,
250
- selected: isSelected(sel.dates, d),
251
- important: isImportant(d, importantDates),
252
- today: isSameDay(d, new Date()),
253
- outside: !isSameMonth(d, panelMonth),
254
- focused: isSameDay(d, focusDate),
255
- preview: inPreview(d),
256
- }
257
- }
258
-
259
- // --- Actions ---
260
- function commit(next: SelectionState) {
261
- sel = next
262
- lastSyncedKey = next.dates.map((x) => x.getTime()).join(',')
263
- 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(
270
- selectDate(sel, d, selectionMode, { ctrl: e?.ctrlKey || e?.metaKey, shift: e?.shiftKey }, firstDayOfWeek),
271
- )
272
- }
273
-
274
- function setView(next: Date, nextMode: DisplayMode = mode) {
275
- view = startOfDay(next)
276
- mode = nextMode
277
- animToken++ // fire the view-change animation on the updated DOM
278
- onNavigate?.(view, mode)
279
- }
280
-
281
- function drillUp() {
282
- navDir = 'drillUp'
283
- if (mode === 'month') setView(view, 'year')
284
- else if (mode === 'year') setView(view, 'decade')
285
- }
286
-
287
- function step(dir: -1 | 1) {
288
- navDir = dir === 1 ? 'next' : 'prev'
289
- if (mode === 'month') setView(addMonths(view, dir))
290
- else if (mode === 'year') setView(addYears(view, dir))
291
- else setView(addYears(view, dir * 10))
292
- }
293
-
294
- function pickMonth(monthIndex: number) {
295
- navDir = 'drillDown'
296
- setView(new Date(view.getFullYear(), monthIndex, 1), 'month')
297
- focusDate = new Date(view.getFullYear(), monthIndex, Math.min(focusDate.getDate(), 28))
298
- }
299
-
300
- function pickYear(year: number) {
301
- navDir = 'drillDown'
302
- setView(new Date(year, view.getMonth(), 1), 'year')
303
- }
304
-
305
- function goToday() {
306
- navDir = 'fade'
307
- const t = new Date()
308
- setView(t, 'month')
309
- focusDate = startOfDay(t)
310
- if (isInteractive) pickDay(t)
311
- }
312
-
313
167
  // Mouse-wheel navigation (opt-in). Non-passive so we can prevent page scroll.
314
168
  function wheelNav(node: HTMLElement) {
315
169
  const onWheel = (e: WheelEvent) => {
316
170
  if (!wheelNavigation || disabled) return
317
171
  e.preventDefault()
318
- step(e.deltaY > 0 || e.deltaX > 0 ? 1 : -1)
172
+ cal.step(e.deltaY > 0 || e.deltaX > 0 ? 1 : -1)
319
173
  }
320
174
  node.addEventListener('wheel', onWheel, { passive: false })
321
175
  return { destroy: () => node.removeEventListener('wheel', onWheel) }
322
176
  }
323
-
324
- // --- Presets (Today / Last 7 days / ...) -----------------------------------
325
- function applyPreset(p: CalendarPreset) {
326
- if (!isInteractive) return
327
- const resolved = typeof p.value === 'function' ? p.value() : p.value
328
- const isRange = Array.isArray(resolved)
329
- const pair = (isRange ? resolved : [resolved, resolved]) as ReadonlyArray<DateLike>
330
- const start = toDate(pair[0] as DateLike)
331
- const end = toDate(pair[1] as DateLike)
332
- if (!start) return
333
- focusDate = startOfDay(start)
334
- navDir = 'fade'
335
- view = startOfMonthSafe(start)
336
- animToken++
337
- if (isRange && end) {
338
- commit({ dates: rangeDays(start, end), anchor: startOfDay(end), rangeStart: null })
339
- } else {
340
- commit({ dates: [startOfDay(start)], anchor: startOfDay(start), rangeStart: null })
341
- }
342
- }
343
- const startOfMonthSafe = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1)
344
-
345
- function clearSelection() {
346
- if (!isInteractive) return
347
- commit(emptySelection())
348
- }
349
-
350
- // --- Keyboard (month grid) ---
351
- function onGridKeydown(e: KeyboardEvent) {
352
- if (mode !== 'month') return onNonMonthKeydown(e)
353
- let next: Date | null = null
354
- switch (e.key) {
355
- case 'ArrowLeft': next = addDays(focusDate, -1); break
356
- case 'ArrowRight': next = addDays(focusDate, 1); break
357
- case 'ArrowUp': next = addDays(focusDate, -7); break
358
- case 'ArrowDown': next = addDays(focusDate, 7); break
359
- case 'Home': next = addDays(focusDate, -((focusDate.getDay() - firstDayOfWeek + 7) % 7)); break
360
- case 'End': next = addDays(focusDate, 6 - ((focusDate.getDay() - firstDayOfWeek + 7) % 7)); break
361
- case 'PageUp': next = e.shiftKey ? addYears(focusDate, -1) : addMonths(focusDate, -1); break
362
- case 'PageDown': next = e.shiftKey ? addYears(focusDate, 1) : addMonths(focusDate, 1); break
363
- case 'Enter':
364
- case ' ':
365
- e.preventDefault()
366
- pickDay(focusDate, e)
367
- return
368
- default:
369
- return
370
- }
371
- if (next) {
372
- e.preventDefault()
373
- focusDate = startOfDay(next)
374
- if (!isSameMonth(focusDate, view)) setView(focusDate, 'month')
375
- }
376
- }
377
-
378
- function onNonMonthKeydown(e: KeyboardEvent) {
379
- if (e.key === 'Enter' || e.key === ' ') {
380
- e.preventDefault()
381
- if (mode === 'year') pickMonth(focusDate.getMonth())
382
- else pickYear(focusDate.getFullYear())
383
- }
384
- }
385
-
386
- const title = $derived(
387
- mode === 'month'
388
- ? monthTitleFmt.format(view)
389
- : mode === 'year'
390
- ? String(view.getFullYear())
391
- : (() => {
392
- const { start, end } = decadeRange(view.getFullYear())
393
- return `${start} - ${end}`
394
- })(),
395
- )
396
-
397
- // Year-mode cells (12 months) and decade-mode cells (10 years + 2 padding).
398
- const yearCells = $derived(
399
- Array.from({ length: 12 }, (_, m) => ({
400
- index: m,
401
- label: monthShortFmt.format(new Date(view.getFullYear(), m, 1)),
402
- current: m === new Date().getMonth() && view.getFullYear() === new Date().getFullYear(),
403
- })),
404
- )
405
- const decadeCells = $derived.by(() => {
406
- const { start } = decadeRange(view.getFullYear())
407
- return Array.from({ length: 12 }, (_, i) => {
408
- const year = start - 1 + i
409
- return { year, outside: i === 0 || i === 11, current: year === new Date().getFullYear() }
410
- })
411
- })
412
-
413
- const canClear = $derived(
414
- selectionMode === 'zeroOrOne' || selectionMode === 'zeroOrMany' || selectionMode === 'none',
415
- )
416
177
  </script>
417
178
 
418
179
  <div
@@ -427,38 +188,32 @@
427
188
  {#if presets && presets.length}
428
189
  <div class="sv-cal__presets" role="group" aria-label="Shortcuts">
429
190
  {#each presets as p, i (i)}
430
- <button type="button" class="sv-cal__preset" onclick={() => applyPreset(p)} disabled={!isInteractive}>{p.label}</button>
191
+ <button class="sv-cal__preset" {...cal.presetProps(p)}>{p.label}</button>
431
192
  {/each}
432
193
  </div>
433
194
  {/if}
434
195
 
435
196
  <div class="sv-cal__main" use:wheelNav>
436
197
  <div class="sv-cal__header">
437
- <button type="button" class="sv-cal__nav" onclick={() => step(-1)} disabled={disabled} aria-label="Previous">
198
+ <button class="sv-cal__nav" {...cal.navProps(-1)}>
438
199
  <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6" /></svg>
439
200
  </button>
440
- <button
441
- type="button"
442
- class="sv-cal__title"
443
- onclick={drillUp}
444
- disabled={disabled || mode === 'decade'}
445
- aria-live="polite"
446
- >{title}</button>
447
- <button type="button" class="sv-cal__nav" onclick={() => step(1)} disabled={disabled} aria-label="Next">
201
+ <button class="sv-cal__title" {...cal.titleProps()}>{cal.title}</button>
202
+ <button class="sv-cal__nav" {...cal.navProps(1)}>
448
203
  <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6" /></svg>
449
204
  </button>
450
205
  </div>
451
206
 
452
207
  <div class="sv-cal__view" bind:this={animEl}>
453
- {#if mode === 'month'}
454
- <div class="sv-cal__panels" onkeydown={onGridKeydown} role="grid" aria-label={title} tabindex="-1">
455
- {#each panels as panel (panel.month.getTime())}
208
+ {#if cal.mode === 'month'}
209
+ <div class="sv-cal__panels" {...cal.gridProps()}>
210
+ {#each cal.panels as panel (panel.month.getTime())}
456
211
  <div class="sv-cal__panel">
457
- {#if months > 1}<div class="sv-cal__panel-title">{monthTitleFmt.format(panel.month)}</div>{/if}
212
+ {#if months > 1}<div class="sv-cal__panel-title">{panel.title}</div>{/if}
458
213
  {#if !hideDayNames}
459
214
  <div class="sv-cal__weekdays" role="row">
460
215
  {#if weekNumbers}<span class="sv-cal__wk-head" aria-hidden="true">#</span>{/if}
461
- {#each weekdayHeaders as wh (wh.weekday)}
216
+ {#each cal.weekdayHeaders as wh (wh.weekday)}
462
217
  <span class="sv-cal__weekday" role="columnheader">{wh.label}</span>
463
218
  {/each}
464
219
  </div>
@@ -467,13 +222,11 @@
467
222
  <div class="sv-cal__week" role="row">
468
223
  {#if weekNumbers}<span class="sv-cal__wk" aria-hidden="true">{week[0]?.week}</span>{/if}
469
224
  {#each week as cell (cell.date.getTime())}
470
- {@const st = dayState(cell.date, panel.month)}
225
+ {@const st = cal.dayState(cell.date, panel.month)}
471
226
  {#if st.outside && hideOtherMonthDays}
472
227
  <span class="sv-cal__day sv-cal__day--empty" role="gridcell" aria-hidden="true"></span>
473
228
  {:else}
474
229
  <button
475
- type="button"
476
- role="gridcell"
477
230
  class="sv-cal__day"
478
231
  class:is-selected={st.selected}
479
232
  class:is-today={st.today}
@@ -481,16 +234,7 @@
481
234
  class:is-important={st.important}
482
235
  class:is-preview={st.preview}
483
236
  class:is-focused={st.focused}
484
- aria-selected={st.selected}
485
- aria-disabled={st.disabled}
486
- aria-current={st.today ? 'date' : undefined}
487
- aria-label={fullDateFmt.format(cell.date)}
488
- title={dateTooltip?.(cell.date) ?? undefined}
489
- tabindex={st.focused ? 0 : -1}
490
- disabled={st.disabled || disabled}
491
- onclick={(e) => pickDay(cell.date, e)}
492
- onpointerenter={() => (hoverDate = cell.date)}
493
- onpointerleave={() => (hoverDate = null)}
237
+ {...cal.dayProps(cell, panel.month)}
494
238
  >{cell.date.getDate()}</button>
495
239
  {/if}
496
240
  {/each}
@@ -499,30 +243,26 @@
499
243
  </div>
500
244
  {/each}
501
245
  </div>
502
- {:else if mode === 'year'}
503
- <div class="sv-cal__grid-mode" onkeydown={onNonMonthKeydown} role="grid" aria-label={title} tabindex="-1">
504
- {#each yearCells as yc (yc.index)}
246
+ {:else if cal.mode === 'year'}
247
+ <div class="sv-cal__grid-mode" {...cal.gridProps()}>
248
+ {#each cal.yearCells as yc (yc.index)}
505
249
  <button
506
- type="button"
507
250
  class="sv-cal__cell"
508
251
  class:is-today={yc.current}
509
- class:is-selected={sel.dates.some((d) => d.getMonth() === yc.index && d.getFullYear() === view.getFullYear())}
510
- onclick={() => pickMonth(yc.index)}
511
- disabled={disabled}
252
+ class:is-selected={yc.selected}
253
+ {...cal.monthCellProps(yc.index)}
512
254
  >{yc.label}</button>
513
255
  {/each}
514
256
  </div>
515
257
  {:else}
516
- <div class="sv-cal__grid-mode" onkeydown={onNonMonthKeydown} role="grid" aria-label={title} tabindex="-1">
517
- {#each decadeCells as dc (dc.year)}
258
+ <div class="sv-cal__grid-mode" {...cal.gridProps()}>
259
+ {#each cal.decadeCells as dc (dc.year)}
518
260
  <button
519
- type="button"
520
261
  class="sv-cal__cell"
521
262
  class:is-outside={dc.outside}
522
263
  class:is-today={dc.current}
523
- class:is-selected={sel.dates.some((d) => d.getFullYear() === dc.year)}
524
- onclick={() => pickYear(dc.year)}
525
- disabled={disabled}
264
+ class:is-selected={dc.selected}
265
+ {...cal.yearCellProps(dc.year)}
526
266
  >{dc.year}</button>
527
267
  {/each}
528
268
  </div>
@@ -531,15 +271,15 @@
531
271
 
532
272
  {#if footer}
533
273
  <div class="sv-cal__footer">
534
- <button type="button" class="sv-cal__foot-btn" onclick={goToday} disabled={disabled}>Today</button>
535
- {#if canClear}
536
- <button type="button" class="sv-cal__foot-btn" onclick={clearSelection} disabled={disabled}>Clear</button>
274
+ <button type="button" class="sv-cal__foot-btn" onclick={cal.goToday} disabled={disabled}>Today</button>
275
+ {#if cal.canClear}
276
+ <button type="button" class="sv-cal__foot-btn" onclick={cal.clearSelection} disabled={disabled}>Clear</button>
537
277
  {/if}
538
278
  </div>
539
279
  {/if}
540
280
  </div>
541
281
 
542
- {#if name}<input type="hidden" {name} value={sel.dates.map((d) => d.toISOString()).join(',')} />{/if}
282
+ {#if name}<input type="hidden" {name} value={cal.selectedDates.map((d) => d.toISOString()).join(',')} />{/if}
543
283
  </div>
544
284
 
545
285
  <style>
@@ -4,6 +4,7 @@
4
4
  * label. Parity: Smart `smart-check-box`. Controlled via `checked` + `onChange`.
5
5
  */
6
6
  import type { Snippet } from 'svelte'
7
+ import { createCheckbox } from './createCheckbox.svelte'
7
8
 
8
9
  type Props = {
9
10
  checked?: boolean
@@ -29,23 +30,23 @@
29
30
  children,
30
31
  }: Props = $props()
31
32
 
32
- function toggle() {
33
- if (disabled) return
34
- onChange?.(indeterminate ? true : !checked)
35
- }
33
+ // The styled checkbox is just a renderer over the headless core. The label
34
+ // fallback depends on `children` (a render concern), so it is computed here.
35
+ const cb = createCheckbox({
36
+ checked: () => checked,
37
+ indeterminate: () => indeterminate,
38
+ onChange: (v) => onChange?.(v),
39
+ disabled: () => disabled,
40
+ ariaLabel: () => ariaLabel ?? (children ? undefined : 'checkbox'),
41
+ })
36
42
  </script>
37
43
 
38
44
  <label class="sv-check sv-check--{size}" class:is-disabled={disabled}>
39
45
  <button
40
- type="button"
41
- role="checkbox"
42
46
  class="sv-check__box"
43
47
  class:is-checked={checked && !indeterminate}
44
48
  class:is-indeterminate={indeterminate}
45
- aria-checked={indeterminate ? 'mixed' : checked}
46
- aria-label={ariaLabel ?? (children ? undefined : 'checkbox')}
47
- {disabled}
48
- onclick={toggle}
49
+ {...cb.boxProps()}
49
50
  >
50
51
  {#if indeterminate}
51
52
  <svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4 8h8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" /></svg>