@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,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the date-format token engine. Emphasis on:
|
|
3
|
+
* - each token formatting exactly (a mutated case label would break these)
|
|
4
|
+
* - format -> parse -> format round-trips across masks and locales
|
|
5
|
+
* - parse rejecting malformed / impossible input (not silently rolling over)
|
|
6
|
+
* - yearCutoff pivot for 2-digit years
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest'
|
|
9
|
+
import { formatDate, parseDate, tokenizeMask } from './date-format'
|
|
10
|
+
|
|
11
|
+
const D = new Date(2026, 5, 7, 9, 4, 3, 50) // 2026-06-07 09:04:03.050 (a Sunday)
|
|
12
|
+
|
|
13
|
+
describe('tokenizeMask', () => {
|
|
14
|
+
it('splits tokens from literals and honors quotes + escapes', () => {
|
|
15
|
+
expect(tokenizeMask('yyyy-MM-dd')).toEqual([
|
|
16
|
+
{ token: 'yyyy' }, { literal: '-' }, { token: 'MM' }, { literal: '-' }, { token: 'dd' },
|
|
17
|
+
])
|
|
18
|
+
expect(tokenizeMask("yyyy'T'HH")).toEqual([{ token: 'yyyy' }, { literal: 'T' }, { token: 'HH' }])
|
|
19
|
+
expect(tokenizeMask('HH\\h mm')).toEqual([
|
|
20
|
+
{ token: 'HH' }, { literal: 'h' }, { literal: ' ' }, { token: 'mm' },
|
|
21
|
+
])
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('formatDate - individual tokens', () => {
|
|
26
|
+
it('day / month / year widths', () => {
|
|
27
|
+
expect(formatDate(D, 'd')).toBe('7')
|
|
28
|
+
expect(formatDate(D, 'dd')).toBe('07')
|
|
29
|
+
expect(formatDate(D, 'M')).toBe('6')
|
|
30
|
+
expect(formatDate(D, 'MM')).toBe('06')
|
|
31
|
+
expect(formatDate(D, 'yy')).toBe('26')
|
|
32
|
+
expect(formatDate(D, 'yyyy')).toBe('2026')
|
|
33
|
+
})
|
|
34
|
+
it('24h vs 12h hours', () => {
|
|
35
|
+
expect(formatDate(D, 'H')).toBe('9')
|
|
36
|
+
expect(formatDate(D, 'HH')).toBe('09')
|
|
37
|
+
expect(formatDate(D, 'h')).toBe('9')
|
|
38
|
+
expect(formatDate(new Date(2026, 0, 1, 13), 'h')).toBe('1')
|
|
39
|
+
expect(formatDate(new Date(2026, 0, 1, 0), 'hh')).toBe('12') // midnight -> 12
|
|
40
|
+
})
|
|
41
|
+
it('minute / second / fractional seconds', () => {
|
|
42
|
+
expect(formatDate(D, 'mm')).toBe('04')
|
|
43
|
+
expect(formatDate(D, 'ss')).toBe('03')
|
|
44
|
+
expect(formatDate(D, 'fff')).toBe('050')
|
|
45
|
+
expect(formatDate(D, 'ff')).toBe('05')
|
|
46
|
+
expect(formatDate(D, 'f')).toBe('0')
|
|
47
|
+
})
|
|
48
|
+
it('AM/PM designator (en-US)', () => {
|
|
49
|
+
expect(formatDate(new Date(2026, 0, 1, 9), 'tt', 'en-US')).toBe('AM')
|
|
50
|
+
expect(formatDate(new Date(2026, 0, 1, 21), 'tt', 'en-US')).toBe('PM')
|
|
51
|
+
expect(formatDate(new Date(2026, 0, 1, 21), 't', 'en-US')).toBe('P')
|
|
52
|
+
})
|
|
53
|
+
it('localized month + weekday names', () => {
|
|
54
|
+
expect(formatDate(D, 'MMM', 'en-US')).toBe('Jun')
|
|
55
|
+
expect(formatDate(D, 'MMMM', 'en-US')).toBe('June')
|
|
56
|
+
expect(formatDate(D, 'dddd', 'en-US')).toBe('Sunday')
|
|
57
|
+
// German month name (partial to survive ICU differences).
|
|
58
|
+
expect(formatDate(D, 'MMMM', 'de-DE').toLowerCase()).toContain('juni')
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('formatDate - full masks', () => {
|
|
63
|
+
it('Smart default-style masks', () => {
|
|
64
|
+
expect(formatDate(D, 'dd-MMM-yy HH:mm:ss.fff', 'en-US')).toBe('07-Jun-26 09:04:03.050')
|
|
65
|
+
expect(formatDate(D, 'yyyy-MM-dd')).toBe('2026-06-07')
|
|
66
|
+
expect(formatDate(new Date(2026, 0, 1, 13, 5), 'hh:mm tt', 'en-US')).toBe('01:05 PM')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('parseDate', () => {
|
|
71
|
+
it('parses a full datetime mask', () => {
|
|
72
|
+
const d = parseDate('07-Jun-26 09:04:03.050', 'dd-MMM-yy HH:mm:ss.fff', 'en-US')!
|
|
73
|
+
expect(d.getFullYear()).toBe(2026)
|
|
74
|
+
expect(d.getMonth()).toBe(5)
|
|
75
|
+
expect(d.getDate()).toBe(7)
|
|
76
|
+
expect([d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()]).toEqual([9, 4, 3, 50])
|
|
77
|
+
})
|
|
78
|
+
it('resolves 12h + PM into 24h', () => {
|
|
79
|
+
const d = parseDate('01:05 PM', 'hh:mm tt', 'en-US')!
|
|
80
|
+
expect(d.getHours()).toBe(13)
|
|
81
|
+
expect(d.getMinutes()).toBe(5)
|
|
82
|
+
const am = parseDate('12:30 AM', 'hh:mm tt', 'en-US')!
|
|
83
|
+
expect(am.getHours()).toBe(0) // 12 AM -> 00
|
|
84
|
+
})
|
|
85
|
+
it('applies yearCutoff pivot for 2-digit years', () => {
|
|
86
|
+
// cutoff 2029 -> pivot 29: '29' -> 2029, '30' -> 1930
|
|
87
|
+
expect(parseDate('29', 'yy', 'en-US', 2029)!.getFullYear()).toBe(2029)
|
|
88
|
+
expect(parseDate('30', 'yy', 'en-US', 2029)!.getFullYear()).toBe(1930)
|
|
89
|
+
})
|
|
90
|
+
it('rejects malformed and impossible input', () => {
|
|
91
|
+
expect(parseDate('2026/06/07', 'yyyy-MM-dd')).toBeNull() // wrong separators
|
|
92
|
+
expect(parseDate('2026-13-01', 'yyyy-MM-dd')).toBeNull() // month 13
|
|
93
|
+
expect(parseDate('2026-02-31', 'yyyy-MM-dd')).toBeNull() // Feb 31 rollover
|
|
94
|
+
expect(parseDate('', 'yyyy-MM-dd')).toBeNull()
|
|
95
|
+
expect(parseDate('2026-06-07 extra', 'yyyy-MM-dd')).toBeNull() // trailing junk
|
|
96
|
+
})
|
|
97
|
+
it('accepts trailing whitespace and flexible spaces', () => {
|
|
98
|
+
expect(parseDate('2026-06-07 ', 'yyyy-MM-dd')).not.toBeNull()
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
describe('round-trip: format -> parse -> format', () => {
|
|
103
|
+
const masks = ['yyyy-MM-dd', 'dd-MMM-yy HH:mm:ss.fff', 'dd/MM/yyyy HH:mm', 'hh:mm:ss tt', 'MMMM d, yyyy']
|
|
104
|
+
const samples = [
|
|
105
|
+
new Date(2026, 5, 7, 9, 4, 3, 50),
|
|
106
|
+
new Date(2000, 0, 1, 0, 0, 0, 0),
|
|
107
|
+
new Date(1999, 11, 31, 23, 59, 59, 999),
|
|
108
|
+
new Date(2024, 1, 29, 13, 30, 0, 0), // leap day, PM
|
|
109
|
+
]
|
|
110
|
+
for (const mask of masks) {
|
|
111
|
+
for (const sample of samples) {
|
|
112
|
+
it(`stable for "${mask}" @ ${sample.toISOString()}`, () => {
|
|
113
|
+
const s1 = formatDate(sample, mask, 'en-US')
|
|
114
|
+
const parsed = parseDate(s1, mask, 'en-US', 2029, sample)
|
|
115
|
+
expect(parsed).not.toBeNull()
|
|
116
|
+
const s2 = formatDate(parsed!, mask, 'en-US')
|
|
117
|
+
expect(s2).toBe(s1)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
})
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* date-format - a small .NET/Smart-style date format-token engine used for
|
|
3
|
+
* `formatString` display and for parsing the masked DateTimePicker input. It is
|
|
4
|
+
* locale-aware for names (month/weekday, AM/PM) via `Intl`, and framework-free.
|
|
5
|
+
*
|
|
6
|
+
* Supported tokens (case-sensitive, matching Smart's `formatString`):
|
|
7
|
+
* d dd day of month (5, 05)
|
|
8
|
+
* ddd dddd weekday name (Mon, Monday) [locale]
|
|
9
|
+
* M MM month number (6, 06)
|
|
10
|
+
* MMM MMMM month name (Jun, June) [locale]
|
|
11
|
+
* yy yyyy year (26, 2026)
|
|
12
|
+
* H HH hour 24 (7, 07)
|
|
13
|
+
* h hh hour 12 (7, 07)
|
|
14
|
+
* m mm minute
|
|
15
|
+
* s ss second
|
|
16
|
+
* f ff fff fractional seconds (tenths..millis)
|
|
17
|
+
* t tt AM/PM designator (A, AM) [locale]
|
|
18
|
+
*
|
|
19
|
+
* Anything else is a literal. Wrap literal letters in single quotes ('T') or
|
|
20
|
+
* escape with a backslash (\T) to keep them out of token matching.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Ordered longest-first so the scanner is greedy and unambiguous. */
|
|
24
|
+
const TOKENS = [
|
|
25
|
+
'dddd', 'ddd', 'dd', 'd',
|
|
26
|
+
'MMMM', 'MMM', 'MM', 'M',
|
|
27
|
+
'yyyy', 'yy',
|
|
28
|
+
'HH', 'H',
|
|
29
|
+
'hh', 'h',
|
|
30
|
+
'mm', 'm',
|
|
31
|
+
'ss', 's',
|
|
32
|
+
'fff', 'ff', 'f',
|
|
33
|
+
'tt', 't',
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
type Part = { token: string } | { literal: string }
|
|
37
|
+
|
|
38
|
+
/** Split a mask into an ordered list of token / literal parts. */
|
|
39
|
+
export function tokenizeMask(mask: string): Part[] {
|
|
40
|
+
const parts: Part[] = []
|
|
41
|
+
let i = 0
|
|
42
|
+
while (i < mask.length) {
|
|
43
|
+
const ch = mask[i]!
|
|
44
|
+
// Backslash escape: next char is a literal.
|
|
45
|
+
if (ch === '\\' && i + 1 < mask.length) {
|
|
46
|
+
parts.push({ literal: mask[i + 1]! })
|
|
47
|
+
i += 2
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
// Single-quoted literal run.
|
|
51
|
+
if (ch === "'") {
|
|
52
|
+
let j = i + 1
|
|
53
|
+
let lit = ''
|
|
54
|
+
while (j < mask.length && mask[j] !== "'") {
|
|
55
|
+
lit += mask[j]
|
|
56
|
+
j++
|
|
57
|
+
}
|
|
58
|
+
parts.push({ literal: lit })
|
|
59
|
+
i = j + 1 // skip closing quote (or run to end)
|
|
60
|
+
continue
|
|
61
|
+
}
|
|
62
|
+
// Greedy longest-token match.
|
|
63
|
+
let matched: string | null = null
|
|
64
|
+
for (const t of TOKENS) {
|
|
65
|
+
if (mask.startsWith(t, i)) {
|
|
66
|
+
matched = t
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (matched) {
|
|
71
|
+
parts.push({ token: matched })
|
|
72
|
+
i += matched.length
|
|
73
|
+
} else {
|
|
74
|
+
parts.push({ literal: ch })
|
|
75
|
+
i += 1
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return parts
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const pad = (n: number, len: number) => String(Math.abs(n)).padStart(len, '0')
|
|
82
|
+
|
|
83
|
+
// Intl name caches keyed by locale.
|
|
84
|
+
const monthCache = new Map<string, { long: string[]; short: string[] }>()
|
|
85
|
+
const weekdayCache = new Map<string, { long: string[]; short: string[] }>()
|
|
86
|
+
const dayperiodCache = new Map<string, { am: string; pm: string }>()
|
|
87
|
+
|
|
88
|
+
function monthNames(locale: string) {
|
|
89
|
+
let c = monthCache.get(locale)
|
|
90
|
+
if (!c) {
|
|
91
|
+
const long = new Intl.DateTimeFormat(locale, { month: 'long' })
|
|
92
|
+
const short = new Intl.DateTimeFormat(locale, { month: 'short' })
|
|
93
|
+
c = {
|
|
94
|
+
long: Array.from({ length: 12 }, (_, m) => long.format(new Date(2021, m, 1))),
|
|
95
|
+
short: Array.from({ length: 12 }, (_, m) => short.format(new Date(2021, m, 1))),
|
|
96
|
+
}
|
|
97
|
+
monthCache.set(locale, c)
|
|
98
|
+
}
|
|
99
|
+
return c
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function weekdayNames(locale: string) {
|
|
103
|
+
let c = weekdayCache.get(locale)
|
|
104
|
+
if (!c) {
|
|
105
|
+
const long = new Intl.DateTimeFormat(locale, { weekday: 'long' })
|
|
106
|
+
const short = new Intl.DateTimeFormat(locale, { weekday: 'short' })
|
|
107
|
+
// 2021-08-01 is a Sunday.
|
|
108
|
+
c = {
|
|
109
|
+
long: Array.from({ length: 7 }, (_, d) => long.format(new Date(2021, 7, 1 + d))),
|
|
110
|
+
short: Array.from({ length: 7 }, (_, d) => short.format(new Date(2021, 7, 1 + d))),
|
|
111
|
+
}
|
|
112
|
+
weekdayCache.set(locale, c)
|
|
113
|
+
}
|
|
114
|
+
return c
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function dayPeriods(locale: string) {
|
|
118
|
+
let c = dayperiodCache.get(locale)
|
|
119
|
+
if (!c) {
|
|
120
|
+
const f = new Intl.DateTimeFormat(locale, { hour: 'numeric', hour12: true })
|
|
121
|
+
const am = f.formatToParts(new Date(2021, 0, 1, 9)).find((p) => p.type === 'dayPeriod')?.value ?? 'AM'
|
|
122
|
+
const pm = f.formatToParts(new Date(2021, 0, 1, 21)).find((p) => p.type === 'dayPeriod')?.value ?? 'PM'
|
|
123
|
+
c = { am, pm }
|
|
124
|
+
dayperiodCache.set(locale, c)
|
|
125
|
+
}
|
|
126
|
+
return c
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Render `date` per `mask` in `locale`. */
|
|
130
|
+
export function formatDate(date: Date, mask: string, locale = 'en-US'): string {
|
|
131
|
+
const parts = tokenizeMask(mask)
|
|
132
|
+
const h24 = date.getHours()
|
|
133
|
+
const h12 = h24 % 12 === 0 ? 12 : h24 % 12
|
|
134
|
+
const ms = date.getMilliseconds()
|
|
135
|
+
let out = ''
|
|
136
|
+
for (const p of parts) {
|
|
137
|
+
if ('literal' in p) {
|
|
138
|
+
out += p.literal
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
switch (p.token) {
|
|
142
|
+
case 'd': out += String(date.getDate()); break
|
|
143
|
+
case 'dd': out += pad(date.getDate(), 2); break
|
|
144
|
+
case 'ddd': out += weekdayNames(locale).short[date.getDay()]; break
|
|
145
|
+
case 'dddd': out += weekdayNames(locale).long[date.getDay()]; break
|
|
146
|
+
case 'M': out += String(date.getMonth() + 1); break
|
|
147
|
+
case 'MM': out += pad(date.getMonth() + 1, 2); break
|
|
148
|
+
case 'MMM': out += monthNames(locale).short[date.getMonth()]; break
|
|
149
|
+
case 'MMMM': out += monthNames(locale).long[date.getMonth()]; break
|
|
150
|
+
case 'yy': out += pad(date.getFullYear() % 100, 2); break
|
|
151
|
+
case 'yyyy': out += pad(date.getFullYear(), 4); break
|
|
152
|
+
case 'H': out += String(h24); break
|
|
153
|
+
case 'HH': out += pad(h24, 2); break
|
|
154
|
+
case 'h': out += String(h12); break
|
|
155
|
+
case 'hh': out += pad(h12, 2); break
|
|
156
|
+
case 'm': out += String(date.getMinutes()); break
|
|
157
|
+
case 'mm': out += pad(date.getMinutes(), 2); break
|
|
158
|
+
case 's': out += String(date.getSeconds()); break
|
|
159
|
+
case 'ss': out += pad(date.getSeconds(), 2); break
|
|
160
|
+
case 'f': out += pad(ms, 3).slice(0, 1); break
|
|
161
|
+
case 'ff': out += pad(ms, 3).slice(0, 2); break
|
|
162
|
+
case 'fff': out += pad(ms, 3); break
|
|
163
|
+
case 't': out += (h24 < 12 ? dayPeriods(locale).am : dayPeriods(locale).pm).slice(0, 1); break
|
|
164
|
+
case 'tt': out += h24 < 12 ? dayPeriods(locale).am : dayPeriods(locale).pm; break
|
|
165
|
+
default: out += p.token
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type Acc = {
|
|
172
|
+
year?: number
|
|
173
|
+
month?: number // 0-11
|
|
174
|
+
day?: number
|
|
175
|
+
hour?: number // 24h accumulator
|
|
176
|
+
hour12?: number
|
|
177
|
+
minute?: number
|
|
178
|
+
second?: number
|
|
179
|
+
ms?: number
|
|
180
|
+
pm?: boolean
|
|
181
|
+
isPm?: boolean
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const MAX_DIGITS: Record<string, number> = {
|
|
185
|
+
d: 2, dd: 2, M: 2, MM: 2, yy: 2, yyyy: 4,
|
|
186
|
+
H: 2, HH: 2, h: 2, hh: 2, m: 2, mm: 2, s: 2, ss: 2, f: 1, ff: 2, fff: 3,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function readDigits(str: string, at: number, max: number): { value: number; len: number } | null {
|
|
190
|
+
let len = 0
|
|
191
|
+
while (len < max && at + len < str.length && str[at + len]! >= '0' && str[at + len]! <= '9') len++
|
|
192
|
+
if (len === 0) return null
|
|
193
|
+
return { value: parseInt(str.slice(at, at + len), 10), len }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function matchName(str: string, at: number, names: string[]): { index: number; len: number } | null {
|
|
197
|
+
// Longest-first so 'June' wins over a hypothetical 'Jun' prefix collision.
|
|
198
|
+
const ordered = names
|
|
199
|
+
.map((name, index) => ({ name, index }))
|
|
200
|
+
.sort((a, b) => b.name.length - a.name.length)
|
|
201
|
+
const lower = str.slice(at).toLowerCase()
|
|
202
|
+
for (const { name, index } of ordered) {
|
|
203
|
+
if (name && lower.startsWith(name.toLowerCase())) return { index, len: name.length }
|
|
204
|
+
}
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Parse `str` against `mask` in `locale`. Returns a Date or null if the string
|
|
210
|
+
* does not satisfy the mask. `yearCutoff` disambiguates 2-digit years: values
|
|
211
|
+
* <= (yearCutoff % 100) map to 2000s, above to 1900s (Smart's default 1926 ->
|
|
212
|
+
* a 26 pivot). `base` supplies fields the mask omits (defaults to epoch-zero
|
|
213
|
+
* local midnight so a time-only mask yields a today-agnostic time).
|
|
214
|
+
*/
|
|
215
|
+
export function parseDate(
|
|
216
|
+
str: string,
|
|
217
|
+
mask: string,
|
|
218
|
+
locale = 'en-US',
|
|
219
|
+
yearCutoff = 2029,
|
|
220
|
+
base?: Date,
|
|
221
|
+
): Date | null {
|
|
222
|
+
const parts = tokenizeMask(mask)
|
|
223
|
+
const acc: Acc = {}
|
|
224
|
+
let i = 0
|
|
225
|
+
const s = str
|
|
226
|
+
for (const p of parts) {
|
|
227
|
+
if ('literal' in p) {
|
|
228
|
+
// Literals must match (whitespace-insensitive at the boundary).
|
|
229
|
+
for (const ch of p.literal) {
|
|
230
|
+
if (ch === ' ') {
|
|
231
|
+
while (s[i] === ' ') i++
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
if (s[i] !== ch) return null
|
|
235
|
+
i++
|
|
236
|
+
}
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
const tok = p.token
|
|
240
|
+
switch (tok) {
|
|
241
|
+
case 'ddd':
|
|
242
|
+
case 'dddd': {
|
|
243
|
+
const m = matchName(s, i, weekdayNames(locale)[tok === 'ddd' ? 'short' : 'long'])
|
|
244
|
+
if (!m) return null
|
|
245
|
+
i += m.len // weekday is informational; day-of-month drives the date
|
|
246
|
+
break
|
|
247
|
+
}
|
|
248
|
+
case 'MMM':
|
|
249
|
+
case 'MMMM': {
|
|
250
|
+
const m = matchName(s, i, monthNames(locale)[tok === 'MMM' ? 'short' : 'long'])
|
|
251
|
+
if (!m) return null
|
|
252
|
+
acc.month = m.index
|
|
253
|
+
i += m.len
|
|
254
|
+
break
|
|
255
|
+
}
|
|
256
|
+
case 't':
|
|
257
|
+
case 'tt': {
|
|
258
|
+
const dp = dayPeriods(locale)
|
|
259
|
+
const lower = s.slice(i).toLowerCase()
|
|
260
|
+
if (lower.startsWith(dp.pm.toLowerCase())) { acc.isPm = true; i += dp.pm.length }
|
|
261
|
+
else if (lower.startsWith(dp.am.toLowerCase())) { acc.isPm = false; i += dp.am.length }
|
|
262
|
+
else if (lower.startsWith(dp.pm.slice(0, 1).toLowerCase())) { acc.isPm = true; i += 1 }
|
|
263
|
+
else if (lower.startsWith(dp.am.slice(0, 1).toLowerCase())) { acc.isPm = false; i += 1 }
|
|
264
|
+
else return null
|
|
265
|
+
break
|
|
266
|
+
}
|
|
267
|
+
default: {
|
|
268
|
+
const r = readDigits(s, i, MAX_DIGITS[tok] ?? 2)
|
|
269
|
+
if (!r) return null
|
|
270
|
+
i += r.len
|
|
271
|
+
const v = r.value
|
|
272
|
+
switch (tok) {
|
|
273
|
+
case 'd': case 'dd': acc.day = v; break
|
|
274
|
+
case 'M': case 'MM': acc.month = v - 1; break
|
|
275
|
+
case 'yy': acc.year = v <= yearCutoff % 100 ? 2000 + v : 1900 + v; break
|
|
276
|
+
case 'yyyy': acc.year = v; break
|
|
277
|
+
case 'H': case 'HH': acc.hour = v; break
|
|
278
|
+
case 'h': case 'hh': acc.hour12 = v; break
|
|
279
|
+
case 'm': case 'mm': acc.minute = v; break
|
|
280
|
+
case 's': case 'ss': acc.second = v; break
|
|
281
|
+
case 'f': acc.ms = v * 100; break
|
|
282
|
+
case 'ff': acc.ms = v * 10; break
|
|
283
|
+
case 'fff': acc.ms = v; break
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Trailing input that the mask did not consume is a mismatch.
|
|
289
|
+
if (i !== s.length && s.slice(i).trim() !== '') return null
|
|
290
|
+
|
|
291
|
+
const b = base ?? new Date(1970, 0, 1)
|
|
292
|
+
let hour = acc.hour
|
|
293
|
+
if (hour == null && acc.hour12 != null) {
|
|
294
|
+
hour = acc.hour12 % 12
|
|
295
|
+
if (acc.isPm) hour += 12
|
|
296
|
+
}
|
|
297
|
+
const year = acc.year ?? b.getFullYear()
|
|
298
|
+
const month = acc.month ?? b.getMonth()
|
|
299
|
+
const day = acc.day ?? b.getDate()
|
|
300
|
+
const result = new Date(
|
|
301
|
+
year,
|
|
302
|
+
month,
|
|
303
|
+
day,
|
|
304
|
+
hour ?? b.getHours(),
|
|
305
|
+
acc.minute ?? b.getMinutes(),
|
|
306
|
+
acc.second ?? b.getSeconds(),
|
|
307
|
+
acc.ms ?? b.getMilliseconds(),
|
|
308
|
+
)
|
|
309
|
+
// Reject impossible dates the constructor silently rolls over (e.g. Feb 31).
|
|
310
|
+
if (
|
|
311
|
+
(acc.day != null && result.getDate() !== day) ||
|
|
312
|
+
(acc.month != null && result.getMonth() !== month)
|
|
313
|
+
) {
|
|
314
|
+
return null
|
|
315
|
+
}
|
|
316
|
+
return isNaN(result.getTime()) ? null : result
|
|
317
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* date-restrict - predicates for calendar constraints: min/max bounds,
|
|
3
|
+
* `restrictedDates` (non-selectable), and `importantDates` (highlighted but
|
|
4
|
+
* still selectable). Mirrors Smart's calendar constraint model. Framework-free.
|
|
5
|
+
*/
|
|
6
|
+
import { compareDay, isSameDay, toDate, type DateLike } from './date-core'
|
|
7
|
+
|
|
8
|
+
export type RestrictOptions = {
|
|
9
|
+
min?: DateLike | null
|
|
10
|
+
max?: DateLike | null
|
|
11
|
+
/** Individual dates (or predicate) that cannot be selected. */
|
|
12
|
+
restrictedDates?: ReadonlyArray<DateLike> | ((d: Date) => boolean) | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function normalizeList(list: ReadonlyArray<DateLike> | null | undefined): Date[] {
|
|
16
|
+
if (!list) return []
|
|
17
|
+
const out: Date[] = []
|
|
18
|
+
for (const item of list) {
|
|
19
|
+
const d = toDate(item)
|
|
20
|
+
if (d) out.push(d)
|
|
21
|
+
}
|
|
22
|
+
return out
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when `d` is outside the inclusive [min, max] day range. */
|
|
26
|
+
export function isOutOfRange(d: Date, min?: DateLike | null, max?: DateLike | null): boolean {
|
|
27
|
+
const lo = toDate(min ?? null)
|
|
28
|
+
const hi = toDate(max ?? null)
|
|
29
|
+
if (lo && compareDay(d, lo) < 0) return true
|
|
30
|
+
if (hi && compareDay(d, hi) > 0) return true
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when `d` is explicitly restricted (by list membership or predicate). */
|
|
35
|
+
export function isRestricted(
|
|
36
|
+
d: Date,
|
|
37
|
+
restrictedDates?: ReadonlyArray<DateLike> | ((day: Date) => boolean) | null,
|
|
38
|
+
): boolean {
|
|
39
|
+
if (!restrictedDates) return false
|
|
40
|
+
if (typeof restrictedDates === 'function') return restrictedDates(d)
|
|
41
|
+
return normalizeList(restrictedDates).some((r) => isSameDay(r, d))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True when `d` cannot be selected: out of [min, max] OR restricted. This is
|
|
46
|
+
* the single predicate the calendar/pickers use to disable a day.
|
|
47
|
+
*/
|
|
48
|
+
export function isDisabledDay(d: Date, opts: RestrictOptions): boolean {
|
|
49
|
+
return isOutOfRange(d, opts.min, opts.max) || isRestricted(d, opts.restrictedDates)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** True when `d` is flagged important (highlighted). Never affects selectability. */
|
|
53
|
+
export function isImportant(
|
|
54
|
+
d: Date,
|
|
55
|
+
importantDates?: ReadonlyArray<DateLike> | ((day: Date) => boolean) | null,
|
|
56
|
+
): boolean {
|
|
57
|
+
if (!importantDates) return false
|
|
58
|
+
if (typeof importantDates === 'function') return importantDates(d)
|
|
59
|
+
return normalizeList(importantDates).some((r) => isSameDay(r, d))
|
|
60
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
emptySelection,
|
|
4
|
+
isSelected,
|
|
5
|
+
rangeDays,
|
|
6
|
+
selectDate,
|
|
7
|
+
isMultiSelectMode,
|
|
8
|
+
type SelectionState,
|
|
9
|
+
} from './date-selection'
|
|
10
|
+
import { isDisabledDay, isImportant, isOutOfRange, isRestricted } from './date-restrict'
|
|
11
|
+
|
|
12
|
+
const day = (y: number, m: number, d: number) => new Date(y, m, d)
|
|
13
|
+
const state = (dates: Date[] = [], anchor: Date | null = null, rangeStart: Date | null = null): SelectionState => ({
|
|
14
|
+
dates,
|
|
15
|
+
anchor,
|
|
16
|
+
rangeStart,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('rangeDays', () => {
|
|
20
|
+
it('is inclusive and order-independent', () => {
|
|
21
|
+
const fwd = rangeDays(day(2026, 5, 1), day(2026, 5, 5))
|
|
22
|
+
expect(fwd.map((d) => d.getDate())).toEqual([1, 2, 3, 4, 5])
|
|
23
|
+
const rev = rangeDays(day(2026, 5, 5), day(2026, 5, 1))
|
|
24
|
+
expect(rev.map((d) => d.getDate())).toEqual([1, 2, 3, 4, 5])
|
|
25
|
+
expect(rangeDays(day(2026, 5, 3), day(2026, 5, 3))).toHaveLength(1)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe('selectDate modes', () => {
|
|
30
|
+
it('none does nothing', () => {
|
|
31
|
+
const r = selectDate(state([day(2026, 5, 1)]), day(2026, 5, 9), 'none')
|
|
32
|
+
expect(r.dates.map((d) => d.getDate())).toEqual([1])
|
|
33
|
+
})
|
|
34
|
+
it('one/default replaces', () => {
|
|
35
|
+
const r = selectDate(state([day(2026, 5, 1)]), day(2026, 5, 9), 'one')
|
|
36
|
+
expect(r.dates.map((d) => d.getDate())).toEqual([9])
|
|
37
|
+
expect(isSelected(r.dates, day(2026, 5, 1))).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
it('zeroOrOne toggles the sole day off', () => {
|
|
40
|
+
const first = selectDate(emptySelection(), day(2026, 5, 9), 'zeroOrOne')
|
|
41
|
+
expect(first.dates).toHaveLength(1)
|
|
42
|
+
const off = selectDate(first, day(2026, 5, 9), 'zeroOrOne')
|
|
43
|
+
expect(off.dates).toHaveLength(0)
|
|
44
|
+
})
|
|
45
|
+
it('many toggles add/remove and keeps sorted', () => {
|
|
46
|
+
let r = selectDate(emptySelection(), day(2026, 5, 9), 'many')
|
|
47
|
+
r = selectDate(r, day(2026, 5, 1), 'many')
|
|
48
|
+
r = selectDate(r, day(2026, 5, 5), 'many')
|
|
49
|
+
expect(r.dates.map((d) => d.getDate())).toEqual([1, 5, 9]) // sorted
|
|
50
|
+
r = selectDate(r, day(2026, 5, 5), 'many') // remove
|
|
51
|
+
expect(r.dates.map((d) => d.getDate())).toEqual([1, 9])
|
|
52
|
+
})
|
|
53
|
+
it('oneOrMany refuses to drop below one', () => {
|
|
54
|
+
const one = selectDate(emptySelection(), day(2026, 5, 9), 'oneOrMany')
|
|
55
|
+
const stillOne = selectDate(one, day(2026, 5, 9), 'oneOrMany') // toggling last stays
|
|
56
|
+
expect(stillOne.dates).toHaveLength(1)
|
|
57
|
+
})
|
|
58
|
+
it('oneExtended: plain click single, ctrl toggles, shift extends from anchor', () => {
|
|
59
|
+
const base = selectDate(emptySelection(), day(2026, 5, 10), 'oneExtended')
|
|
60
|
+
expect(base.dates.map((d) => d.getDate())).toEqual([10])
|
|
61
|
+
const ctrl = selectDate(base, day(2026, 5, 12), 'oneExtended', { ctrl: true })
|
|
62
|
+
expect(ctrl.dates.map((d) => d.getDate())).toEqual([10, 12])
|
|
63
|
+
const shift = selectDate(base, day(2026, 5, 13), 'oneExtended', { shift: true })
|
|
64
|
+
expect(shift.dates.map((d) => d.getDate())).toEqual([10, 11, 12, 13]) // contiguous
|
|
65
|
+
})
|
|
66
|
+
it('week selects seven consecutive days from the week start', () => {
|
|
67
|
+
const r = selectDate(emptySelection(), day(2026, 5, 10), 'week', {}, 1) // Monday-first
|
|
68
|
+
expect(r.dates).toHaveLength(7)
|
|
69
|
+
expect(r.dates[0]!.getDay()).toBe(1)
|
|
70
|
+
// consecutive
|
|
71
|
+
for (let i = 1; i < 7; i++) {
|
|
72
|
+
expect(r.dates[i]!.getTime() - r.dates[i - 1]!.getTime()).toBe(24 * 3600 * 1000)
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
it('range: first click starts, second completes inclusive', () => {
|
|
76
|
+
const start = selectDate(emptySelection(), day(2026, 5, 3), 'range')
|
|
77
|
+
expect(start.rangeStart?.getDate()).toBe(3)
|
|
78
|
+
expect(start.dates.map((d) => d.getDate())).toEqual([3])
|
|
79
|
+
const done = selectDate(start, day(2026, 5, 6), 'range')
|
|
80
|
+
expect(done.rangeStart).toBeNull()
|
|
81
|
+
expect(done.dates.map((d) => d.getDate())).toEqual([3, 4, 5, 6])
|
|
82
|
+
// A third click starts a new range.
|
|
83
|
+
const restart = selectDate(done, day(2026, 5, 20), 'range')
|
|
84
|
+
expect(restart.rangeStart?.getDate()).toBe(20)
|
|
85
|
+
expect(restart.dates.map((d) => d.getDate())).toEqual([20])
|
|
86
|
+
})
|
|
87
|
+
it('does not mutate the input state', () => {
|
|
88
|
+
const input = state([day(2026, 5, 1)], day(2026, 5, 1))
|
|
89
|
+
const snapshot = input.dates.slice()
|
|
90
|
+
selectDate(input, day(2026, 5, 9), 'one')
|
|
91
|
+
expect(input.dates).toEqual(snapshot)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('isMultiSelectMode', () => {
|
|
96
|
+
it('classifies modes', () => {
|
|
97
|
+
expect(isMultiSelectMode('one')).toBe(false)
|
|
98
|
+
expect(isMultiSelectMode('zeroOrOne')).toBe(false)
|
|
99
|
+
expect(isMultiSelectMode('many')).toBe(true)
|
|
100
|
+
expect(isMultiSelectMode('range')).toBe(true)
|
|
101
|
+
expect(isMultiSelectMode('week')).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('date-restrict', () => {
|
|
106
|
+
const min = day(2026, 5, 5)
|
|
107
|
+
const max = day(2026, 5, 25)
|
|
108
|
+
it('isOutOfRange respects inclusive bounds', () => {
|
|
109
|
+
expect(isOutOfRange(day(2026, 5, 4), min, max)).toBe(true)
|
|
110
|
+
expect(isOutOfRange(day(2026, 5, 5), min, max)).toBe(false) // inclusive
|
|
111
|
+
expect(isOutOfRange(day(2026, 5, 25), min, max)).toBe(false) // inclusive
|
|
112
|
+
expect(isOutOfRange(day(2026, 5, 26), min, max)).toBe(true)
|
|
113
|
+
expect(isOutOfRange(day(2026, 5, 26), null, null)).toBe(false) // open
|
|
114
|
+
})
|
|
115
|
+
it('isRestricted supports list and predicate', () => {
|
|
116
|
+
expect(isRestricted(day(2026, 5, 10), [day(2026, 5, 10)])).toBe(true)
|
|
117
|
+
expect(isRestricted(day(2026, 5, 11), [day(2026, 5, 10)])).toBe(false)
|
|
118
|
+
expect(isRestricted(day(2026, 5, 13), (d) => d.getDay() === 6)).toBe(true) // Saturday
|
|
119
|
+
})
|
|
120
|
+
it('isDisabledDay combines range + restriction', () => {
|
|
121
|
+
expect(isDisabledDay(day(2026, 5, 1), { min, max })).toBe(true) // below min
|
|
122
|
+
expect(isDisabledDay(day(2026, 5, 10), { min, max, restrictedDates: [day(2026, 5, 10)] })).toBe(true)
|
|
123
|
+
expect(isDisabledDay(day(2026, 5, 10), { min, max })).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
it('isImportant never affects selectability', () => {
|
|
126
|
+
expect(isImportant(day(2026, 5, 10), [day(2026, 5, 10)])).toBe(true)
|
|
127
|
+
expect(isImportant(day(2026, 5, 11), [day(2026, 5, 10)])).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
})
|