@svgrid/grid 1.2.22 → 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.
- package/dist/GridFooter.svelte +34 -20
- package/dist/GridFooter.svelte.d.ts +8 -0
- package/dist/SvAutoComplete.svelte +89 -0
- package/dist/SvAutoComplete.svelte.d.ts +16 -0
- package/dist/SvButton.svelte +97 -0
- package/dist/SvButton.svelte.d.ts +23 -0
- package/dist/SvCalendar.svelte +409 -0
- package/dist/SvCalendar.svelte.d.ts +52 -0
- package/dist/SvCheckBox.svelte +82 -0
- package/dist/SvCheckBox.svelte.d.ts +19 -0
- package/dist/SvColorInput.svelte +160 -0
- package/dist/SvColorInput.svelte.d.ts +15 -0
- package/dist/SvComboBox.svelte +108 -0
- package/dist/SvComboBox.svelte.d.ts +15 -0
- package/dist/SvCountryInput.svelte +125 -0
- package/dist/SvCountryInput.svelte.d.ts +14 -0
- package/dist/SvDateTimePicker.svelte +265 -0
- package/dist/SvDateTimePicker.svelte.d.ts +38 -0
- package/dist/SvDropDownList.svelte +120 -0
- package/dist/SvDropDownList.svelte.d.ts +15 -0
- package/dist/SvForm.svelte +147 -0
- package/dist/SvForm.svelte.d.ts +29 -0
- package/dist/SvGauge.svelte +97 -0
- package/dist/SvGauge.svelte.d.ts +26 -0
- package/dist/SvGrid.controller.svelte.d.ts +16 -2
- package/dist/SvGrid.controller.svelte.js +63 -8
- package/dist/SvGrid.css +84 -31
- package/dist/SvGrid.helpers.d.ts +4 -0
- package/dist/SvGrid.helpers.js +40 -12
- package/dist/SvGrid.svelte +84 -5
- package/dist/SvGrid.types.d.ts +36 -0
- package/dist/SvGridDropdown.svelte +16 -30
- package/dist/SvGridDropdown.svelte.d.ts +4 -0
- package/dist/SvListBox.svelte +89 -0
- package/dist/SvListBox.svelte.d.ts +22 -0
- package/dist/SvMaskedInput.svelte +66 -0
- package/dist/SvMaskedInput.svelte.d.ts +15 -0
- package/dist/SvNumberInput.svelte +116 -0
- package/dist/SvNumberInput.svelte.d.ts +18 -0
- package/dist/SvPasswordInput.svelte +100 -0
- package/dist/SvPasswordInput.svelte.d.ts +18 -0
- package/dist/SvPhoneInput.svelte +87 -0
- package/dist/SvPhoneInput.svelte.d.ts +19 -0
- package/dist/SvRadioGroup.svelte +85 -0
- package/dist/SvRadioGroup.svelte.d.ts +20 -0
- package/dist/SvRating.svelte +91 -0
- package/dist/SvRating.svelte.d.ts +18 -0
- package/dist/SvRepeatButton.svelte +89 -0
- package/dist/SvRepeatButton.svelte.d.ts +21 -0
- package/dist/SvSlider.svelte +155 -0
- package/dist/SvSlider.svelte.d.ts +20 -0
- package/dist/SvSwitchButton.svelte +87 -0
- package/dist/SvSwitchButton.svelte.d.ts +14 -0
- package/dist/SvTabs.svelte +109 -0
- package/dist/SvTabs.svelte.d.ts +26 -0
- package/dist/SvTagsInput.svelte +70 -0
- package/dist/SvTagsInput.svelte.d.ts +14 -0
- package/dist/SvTimePicker.svelte +201 -0
- package/dist/SvTimePicker.svelte.d.ts +30 -0
- package/dist/SvToggleButton.svelte +57 -0
- package/dist/SvToggleButton.svelte.d.ts +16 -0
- package/dist/SvTree.svelte +131 -0
- package/dist/SvTree.svelte.d.ts +27 -0
- package/dist/build-api.js +78 -1
- package/dist/cdn/svgrid.js +11674 -5907
- package/dist/cdn/svgrid.svelte-external.js +10436 -4671
- package/dist/cell-render.d.ts +4 -0
- package/dist/cell-render.js +35 -1
- package/dist/clipboard.js +19 -1
- package/dist/collaboration.d.ts +2 -0
- package/dist/collaboration.js +4 -0
- package/dist/core.d.ts +22 -1
- package/dist/countries.d.ts +15 -0
- package/dist/countries.js +61 -0
- 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/datetime/date-core.d.ts +88 -0
- package/dist/datetime/date-core.js +165 -0
- package/dist/datetime/date-format.d.ts +39 -0
- package/dist/datetime/date-format.js +371 -0
- package/dist/datetime/date-restrict.d.ts +23 -0
- package/dist/datetime/date-restrict.js +50 -0
- package/dist/datetime/date-selection.d.ts +25 -0
- package/dist/datetime/date-selection.js +94 -0
- package/dist/datetime/mask.d.ts +21 -0
- package/dist/datetime/mask.js +90 -0
- package/dist/editing.d.ts +1 -1
- package/dist/editing.js +7 -0
- package/dist/editor-contract.d.ts +48 -0
- package/dist/editor-contract.js +26 -0
- package/dist/editors/cell-editors.d.ts +2 -2
- package/dist/editors/cell-editors.js +3 -1
- package/dist/export-format.d.ts +178 -0
- package/dist/export-format.js +419 -0
- package/dist/hyperformula-adapter.d.ts +4 -0
- package/dist/hyperformula-adapter.js +15 -6
- package/dist/index.d.ts +60 -0
- package/dist/index.js +72 -0
- package/dist/list-option.d.ts +10 -0
- package/dist/list-option.js +7 -0
- package/dist/menus.js +18 -2
- package/dist/popover.d.ts +48 -0
- package/dist/popover.js +65 -0
- package/dist/row-resize.d.ts +4 -4
- package/dist/row-resize.js +8 -5
- package/dist/selection.d.ts +6 -0
- package/dist/selection.js +31 -5
- package/dist/server-data-source.d.ts +43 -0
- package/dist/server-data-source.js +100 -0
- package/dist/sparkline.js +8 -4
- package/dist/svgrid-wrapper.types.d.ts +26 -1
- package/package.json +6 -1
- package/src/GridFooter.svelte +34 -20
- package/src/SvAutoComplete.svelte +89 -0
- package/src/SvButton.svelte +97 -0
- package/src/SvCalendar.svelte +409 -0
- package/src/SvCalendar.test.ts +211 -0
- package/src/SvCheckBox.svelte +82 -0
- package/src/SvColorInput.svelte +160 -0
- package/src/SvComboBox.svelte +108 -0
- package/src/SvCountryInput.svelte +125 -0
- package/src/SvDateTimePicker.svelte +265 -0
- package/src/SvDateTimePicker.test.ts +136 -0
- package/src/SvDropDownList.svelte +120 -0
- package/src/SvForm.svelte +147 -0
- package/src/SvGauge.svelte +97 -0
- package/src/SvGrid.controller.svelte.ts +74 -8
- package/src/SvGrid.css +84 -31
- package/src/SvGrid.helpers.ts +41 -12
- package/src/SvGrid.svelte +84 -5
- package/src/SvGrid.types.ts +33 -0
- package/src/SvGridDropdown.svelte +16 -30
- package/src/SvListBox.svelte +89 -0
- package/src/SvMaskedInput.svelte +66 -0
- package/src/SvNumberInput.svelte +116 -0
- package/src/SvPasswordInput.svelte +100 -0
- package/src/SvPhoneInput.svelte +87 -0
- package/src/SvRadioGroup.svelte +85 -0
- package/src/SvRating.svelte +91 -0
- package/src/SvRepeatButton.svelte +89 -0
- package/src/SvSlider.svelte +155 -0
- package/src/SvSwitchButton.svelte +87 -0
- package/src/SvTabs.svelte +109 -0
- package/src/SvTagsInput.svelte +70 -0
- package/src/SvTimePicker.svelte +201 -0
- package/src/SvTimePicker.test.ts +98 -0
- package/src/SvToggleButton.svelte +57 -0
- package/src/SvTree.svelte +131 -0
- package/src/build-api.ts +115 -0
- package/src/cell-render.test.ts +38 -0
- package/src/cell-render.ts +43 -0
- package/src/clipboard.test.ts +18 -0
- package/src/clipboard.ts +24 -1
- package/src/collaboration.test.ts +30 -0
- package/src/collaboration.ts +6 -0
- package/src/core.ts +27 -3
- package/src/countries.ts +71 -0
- 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/datetime/date-core.test.ts +217 -0
- package/src/datetime/date-core.ts +204 -0
- package/src/datetime/date-format.test.ts +121 -0
- package/src/datetime/date-format.ts +317 -0
- package/src/datetime/date-restrict.ts +60 -0
- package/src/datetime/date-selection.test.ts +129 -0
- package/src/datetime/date-selection.ts +137 -0
- package/src/datetime/mask.test.ts +36 -0
- package/src/datetime/mask.ts +84 -0
- package/src/editing.test.ts +22 -0
- package/src/editing.ts +7 -1
- package/src/editor-contract.test.ts +32 -0
- package/src/editor-contract.ts +60 -0
- package/src/editors/cell-editors.ts +7 -1
- package/src/export-data-api.test.ts +126 -0
- package/src/export-format.test.ts +107 -0
- package/src/export-format.ts +598 -0
- package/src/hyperformula-adapter.test.ts +35 -1
- package/src/hyperformula-adapter.ts +18 -6
- package/src/index.ts +110 -0
- package/src/list-option.ts +15 -0
- package/src/menus.ts +21 -2
- package/src/popover.ts +79 -0
- package/src/row-resize.test.ts +25 -0
- package/src/row-resize.ts +8 -5
- package/src/selection.test.ts +36 -4
- package/src/selection.ts +30 -9
- package/src/server-data-source.test.ts +201 -0
- package/src/server-data-source.ts +148 -0
- package/src/sparkline.test.ts +9 -0
- package/src/sparkline.ts +9 -4
- package/src/svgrid-wrapper.types.ts +28 -1
- package/src/svgrid.behavior.test.ts +27 -0
- package/src/svgrid.new-features.wrapper.test.ts +27 -1
- package/src/ui-buttons.test.ts +105 -0
- package/src/ui-composite.test.ts +89 -0
- package/src/ui-inputs.test.ts +118 -0
- package/src/ui-range.test.ts +61 -0
- package/src/ui-selection.test.ts +119 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createTree - the HEADLESS core behind <SvTree>, in the same spirit as
|
|
3
|
+
* `createSvGrid` for the grid: a runes-based state machine (expand/collapse,
|
|
4
|
+
* single-select highlight, cascading tri-state checkboxes, full keyboard) with
|
|
5
|
+
* **prop-getters** you spread onto YOUR OWN markup. The visible tree is
|
|
6
|
+
* flattened into `rows` so roving-focus keyboard nav stays a flat index walk.
|
|
7
|
+
*
|
|
8
|
+
* ```svelte
|
|
9
|
+
* <script lang="ts">
|
|
10
|
+
* import { createTree } from '@svgrid/grid'
|
|
11
|
+
* let selected = $state<string | null>(null)
|
|
12
|
+
* const tree = createTree({ nodes: () => nodes, selected: () => selected, onSelect: (id) => (selected = id) })
|
|
13
|
+
* </script>
|
|
14
|
+
* <div {...tree.treeProps()}>
|
|
15
|
+
* {#each tree.rows as row (row.node.id)}
|
|
16
|
+
* <div {...tree.itemProps(row)}>{row.node.label}</div>
|
|
17
|
+
* {/each}
|
|
18
|
+
* </div>
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* The styled <SvTree> is just one renderer over this core. The cascade math
|
|
22
|
+
* (`treeDescendantIds`, `treeCheckState`) is exported as pure functions.
|
|
23
|
+
*/
|
|
24
|
+
export type TreeNode = {
|
|
25
|
+
id: string
|
|
26
|
+
label: string
|
|
27
|
+
children?: TreeNode[]
|
|
28
|
+
disabled?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type CheckState = 'checked' | 'indeterminate' | 'unchecked'
|
|
32
|
+
|
|
33
|
+
/** A single visible (flattened) tree row. */
|
|
34
|
+
export type TreeRow = {
|
|
35
|
+
node: TreeNode
|
|
36
|
+
depth: number
|
|
37
|
+
hasChildren: boolean
|
|
38
|
+
open: boolean
|
|
39
|
+
parentId: string | null
|
|
40
|
+
index: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** All descendant ids of `node` (pure, depth-first). */
|
|
44
|
+
export function treeDescendantIds(node: TreeNode): string[] {
|
|
45
|
+
const ids: string[] = []
|
|
46
|
+
const walk = (n: TreeNode) => { for (const c of n.children ?? []) { ids.push(c.id); walk(c) } }
|
|
47
|
+
walk(node)
|
|
48
|
+
return ids
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Tri-state check status of `node` given the set of checked ids (pure). */
|
|
52
|
+
export function treeCheckState(node: TreeNode, checked: ReadonlySet<string>): CheckState {
|
|
53
|
+
if (!node.children?.length) return checked.has(node.id) ? 'checked' : 'unchecked'
|
|
54
|
+
const desc = treeDescendantIds(node)
|
|
55
|
+
const on = desc.filter((id) => checked.has(id)).length
|
|
56
|
+
if (on === 0 && !checked.has(node.id)) return 'unchecked'
|
|
57
|
+
if (on === desc.length) return 'checked'
|
|
58
|
+
return 'indeterminate'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function findNode(list: ReadonlyArray<TreeNode>, id: string): TreeNode | null {
|
|
62
|
+
for (const n of list) {
|
|
63
|
+
if (n.id === id) return n
|
|
64
|
+
if (n.children) { const f = findNode(n.children, id); if (f) return f }
|
|
65
|
+
}
|
|
66
|
+
return null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Reactive inputs are passed as getters so the core tracks live prop changes. */
|
|
70
|
+
export type TreeConfig = {
|
|
71
|
+
nodes: () => ReadonlyArray<TreeNode>
|
|
72
|
+
selected?: () => string | null
|
|
73
|
+
onSelect?: (id: string) => void
|
|
74
|
+
/** Seed the internally-managed expanded set (read once). */
|
|
75
|
+
expandedIds?: () => string[] | undefined
|
|
76
|
+
onToggle?: (id: string, expanded: boolean) => void
|
|
77
|
+
checkable?: () => boolean
|
|
78
|
+
checked?: () => string[]
|
|
79
|
+
onCheck?: (ids: string[]) => void
|
|
80
|
+
ariaLabel?: () => string | undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createTree(config: TreeConfig) {
|
|
84
|
+
const nodes = () => config.nodes()
|
|
85
|
+
const checkable = () => config.checkable?.() ?? false
|
|
86
|
+
const selected = () => config.selected?.() ?? null
|
|
87
|
+
|
|
88
|
+
// Internal expanded set, seeded from the prop once (then self-managed).
|
|
89
|
+
let expanded = $state<Set<string>>(new Set())
|
|
90
|
+
let seeded = false
|
|
91
|
+
$effect(() => {
|
|
92
|
+
if (!seeded) { seeded = true; const ids = config.expandedIds?.(); if (ids) expanded = new Set(ids) }
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const rows = $derived.by<TreeRow[]>(() => {
|
|
96
|
+
const out: TreeRow[] = []
|
|
97
|
+
const walk = (list: ReadonlyArray<TreeNode>, depth: number, parentId: string | null) => {
|
|
98
|
+
for (const node of list) {
|
|
99
|
+
const hasChildren = !!node.children?.length
|
|
100
|
+
const open = expanded.has(node.id)
|
|
101
|
+
out.push({ node, depth, hasChildren, open, parentId, index: out.length })
|
|
102
|
+
if (hasChildren && open) walk(node.children!, depth + 1, node.id)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
walk(nodes(), 0, null)
|
|
106
|
+
return out
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
let active = $state(0)
|
|
110
|
+
$effect(() => { if (active >= rows.length) active = Math.max(0, rows.length - 1) })
|
|
111
|
+
|
|
112
|
+
const checkedSet = $derived(new Set(config.checked?.() ?? []))
|
|
113
|
+
|
|
114
|
+
// Roving focus target; `focusTick` bumps only on keyboard moves.
|
|
115
|
+
let focusTick = $state(0)
|
|
116
|
+
function setActive(i: number, focus = false) { active = i; if (focus) focusTick++ }
|
|
117
|
+
|
|
118
|
+
const checkStateOf = (node: TreeNode): CheckState => treeCheckState(node, checkedSet)
|
|
119
|
+
const isExpanded = (id: string) => expanded.has(id)
|
|
120
|
+
const isSelected = (id: string) => id === selected()
|
|
121
|
+
|
|
122
|
+
function toggleExpand(node: TreeNode) {
|
|
123
|
+
if (!node.children?.length) return
|
|
124
|
+
const next = new Set(expanded)
|
|
125
|
+
const willOpen = !next.has(node.id)
|
|
126
|
+
willOpen ? next.add(node.id) : next.delete(node.id)
|
|
127
|
+
expanded = next
|
|
128
|
+
config.onToggle?.(node.id, willOpen)
|
|
129
|
+
}
|
|
130
|
+
const toggle = (id: string) => { const n = findNode(nodes(), id); if (n) toggleExpand(n) }
|
|
131
|
+
|
|
132
|
+
function select(node: TreeNode) {
|
|
133
|
+
if (node.disabled) return
|
|
134
|
+
config.onSelect?.(node.id)
|
|
135
|
+
}
|
|
136
|
+
const selectId = (id: string) => { const n = findNode(nodes(), id); if (n) select(n) }
|
|
137
|
+
|
|
138
|
+
function toggleCheck(node: TreeNode) {
|
|
139
|
+
if (node.disabled) return
|
|
140
|
+
const next = new Set(checkedSet)
|
|
141
|
+
const ids = [node.id, ...treeDescendantIds(node)]
|
|
142
|
+
const currentlyOn = checkStateOf(node) === 'checked'
|
|
143
|
+
for (const id of ids) currentlyOn ? next.delete(id) : next.add(id)
|
|
144
|
+
config.onCheck?.([...next])
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function onKeydown(e: KeyboardEvent) {
|
|
148
|
+
const item = rows[active]
|
|
149
|
+
if (!item) return
|
|
150
|
+
switch (e.key) {
|
|
151
|
+
case 'ArrowDown': e.preventDefault(); setActive(Math.min(active + 1, rows.length - 1), true); break
|
|
152
|
+
case 'ArrowUp': e.preventDefault(); setActive(Math.max(active - 1, 0), true); break
|
|
153
|
+
case 'ArrowRight':
|
|
154
|
+
e.preventDefault()
|
|
155
|
+
if (item.hasChildren && !item.open) toggleExpand(item.node)
|
|
156
|
+
else if (item.hasChildren && item.open) setActive(Math.min(active + 1, rows.length - 1), true)
|
|
157
|
+
break
|
|
158
|
+
case 'ArrowLeft':
|
|
159
|
+
e.preventDefault()
|
|
160
|
+
if (item.hasChildren && item.open) toggleExpand(item.node)
|
|
161
|
+
else if (item.parentId) { const pi = rows.findIndex((r) => r.node.id === item.parentId); if (pi >= 0) setActive(pi, true) }
|
|
162
|
+
break
|
|
163
|
+
case 'Enter': e.preventDefault(); select(item.node); break
|
|
164
|
+
case ' ': e.preventDefault(); checkable() ? toggleCheck(item.node) : select(item.node); break
|
|
165
|
+
case 'Home': e.preventDefault(); setActive(0, true); break
|
|
166
|
+
case 'End': e.preventDefault(); setActive(rows.length - 1, true); break
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
/** Internally-managed set of expanded node ids. */
|
|
172
|
+
get expanded() { return expanded },
|
|
173
|
+
/** Selected node id (single-select highlight). */
|
|
174
|
+
get selected() { return selected() },
|
|
175
|
+
/** Flattened list of visible rows. */
|
|
176
|
+
get rows() { return rows },
|
|
177
|
+
/** Roving-focus row index. */
|
|
178
|
+
get activeIndex() { return active },
|
|
179
|
+
/** Monotonic counter; changes only on keyboard navigation. */
|
|
180
|
+
get focusTick() { return focusTick },
|
|
181
|
+
isExpanded,
|
|
182
|
+
isSelected,
|
|
183
|
+
checkStateOf,
|
|
184
|
+
toggle,
|
|
185
|
+
toggleExpand,
|
|
186
|
+
select: selectId,
|
|
187
|
+
toggleCheck,
|
|
188
|
+
setActive,
|
|
189
|
+
onKeydown,
|
|
190
|
+
/** Spread onto the tree container element. */
|
|
191
|
+
treeProps: () => ({
|
|
192
|
+
role: 'tree' as const,
|
|
193
|
+
'aria-label': config.ariaLabel?.(),
|
|
194
|
+
'aria-multiselectable': checkable(),
|
|
195
|
+
}),
|
|
196
|
+
/** Spread onto the treeitem element for a flattened `row`. */
|
|
197
|
+
itemProps: (row: TreeRow) => {
|
|
198
|
+
const cs = checkable() ? checkStateOf(row.node) : 'unchecked'
|
|
199
|
+
const ariaChecked: 'true' | 'mixed' | 'false' | undefined =
|
|
200
|
+
checkable() ? (cs === 'checked' ? 'true' : cs === 'indeterminate' ? 'mixed' : 'false') : undefined
|
|
201
|
+
return {
|
|
202
|
+
role: 'treeitem' as const,
|
|
203
|
+
'aria-level': row.depth + 1,
|
|
204
|
+
'aria-selected': row.node.id === selected(),
|
|
205
|
+
'aria-expanded': row.hasChildren ? row.open : undefined,
|
|
206
|
+
'aria-checked': ariaChecked,
|
|
207
|
+
'data-row': row.index,
|
|
208
|
+
tabindex: row.index === active ? 0 : -1,
|
|
209
|
+
onclick: () => { setActive(row.index); select(row.node) },
|
|
210
|
+
onkeydown: onKeydown,
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export type Tree = ReturnType<typeof createTree>
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for date-core math. These are written to be *discriminating*: each
|
|
3
|
+
* asserts a value that a plausible mutation (off-by-one, wrong operator, dropped
|
|
4
|
+
* clamp) would break - not just "does not throw".
|
|
5
|
+
*/
|
|
6
|
+
import { describe, expect, it } from 'vitest'
|
|
7
|
+
import {
|
|
8
|
+
toDate,
|
|
9
|
+
startOfDay,
|
|
10
|
+
startOfMonth,
|
|
11
|
+
endOfMonth,
|
|
12
|
+
daysInMonth,
|
|
13
|
+
startOfWeek,
|
|
14
|
+
isSameDay,
|
|
15
|
+
isSameMonth,
|
|
16
|
+
compareDay,
|
|
17
|
+
addDays,
|
|
18
|
+
addMonths,
|
|
19
|
+
addYears,
|
|
20
|
+
clampDate,
|
|
21
|
+
isoWeek,
|
|
22
|
+
decadeRange,
|
|
23
|
+
centuryRange,
|
|
24
|
+
monthMatrix,
|
|
25
|
+
weekdayOrder,
|
|
26
|
+
withTime,
|
|
27
|
+
snapMinute,
|
|
28
|
+
} from './date-core'
|
|
29
|
+
|
|
30
|
+
describe('toDate', () => {
|
|
31
|
+
it('passes through valid Date/number/string and clones Dates', () => {
|
|
32
|
+
const d = new Date(2026, 5, 15)
|
|
33
|
+
const out = toDate(d)!
|
|
34
|
+
expect(out.getTime()).toBe(d.getTime())
|
|
35
|
+
expect(out).not.toBe(d) // clone, not same ref
|
|
36
|
+
expect(toDate(d.getTime())!.getTime()).toBe(d.getTime())
|
|
37
|
+
expect(toDate('2026-06-15T00:00:00')!.getFullYear()).toBe(2026)
|
|
38
|
+
})
|
|
39
|
+
it('returns null for null/undefined/invalid', () => {
|
|
40
|
+
expect(toDate(null)).toBeNull()
|
|
41
|
+
expect(toDate(undefined)).toBeNull()
|
|
42
|
+
expect(toDate('not-a-date')).toBeNull()
|
|
43
|
+
expect(toDate(NaN)).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('startOfDay / month boundaries', () => {
|
|
48
|
+
it('strips time to local midnight', () => {
|
|
49
|
+
const d = startOfDay(new Date(2026, 2, 9, 13, 45, 30, 500))
|
|
50
|
+
expect([d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()]).toEqual([0, 0, 0, 0])
|
|
51
|
+
expect(d.getDate()).toBe(9)
|
|
52
|
+
})
|
|
53
|
+
it('startOfMonth / endOfMonth / daysInMonth', () => {
|
|
54
|
+
const mid = new Date(2026, 1, 14) // Feb 2026 (28 days)
|
|
55
|
+
expect(startOfMonth(mid).getDate()).toBe(1)
|
|
56
|
+
expect(endOfMonth(mid).getDate()).toBe(28)
|
|
57
|
+
expect(daysInMonth(mid)).toBe(28)
|
|
58
|
+
expect(daysInMonth(new Date(2024, 1, 1))).toBe(29) // leap year
|
|
59
|
+
expect(daysInMonth(new Date(2026, 0, 1))).toBe(31)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
describe('startOfWeek', () => {
|
|
64
|
+
it('honors firstDayOfWeek=0 (Sunday)', () => {
|
|
65
|
+
// 2026-06-15 is a Monday.
|
|
66
|
+
const s = startOfWeek(new Date(2026, 5, 15), 0)
|
|
67
|
+
expect(s.getDay()).toBe(0)
|
|
68
|
+
expect(s.getDate()).toBe(14) // the Sunday before
|
|
69
|
+
})
|
|
70
|
+
it('honors firstDayOfWeek=1 (Monday)', () => {
|
|
71
|
+
const s = startOfWeek(new Date(2026, 5, 15), 1)
|
|
72
|
+
expect(s.getDay()).toBe(1)
|
|
73
|
+
expect(s.getDate()).toBe(15) // Monday itself
|
|
74
|
+
})
|
|
75
|
+
it('wraps correctly when day < firstDayOfWeek', () => {
|
|
76
|
+
// Sunday 2026-06-14 with week starting Monday -> previous Monday (Jun 8)
|
|
77
|
+
const s = startOfWeek(new Date(2026, 5, 14), 1)
|
|
78
|
+
expect(s.getDate()).toBe(8)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('sameness + comparison', () => {
|
|
83
|
+
it('isSameDay ignores time, isSameMonth ignores day', () => {
|
|
84
|
+
expect(isSameDay(new Date(2026, 5, 15, 9), new Date(2026, 5, 15, 23))).toBe(true)
|
|
85
|
+
expect(isSameDay(new Date(2026, 5, 15), new Date(2026, 5, 16))).toBe(false)
|
|
86
|
+
expect(isSameDay(null, new Date())).toBe(false)
|
|
87
|
+
expect(isSameMonth(new Date(2026, 5, 1), new Date(2026, 5, 30))).toBe(true)
|
|
88
|
+
expect(isSameMonth(new Date(2026, 5, 30), new Date(2026, 6, 1))).toBe(false)
|
|
89
|
+
})
|
|
90
|
+
it('compareDay returns -1/0/1 by day', () => {
|
|
91
|
+
expect(compareDay(new Date(2026, 5, 15, 23), new Date(2026, 5, 15, 1))).toBe(0)
|
|
92
|
+
expect(compareDay(new Date(2026, 5, 14), new Date(2026, 5, 15))).toBe(-1)
|
|
93
|
+
expect(compareDay(new Date(2026, 5, 16), new Date(2026, 5, 15))).toBe(1)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('addDays / addMonths / addYears clamping', () => {
|
|
98
|
+
it('addDays crosses month boundary', () => {
|
|
99
|
+
expect(addDays(new Date(2026, 0, 31), 1).getMonth()).toBe(1) // -> Feb 1
|
|
100
|
+
expect(addDays(new Date(2026, 5, 15), -20).getMonth()).toBe(4) // -> May
|
|
101
|
+
})
|
|
102
|
+
it('addMonths clamps day-of-month (Jan 31 + 1mo = Feb 28)', () => {
|
|
103
|
+
const r = addMonths(new Date(2026, 0, 31), 1)
|
|
104
|
+
expect(r.getMonth()).toBe(1)
|
|
105
|
+
expect(r.getDate()).toBe(28) // NOT March 3
|
|
106
|
+
})
|
|
107
|
+
it('addMonths preserves time-of-day', () => {
|
|
108
|
+
const r = addMonths(new Date(2026, 0, 15, 8, 30), 2)
|
|
109
|
+
expect([r.getHours(), r.getMinutes()]).toEqual([8, 30])
|
|
110
|
+
expect(r.getMonth()).toBe(2)
|
|
111
|
+
})
|
|
112
|
+
it('addYears clamps Feb 29 -> Feb 28 on non-leap target', () => {
|
|
113
|
+
const r = addYears(new Date(2024, 1, 29), 1)
|
|
114
|
+
expect(r.getFullYear()).toBe(2025)
|
|
115
|
+
expect(r.getMonth()).toBe(1)
|
|
116
|
+
expect(r.getDate()).toBe(28)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('clampDate', () => {
|
|
121
|
+
const min = new Date(2026, 0, 1)
|
|
122
|
+
const max = new Date(2026, 11, 31)
|
|
123
|
+
it('clamps below min and above max, passes through inside', () => {
|
|
124
|
+
expect(clampDate(new Date(2025, 5, 1), min, max).getTime()).toBe(min.getTime())
|
|
125
|
+
expect(clampDate(new Date(2027, 5, 1), min, max).getTime()).toBe(max.getTime())
|
|
126
|
+
const inside = new Date(2026, 5, 1)
|
|
127
|
+
expect(clampDate(inside, min, max).getTime()).toBe(inside.getTime())
|
|
128
|
+
})
|
|
129
|
+
it('treats null bounds as open', () => {
|
|
130
|
+
const d = new Date(1000, 0, 1)
|
|
131
|
+
expect(clampDate(d, null, null).getTime()).toBe(d.getTime())
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('isoWeek', () => {
|
|
136
|
+
it('matches known ISO week numbers', () => {
|
|
137
|
+
// 2026-01-01 is a Thursday -> ISO week 1.
|
|
138
|
+
expect(isoWeek(new Date(2026, 0, 1))).toBe(1)
|
|
139
|
+
// 2026-06-15 (Mon) -> ISO week 25.
|
|
140
|
+
expect(isoWeek(new Date(2026, 5, 15))).toBe(25)
|
|
141
|
+
// 2027-01-01 is a Friday -> belongs to ISO week 53 of 2026.
|
|
142
|
+
expect(isoWeek(new Date(2027, 0, 1))).toBe(53)
|
|
143
|
+
// 2024-12-30 (Mon) -> ISO week 1 of 2025.
|
|
144
|
+
expect(isoWeek(new Date(2024, 11, 30))).toBe(1)
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
describe('decadeRange / centuryRange', () => {
|
|
149
|
+
it('decade block anchors to the base-10 start', () => {
|
|
150
|
+
expect(decadeRange(2026)).toEqual({ start: 2020, end: 2029 })
|
|
151
|
+
expect(decadeRange(2020)).toEqual({ start: 2020, end: 2029 })
|
|
152
|
+
expect(decadeRange(2019)).toEqual({ start: 2010, end: 2019 })
|
|
153
|
+
})
|
|
154
|
+
it('century block anchors to the base-100 start', () => {
|
|
155
|
+
expect(centuryRange(2026)).toEqual({ start: 2000, end: 2099 })
|
|
156
|
+
expect(centuryRange(1999)).toEqual({ start: 1900, end: 1999 })
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('monthMatrix', () => {
|
|
161
|
+
it('produces weeks x 7 cells starting on firstDayOfWeek', () => {
|
|
162
|
+
const m = monthMatrix(new Date(2026, 5, 15), 0, 6) // June 2026, Sunday-first
|
|
163
|
+
expect(m.length).toBe(6)
|
|
164
|
+
expect(m.every((row) => row.length === 7)).toBe(true)
|
|
165
|
+
// Every first cell of a row is the configured first weekday.
|
|
166
|
+
expect(m.every((row) => row[0]!.date.getDay() === 0)).toBe(true)
|
|
167
|
+
// June 1 2026 is a Monday, so the grid starts on Sunday May 31.
|
|
168
|
+
expect(m[0]![0]!.date.getMonth()).toBe(4)
|
|
169
|
+
expect(m[0]![0]!.date.getDate()).toBe(31)
|
|
170
|
+
expect(m[0]![0]!.inMonth).toBe(false)
|
|
171
|
+
expect(m[0]![1]!.inMonth).toBe(true) // June 1
|
|
172
|
+
expect(m[0]![1]!.date.getDate()).toBe(1)
|
|
173
|
+
})
|
|
174
|
+
it('honors firstDayOfWeek=1 (Monday-first grid)', () => {
|
|
175
|
+
const m = monthMatrix(new Date(2026, 5, 15), 1, 6)
|
|
176
|
+
expect(m.every((row) => row[0]!.date.getDay() === 1)).toBe(true)
|
|
177
|
+
// June 1 2026 is Monday, so the first cell IS June 1.
|
|
178
|
+
expect(m[0]![0]!.date.getDate()).toBe(1)
|
|
179
|
+
expect(m[0]![0]!.inMonth).toBe(true)
|
|
180
|
+
})
|
|
181
|
+
it('tags cells with an ISO week number', () => {
|
|
182
|
+
const m = monthMatrix(new Date(2026, 0, 1), 1, 6)
|
|
183
|
+
expect(m[0]![0]!.week).toBe(isoWeek(m[0]![0]!.date))
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('weekdayOrder', () => {
|
|
188
|
+
it('rotates the 0-6 sequence by firstDayOfWeek', () => {
|
|
189
|
+
expect(weekdayOrder(0)).toEqual([0, 1, 2, 3, 4, 5, 6])
|
|
190
|
+
expect(weekdayOrder(1)).toEqual([1, 2, 3, 4, 5, 6, 0])
|
|
191
|
+
expect(weekdayOrder(6)).toEqual([6, 0, 1, 2, 3, 4, 5])
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('withTime', () => {
|
|
196
|
+
it('grafts time-of-day onto a calendar day', () => {
|
|
197
|
+
const day = new Date(2026, 5, 15)
|
|
198
|
+
const time = new Date(2000, 0, 1, 14, 25, 9, 123)
|
|
199
|
+
const r = withTime(day, time)
|
|
200
|
+
expect([r.getFullYear(), r.getMonth(), r.getDate()]).toEqual([2026, 5, 15])
|
|
201
|
+
expect([r.getHours(), r.getMinutes(), r.getSeconds(), r.getMilliseconds()]).toEqual([14, 25, 9, 123])
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
describe('snapMinute', () => {
|
|
206
|
+
it('snaps down to the nearest interval multiple', () => {
|
|
207
|
+
expect(snapMinute(0, 15)).toBe(0)
|
|
208
|
+
expect(snapMinute(14, 15)).toBe(0)
|
|
209
|
+
expect(snapMinute(15, 15)).toBe(15)
|
|
210
|
+
expect(snapMinute(59, 15)).toBe(45)
|
|
211
|
+
expect(snapMinute(37, 5)).toBe(35)
|
|
212
|
+
})
|
|
213
|
+
it('interval < 1 behaves as 1 (no snapping)', () => {
|
|
214
|
+
expect(snapMinute(37, 0)).toBe(37)
|
|
215
|
+
expect(snapMinute(37, 1)).toBe(37)
|
|
216
|
+
})
|
|
217
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* date-core - framework-free date math shared by SvCalendar, SvTimePicker and
|
|
3
|
+
* SvDateTimePicker. No Svelte, no DOM: pure functions over the native `Date`
|
|
4
|
+
* so it is trivially unit- and mutation-testable and reusable standalone.
|
|
5
|
+
*
|
|
6
|
+
* Conventions:
|
|
7
|
+
* - A "day" is compared by local calendar date (year/month/date), ignoring the
|
|
8
|
+
* time-of-day, unless a helper says otherwise.
|
|
9
|
+
* - `firstDayOfWeek` is 0-6 (0 = Sunday), matching `Date.getDay()` and Smart's
|
|
10
|
+
* `firstDayOfWeek`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type DateLike = Date | number | string
|
|
14
|
+
|
|
15
|
+
/** Coerce a Date | epoch-ms | parseable string to a Date, or null if invalid. */
|
|
16
|
+
export function toDate(value: DateLike | null | undefined): Date | null {
|
|
17
|
+
if (value == null) return null
|
|
18
|
+
const d = value instanceof Date ? new Date(value.getTime()) : new Date(value)
|
|
19
|
+
return isNaN(d.getTime()) ? null : d
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A new Date at local midnight of the same calendar day (time stripped). */
|
|
23
|
+
export function startOfDay(d: Date): Date {
|
|
24
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate())
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** First day (midnight) of the month `d` falls in. */
|
|
28
|
+
export function startOfMonth(d: Date): Date {
|
|
29
|
+
return new Date(d.getFullYear(), d.getMonth(), 1)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Last day (midnight) of the month `d` falls in. */
|
|
33
|
+
export function endOfMonth(d: Date): Date {
|
|
34
|
+
return new Date(d.getFullYear(), d.getMonth() + 1, 0)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Number of days in the month `d` falls in. */
|
|
38
|
+
export function daysInMonth(d: Date): number {
|
|
39
|
+
return endOfMonth(d).getDate()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Start of the week containing `d`, honoring `firstDayOfWeek` (0-6). Returns a
|
|
44
|
+
* local-midnight Date on or before `d`.
|
|
45
|
+
*/
|
|
46
|
+
export function startOfWeek(d: Date, firstDayOfWeek = 0): Date {
|
|
47
|
+
const start = startOfDay(d)
|
|
48
|
+
const diff = (start.getDay() - firstDayOfWeek + 7) % 7
|
|
49
|
+
start.setDate(start.getDate() - diff)
|
|
50
|
+
return start
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** True when both dates are the same local calendar day. */
|
|
54
|
+
export function isSameDay(a: Date | null, b: Date | null): boolean {
|
|
55
|
+
if (!a || !b) return false
|
|
56
|
+
return (
|
|
57
|
+
a.getFullYear() === b.getFullYear() &&
|
|
58
|
+
a.getMonth() === b.getMonth() &&
|
|
59
|
+
a.getDate() === b.getDate()
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when both dates are in the same year+month. */
|
|
64
|
+
export function isSameMonth(a: Date | null, b: Date | null): boolean {
|
|
65
|
+
if (!a || !b) return false
|
|
66
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** -1 / 0 / 1 comparison by calendar day (time-of-day ignored). */
|
|
70
|
+
export function compareDay(a: Date, b: Date): number {
|
|
71
|
+
const av = startOfDay(a).getTime()
|
|
72
|
+
const bv = startOfDay(b).getTime()
|
|
73
|
+
return av < bv ? -1 : av > bv ? 1 : 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Add `n` days (can be negative), returning a new Date. */
|
|
77
|
+
export function addDays(d: Date, n: number): Date {
|
|
78
|
+
const r = new Date(d.getTime())
|
|
79
|
+
r.setDate(r.getDate() + n)
|
|
80
|
+
return r
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Add `n` calendar months, clamping the day-of-month so 31 Jan + 1 month = 28/29
|
|
85
|
+
* Feb rather than spilling into March (matches how calendars paginate).
|
|
86
|
+
*/
|
|
87
|
+
export function addMonths(d: Date, n: number): Date {
|
|
88
|
+
const day = d.getDate()
|
|
89
|
+
const r = new Date(d.getFullYear(), d.getMonth() + n, 1, d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds())
|
|
90
|
+
r.setDate(Math.min(day, daysInMonth(r)))
|
|
91
|
+
return r
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Add `n` years, clamping Feb-29 to Feb-28 on non-leap targets. */
|
|
95
|
+
export function addYears(d: Date, n: number): Date {
|
|
96
|
+
return addMonths(d, n * 12)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Clamp `d` to the inclusive [min, max] range (either bound may be null). */
|
|
100
|
+
export function clampDate(d: Date, min: Date | null, max: Date | null): Date {
|
|
101
|
+
if (min && d.getTime() < min.getTime()) return new Date(min.getTime())
|
|
102
|
+
if (max && d.getTime() > max.getTime()) return new Date(max.getTime())
|
|
103
|
+
return d
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* ISO 8601 week number (1-53) for `d`. Week 1 is the week containing the first
|
|
108
|
+
* Thursday of the year; weeks start Monday. Independent of `firstDayOfWeek`,
|
|
109
|
+
* matching Smart's `weekNumbers` column which is always ISO.
|
|
110
|
+
*/
|
|
111
|
+
export function isoWeek(d: Date): number {
|
|
112
|
+
// Shift to the Thursday of this week, then count weeks from Jan 1.
|
|
113
|
+
const date = startOfDay(d)
|
|
114
|
+
const day = (date.getDay() + 6) % 7 // Mon=0..Sun=6
|
|
115
|
+
date.setDate(date.getDate() - day + 3) // Thursday of this week
|
|
116
|
+
const firstThursday = new Date(date.getFullYear(), 0, 4)
|
|
117
|
+
const firstDay = (firstThursday.getDay() + 6) % 7
|
|
118
|
+
firstThursday.setDate(firstThursday.getDate() - firstDay + 3)
|
|
119
|
+
const diffMs = date.getTime() - firstThursday.getTime()
|
|
120
|
+
return 1 + Math.round(diffMs / (7 * 24 * 3600 * 1000))
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The [start, end] years of the decade block that `year` belongs to, as Smart
|
|
125
|
+
* renders it: a 12-cell page running from the decade start minus 1 to plus 10
|
|
126
|
+
* (one leading + one trailing year for context). We expose the canonical
|
|
127
|
+
* decade [Y0, Y0+9] and let the view pad.
|
|
128
|
+
*/
|
|
129
|
+
export function decadeRange(year: number): { start: number; end: number } {
|
|
130
|
+
const start = Math.floor(year / 10) * 10
|
|
131
|
+
return { start, end: start + 9 }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The [start, end] years of the century block (100-year) `year` belongs to. */
|
|
135
|
+
export function centuryRange(year: number): { start: number; end: number } {
|
|
136
|
+
const start = Math.floor(year / 100) * 100
|
|
137
|
+
return { start, end: start + 99 }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type MonthMatrixCell = {
|
|
141
|
+
date: Date
|
|
142
|
+
/** In the displayed month (vs. leading/trailing days of adjacent months). */
|
|
143
|
+
inMonth: boolean
|
|
144
|
+
/** ISO week number of the row this cell sits in. */
|
|
145
|
+
week: number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Build a month grid of `weeks` rows x 7 columns starting at `firstDayOfWeek`,
|
|
150
|
+
* padded with the trailing days of the previous month and leading days of the
|
|
151
|
+
* next so every row is full. `weeks` defaults to 6 (the max any month needs),
|
|
152
|
+
* matching Smart's fixed-height calendar.
|
|
153
|
+
*/
|
|
154
|
+
export function monthMatrix(
|
|
155
|
+
monthDate: Date,
|
|
156
|
+
firstDayOfWeek = 0,
|
|
157
|
+
weeks = 6,
|
|
158
|
+
): MonthMatrixCell[][] {
|
|
159
|
+
const first = startOfMonth(monthDate)
|
|
160
|
+
const gridStart = startOfWeek(first, firstDayOfWeek)
|
|
161
|
+
const rows: MonthMatrixCell[][] = []
|
|
162
|
+
let cursor = gridStart
|
|
163
|
+
for (let r = 0; r < weeks; r++) {
|
|
164
|
+
const row: MonthMatrixCell[] = []
|
|
165
|
+
const weekNo = isoWeek(cursor)
|
|
166
|
+
for (let c = 0; c < 7; c++) {
|
|
167
|
+
row.push({
|
|
168
|
+
date: cursor,
|
|
169
|
+
inMonth: cursor.getMonth() === first.getMonth() && cursor.getFullYear() === first.getFullYear(),
|
|
170
|
+
week: weekNo,
|
|
171
|
+
})
|
|
172
|
+
cursor = addDays(cursor, 1)
|
|
173
|
+
}
|
|
174
|
+
rows.push(row)
|
|
175
|
+
}
|
|
176
|
+
return rows
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Ordered weekday indexes (0-6) for a header row starting at `firstDayOfWeek`.
|
|
181
|
+
* e.g. firstDayOfWeek=1 -> [1,2,3,4,5,6,0].
|
|
182
|
+
*/
|
|
183
|
+
export function weekdayOrder(firstDayOfWeek = 0): number[] {
|
|
184
|
+
return Array.from({ length: 7 }, (_, i) => (firstDayOfWeek + i) % 7)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Merge a calendar day with a time-of-day taken from `time`, returning a new Date. */
|
|
188
|
+
export function withTime(day: Date, time: Date): Date {
|
|
189
|
+
return new Date(
|
|
190
|
+
day.getFullYear(),
|
|
191
|
+
day.getMonth(),
|
|
192
|
+
day.getDate(),
|
|
193
|
+
time.getHours(),
|
|
194
|
+
time.getMinutes(),
|
|
195
|
+
time.getSeconds(),
|
|
196
|
+
time.getMilliseconds(),
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Snap a minute value to the nearest lower multiple of `interval` (>=1). */
|
|
201
|
+
export function snapMinute(minute: number, interval: number): number {
|
|
202
|
+
const step = Math.max(1, Math.floor(interval))
|
|
203
|
+
return Math.floor(minute / step) * step
|
|
204
|
+
}
|