foldkit 0.60.0 → 0.62.0
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/calendar/arithmetic.d.ts +140 -0
- package/dist/calendar/arithmetic.d.ts.map +1 -0
- package/dist/calendar/arithmetic.js +169 -0
- package/dist/calendar/calendarDate.d.ts +162 -0
- package/dist/calendar/calendarDate.d.ts.map +1 -0
- package/dist/calendar/calendarDate.js +196 -0
- package/dist/calendar/comparison.d.ts +163 -0
- package/dist/calendar/comparison.d.ts.map +1 -0
- package/dist/calendar/comparison.js +134 -0
- package/dist/calendar/index.d.ts +7 -0
- package/dist/calendar/index.d.ts.map +1 -0
- package/dist/calendar/index.js +6 -0
- package/dist/calendar/info.d.ts +76 -0
- package/dist/calendar/info.d.ts.map +1 -0
- package/dist/calendar/info.js +125 -0
- package/dist/calendar/locale.d.ts +71 -0
- package/dist/calendar/locale.d.ts.map +1 -0
- package/dist/calendar/locale.js +171 -0
- package/dist/calendar/public.d.ts +2 -0
- package/dist/calendar/public.d.ts.map +1 -0
- package/dist/calendar/public.js +1 -0
- package/dist/calendar/today.d.ts +41 -0
- package/dist/calendar/today.d.ts.map +1 -0
- package/dist/calendar/today.js +33 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/ui/anchor.d.ts +2 -1
- package/dist/ui/anchor.d.ts.map +1 -1
- package/dist/ui/anchor.js +24 -3
- package/dist/ui/calendar/index.d.ts +242 -0
- package/dist/ui/calendar/index.d.ts.map +1 -0
- package/dist/ui/calendar/index.js +516 -0
- package/dist/ui/calendar/public.d.ts +3 -0
- package/dist/ui/calendar/public.d.ts.map +1 -0
- package/dist/ui/calendar/public.js +1 -0
- package/dist/ui/datePicker/index.d.ts +226 -0
- package/dist/ui/datePicker/index.d.ts.map +1 -0
- package/dist/ui/datePicker/index.js +231 -0
- package/dist/ui/datePicker/public.d.ts +3 -0
- package/dist/ui/datePicker/public.d.ts.map +1 -0
- package/dist/ui/datePicker/public.js +1 -0
- package/dist/ui/dragAndDrop/index.d.ts +1 -1
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +2 -0
- package/dist/ui/menu/index.d.ts.map +1 -1
- package/dist/ui/menu/index.js +1 -5
- package/dist/ui/popover/index.d.ts +4 -1
- package/dist/ui/popover/index.d.ts.map +1 -1
- package/dist/ui/popover/index.js +8 -9
- package/package.json +9 -1
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { Array, Effect, Match as M, Option, Schema as S, pipe } from 'effect';
|
|
2
|
+
import * as Calendar from '../../calendar';
|
|
3
|
+
import * as Command from '../../command';
|
|
4
|
+
import { OptionExt } from '../../effectExtensions';
|
|
5
|
+
import { createLazy, html } from '../../html';
|
|
6
|
+
import { m } from '../../message';
|
|
7
|
+
import { evo } from '../../struct';
|
|
8
|
+
import * as Task from '../../task';
|
|
9
|
+
// MODEL
|
|
10
|
+
/** Schema for the calendar component's state. Tracks the visible month/year,
|
|
11
|
+
* the keyboard-focused and user-selected dates, and the configuration that
|
|
12
|
+
* governs navigation (locale, min/max, disabled days). */
|
|
13
|
+
export const Model = S.Struct({
|
|
14
|
+
id: S.String,
|
|
15
|
+
today: Calendar.CalendarDate,
|
|
16
|
+
viewYear: S.Int,
|
|
17
|
+
viewMonth: S.Int.pipe(S.between(1, 12)),
|
|
18
|
+
maybeFocusedDate: S.OptionFromSelf(Calendar.CalendarDate),
|
|
19
|
+
maybeSelectedDate: S.OptionFromSelf(Calendar.CalendarDate),
|
|
20
|
+
isGridFocused: S.Boolean,
|
|
21
|
+
locale: Calendar.LocaleConfig,
|
|
22
|
+
maybeMinDate: S.OptionFromSelf(Calendar.CalendarDate),
|
|
23
|
+
maybeMaxDate: S.OptionFromSelf(Calendar.CalendarDate),
|
|
24
|
+
disabledDaysOfWeek: S.Array(Calendar.DayOfWeek),
|
|
25
|
+
disabledDates: S.Array(Calendar.CalendarDate),
|
|
26
|
+
});
|
|
27
|
+
// MESSAGE
|
|
28
|
+
/** Sent when the user clicks a day cell in the grid. */
|
|
29
|
+
export const ClickedDay = m('ClickedDay', { date: Calendar.CalendarDate });
|
|
30
|
+
/** Sent when the user presses a key on the grid container. The update maps
|
|
31
|
+
* the key to a navigation or selection action. */
|
|
32
|
+
export const PressedKeyOnGrid = m('PressedKeyOnGrid', {
|
|
33
|
+
key: S.String,
|
|
34
|
+
isShift: S.Boolean,
|
|
35
|
+
});
|
|
36
|
+
/** Sent when the user clicks the previous-month navigation button. */
|
|
37
|
+
export const ClickedPreviousMonthButton = m('ClickedPreviousMonthButton');
|
|
38
|
+
/** Sent when the user clicks the next-month navigation button. */
|
|
39
|
+
export const ClickedNextMonthButton = m('ClickedNextMonthButton');
|
|
40
|
+
/** Sent when the user picks a month from the month dropdown. */
|
|
41
|
+
export const SelectedMonthFromDropdown = m('SelectedMonthFromDropdown', {
|
|
42
|
+
month: S.Int,
|
|
43
|
+
});
|
|
44
|
+
/** Sent when the user picks a year from the year dropdown. */
|
|
45
|
+
export const SelectedYearFromDropdown = m('SelectedYearFromDropdown', {
|
|
46
|
+
year: S.Int,
|
|
47
|
+
});
|
|
48
|
+
/** Sent when the grid container receives DOM focus. */
|
|
49
|
+
export const FocusedGrid = m('FocusedGrid');
|
|
50
|
+
/** Sent when the grid container loses DOM focus. */
|
|
51
|
+
export const BlurredGrid = m('BlurredGrid');
|
|
52
|
+
/** Sent when a long-lived session's "today" reference should be refreshed. */
|
|
53
|
+
export const RefreshedToday = m('RefreshedToday', {
|
|
54
|
+
today: Calendar.CalendarDate,
|
|
55
|
+
});
|
|
56
|
+
/** Sent when a FocusGrid command completes. */
|
|
57
|
+
export const CompletedFocusGrid = m('CompletedFocusGrid');
|
|
58
|
+
/** Union of all messages the calendar component can produce. */
|
|
59
|
+
export const Message = S.Union(ClickedDay, PressedKeyOnGrid, ClickedPreviousMonthButton, ClickedNextMonthButton, SelectedMonthFromDropdown, SelectedYearFromDropdown, FocusedGrid, BlurredGrid, RefreshedToday, CompletedFocusGrid);
|
|
60
|
+
// OUT MESSAGE
|
|
61
|
+
/** Emitted when the visible month changes due to navigation. Consumers of an
|
|
62
|
+
* inline calendar may use this to load month-scoped data (holidays, events).
|
|
63
|
+
*
|
|
64
|
+
* Date selection does NOT use OutMessage — consumers subscribe via the
|
|
65
|
+
* `onSelectedDate` callback in `ViewConfig`, matching the Listbox/Popover
|
|
66
|
+
* controlled-component pattern. Use `Calendar.selectDate(model, date)` to
|
|
67
|
+
* write back when handling the callback. */
|
|
68
|
+
export const ChangedViewMonth = m('ChangedViewMonth', {
|
|
69
|
+
year: S.Int,
|
|
70
|
+
month: S.Int,
|
|
71
|
+
});
|
|
72
|
+
/** The calendar's OutMessage. Only one variant — `ChangedViewMonth` — which
|
|
73
|
+
* fires when navigation shifts the visible month. Date selection goes through
|
|
74
|
+
* the `onSelectedDate` ViewConfig callback, not OutMessage. */
|
|
75
|
+
export const OutMessage = ChangedViewMonth;
|
|
76
|
+
/** Creates an initial calendar model. The view month defaults to the month
|
|
77
|
+
* of the initial selected date, or today if no date is pre-selected. */
|
|
78
|
+
export const init = (config) => {
|
|
79
|
+
const maybeInitialSelectedDate = Option.fromNullable(config.initialSelectedDate);
|
|
80
|
+
const initialFocus = Option.getOrElse(maybeInitialSelectedDate, () => config.today);
|
|
81
|
+
return {
|
|
82
|
+
id: config.id,
|
|
83
|
+
today: config.today,
|
|
84
|
+
viewYear: initialFocus.year,
|
|
85
|
+
viewMonth: initialFocus.month,
|
|
86
|
+
maybeFocusedDate: Option.some(initialFocus),
|
|
87
|
+
maybeSelectedDate: maybeInitialSelectedDate,
|
|
88
|
+
isGridFocused: false,
|
|
89
|
+
locale: config.locale ?? Calendar.defaultEnglishLocale,
|
|
90
|
+
maybeMinDate: Option.fromNullable(config.minDate),
|
|
91
|
+
maybeMaxDate: Option.fromNullable(config.maxDate),
|
|
92
|
+
disabledDaysOfWeek: config.disabledDaysOfWeek ?? [],
|
|
93
|
+
disabledDates: config.disabledDates ?? [],
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
const withUpdateReturn = M.withReturnType();
|
|
97
|
+
const gridId = (modelId) => `${modelId}-grid`;
|
|
98
|
+
const gridSelector = (modelId) => `#${gridId(modelId)}`;
|
|
99
|
+
/** Focuses the calendar grid container. */
|
|
100
|
+
export const FocusGrid = Command.define('FocusGrid', CompletedFocusGrid);
|
|
101
|
+
/** Builds a command that focuses the calendar grid container. Parent
|
|
102
|
+
* components like DatePicker dispatch this after opening to hand focus to
|
|
103
|
+
* the grid's keyboard layer. */
|
|
104
|
+
export const focusGrid = (modelId) => FocusGrid(Task.focus(gridSelector(modelId)).pipe(Effect.ignore, Effect.as(CompletedFocusGrid())));
|
|
105
|
+
/** Programmatically selects a date on the calendar, committing it as the
|
|
106
|
+
* chosen value and moving the cursor onto it. Use this in controlled-mode
|
|
107
|
+
* handlers (when the view's `onSelectedDate` callback is provided) to write
|
|
108
|
+
* the selection back to the calendar's internal state.
|
|
109
|
+
*
|
|
110
|
+
* Equivalent to dispatching `ClickedDay({ date })` through `update`. */
|
|
111
|
+
export const selectDate = (model, date) => update(model, ClickedDay({ date }));
|
|
112
|
+
const DAY_SKIP_CAP = 31;
|
|
113
|
+
const MONTH_SKIP_CAP = 12;
|
|
114
|
+
const isDateDisabled = (model, date) => {
|
|
115
|
+
const belowMin = Option.exists(model.maybeMinDate, min => Calendar.isBefore(date, min));
|
|
116
|
+
if (belowMin) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
const aboveMax = Option.exists(model.maybeMaxDate, max => Calendar.isAfter(date, max));
|
|
120
|
+
if (aboveMax) {
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (model.disabledDaysOfWeek.includes(Calendar.dayOfWeek(date))) {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (model.disabledDates.some(Calendar.isEqual(date))) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
};
|
|
131
|
+
/** Walks from `start` in `direction`, returning the first non-disabled date
|
|
132
|
+
* within `cap` steps. Falls back to `start` if every candidate is disabled. */
|
|
133
|
+
const skipDisabled = (model, start, direction, cap) => pipe(cap, Array.makeBy(step => Calendar.addDays(start, step * direction)), Array.findFirst(date => !isDateDisabled(model, date)), Option.getOrElse(() => start));
|
|
134
|
+
const clampToRange = (model, candidate) => {
|
|
135
|
+
const afterMin = Option.match(model.maybeMinDate, {
|
|
136
|
+
onNone: () => candidate,
|
|
137
|
+
onSome: min => Calendar.max(candidate, min),
|
|
138
|
+
});
|
|
139
|
+
return Option.match(model.maybeMaxDate, {
|
|
140
|
+
onNone: () => afterMin,
|
|
141
|
+
onSome: max => Calendar.min(afterMin, max),
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
/** Resolves a navigation key press to the next focused date candidate,
|
|
145
|
+
* along with the direction and search cap for disabled-date skipping. */
|
|
146
|
+
const resolveNavigationKey = (key, isShift, focused, firstDayOfWeek) => M.value(key).pipe(M.withReturnType(), M.when('ArrowLeft', () => [
|
|
147
|
+
Calendar.addDays(focused, -1),
|
|
148
|
+
-1,
|
|
149
|
+
DAY_SKIP_CAP,
|
|
150
|
+
]), M.when('ArrowRight', () => [Calendar.addDays(focused, 1), 1, DAY_SKIP_CAP]), M.when('ArrowUp', () => [Calendar.addDays(focused, -7), -1, DAY_SKIP_CAP]), M.when('ArrowDown', () => [Calendar.addDays(focused, 7), 1, DAY_SKIP_CAP]), M.when('Home', () => [
|
|
151
|
+
Calendar.startOfWeek(focused, firstDayOfWeek),
|
|
152
|
+
-1,
|
|
153
|
+
DAY_SKIP_CAP,
|
|
154
|
+
]), M.when('End', () => [
|
|
155
|
+
Calendar.endOfWeek(focused, firstDayOfWeek),
|
|
156
|
+
1,
|
|
157
|
+
DAY_SKIP_CAP,
|
|
158
|
+
]), M.when('PageUp', () => [
|
|
159
|
+
isShift
|
|
160
|
+
? Calendar.addYears(focused, -1)
|
|
161
|
+
: Calendar.addMonths(focused, -1),
|
|
162
|
+
-1,
|
|
163
|
+
MONTH_SKIP_CAP,
|
|
164
|
+
]), M.when('PageDown', () => [
|
|
165
|
+
isShift ? Calendar.addYears(focused, 1) : Calendar.addMonths(focused, 1),
|
|
166
|
+
1,
|
|
167
|
+
MONTH_SKIP_CAP,
|
|
168
|
+
]), M.option);
|
|
169
|
+
const isCommitKey = (key) => key === 'Enter' || key === ' ';
|
|
170
|
+
const currentOrFallbackFocus = (model) => Option.getOrElse(model.maybeFocusedDate, () => Calendar.make(model.viewYear, model.viewMonth, 1));
|
|
171
|
+
/** Applies a date selection to the model: commits the selection, moves the
|
|
172
|
+
* cursor onto the date, and syncs the view month if the selection crosses a
|
|
173
|
+
* month boundary. Emits `ChangedViewMonth` only when the commit crosses a
|
|
174
|
+
* month boundary. */
|
|
175
|
+
const commitSelection = (model, date) => {
|
|
176
|
+
const crossedMonth = date.year !== model.viewYear || date.month !== model.viewMonth;
|
|
177
|
+
const nextModel = evo(model, {
|
|
178
|
+
maybeSelectedDate: () => Option.some(date),
|
|
179
|
+
maybeFocusedDate: () => Option.some(date),
|
|
180
|
+
viewYear: () => date.year,
|
|
181
|
+
viewMonth: () => date.month,
|
|
182
|
+
});
|
|
183
|
+
const maybeOutMessage = OptionExt.when(crossedMonth, ChangedViewMonth({ year: date.year, month: date.month }));
|
|
184
|
+
return [nextModel, maybeOutMessage];
|
|
185
|
+
};
|
|
186
|
+
/** Applies a focus move to the model, clamping to the allowed range and
|
|
187
|
+
* skipping disabled dates. Emits `ChangedViewMonth` if the move crossed a
|
|
188
|
+
* month boundary. */
|
|
189
|
+
const applyFocusMove = (model, candidate, direction, cap) => {
|
|
190
|
+
const clamped = clampToRange(model, candidate);
|
|
191
|
+
const nextFocus = skipDisabled(model, clamped, direction, cap);
|
|
192
|
+
const crossedMonth = nextFocus.year !== model.viewYear || nextFocus.month !== model.viewMonth;
|
|
193
|
+
const nextModel = evo(model, {
|
|
194
|
+
maybeFocusedDate: () => Option.some(nextFocus),
|
|
195
|
+
viewYear: () => nextFocus.year,
|
|
196
|
+
viewMonth: () => nextFocus.month,
|
|
197
|
+
});
|
|
198
|
+
const maybeOutMessage = OptionExt.when(crossedMonth, ChangedViewMonth({ year: nextFocus.year, month: nextFocus.month }));
|
|
199
|
+
return [nextModel, maybeOutMessage];
|
|
200
|
+
};
|
|
201
|
+
/** Computes the focused-date cursor for a view-month change. Preserves the
|
|
202
|
+
* current day-of-month (clamping to the new month's length when needed),
|
|
203
|
+
* then runs the candidate through min/max clamping and disabled-date skipping
|
|
204
|
+
* so the cursor always lands on a real, navigable cell. */
|
|
205
|
+
const moveFocusForViewChange = (model, year, month, direction) => {
|
|
206
|
+
const currentDay = Option.match(model.maybeFocusedDate, {
|
|
207
|
+
onNone: () => 1,
|
|
208
|
+
onSome: focused => focused.day,
|
|
209
|
+
});
|
|
210
|
+
const dayInNewMonth = Math.min(currentDay, Calendar.daysInMonth(year, month));
|
|
211
|
+
const candidate = Calendar.make(year, month, dayInNewMonth);
|
|
212
|
+
const clamped = clampToRange(model, candidate);
|
|
213
|
+
return skipDisabled(model, clamped, direction, DAY_SKIP_CAP);
|
|
214
|
+
};
|
|
215
|
+
const applyViewMonthChange = (model, year, month, direction) => {
|
|
216
|
+
if (year === model.viewYear && month === model.viewMonth) {
|
|
217
|
+
return [model, [], Option.none()];
|
|
218
|
+
}
|
|
219
|
+
const nextFocus = moveFocusForViewChange(model, year, month, direction);
|
|
220
|
+
const nextModel = evo(model, {
|
|
221
|
+
viewYear: () => year,
|
|
222
|
+
viewMonth: () => month,
|
|
223
|
+
maybeFocusedDate: () => Option.some(nextFocus),
|
|
224
|
+
});
|
|
225
|
+
return [nextModel, [], Option.some(ChangedViewMonth({ year, month }))];
|
|
226
|
+
};
|
|
227
|
+
/** Direction the user moved when jumping to a new view month via dropdown.
|
|
228
|
+
* Used by `skipDisabled` so a forward jump skips forward through disabled
|
|
229
|
+
* dates and a backward jump skips backward. */
|
|
230
|
+
const dropdownDirection = (model, year, month) => {
|
|
231
|
+
const next = Calendar.make(year, month, 1);
|
|
232
|
+
const current = Calendar.make(model.viewYear, model.viewMonth, 1);
|
|
233
|
+
return Calendar.isAfter(next, current) ? 1 : -1;
|
|
234
|
+
};
|
|
235
|
+
/** Processes a calendar message and returns the next model, commands, and
|
|
236
|
+
* optional OutMessage. */
|
|
237
|
+
export const update = (model, message) => M.value(message).pipe(withUpdateReturn, M.tagsExhaustive({
|
|
238
|
+
ClickedDay: ({ date }) => {
|
|
239
|
+
if (isDateDisabled(model, date)) {
|
|
240
|
+
return [model, [], Option.none()];
|
|
241
|
+
}
|
|
242
|
+
const [nextModel, maybeOutMessage] = commitSelection(model, date);
|
|
243
|
+
return [nextModel, [], maybeOutMessage];
|
|
244
|
+
},
|
|
245
|
+
PressedKeyOnGrid: ({ key, isShift }) => {
|
|
246
|
+
const focused = currentOrFallbackFocus(model);
|
|
247
|
+
if (isCommitKey(key)) {
|
|
248
|
+
if (isDateDisabled(model, focused)) {
|
|
249
|
+
return [model, [], Option.none()];
|
|
250
|
+
}
|
|
251
|
+
const [nextModel, maybeOutMessage] = commitSelection(model, focused);
|
|
252
|
+
return [nextModel, [], maybeOutMessage];
|
|
253
|
+
}
|
|
254
|
+
return Option.match(resolveNavigationKey(key, isShift, focused, model.locale.firstDayOfWeek), {
|
|
255
|
+
onNone: () => [model, [], Option.none()],
|
|
256
|
+
onSome: ([candidate, direction, cap]) => {
|
|
257
|
+
const [nextModel, maybeOutMessage] = applyFocusMove(model, candidate, direction, cap);
|
|
258
|
+
return [nextModel, [], maybeOutMessage];
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
ClickedPreviousMonthButton: () => {
|
|
263
|
+
const next = Calendar.subtractMonths(Calendar.make(model.viewYear, model.viewMonth, 1), 1);
|
|
264
|
+
return applyViewMonthChange(model, next.year, next.month, -1);
|
|
265
|
+
},
|
|
266
|
+
ClickedNextMonthButton: () => {
|
|
267
|
+
const next = Calendar.addMonths(Calendar.make(model.viewYear, model.viewMonth, 1), 1);
|
|
268
|
+
return applyViewMonthChange(model, next.year, next.month, 1);
|
|
269
|
+
},
|
|
270
|
+
SelectedMonthFromDropdown: ({ month }) => applyViewMonthChange(model, model.viewYear, month, dropdownDirection(model, model.viewYear, month)),
|
|
271
|
+
SelectedYearFromDropdown: ({ year }) => applyViewMonthChange(model, year, model.viewMonth, dropdownDirection(model, year, model.viewMonth)),
|
|
272
|
+
FocusedGrid: () => [
|
|
273
|
+
evo(model, { isGridFocused: () => true }),
|
|
274
|
+
[],
|
|
275
|
+
Option.none(),
|
|
276
|
+
],
|
|
277
|
+
BlurredGrid: () => [
|
|
278
|
+
evo(model, { isGridFocused: () => false }),
|
|
279
|
+
[],
|
|
280
|
+
Option.none(),
|
|
281
|
+
],
|
|
282
|
+
RefreshedToday: ({ today }) => [
|
|
283
|
+
evo(model, { today: () => today }),
|
|
284
|
+
[],
|
|
285
|
+
Option.none(),
|
|
286
|
+
],
|
|
287
|
+
CompletedFocusGrid: () => [model, [], Option.none()],
|
|
288
|
+
}));
|
|
289
|
+
// VIEW
|
|
290
|
+
const headingId = (modelId) => `${modelId}-heading`;
|
|
291
|
+
const cellId = (modelId, date) => `${modelId}-cell-${date.year}-${date.month}-${date.day}`;
|
|
292
|
+
const DAY_NAMES_SUNDAY_FIRST = [
|
|
293
|
+
'Sunday',
|
|
294
|
+
'Monday',
|
|
295
|
+
'Tuesday',
|
|
296
|
+
'Wednesday',
|
|
297
|
+
'Thursday',
|
|
298
|
+
'Friday',
|
|
299
|
+
'Saturday',
|
|
300
|
+
];
|
|
301
|
+
const DAY_OF_WEEK_INDEX = {
|
|
302
|
+
Sunday: 0,
|
|
303
|
+
Monday: 1,
|
|
304
|
+
Tuesday: 2,
|
|
305
|
+
Wednesday: 3,
|
|
306
|
+
Thursday: 4,
|
|
307
|
+
Friday: 5,
|
|
308
|
+
Saturday: 6,
|
|
309
|
+
};
|
|
310
|
+
/** Rotates the Sunday-first day-name array so that `firstDayOfWeek` becomes
|
|
311
|
+
* the first entry. Used to build column headers in locale-appropriate order. */
|
|
312
|
+
const rotateDayNames = (names, firstDayOfWeek) => {
|
|
313
|
+
const [front, back] = Array.splitAt(names, DAY_OF_WEEK_INDEX[firstDayOfWeek]);
|
|
314
|
+
return [...back, ...front];
|
|
315
|
+
};
|
|
316
|
+
const WEEKS_IN_GRID = 6;
|
|
317
|
+
const DAYS_IN_WEEK = 7;
|
|
318
|
+
/** Builds the 6×7 grid of dates that a calendar view renders for a given
|
|
319
|
+
* month. The grid always has 6 rows to keep height stable across months.
|
|
320
|
+
* Returns the 2D grid alongside the starting date (top-left cell) so
|
|
321
|
+
* callers can derive per-week positions without recomputing. */
|
|
322
|
+
const buildGrid = (viewYear, viewMonth, firstDayOfWeek) => {
|
|
323
|
+
const firstOfMonth = Calendar.make(viewYear, viewMonth, 1);
|
|
324
|
+
const gridStart = Calendar.startOfWeek(firstOfMonth, firstDayOfWeek);
|
|
325
|
+
const weeks = Array.makeBy(WEEKS_IN_GRID, weekIndex => Array.makeBy(DAYS_IN_WEEK, dayIndex => Calendar.addDays(gridStart, weekIndex * DAYS_IN_WEEK + dayIndex)));
|
|
326
|
+
return { gridStart, weeks };
|
|
327
|
+
};
|
|
328
|
+
const YEAR_RANGE_LOOKAHEAD = 10;
|
|
329
|
+
const YEAR_RANGE_LOOKBEHIND = 100;
|
|
330
|
+
/** Computes the inclusive year range the year dropdown should expose,
|
|
331
|
+
* derived from min/max constraints if set, else a sensible default window
|
|
332
|
+
* around today. */
|
|
333
|
+
const resolveYearRange = (model) => {
|
|
334
|
+
const defaultStart = model.today.year - YEAR_RANGE_LOOKBEHIND;
|
|
335
|
+
const defaultEnd = model.today.year + YEAR_RANGE_LOOKAHEAD;
|
|
336
|
+
const start = Option.match(model.maybeMinDate, {
|
|
337
|
+
onNone: () => defaultStart,
|
|
338
|
+
onSome: min => min.year,
|
|
339
|
+
});
|
|
340
|
+
const end = Option.match(model.maybeMaxDate, {
|
|
341
|
+
onNone: () => defaultEnd,
|
|
342
|
+
onSome: max => max.year,
|
|
343
|
+
});
|
|
344
|
+
return [start, end];
|
|
345
|
+
};
|
|
346
|
+
const NAV_KEYS = new Set([
|
|
347
|
+
'ArrowLeft',
|
|
348
|
+
'ArrowRight',
|
|
349
|
+
'ArrowUp',
|
|
350
|
+
'ArrowDown',
|
|
351
|
+
'Home',
|
|
352
|
+
'End',
|
|
353
|
+
'PageUp',
|
|
354
|
+
'PageDown',
|
|
355
|
+
'Enter',
|
|
356
|
+
' ',
|
|
357
|
+
]);
|
|
358
|
+
/** Renders an accessible calendar grid. Builds ARIA attribute groups (grid,
|
|
359
|
+
* row, gridcell, column header) plus the derived month grid, then delegates
|
|
360
|
+
* layout to the consumer's `toView` callback. */
|
|
361
|
+
export const view = (config) => {
|
|
362
|
+
const { AriaActiveDescendant, AriaColcount, AriaColindex, AriaDisabled, AriaLabel, AriaRowcount, AriaRowindex, AriaSelected, DataAttribute, Id, OnBlur, OnChange, OnClick, OnFocus, OnKeyDownPreventDefault, Role, Tabindex, Type, } = html();
|
|
363
|
+
const { model, toParentMessage, toView, onSelectedDate } = config;
|
|
364
|
+
const { id, viewYear, viewMonth, maybeFocusedDate, maybeSelectedDate, today, locale, isGridFocused, } = model;
|
|
365
|
+
/** Returns the parent message to dispatch when the user commits a date. In
|
|
366
|
+
* controlled mode (when `onSelectedDate` is provided), dispatches the
|
|
367
|
+
* callback directly. In uncontrolled mode, routes through the internal
|
|
368
|
+
* `ClickedDay` message so the calendar's own update manages selection. */
|
|
369
|
+
const dispatchSelectedDate = (date) => onSelectedDate !== undefined
|
|
370
|
+
? onSelectedDate(date)
|
|
371
|
+
: toParentMessage(ClickedDay({ date }));
|
|
372
|
+
const previousMonthLabel = config.previousMonthLabel ?? 'Previous month';
|
|
373
|
+
const nextMonthLabel = config.nextMonthLabel ?? 'Next month';
|
|
374
|
+
const monthSelectLabel = config.monthSelectLabel ?? 'Select month';
|
|
375
|
+
const yearSelectLabel = config.yearSelectLabel ?? 'Select year';
|
|
376
|
+
const headingText = `${locale.monthNames[viewMonth - 1]} ${viewYear}`;
|
|
377
|
+
const monthOptions = locale.monthNames.map((label, index) => ({
|
|
378
|
+
value: index + 1,
|
|
379
|
+
label,
|
|
380
|
+
}));
|
|
381
|
+
const [yearRangeStart, yearRangeEnd] = resolveYearRange(model);
|
|
382
|
+
const yearOptions = Array.makeBy(Math.max(0, yearRangeEnd - yearRangeStart + 1), index => yearRangeStart + index);
|
|
383
|
+
const rotatedDayNames = rotateDayNames(DAY_NAMES_SUNDAY_FIRST, locale.firstDayOfWeek);
|
|
384
|
+
const rotatedShortDayNames = rotateDayNames(locale.shortDayNames, locale.firstDayOfWeek);
|
|
385
|
+
const { gridStart, weeks: weeksDates } = buildGrid(viewYear, viewMonth, locale.firstDayOfWeek);
|
|
386
|
+
const rootAttributes = [Id(id)];
|
|
387
|
+
const previousMonthButtonAttributes = [
|
|
388
|
+
Type('button'),
|
|
389
|
+
AriaLabel(previousMonthLabel),
|
|
390
|
+
OnClick(toParentMessage(ClickedPreviousMonthButton())),
|
|
391
|
+
];
|
|
392
|
+
const nextMonthButtonAttributes = [
|
|
393
|
+
Type('button'),
|
|
394
|
+
AriaLabel(nextMonthLabel),
|
|
395
|
+
OnClick(toParentMessage(ClickedNextMonthButton())),
|
|
396
|
+
];
|
|
397
|
+
const monthSelectAttributes = [
|
|
398
|
+
AriaLabel(monthSelectLabel),
|
|
399
|
+
OnChange(value => toParentMessage(SelectedMonthFromDropdown({ month: Number(value) }))),
|
|
400
|
+
];
|
|
401
|
+
const yearSelectAttributes = [
|
|
402
|
+
AriaLabel(yearSelectLabel),
|
|
403
|
+
OnChange(value => toParentMessage(SelectedYearFromDropdown({ year: Number(value) }))),
|
|
404
|
+
];
|
|
405
|
+
const handleKeyDown = (key, modifiers) => {
|
|
406
|
+
if (!NAV_KEYS.has(key)) {
|
|
407
|
+
return Option.none();
|
|
408
|
+
}
|
|
409
|
+
if (isCommitKey(key) && onSelectedDate !== undefined) {
|
|
410
|
+
return pipe(maybeFocusedDate, Option.filter(date => !isDateDisabled(model, date)), Option.map(onSelectedDate));
|
|
411
|
+
}
|
|
412
|
+
return Option.some(toParentMessage(PressedKeyOnGrid({ key, isShift: modifiers.shiftKey })));
|
|
413
|
+
};
|
|
414
|
+
const activeDescendantAttributes = pipe(maybeFocusedDate, Option.map(date => AriaActiveDescendant(cellId(id, date))), Option.toArray);
|
|
415
|
+
const gridAttributes = [
|
|
416
|
+
Id(gridId(id)),
|
|
417
|
+
Role('grid'),
|
|
418
|
+
AriaLabel(`Calendar, ${headingText}`),
|
|
419
|
+
AriaRowcount(WEEKS_IN_GRID + 1),
|
|
420
|
+
AriaColcount(DAYS_IN_WEEK),
|
|
421
|
+
Tabindex(0),
|
|
422
|
+
OnFocus(toParentMessage(FocusedGrid())),
|
|
423
|
+
OnBlur(toParentMessage(BlurredGrid())),
|
|
424
|
+
OnKeyDownPreventDefault(handleKeyDown),
|
|
425
|
+
...activeDescendantAttributes,
|
|
426
|
+
];
|
|
427
|
+
const headerRowAttributes = [
|
|
428
|
+
Role('row'),
|
|
429
|
+
AriaRowindex(1),
|
|
430
|
+
];
|
|
431
|
+
const columnHeaders = Array.zipWith(rotatedShortDayNames, rotatedDayNames, (name, fullName) => ({
|
|
432
|
+
name,
|
|
433
|
+
fullName,
|
|
434
|
+
})).map(({ name, fullName }, columnIndex) => ({
|
|
435
|
+
name,
|
|
436
|
+
attributes: [
|
|
437
|
+
Role('columnheader'),
|
|
438
|
+
AriaLabel(fullName),
|
|
439
|
+
AriaColindex(columnIndex + 1),
|
|
440
|
+
],
|
|
441
|
+
}));
|
|
442
|
+
const buildDayCell = (date, columnIndex) => {
|
|
443
|
+
const isSelected = Option.exists(maybeSelectedDate, Calendar.isEqual(date));
|
|
444
|
+
const isFocused = Option.exists(maybeFocusedDate, Calendar.isEqual(date));
|
|
445
|
+
const isToday = Calendar.isEqual(today, date);
|
|
446
|
+
const isInViewMonth = date.month === viewMonth && date.year === viewYear;
|
|
447
|
+
const isDisabled = isDateDisabled(model, date);
|
|
448
|
+
const stateDataAttributes = Array.getSomes([
|
|
449
|
+
OptionExt.when(isToday, DataAttribute('today', '')),
|
|
450
|
+
OptionExt.when(isSelected, DataAttribute('selected', '')),
|
|
451
|
+
OptionExt.when(isFocused && isGridFocused, DataAttribute('focused', '')),
|
|
452
|
+
OptionExt.when(!isInViewMonth, DataAttribute('outside-month', '')),
|
|
453
|
+
OptionExt.when(isDisabled, DataAttribute('disabled', '')),
|
|
454
|
+
]);
|
|
455
|
+
const cellAttributes = [
|
|
456
|
+
Id(cellId(id, date)),
|
|
457
|
+
Role('gridcell'),
|
|
458
|
+
AriaSelected(isSelected),
|
|
459
|
+
AriaColindex(columnIndex + 1),
|
|
460
|
+
...stateDataAttributes,
|
|
461
|
+
];
|
|
462
|
+
const buttonAttributes = [
|
|
463
|
+
Type('button'),
|
|
464
|
+
Tabindex(-1),
|
|
465
|
+
AriaLabel(Calendar.formatAriaLabel(date, locale)),
|
|
466
|
+
AriaDisabled(isDisabled),
|
|
467
|
+
...(isDisabled ? [] : [OnClick(dispatchSelectedDate(date))]),
|
|
468
|
+
];
|
|
469
|
+
return {
|
|
470
|
+
date,
|
|
471
|
+
label: String(date.day),
|
|
472
|
+
cellAttributes,
|
|
473
|
+
buttonAttributes,
|
|
474
|
+
isSelected,
|
|
475
|
+
isFocused: isFocused && isGridFocused,
|
|
476
|
+
isToday,
|
|
477
|
+
isInViewMonth,
|
|
478
|
+
isDisabled,
|
|
479
|
+
};
|
|
480
|
+
};
|
|
481
|
+
const weeks = weeksDates.map((weekDates, weekIndex) => {
|
|
482
|
+
const weekStart = Calendar.addDays(gridStart, weekIndex * DAYS_IN_WEEK);
|
|
483
|
+
return {
|
|
484
|
+
attributes: [
|
|
485
|
+
Role('row'),
|
|
486
|
+
AriaRowindex(weekIndex + 2),
|
|
487
|
+
AriaLabel(`Week of ${Calendar.formatLong(weekStart, locale)}`),
|
|
488
|
+
],
|
|
489
|
+
cells: weekDates.map(buildDayCell),
|
|
490
|
+
};
|
|
491
|
+
});
|
|
492
|
+
return toView({
|
|
493
|
+
root: rootAttributes,
|
|
494
|
+
previousMonthButton: previousMonthButtonAttributes,
|
|
495
|
+
nextMonthButton: nextMonthButtonAttributes,
|
|
496
|
+
heading: { id: headingId(id), text: headingText },
|
|
497
|
+
monthSelect: monthSelectAttributes,
|
|
498
|
+
monthOptions,
|
|
499
|
+
yearSelect: yearSelectAttributes,
|
|
500
|
+
yearOptions,
|
|
501
|
+
grid: gridAttributes,
|
|
502
|
+
headerRow: headerRowAttributes,
|
|
503
|
+
columnHeaders,
|
|
504
|
+
weeks,
|
|
505
|
+
});
|
|
506
|
+
};
|
|
507
|
+
/** Creates a memoized calendar view. Static config is captured in a closure;
|
|
508
|
+
* only `model` and `toParentMessage` are compared per render via `createLazy`. */
|
|
509
|
+
export const lazy = (staticConfig) => {
|
|
510
|
+
const lazyView = createLazy();
|
|
511
|
+
return (model, toParentMessage) => lazyView((currentModel, currentToMessage) => view({
|
|
512
|
+
...staticConfig,
|
|
513
|
+
model: currentModel,
|
|
514
|
+
toParentMessage: currentToMessage,
|
|
515
|
+
}), [model, toParentMessage]);
|
|
516
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { init, update, view, lazy, focusGrid, selectDate, Model, Message, OutMessage, ChangedViewMonth, ClickedDay, PressedKeyOnGrid, ClickedPreviousMonthButton, ClickedNextMonthButton, SelectedMonthFromDropdown, SelectedYearFromDropdown, FocusedGrid, BlurredGrid, RefreshedToday, CompletedFocusGrid, FocusGrid, } from './index';
|
|
2
|
+
export type { InitConfig, ViewConfig, CalendarAttributes, DayCell, ColumnHeader, Week, } from './index';
|
|
3
|
+
//# sourceMappingURL=public.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../../src/ui/calendar/public.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,IAAI,EACJ,MAAM,EACN,IAAI,EACJ,IAAI,EACJ,SAAS,EACT,UAAU,EACV,KAAK,EACL,OAAO,EACP,UAAU,EACV,gBAAgB,EAChB,UAAU,EACV,gBAAgB,EAChB,0BAA0B,EAC1B,sBAAsB,EACtB,yBAAyB,EACzB,wBAAwB,EACxB,WAAW,EACX,WAAW,EACX,cAAc,EACd,kBAAkB,EAClB,SAAS,GACV,MAAM,SAAS,CAAA;AAEhB,YAAY,EACV,UAAU,EACV,UAAU,EACV,kBAAkB,EAClB,OAAO,EACP,YAAY,EACZ,IAAI,GACL,MAAM,SAAS,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { init, update, view, lazy, focusGrid, selectDate, Model, Message, OutMessage, ChangedViewMonth, ClickedDay, PressedKeyOnGrid, ClickedPreviousMonthButton, ClickedNextMonthButton, SelectedMonthFromDropdown, SelectedYearFromDropdown, FocusedGrid, BlurredGrid, RefreshedToday, CompletedFocusGrid, FocusGrid, } from './index';
|