@nepdatejs/datepicker 0.0.0-stage → 0.1.1

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/src/input.ts ADDED
@@ -0,0 +1,128 @@
1
+ import {
2
+ formatDate,
3
+ formatMessage,
4
+ fromIsoDate,
5
+ getLocale,
6
+ isWithin,
7
+ parseDate,
8
+ toCalendar,
9
+ type AdDate,
10
+ type CalendarDate,
11
+ type CalendarSystem,
12
+ type DateFormatPattern,
13
+ type LocaleCode,
14
+ } from '@nepdatejs/core';
15
+ import type { DateDetail, DateError } from './types';
16
+
17
+ /** What a picker allows, in any calendar. */
18
+ export interface InputLimits {
19
+ readonly min: CalendarDate;
20
+ readonly max: CalendarDate;
21
+ /** Whether the app set min/max (else they are the edges of the BS table). Picks the message. */
22
+ readonly hasMin?: boolean;
23
+ readonly hasMax?: boolean;
24
+ readonly isDateDisabled?: ((date: CalendarDate) => boolean) | undefined;
25
+ }
26
+
27
+ export type ParseResult =
28
+ | { readonly kind: 'empty' }
29
+ | { readonly kind: 'date'; readonly date: AdDate }
30
+ | { readonly kind: 'error'; readonly error: DateError };
31
+
32
+ /**
33
+ * Read what the user typed: year first, ASCII or Devanagari digits, in the
34
+ * calendar on screen. All date work is done by core (docs/plan D-005).
35
+ */
36
+ export function parseInput(
37
+ text: string,
38
+ calendar: CalendarSystem,
39
+ limits: InputLimits,
40
+ locale: LocaleCode,
41
+ format: DateFormatPattern = 'iso',
42
+ ): ParseResult {
43
+ if (text.trim() === '') {
44
+ return { kind: 'empty' };
45
+ }
46
+ const messages = getLocale(locale).messages;
47
+ const date = parseDate(text, calendar);
48
+ if (!date) {
49
+ return {
50
+ kind: 'error',
51
+ error: { code: 'format', message: messages.invalidFormat[calendar] },
52
+ };
53
+ }
54
+ const error = checkDate(date, calendar, limits, locale, format);
55
+
56
+ return error
57
+ ? { kind: 'error', error }
58
+ : { kind: 'date', date: toCalendar(date, 'AD') };
59
+ }
60
+
61
+ /** Null when the day is allowed; otherwise a translated error. */
62
+ export function checkDate(
63
+ date: CalendarDate,
64
+ calendar: CalendarSystem,
65
+ limits: InputLimits,
66
+ locale: LocaleCode,
67
+ format: DateFormatPattern = 'iso',
68
+ ): DateError | null {
69
+ const messages = getLocale(locale).messages;
70
+ if (!isWithin(date, limits.min, limits.max)) {
71
+ const values = {
72
+ min: formatInput(limits.min, calendar, format, locale),
73
+ max: formatInput(limits.max, calendar, format, locale),
74
+ };
75
+ const template =
76
+ limits.hasMin === limits.hasMax
77
+ ? messages.outOfRange
78
+ : limits.hasMin
79
+ ? messages.onOrAfter
80
+ : messages.onOrBefore;
81
+
82
+ return { code: 'range', message: formatMessage(template, values) };
83
+ }
84
+ if (limits.isDateDisabled?.(date)) {
85
+ return { code: 'disabled', message: messages.unavailable };
86
+ }
87
+
88
+ return null;
89
+ }
90
+
91
+ /** How the input shows a date: in the calendar on screen, with the locale's digits. */
92
+ export function formatInput(
93
+ date: CalendarDate | null,
94
+ calendar: CalendarSystem,
95
+ format: DateFormatPattern,
96
+ locale: LocaleCode,
97
+ ): string {
98
+ return date ? formatDate(toCalendar(date, calendar), format, locale) : '';
99
+ }
100
+
101
+ /** "10 October 2026 AD": the same day in the other calendar. */
102
+ export function equivalentText(
103
+ date: CalendarDate | null,
104
+ calendar: CalendarSystem,
105
+ locale: LocaleCode,
106
+ ): string | null {
107
+ if (!date) {
108
+ return null;
109
+ }
110
+ const other: CalendarSystem = calendar === 'BS' ? 'AD' : 'BS';
111
+
112
+ return `${formatDate(toCalendar(date, other), 'long', locale)} ${getLocale(locale).calendarNames[other]}`;
113
+ }
114
+
115
+ export function placeholderText(calendar: CalendarSystem, locale: LocaleCode): string {
116
+ return `YYYY-MM-DD (${getLocale(locale).calendarNames[calendar]})`;
117
+ }
118
+
119
+ /** An AD "YYYY-MM-DD" string as a date; anything else is null. */
120
+ export function readIso(value: string | null | undefined): AdDate | null {
121
+ return value ? fromIsoDate(value) : null;
122
+ }
123
+
124
+ export function detailOf(date: CalendarDate | null): DateDetail | null {
125
+ return date
126
+ ? Object.freeze({ ad: toCalendar(date, 'AD'), bs: toCalendar(date, 'BS') })
127
+ : null;
128
+ }
package/src/mask.ts ADDED
@@ -0,0 +1,42 @@
1
+ const DIGIT = '[0-9\u0966-\u096f]';
2
+ const DIGITS_ONLY = new RegExp(`^${DIGIT}{1,8}$`);
3
+ const YEAR_MONTH = new RegExp(`^(${DIGIT}{4}-${DIGIT}{2})(${DIGIT}?)$`);
4
+
5
+ /**
6
+ * Add the dashes of YYYY-MM-DD while the user types digits: "2083" becomes
7
+ * "2083-", "208306" becomes "2083-06-" and a pasted "20830624" becomes
8
+ * "2083-06-24". ASCII and Devanagari digits keep their script, and a
9
+ * separator typed straight after a dash is dropped, so typing the dashes
10
+ * yourself works too. Only text that grew at the end (typing, or a paste into
11
+ * an empty field) is touched: deleting, editing in the middle and other
12
+ * separators ("2083/6/4") are left as the user wrote them. Pure.
13
+ */
14
+ export function maskDateText(next: string, previous = ''): string {
15
+ if (next.length <= previous.length || !next.startsWith(previous)) {
16
+ return next;
17
+ }
18
+ if (previous.endsWith('-') && /^[-./]$/.test(next.slice(previous.length))) {
19
+ // The user typed the separator the mask already added.
20
+ return previous;
21
+ }
22
+ if (DIGITS_ONLY.test(next)) {
23
+ // Every character is one ASCII or Devanagari digit, so string offsets are safe.
24
+ let out = next.slice(0, 4);
25
+ if (next.length >= 4) {
26
+ out += '-';
27
+ }
28
+ out += next.slice(4, 6);
29
+ if (next.length >= 6) {
30
+ out += '-';
31
+ }
32
+
33
+ return out + next.slice(6);
34
+ }
35
+ const yearMonth = YEAR_MONTH.exec(next);
36
+ if (yearMonth) {
37
+ // "2083-06" → "2083-06-"; "2083-062" → "2083-06-2".
38
+ return `${yearMonth[1] ?? ''}-${yearMonth[2] ?? ''}`;
39
+ }
40
+
41
+ return next;
42
+ }
package/src/multi.ts ADDED
@@ -0,0 +1,318 @@
1
+ import {
2
+ formatMessage,
3
+ getLocale,
4
+ toCalendar,
5
+ toIsoDate,
6
+ type AdDate,
7
+ type CalendarSystem,
8
+ } from '@nepdatejs/core';
9
+ import { createCalendar, isDayList } from '@nepdatejs/calendar';
10
+ import { createEmitter, inputLimits, localeOf, panelOptions } from './base';
11
+ import { formatInput, parseInput, placeholderText, readIso } from './input';
12
+ import { maskDateText } from './mask';
13
+ import type {
14
+ DateDetail,
15
+ DateError,
16
+ MultiDatePickerActions,
17
+ MultiDatePickerOptions,
18
+ MultiDatePickerState,
19
+ MultiDatePickerStore,
20
+ } from './types';
21
+
22
+ const SEPARATOR = /[,\s\u060c]+/;
23
+
24
+ /**
25
+ * A picker for several separate days (PICK-001). The value is a sorted list of
26
+ * AD "YYYY-MM-DD" strings; the input lists the days in the calendar on screen,
27
+ * separated by ", ", and clicking a day in the panel adds or removes it. The
28
+ * list is always written as YYYY-MM-DD so it can be typed back.
29
+ */
30
+ export function createMultiDatePicker(
31
+ initial: MultiDatePickerOptions = {},
32
+ ): MultiDatePickerStore {
33
+ let options: MultiDatePickerOptions = { ...initial };
34
+ let calendar: CalendarSystem = options.calendar ?? options.defaultCalendar ?? 'BS';
35
+ let dates = readAll(options.value ?? options.defaultValue);
36
+ let value = valuesOf(dates);
37
+ let text = format(dates);
38
+ let error: DateError | null = null;
39
+ /** False while the typed text has a problem: then it is left as typed. */
40
+ let textOk = true;
41
+ let open = false;
42
+ let destroyed = false;
43
+
44
+ const panel = createCalendar({
45
+ ...panelOptions(options, calendar),
46
+ selectionMode: 'multiple',
47
+ selected: dates,
48
+ onSelect: (selection) => {
49
+ if (isDayList(selection)) {
50
+ pick(selection.map((day) => toCalendar(day, 'AD')));
51
+ }
52
+ },
53
+ });
54
+ let snapshot = build();
55
+ const emitter = createEmitter(() => {
56
+ snapshot = build();
57
+ });
58
+ panel.subscribe(() => {
59
+ emitter.notify();
60
+ });
61
+
62
+ function build(): MultiDatePickerState {
63
+ return Object.freeze({
64
+ value,
65
+ dates: detailsOf(dates),
66
+ text,
67
+ calendar,
68
+ canSwitchCalendar: options.calendar === undefined,
69
+ open: open || options.inline === true,
70
+ inline: options.inline === true,
71
+ error,
72
+ placeholder: placeholderText(calendar, localeOf(options)),
73
+ calendarState: panel.getState(),
74
+ });
75
+ }
76
+
77
+ function format(list: readonly AdDate[]): string {
78
+ return list
79
+ .map((day) => formatInput(day, calendar, 'iso', localeOf(options)))
80
+ .join(', ');
81
+ }
82
+
83
+ function tooMany(count: number): DateError | null {
84
+ const max = options.maxDates;
85
+
86
+ return max !== undefined && count > max
87
+ ? {
88
+ code: 'tooMany',
89
+ message: formatMessage(getLocale(localeOf(options)).messages.tooManyDates, {
90
+ max: String(max),
91
+ }),
92
+ }
93
+ : null;
94
+ }
95
+
96
+ function syncPanel(): void {
97
+ panel.setOptions({ ...panelOptions(options, calendar), selected: dates });
98
+ }
99
+
100
+ /** Change the value; queue onChange when it differs and the user caused it. */
101
+ function setDates(next: readonly AdDate[], byUser: boolean): void {
102
+ const sorted = unique(next);
103
+ const nextValue = valuesOf(sorted);
104
+ const changed = nextValue.join() !== value.join();
105
+ dates = sorted;
106
+ value = nextValue;
107
+ syncPanel();
108
+ if (changed && byUser) {
109
+ const details = detailsOf(sorted);
110
+ emitter.after(() => options.onChange?.(nextValue, details));
111
+ }
112
+ }
113
+
114
+ function pick(next: readonly AdDate[]): void {
115
+ emitter.act(() => {
116
+ error = tooMany(next.length);
117
+ if (error) {
118
+ syncPanel(); // undo the calendar's toggle
119
+ return;
120
+ }
121
+ text = format(unique(next));
122
+ textOk = true;
123
+ setDates(next, true);
124
+ if (options.closeOnSelect === true) {
125
+ open = false;
126
+ }
127
+ });
128
+ }
129
+
130
+ const actions: MultiDatePickerActions = {
131
+ type(next) {
132
+ if (destroyed) {
133
+ return;
134
+ }
135
+ emitter.act(() => {
136
+ text = options.mask === false ? next : maskLast(next, text);
137
+ const limits = inputLimits(options, panel);
138
+ const parsed: AdDate[] = [];
139
+ let firstError: DateError | null = null;
140
+ for (const token of text.split(SEPARATOR)) {
141
+ const result = parseInput(token, calendar, limits, localeOf(options));
142
+ if (result.kind === 'date') {
143
+ parsed.push(result.date);
144
+ } else if (result.kind === 'error') {
145
+ firstError ??= result.error;
146
+ }
147
+ }
148
+ error = firstError ?? tooMany(unique(parsed).length);
149
+ textOk = error === null;
150
+ setDates(parsed, true);
151
+ });
152
+ },
153
+ blur() {
154
+ emitter.act(() => {
155
+ if (textOk) {
156
+ text = format(dates);
157
+ }
158
+ });
159
+ },
160
+ clear() {
161
+ emitter.act(() => {
162
+ text = '';
163
+ error = null;
164
+ textOk = true;
165
+ setDates([], true);
166
+ });
167
+ },
168
+ setValue(next) {
169
+ emitter.act(() => {
170
+ applyValue(next);
171
+ });
172
+ },
173
+ open() {
174
+ emitter.act(() => {
175
+ open = true;
176
+ const first = dates[0];
177
+ if (first) {
178
+ panel.actions.goTo(first);
179
+ }
180
+ });
181
+ },
182
+ close() {
183
+ emitter.act(() => {
184
+ open = false;
185
+ });
186
+ },
187
+ toggle() {
188
+ if (open) {
189
+ actions.close();
190
+ } else {
191
+ actions.open();
192
+ }
193
+ },
194
+ switchCalendar(next) {
195
+ if (options.calendar !== undefined || next === calendar || destroyed) {
196
+ return;
197
+ }
198
+ emitter.act(() => {
199
+ calendar = next;
200
+ if (textOk) {
201
+ text = format(dates);
202
+ }
203
+ syncPanel();
204
+ emitter.after(() => options.onCalendarChange?.(next));
205
+ });
206
+ },
207
+ select(day) {
208
+ if (destroyed) {
209
+ return false;
210
+ }
211
+ const before = value;
212
+ emitter.act(() => {
213
+ panel.actions.select(day);
214
+ });
215
+
216
+ return value !== before;
217
+ },
218
+ remove(iso) {
219
+ emitter.act(() => {
220
+ error = null;
221
+ const next = dates.filter((day) => toIsoDate(day) !== iso);
222
+ text = format(next);
223
+ textOk = true;
224
+ setDates(next, true);
225
+ });
226
+ },
227
+ reset() {
228
+ emitter.act(() => {
229
+ const next = readAll(options.defaultValue);
230
+ error = null;
231
+ text = format(next);
232
+ textOk = true;
233
+ setDates(next, true);
234
+ });
235
+ },
236
+ };
237
+
238
+ /** An outside value: replaces the text only when it is a different value (echo guard). */
239
+ function applyValue(next: readonly string[] | undefined): void {
240
+ const nextDates = readAll(next);
241
+ if (valuesOf(nextDates).join() === value.join()) {
242
+ return;
243
+ }
244
+ error = null;
245
+ text = format(nextDates);
246
+ textOk = true;
247
+ setDates(nextDates, false);
248
+ }
249
+
250
+ return {
251
+ getState: () => snapshot,
252
+ subscribe: (listener) => emitter.subscribe(listener),
253
+ setOptions(changes) {
254
+ if (destroyed) {
255
+ return;
256
+ }
257
+ emitter.act(() => {
258
+ const before = { calendar, locale: localeOf(options) };
259
+ options = { ...options, ...changes };
260
+ if (options.calendar !== undefined) {
261
+ calendar = options.calendar;
262
+ }
263
+ if ('value' in changes) {
264
+ applyValue(changes.value);
265
+ }
266
+ if (
267
+ (before.calendar !== calendar || before.locale !== localeOf(options)) &&
268
+ textOk
269
+ ) {
270
+ text = format(dates);
271
+ }
272
+ syncPanel();
273
+ });
274
+ },
275
+ actions,
276
+ calendar: panel,
277
+ destroy() {
278
+ destroyed = true;
279
+ panel.destroy();
280
+ emitter.clear();
281
+ },
282
+ };
283
+ }
284
+
285
+ function readAll(values: readonly string[] | undefined): AdDate[] {
286
+ return unique((values ?? []).map(readIso).filter((day) => day !== null));
287
+ }
288
+
289
+ function unique(list: readonly AdDate[]): AdDate[] {
290
+ const byValue = new Map(list.map((day) => [toIsoDate(day), day]));
291
+
292
+ return [...byValue.entries()]
293
+ .sort(([a], [b]) => (a < b ? -1 : 1))
294
+ .map(([, day]) => day);
295
+ }
296
+
297
+ function valuesOf(list: readonly AdDate[]): readonly string[] {
298
+ return Object.freeze(list.map((day) => toIsoDate(day)));
299
+ }
300
+
301
+ function detailsOf(list: readonly AdDate[]): readonly DateDetail[] {
302
+ return Object.freeze(
303
+ list.map((day) => Object.freeze({ ad: day, bs: toCalendar(day, 'BS') })),
304
+ );
305
+ }
306
+
307
+ /** Mask only the day being typed: the text after the last separator. */
308
+ function maskLast(next: string, previous: string): string {
309
+ if (!next.startsWith(previous)) {
310
+ return next;
311
+ }
312
+ const match = /^(.*[,\s\u060c])?([^,\s\u060c]*)$/.exec(next);
313
+ const head = match?.[1] ?? '';
314
+ const tail = match?.[2] ?? next;
315
+ const previousTail = previous.length > head.length ? previous.slice(head.length) : '';
316
+
317
+ return head + maskDateText(tail, previousTail);
318
+ }