@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.
- package/dist/SvAutoComplete.svelte +23 -34
- package/dist/SvCalendar.svelte +74 -334
- package/dist/SvCalendar.svelte.d.ts +5 -23
- package/dist/SvCheckBox.svelte +11 -10
- package/dist/SvColorInput.svelte +31 -58
- package/dist/SvComboBox.svelte +32 -54
- package/dist/SvCountryInput.svelte +31 -29
- package/dist/SvDateTimePicker.svelte +53 -110
- package/dist/SvDateTimePicker.svelte.d.ts +4 -3
- package/dist/SvDropDownList.svelte +31 -54
- package/dist/SvGauge.svelte +21 -23
- package/dist/SvGauge.svelte.d.ts +0 -5
- package/dist/SvListBox.svelte +25 -58
- package/dist/SvListBox.svelte.d.ts +3 -0
- package/dist/SvMaskedInput.svelte +0 -0
- package/dist/SvNumberInput.svelte +0 -0
- package/dist/SvNumberInput.svelte.d.ts +2 -6
- package/dist/SvPasswordInput.svelte +18 -28
- package/dist/SvPasswordInput.svelte.d.ts +0 -4
- package/dist/SvPhoneInput.svelte +0 -0
- package/dist/SvRadioGroup.svelte +14 -37
- package/dist/SvRadioGroup.svelte.d.ts +6 -9
- package/dist/SvRating.svelte +22 -42
- package/dist/SvRating.svelte.d.ts +3 -5
- package/dist/SvSlider.svelte +30 -57
- package/dist/SvSlider.svelte.d.ts +0 -5
- package/dist/SvSwitchButton.svelte +10 -17
- package/dist/SvSwitchButton.svelte.d.ts +0 -5
- package/dist/SvTabs.svelte +26 -39
- package/dist/SvTabs.svelte.d.ts +5 -5
- package/dist/SvTagsInput.svelte +17 -28
- package/dist/SvTagsInput.svelte.d.ts +0 -5
- package/dist/SvTimePicker.svelte +52 -210
- package/dist/SvTimePicker.svelte.d.ts +11 -8
- package/dist/SvToggleButton.svelte +9 -9
- package/dist/SvTree.svelte +30 -109
- package/dist/SvTree.svelte.d.ts +9 -11
- package/dist/cdn/svgrid.js +7488 -5839
- package/dist/cdn/svgrid.svelte-external.js +4691 -3028
- package/dist/createAutocomplete.svelte.d.ts +77 -0
- package/dist/createAutocomplete.svelte.js +100 -0
- package/dist/createCalendar.svelte.d.ts +192 -0
- package/dist/createCalendar.svelte.js +402 -0
- package/dist/createCheckbox.svelte.d.ts +45 -0
- package/dist/createCheckbox.svelte.js +26 -0
- package/dist/createColorInput.svelte.d.ts +67 -0
- package/dist/createColorInput.svelte.js +100 -0
- package/dist/createCombobox.svelte.d.ts +102 -0
- package/dist/createCombobox.svelte.js +171 -0
- package/dist/createCountryInput.svelte.d.ts +86 -0
- package/dist/createCountryInput.svelte.js +126 -0
- package/dist/createDateTimePicker.svelte.d.ts +83 -0
- package/dist/createDateTimePicker.svelte.js +202 -0
- package/dist/createDropdownList.svelte.d.ts +78 -0
- package/dist/createDropdownList.svelte.js +117 -0
- package/dist/createGauge.svelte.d.ts +56 -0
- package/dist/createGauge.svelte.js +49 -0
- package/dist/createListbox.svelte.d.ts +79 -0
- package/dist/createListbox.svelte.js +117 -0
- package/dist/createMaskedInput.svelte.d.ts +30 -0
- package/dist/createMaskedInput.svelte.js +62 -0
- package/dist/createNumberInput.svelte.d.ts +72 -0
- package/dist/createNumberInput.svelte.js +167 -0
- package/dist/createPasswordInput.svelte.d.ts +60 -0
- package/dist/createPasswordInput.svelte.js +72 -0
- package/dist/createPhoneInput.svelte.d.ts +46 -0
- package/dist/createPhoneInput.svelte.js +105 -0
- package/dist/createRadioGroup.svelte.d.ts +64 -0
- package/dist/createRadioGroup.svelte.js +73 -0
- package/dist/createRating.svelte.d.ts +70 -0
- package/dist/createRating.svelte.js +85 -0
- package/dist/createSlider.svelte.d.ts +71 -0
- package/dist/createSlider.svelte.js +123 -0
- package/dist/createSwitch.svelte.d.ts +43 -0
- package/dist/createSwitch.svelte.js +41 -0
- package/dist/createTabs.svelte.d.ts +78 -0
- package/dist/createTabs.svelte.js +92 -0
- package/dist/createTagsInput.svelte.d.ts +65 -0
- package/dist/createTagsInput.svelte.js +93 -0
- package/dist/createTimePicker.svelte.d.ts +106 -0
- package/dist/createTimePicker.svelte.js +248 -0
- package/dist/createToggle.svelte.d.ts +43 -0
- package/dist/createToggle.svelte.js +39 -0
- package/dist/createTree.svelte.d.ts +96 -0
- package/dist/createTree.svelte.js +194 -0
- package/dist/editor-contract.d.ts +48 -0
- package/dist/editor-contract.js +26 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +31 -0
- package/package.json +1 -1
- package/src/SvAutoComplete.svelte +23 -34
- package/src/SvCalendar.svelte +74 -334
- package/src/SvCheckBox.svelte +11 -10
- package/src/SvColorInput.svelte +31 -58
- package/src/SvComboBox.svelte +32 -54
- package/src/SvCountryInput.svelte +31 -29
- package/src/SvDateTimePicker.svelte +53 -110
- package/src/SvDropDownList.svelte +31 -54
- package/src/SvGauge.svelte +21 -23
- package/src/SvListBox.svelte +25 -58
- package/src/SvMaskedInput.svelte +0 -0
- package/src/SvNumberInput.svelte +0 -0
- package/src/SvPasswordInput.svelte +18 -28
- package/src/SvPhoneInput.svelte +0 -0
- package/src/SvRadioGroup.svelte +14 -37
- package/src/SvRating.svelte +22 -42
- package/src/SvSlider.svelte +30 -57
- package/src/SvSwitchButton.svelte +10 -17
- package/src/SvTabs.svelte +26 -39
- package/src/SvTagsInput.svelte +17 -28
- package/src/SvTimePicker.svelte +52 -210
- package/src/SvToggleButton.svelte +9 -9
- package/src/SvTree.svelte +30 -109
- package/src/createAutocomplete.svelte.ts +104 -0
- package/src/createCalendar.svelte.ts +488 -0
- package/src/createCheckbox.svelte.ts +74 -0
- package/src/createColorInput.svelte.ts +115 -0
- package/src/createCombobox.svelte.ts +158 -0
- package/src/createCountryInput.svelte.ts +126 -0
- package/src/createDateTimePicker.svelte.ts +216 -0
- package/src/createDropdownList.svelte.ts +125 -0
- package/src/createGauge.svelte.ts +86 -0
- package/src/createListbox.svelte.ts +186 -0
- package/src/createMaskedInput.svelte.ts +81 -0
- package/src/createNumberInput.svelte.ts +193 -0
- package/src/createPasswordInput.svelte.ts +87 -0
- package/src/createPhoneInput.svelte.ts +119 -0
- package/src/createRadioGroup.svelte.ts +132 -0
- package/src/createRating.svelte.ts +145 -0
- package/src/createSlider.svelte.ts +160 -0
- package/src/createSwitch.svelte.ts +78 -0
- package/src/createTabs.svelte.ts +129 -0
- package/src/createTagsInput.svelte.ts +98 -0
- package/src/createTimePicker.svelte.ts +289 -0
- package/src/createToggle.svelte.ts +69 -0
- package/src/createTree.svelte.ts +216 -0
- package/src/editor-contract.test.ts +32 -0
- package/src/editor-contract.ts +60 -0
- package/src/index.ts +39 -0
- package/src/ui-inputs.test.ts +26 -0
- 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>
|