@svgrid/grid 1.2.22 → 1.2.23
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 +100 -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 +669 -0
- package/dist/SvCalendar.svelte.d.ts +70 -0
- package/dist/SvCheckBox.svelte +81 -0
- package/dist/SvCheckBox.svelte.d.ts +19 -0
- package/dist/SvColorInput.svelte +187 -0
- package/dist/SvColorInput.svelte.d.ts +15 -0
- package/dist/SvComboBox.svelte +130 -0
- package/dist/SvComboBox.svelte.d.ts +15 -0
- package/dist/SvCountryInput.svelte +123 -0
- package/dist/SvCountryInput.svelte.d.ts +14 -0
- package/dist/SvDateTimePicker.svelte +322 -0
- package/dist/SvDateTimePicker.svelte.d.ts +37 -0
- package/dist/SvDropDownList.svelte +143 -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 +99 -0
- package/dist/SvGauge.svelte.d.ts +31 -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 +122 -0
- package/dist/SvListBox.svelte.d.ts +19 -0
- package/dist/SvMaskedInput.svelte +0 -0
- package/dist/SvMaskedInput.svelte.d.ts +15 -0
- package/dist/SvNumberInput.svelte +0 -0
- package/dist/SvNumberInput.svelte.d.ts +22 -0
- package/dist/SvPasswordInput.svelte +110 -0
- package/dist/SvPasswordInput.svelte.d.ts +22 -0
- package/dist/SvPhoneInput.svelte +0 -0
- package/dist/SvPhoneInput.svelte.d.ts +19 -0
- package/dist/SvRadioGroup.svelte +108 -0
- package/dist/SvRadioGroup.svelte.d.ts +23 -0
- package/dist/SvRating.svelte +111 -0
- package/dist/SvRating.svelte.d.ts +20 -0
- package/dist/SvRepeatButton.svelte +89 -0
- package/dist/SvRepeatButton.svelte.d.ts +21 -0
- package/dist/SvSlider.svelte +182 -0
- package/dist/SvSlider.svelte.d.ts +25 -0
- package/dist/SvSwitchButton.svelte +94 -0
- package/dist/SvSwitchButton.svelte.d.ts +19 -0
- package/dist/SvTabs.svelte +122 -0
- package/dist/SvTabs.svelte.d.ts +26 -0
- package/dist/SvTagsInput.svelte +81 -0
- package/dist/SvTagsInput.svelte.d.ts +19 -0
- package/dist/SvTimePicker.svelte +359 -0
- package/dist/SvTimePicker.svelte.d.ts +27 -0
- package/dist/SvToggleButton.svelte +57 -0
- package/dist/SvToggleButton.svelte.d.ts +16 -0
- package/dist/SvTree.svelte +210 -0
- package/dist/SvTree.svelte.d.ts +29 -0
- package/dist/build-api.js +78 -1
- package/dist/cdn/svgrid.js +10022 -5904
- package/dist/cdn/svgrid.svelte-external.js +8531 -4429
- 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/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/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 +36 -0
- package/dist/index.js +41 -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 +100 -0
- package/src/SvButton.svelte +97 -0
- package/src/SvCalendar.svelte +669 -0
- package/src/SvCalendar.test.ts +211 -0
- package/src/SvCheckBox.svelte +81 -0
- package/src/SvColorInput.svelte +187 -0
- package/src/SvComboBox.svelte +130 -0
- package/src/SvCountryInput.svelte +123 -0
- package/src/SvDateTimePicker.svelte +322 -0
- package/src/SvDateTimePicker.test.ts +136 -0
- package/src/SvDropDownList.svelte +143 -0
- package/src/SvForm.svelte +147 -0
- package/src/SvGauge.svelte +99 -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 +122 -0
- package/src/SvMaskedInput.svelte +0 -0
- package/src/SvNumberInput.svelte +0 -0
- package/src/SvPasswordInput.svelte +110 -0
- package/src/SvPhoneInput.svelte +0 -0
- package/src/SvRadioGroup.svelte +108 -0
- package/src/SvRating.svelte +111 -0
- package/src/SvRepeatButton.svelte +89 -0
- package/src/SvSlider.svelte +182 -0
- package/src/SvSwitchButton.svelte +94 -0
- package/src/SvTabs.svelte +122 -0
- package/src/SvTagsInput.svelte +81 -0
- package/src/SvTimePicker.svelte +359 -0
- package/src/SvTimePicker.test.ts +98 -0
- package/src/SvToggleButton.svelte +57 -0
- package/src/SvTree.svelte +210 -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/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/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 +71 -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 +92 -0
- package/src/ui-range.test.ts +61 -0
- package/src/ui-selection.test.ts +106 -0
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* date-selection - implements every Smart calendar `selectionMode` as a pure
|
|
3
|
+
* reducer over the current selection. The calendar component owns pointer/key
|
|
4
|
+
* handling and delegates the "what gets selected" decision here so the rules
|
|
5
|
+
* are unit-testable in isolation.
|
|
6
|
+
*/
|
|
7
|
+
import { addDays, compareDay, isSameDay, startOfDay, startOfWeek } from './date-core'
|
|
8
|
+
|
|
9
|
+
export type SelectionMode =
|
|
10
|
+
| 'none'
|
|
11
|
+
| 'default'
|
|
12
|
+
| 'one'
|
|
13
|
+
| 'zeroOrOne'
|
|
14
|
+
| 'oneExtended'
|
|
15
|
+
| 'many'
|
|
16
|
+
| 'zeroOrMany'
|
|
17
|
+
| 'oneOrMany'
|
|
18
|
+
| 'week'
|
|
19
|
+
| 'range'
|
|
20
|
+
|
|
21
|
+
export type SelectionModifiers = { ctrl?: boolean; shift?: boolean }
|
|
22
|
+
|
|
23
|
+
export type SelectionState = {
|
|
24
|
+
/** Selected days (deduped, sorted ascending), always local-midnight. */
|
|
25
|
+
dates: Date[]
|
|
26
|
+
/** Anchor for shift-extend (oneExtended). */
|
|
27
|
+
anchor: Date | null
|
|
28
|
+
/** Pending range start (range mode, between first and second click). */
|
|
29
|
+
rangeStart: Date | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const emptySelection = (): SelectionState => ({ dates: [], anchor: null, rangeStart: null })
|
|
33
|
+
|
|
34
|
+
/** True when `day` is in `dates` (by calendar day). */
|
|
35
|
+
export function isSelected(dates: ReadonlyArray<Date>, day: Date): boolean {
|
|
36
|
+
return dates.some((d) => isSameDay(d, day))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Inclusive ordered list of day-midnight dates from a..b (either order). */
|
|
40
|
+
export function rangeDays(a: Date, b: Date): Date[] {
|
|
41
|
+
const lo = compareDay(a, b) <= 0 ? startOfDay(a) : startOfDay(b)
|
|
42
|
+
const hi = compareDay(a, b) <= 0 ? startOfDay(b) : startOfDay(a)
|
|
43
|
+
const out: Date[] = []
|
|
44
|
+
let cursor = lo
|
|
45
|
+
while (compareDay(cursor, hi) <= 0) {
|
|
46
|
+
out.push(cursor)
|
|
47
|
+
cursor = addDays(cursor, 1)
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sortDedup(dates: Date[]): Date[] {
|
|
53
|
+
const uniq: Date[] = []
|
|
54
|
+
for (const d of dates) if (!uniq.some((u) => isSameDay(u, d))) uniq.push(startOfDay(d))
|
|
55
|
+
uniq.sort((a, b) => a.getTime() - b.getTime())
|
|
56
|
+
return uniq
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toggle(dates: Date[], day: Date): Date[] {
|
|
60
|
+
return isSelected(dates, day) ? dates.filter((d) => !isSameDay(d, day)) : [...dates, startOfDay(day)]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Apply a click on `clicked` to `state` under `mode`. `firstDayOfWeek` only
|
|
65
|
+
* matters for `week` mode. Returns a fresh state (never mutates the input).
|
|
66
|
+
*/
|
|
67
|
+
export function selectDate(
|
|
68
|
+
state: SelectionState,
|
|
69
|
+
clicked: Date,
|
|
70
|
+
mode: SelectionMode,
|
|
71
|
+
modifiers: SelectionModifiers = {},
|
|
72
|
+
firstDayOfWeek = 0,
|
|
73
|
+
): SelectionState {
|
|
74
|
+
const day = startOfDay(clicked)
|
|
75
|
+
const single = (): SelectionState => ({ dates: [day], anchor: day, rangeStart: null })
|
|
76
|
+
|
|
77
|
+
switch (mode) {
|
|
78
|
+
case 'none':
|
|
79
|
+
return { ...state }
|
|
80
|
+
|
|
81
|
+
case 'default':
|
|
82
|
+
case 'one':
|
|
83
|
+
return single()
|
|
84
|
+
|
|
85
|
+
case 'zeroOrOne':
|
|
86
|
+
// Toggle: clicking the sole selected day clears it.
|
|
87
|
+
return isSelected(state.dates, day) && state.dates.length === 1
|
|
88
|
+
? emptySelection()
|
|
89
|
+
: single()
|
|
90
|
+
|
|
91
|
+
case 'many':
|
|
92
|
+
case 'zeroOrMany': {
|
|
93
|
+
const next = sortDedup(toggle(state.dates, day))
|
|
94
|
+
return { dates: next, anchor: day, rangeStart: null }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
case 'oneOrMany': {
|
|
98
|
+
const toggled = toggle(state.dates, day)
|
|
99
|
+
// Never allow emptying below one.
|
|
100
|
+
const next = toggled.length === 0 ? [day] : toggled
|
|
101
|
+
return { dates: sortDedup(next), anchor: day, rangeStart: null }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case 'oneExtended': {
|
|
105
|
+
if (modifiers.shift && state.anchor) {
|
|
106
|
+
return { dates: rangeDays(state.anchor, day), anchor: state.anchor, rangeStart: null }
|
|
107
|
+
}
|
|
108
|
+
if (modifiers.ctrl) {
|
|
109
|
+
const toggled = sortDedup(toggle(state.dates, day))
|
|
110
|
+
return { dates: toggled, anchor: day, rangeStart: null }
|
|
111
|
+
}
|
|
112
|
+
return single()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
case 'week': {
|
|
116
|
+
const start = startOfWeek(day, firstDayOfWeek)
|
|
117
|
+
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i))
|
|
118
|
+
return { dates: days, anchor: start, rangeStart: null }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
case 'range': {
|
|
122
|
+
// First click sets the start; second click completes the range.
|
|
123
|
+
if (!state.rangeStart) {
|
|
124
|
+
return { dates: [day], anchor: day, rangeStart: day }
|
|
125
|
+
}
|
|
126
|
+
return { dates: rangeDays(state.rangeStart, day), anchor: state.rangeStart, rangeStart: null }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
default:
|
|
130
|
+
return single()
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whether a mode ever holds more than one date (drives multi-select UI hints). */
|
|
135
|
+
export function isMultiSelectMode(mode: SelectionMode): boolean {
|
|
136
|
+
return mode === 'many' || mode === 'zeroOrMany' || mode === 'oneOrMany' || mode === 'oneExtended' || mode === 'week' || mode === 'range'
|
|
137
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { applyMask, unmask, isMaskComplete } from './mask'
|
|
3
|
+
|
|
4
|
+
describe('applyMask', () => {
|
|
5
|
+
it('formats a phone pattern, inserting literals', () => {
|
|
6
|
+
expect(applyMask('1234567890', '(###) ###-####').formatted).toBe('(123) 456-7890')
|
|
7
|
+
expect(applyMask('123', '(###) ###-####').formatted).toBe('(123')
|
|
8
|
+
expect(applyMask('1234', '(###) ###-####').formatted).toBe('(123) 4')
|
|
9
|
+
})
|
|
10
|
+
it('drops chars that do not fit the token', () => {
|
|
11
|
+
expect(applyMask('12ab34', '####').formatted).toBe('1234')
|
|
12
|
+
expect(applyMask('ab12', 'AA##').formatted).toBe('ab12')
|
|
13
|
+
// *### : '*' takes 'a', then the three '#' take the digits, skipping 'b'.
|
|
14
|
+
expect(applyMask('a1b2', '*###').formatted).toBe('a12')
|
|
15
|
+
})
|
|
16
|
+
it('* accepts letter or digit', () => {
|
|
17
|
+
expect(applyMask('a1', '**').formatted).toBe('a1')
|
|
18
|
+
})
|
|
19
|
+
it('empty input yields empty', () => {
|
|
20
|
+
expect(applyMask('', '(###)').formatted).toBe('')
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('unmask', () => {
|
|
25
|
+
it('returns only data characters', () => {
|
|
26
|
+
expect(unmask('(123) 456-7890', '(###) ###-####')).toBe('1234567890')
|
|
27
|
+
expect(unmask('ab-12', 'AA-##')).toBe('ab12')
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('isMaskComplete', () => {
|
|
32
|
+
it('true only when every token is filled', () => {
|
|
33
|
+
expect(isMaskComplete('(123) 456-7890', '(###) ###-####')).toBe(true)
|
|
34
|
+
expect(isMaskComplete('(123) 456', '(###) ###-####')).toBe(false)
|
|
35
|
+
})
|
|
36
|
+
})
|