@marianmeres/stuic 3.170.0 → 3.172.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/AGENTS.md +10 -8
- package/API.md +90 -0
- package/README.md +1 -1
- package/dist/components/AssetsPreview/AssetsPreview.fixture.svelte +1 -1
- package/dist/components/Calendar/Calendar.svelte +799 -0
- package/dist/components/Calendar/Calendar.svelte.d.ts +135 -0
- package/dist/components/Calendar/README.md +294 -0
- package/dist/components/Calendar/calendar-i18n-sk.d.ts +20 -0
- package/dist/components/Calendar/calendar-i18n-sk.js +46 -0
- package/dist/components/Calendar/calendar-i18n.d.ts +61 -0
- package/dist/components/Calendar/calendar-i18n.js +73 -0
- package/dist/components/Calendar/index.css +307 -0
- package/dist/components/Calendar/index.d.ts +5 -0
- package/dist/components/Calendar/index.js +5 -0
- package/dist/components/Calendar/iso-date.d.ts +107 -0
- package/dist/components/Calendar/iso-date.js +247 -0
- package/dist/components/CommandMenu/CommandMenu.fixture.svelte +1 -1
- package/dist/components/ContextMenu/ContextMenu.svelte +1 -1
- package/dist/components/ContextMenu/ContextMenu.svelte.d.ts +1 -1
- package/dist/components/ContextMenu/README.md +1 -1
- package/dist/components/DropdownMenu/DropdownMenu.svelte +57 -3
- package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +4 -2
- package/dist/components/DropdownMenu/README.md +1 -0
- package/dist/components/Input/FieldDate.svelte +349 -0
- package/dist/components/Input/FieldDate.svelte.d.ts +79 -0
- package/dist/components/Input/FieldDateRange.svelte +373 -0
- package/dist/components/Input/FieldDateRange.svelte.d.ts +90 -0
- package/dist/components/Input/README.md +149 -16
- package/dist/components/Input/_internal/FieldDateShell.svelte +327 -0
- package/dist/components/Input/_internal/FieldDateShell.svelte.d.ts +66 -0
- package/dist/components/Input/index.css +119 -0
- package/dist/components/Input/index.d.ts +2 -0
- package/dist/components/Input/index.js +2 -0
- package/dist/components/ModalDialog/ModalDialog.fixture.svelte +1 -1
- package/dist/components/SlidingPanels/SlidingPanels.fixture.svelte +1 -1
- package/dist/icons/index.d.ts +1 -0
- package/dist/icons/index.js +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/docs/_archive/README.md +10 -0
- package/docs/{component-testing → _archive/component-testing}/00-overview-and-roadmap.md +8 -6
- package/docs/{component-testing → _archive/component-testing}/01-framework-setup.md +4 -2
- package/docs/{component-testing → _archive/component-testing}/03-component-coverage-roadmap.md +3 -1
- package/docs/{component-testing → _archive/component-testing}/04-hard-cases-and-e2e.md +5 -3
- package/docs/{component-testing → _archive/component-testing}/05-ci.md +2 -0
- package/docs/{component-testing → _archive/component-testing}/PROGRESS.md +3 -1
- package/docs/_archive/component-testing/README.md +28 -0
- package/docs/{upgrading.md → _archive/upgrading.md} +2 -0
- package/docs/architecture.md +19 -11
- package/docs/domains/actions.md +4 -3
- package/docs/domains/components.md +22 -19
- package/docs/domains/utils.md +2 -1
- package/docs/maybe-todo.md +6 -6
- package/docs/tasks.md +19 -9
- package/docs/{component-testing/02-test-conventions.md → testing-components.md} +30 -31
- package/docs/testing.md +3 -3
- package/package.json +2 -1
- package/docs/component-testing/README.md +0 -38
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Calendar — an accessible month grid for picking a single date or a date range.
|
|
3
|
+
|
|
4
|
+
Values are calendar dates in ISO `YYYY-MM-DD` form (zone-independent; see
|
|
5
|
+
`iso-date.ts`). The grid, "today", week numbers and DST-safe day stepping come
|
|
6
|
+
from `@marianmeres/calendar-utils`; day/month names come from `Intl` via `locale`.
|
|
7
|
+
|
|
8
|
+
Keyboard (APG "date picker" grid pattern): the day buttons form a roving-tabindex
|
|
9
|
+
grid — one is in the tab order, arrows move by day/week, Home/End jump to the
|
|
10
|
+
week's ends, PageUp/PageDown step a month (Shift: a year), Enter/Space select.
|
|
11
|
+
Moving past the visible months navigates the view.
|
|
12
|
+
|
|
13
|
+
Range selection: first pick is the anchor, second completes (order-agnostic —
|
|
14
|
+
an earlier second pick becomes the start), a third pick starts over. While an
|
|
15
|
+
anchor waits, hovered (mouse) / focused (keyboard) days preview the range.
|
|
16
|
+
|
|
17
|
+
The component is presentation-agnostic: `FieldDate` / `FieldDateRange` embed it
|
|
18
|
+
in a field or a dialog; it can equally sit inline on a page.
|
|
19
|
+
-->
|
|
20
|
+
<script lang="ts" module>
|
|
21
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
22
|
+
import type { Snippet } from "svelte";
|
|
23
|
+
import type { DayCell, Weekday } from "@marianmeres/calendar-utils";
|
|
24
|
+
import type { TranslateFn } from "../../types.js";
|
|
25
|
+
import type { IsoDate, YearMonth } from "./iso-date.js";
|
|
26
|
+
|
|
27
|
+
/** What the calendar selects. */
|
|
28
|
+
export type CalendarMode = "single" | "range";
|
|
29
|
+
|
|
30
|
+
/** How the month/year caption renders. */
|
|
31
|
+
export type CalendarCaptionLayout = "label" | "dropdown";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Everything the `renderDay` snippet knows about one day. The same facts drive
|
|
35
|
+
* the `data-*` styling hooks on the day button / cell.
|
|
36
|
+
*/
|
|
37
|
+
export interface CalendarDayState {
|
|
38
|
+
/** `YYYY-MM-DD` */
|
|
39
|
+
iso: IsoDate;
|
|
40
|
+
/** Day of month (1–31) */
|
|
41
|
+
day: number;
|
|
42
|
+
/** The calendar-utils cell (Luxon `date`, `weekNumber`, …) */
|
|
43
|
+
cell: DayCell;
|
|
44
|
+
/** The selected date (single) or one of the range ends (range) */
|
|
45
|
+
selected: boolean;
|
|
46
|
+
rangeStart: boolean;
|
|
47
|
+
rangeEnd: boolean;
|
|
48
|
+
/** Strictly between the two committed range ends */
|
|
49
|
+
inRange: boolean;
|
|
50
|
+
/** Inside the not-yet-committed range (anchor → hovered / focused day) */
|
|
51
|
+
preview: boolean;
|
|
52
|
+
today: boolean;
|
|
53
|
+
/** Belongs to the previous / next month (grid filler) */
|
|
54
|
+
outside: boolean;
|
|
55
|
+
weekend: boolean;
|
|
56
|
+
disabled: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Payload of `onRangeChange`. */
|
|
60
|
+
export interface CalendarRangeChange {
|
|
61
|
+
start: IsoDate | null;
|
|
62
|
+
end: IsoDate | null;
|
|
63
|
+
/** `true` once both ends are set */
|
|
64
|
+
complete: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
68
|
+
/** `"single"` (default) binds `value`; `"range"` binds `start` + `end`. */
|
|
69
|
+
mode?: CalendarMode;
|
|
70
|
+
/** The selected date, `YYYY-MM-DD` (bindable; single mode). */
|
|
71
|
+
value?: IsoDate | null;
|
|
72
|
+
/** Range start, `YYYY-MM-DD` (bindable; range mode). */
|
|
73
|
+
start?: IsoDate | null;
|
|
74
|
+
/** Range end, `YYYY-MM-DD` (bindable; range mode). */
|
|
75
|
+
end?: IsoDate | null;
|
|
76
|
+
/**
|
|
77
|
+
* The first displayed month (bindable). Defaults to the month of the
|
|
78
|
+
* selection, else the current month.
|
|
79
|
+
*/
|
|
80
|
+
view?: YearMonth;
|
|
81
|
+
/** How many consecutive months to render side by side. Default `1`. */
|
|
82
|
+
months?: number;
|
|
83
|
+
/** Earliest selectable date (inclusive); earlier days are disabled. */
|
|
84
|
+
min?: IsoDate | null;
|
|
85
|
+
/** Latest selectable date (inclusive); later days are disabled. */
|
|
86
|
+
max?: IsoDate | null;
|
|
87
|
+
/** Disable individual days (blackout dates, weekends, …). */
|
|
88
|
+
isDateDisabled?: (iso: IsoDate, cell: DayCell) => boolean;
|
|
89
|
+
/** First day of the week, ISO numbering: 1 = Monday (default) … 7 = Sunday. */
|
|
90
|
+
weekStartsOn?: Weekday;
|
|
91
|
+
/** Days flagged as weekend (`data-weekend`). Default `[6, 7]`. */
|
|
92
|
+
weekendDays?: Weekday[];
|
|
93
|
+
/** BCP 47 locale of the day / month names. Browser default when omitted. */
|
|
94
|
+
locale?: string;
|
|
95
|
+
/** IANA zone that decides which day is "today". Default local. */
|
|
96
|
+
zone?: string;
|
|
97
|
+
/** `"label"` (default) or `"dropdown"` (month + year selects, e.g. for birth dates). */
|
|
98
|
+
captionLayout?: CalendarCaptionLayout;
|
|
99
|
+
/** Years offered by the dropdown caption. Defaults to `min`/`max` years, else today ±100/+20. */
|
|
100
|
+
yearRange?: [number, number];
|
|
101
|
+
/** Show the ISO week number column. */
|
|
102
|
+
showWeekNumbers?: boolean;
|
|
103
|
+
/** Render the previous / next month's filler days. Default `true`. */
|
|
104
|
+
showOutsideDays?: boolean;
|
|
105
|
+
/** Always render 6 weeks so the height never jumps while navigating. */
|
|
106
|
+
fixedWeeks?: boolean;
|
|
107
|
+
/** Footer "Today" button (navigates to and focuses today; does not select). */
|
|
108
|
+
showToday?: boolean;
|
|
109
|
+
/** Footer "Clear" button. */
|
|
110
|
+
showClear?: boolean;
|
|
111
|
+
/** Whole calendar non-interactive. */
|
|
112
|
+
disabled?: boolean;
|
|
113
|
+
/** Move focus into the grid once mounted (dialog use). */
|
|
114
|
+
focusOnMount?: boolean;
|
|
115
|
+
/** i18n — see `createCalendarT`. */
|
|
116
|
+
t?: TranslateFn;
|
|
117
|
+
/** Single mode: a date was picked (or cleared). */
|
|
118
|
+
onSelect?: (value: IsoDate | null) => void;
|
|
119
|
+
/** Range mode: an end was picked / the range cleared. */
|
|
120
|
+
onRangeChange?: (range: CalendarRangeChange) => void;
|
|
121
|
+
/** The displayed month changed (navigation, keyboard, dropdowns). */
|
|
122
|
+
onViewChange?: (view: YearMonth) => void;
|
|
123
|
+
/** Custom day cell content (receives `CalendarDayState`). */
|
|
124
|
+
renderDay?: Snippet<[CalendarDayState]>;
|
|
125
|
+
/** Extra footer content (rendered on the end side of the footer row). */
|
|
126
|
+
footer?: Snippet;
|
|
127
|
+
/** Skip all default styling */
|
|
128
|
+
unstyled?: boolean;
|
|
129
|
+
/** Additional CSS classes for the root */
|
|
130
|
+
class?: string;
|
|
131
|
+
/** Class for each month block */
|
|
132
|
+
classMonth?: string;
|
|
133
|
+
/** Class for each month's header row (prev / caption / next) */
|
|
134
|
+
classHeader?: string;
|
|
135
|
+
/** Class for the caption (month + year) */
|
|
136
|
+
classCaption?: string;
|
|
137
|
+
/** Class for the `<table>` */
|
|
138
|
+
classGrid?: string;
|
|
139
|
+
/** Class for the weekday header cells */
|
|
140
|
+
classWeekday?: string;
|
|
141
|
+
/** Class for the day cells (`<td>`) */
|
|
142
|
+
classCell?: string;
|
|
143
|
+
/** Class for the day buttons */
|
|
144
|
+
classDay?: string;
|
|
145
|
+
/** Class for the footer row */
|
|
146
|
+
classFooter?: string;
|
|
147
|
+
/** Bindable element reference */
|
|
148
|
+
el?: HTMLDivElement;
|
|
149
|
+
}
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<script lang="ts">
|
|
153
|
+
import { tick, untrack } from "svelte";
|
|
154
|
+
import { getWeekdayHeaders } from "@marianmeres/calendar-utils";
|
|
155
|
+
import { iconChevronLeft, iconChevronRight } from "../../icons/index.js";
|
|
156
|
+
import { getId } from "../../utils/get-id.js";
|
|
157
|
+
import { twMerge } from "../../utils/tw-merge.js";
|
|
158
|
+
import Button from "../Button/Button.svelte";
|
|
159
|
+
import { t_default } from "./calendar-i18n.js";
|
|
160
|
+
import {
|
|
161
|
+
addDaysIso,
|
|
162
|
+
addMonths,
|
|
163
|
+
addMonthsIso,
|
|
164
|
+
buildMonthGrid,
|
|
165
|
+
clampIso,
|
|
166
|
+
compareYearMonth,
|
|
167
|
+
firstOfMonth,
|
|
168
|
+
formatIsoDateLong,
|
|
169
|
+
formatYearMonth,
|
|
170
|
+
getMonthNames,
|
|
171
|
+
isWithinBounds,
|
|
172
|
+
isWithinRange,
|
|
173
|
+
normalizeIsoDate,
|
|
174
|
+
orderedRange,
|
|
175
|
+
todayIso,
|
|
176
|
+
weekdayColumn,
|
|
177
|
+
yearMonthOf,
|
|
178
|
+
} from "./iso-date.js";
|
|
179
|
+
|
|
180
|
+
let {
|
|
181
|
+
mode = "single",
|
|
182
|
+
value = $bindable(null),
|
|
183
|
+
start = $bindable(null),
|
|
184
|
+
end = $bindable(null),
|
|
185
|
+
view = $bindable(),
|
|
186
|
+
months = 1,
|
|
187
|
+
min = null,
|
|
188
|
+
max = null,
|
|
189
|
+
isDateDisabled,
|
|
190
|
+
weekStartsOn = 1,
|
|
191
|
+
weekendDays,
|
|
192
|
+
locale,
|
|
193
|
+
zone = "local",
|
|
194
|
+
captionLayout = "label",
|
|
195
|
+
yearRange,
|
|
196
|
+
showWeekNumbers = false,
|
|
197
|
+
showOutsideDays = true,
|
|
198
|
+
fixedWeeks = false,
|
|
199
|
+
showToday = false,
|
|
200
|
+
showClear = false,
|
|
201
|
+
disabled = false,
|
|
202
|
+
focusOnMount = false,
|
|
203
|
+
t = t_default,
|
|
204
|
+
onSelect,
|
|
205
|
+
onRangeChange,
|
|
206
|
+
onViewChange,
|
|
207
|
+
renderDay,
|
|
208
|
+
footer,
|
|
209
|
+
unstyled = false,
|
|
210
|
+
class: classProp,
|
|
211
|
+
classMonth: classMonthProp,
|
|
212
|
+
classHeader: classHeaderProp,
|
|
213
|
+
classCaption: classCaptionProp,
|
|
214
|
+
classGrid: classGridProp,
|
|
215
|
+
classWeekday: classWeekdayProp,
|
|
216
|
+
classCell: classCellProp,
|
|
217
|
+
classDay: classDayProp,
|
|
218
|
+
classFooter: classFooterProp,
|
|
219
|
+
el = $bindable(),
|
|
220
|
+
...rest
|
|
221
|
+
}: Props = $props();
|
|
222
|
+
|
|
223
|
+
const uid = getId("stuic-calendar-");
|
|
224
|
+
|
|
225
|
+
// ---- selection (tolerates loose input, see normalizeIsoDate) --------------
|
|
226
|
+
|
|
227
|
+
let _value = $derived(mode === "single" ? normalizeIsoDate(value) : null);
|
|
228
|
+
let _start = $derived(mode === "range" ? normalizeIsoDate(start) : null);
|
|
229
|
+
let _end = $derived(mode === "range" ? normalizeIsoDate(end) : null);
|
|
230
|
+
let _min = $derived(normalizeIsoDate(min));
|
|
231
|
+
let _max = $derived(normalizeIsoDate(max));
|
|
232
|
+
// the first pick of a range, waiting for the second
|
|
233
|
+
let anchor = $derived(mode === "range" && _start && !_end ? _start : null);
|
|
234
|
+
let hasSelection = $derived(mode === "single" ? !!_value : !!(_start || _end));
|
|
235
|
+
|
|
236
|
+
let _months = $derived(Math.max(1, Math.floor(months) || 1));
|
|
237
|
+
|
|
238
|
+
// ---- view -------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
// One-time fallback for an unbound / unset `view`: the selection's month, else today.
|
|
241
|
+
const initialView: YearMonth = untrack(() =>
|
|
242
|
+
yearMonthOf((mode === "single" ? _value : _start) ?? todayIso(zone))
|
|
243
|
+
);
|
|
244
|
+
let currentView = $derived(view ?? initialView);
|
|
245
|
+
let lastView = $derived(addMonths(currentView, _months - 1));
|
|
246
|
+
|
|
247
|
+
function setView(ym: YearMonth) {
|
|
248
|
+
if (compareYearMonth(ym, currentView) === 0) return;
|
|
249
|
+
view = ym;
|
|
250
|
+
onViewChange?.(ym);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function isVisibleYm(ym: YearMonth): boolean {
|
|
254
|
+
return compareYearMonth(ym, currentView) >= 0 && compareYearMonth(ym, lastView) <= 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Navigate so that `iso`'s month is on screen (as the first month when it lies
|
|
258
|
+
// before the view, as the last one when it lies after it).
|
|
259
|
+
function ensureVisible(iso: IsoDate) {
|
|
260
|
+
const ym = yearMonthOf(iso);
|
|
261
|
+
if (compareYearMonth(ym, currentView) < 0) setView(ym);
|
|
262
|
+
else if (compareYearMonth(ym, lastView) > 0) setView(addMonths(ym, -(_months - 1)));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// An externally changed selection (model load / switch) is brought on screen.
|
|
266
|
+
// Keyed on the selection so browsing away from it never snaps back.
|
|
267
|
+
let lastSelectionKey: string | null = null;
|
|
268
|
+
$effect(() => {
|
|
269
|
+
const sel = mode === "single" ? _value : (_start ?? _end);
|
|
270
|
+
const key = `${_value}|${_start}|${_end}`;
|
|
271
|
+
untrack(() => {
|
|
272
|
+
if (key === lastSelectionKey) return;
|
|
273
|
+
lastSelectionKey = key;
|
|
274
|
+
if (sel && !isVisibleYm(yearMonthOf(sel))) ensureVisible(sel);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// ---- grids ------------------------------------------------------------------
|
|
279
|
+
|
|
280
|
+
let today = $derived(todayIso(zone));
|
|
281
|
+
let gridConfig = $derived({ zone, weekStartsOn, weekendDays });
|
|
282
|
+
|
|
283
|
+
let monthViews = $derived.by(() =>
|
|
284
|
+
Array.from({ length: _months }, (_, i) => {
|
|
285
|
+
const ym = addMonths(currentView, i);
|
|
286
|
+
return {
|
|
287
|
+
ym,
|
|
288
|
+
label: formatYearMonth(ym, locale),
|
|
289
|
+
captionId: `${uid}-caption-${i}`,
|
|
290
|
+
grid: buildMonthGrid(ym, gridConfig, fixedWeeks),
|
|
291
|
+
};
|
|
292
|
+
})
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
let weekdayShort = $derived(getWeekdayHeaders(weekStartsOn, "short", locale));
|
|
296
|
+
let weekdayLong = $derived(getWeekdayHeaders(weekStartsOn, "long", locale));
|
|
297
|
+
let monthNames = $derived(getMonthNames(locale, "long"));
|
|
298
|
+
|
|
299
|
+
let yearOptions = $derived.by(() => {
|
|
300
|
+
const thisYear = yearMonthOf(today).year;
|
|
301
|
+
let from = yearRange?.[0] ?? (_min ? yearMonthOf(_min).year : thisYear - 100);
|
|
302
|
+
let to = yearRange?.[1] ?? (_max ? yearMonthOf(_max).year : thisYear + 20);
|
|
303
|
+
from = Math.min(from, currentView.year);
|
|
304
|
+
to = Math.max(to, currentView.year);
|
|
305
|
+
const out: number[] = [];
|
|
306
|
+
for (let y = from; y <= to; y++) out.push(y);
|
|
307
|
+
return out;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
let prevDisabled = $derived(
|
|
311
|
+
disabled || (!!_min && compareYearMonth(currentView, yearMonthOf(_min)) <= 0)
|
|
312
|
+
);
|
|
313
|
+
let nextDisabled = $derived(
|
|
314
|
+
disabled || (!!_max && compareYearMonth(lastView, yearMonthOf(_max)) >= 0)
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// ---- focus ------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
// The day the user last focused; drives the roving tabindex and keyboard stepping.
|
|
320
|
+
let focusedIso = $state<IsoDate | null>(null);
|
|
321
|
+
// Mouse hover, for the range preview.
|
|
322
|
+
let hoverIso = $state<IsoDate | null>(null);
|
|
323
|
+
|
|
324
|
+
// Exactly one visible, current-month day is in the tab order: the last focused
|
|
325
|
+
// day if still on screen, else the selection, today, or the first of the month.
|
|
326
|
+
let tabbableIso = $derived.by(() => {
|
|
327
|
+
const visible = (iso: IsoDate | null): iso is IsoDate =>
|
|
328
|
+
!!iso && isVisibleYm(yearMonthOf(iso));
|
|
329
|
+
if (visible(focusedIso)) return focusedIso;
|
|
330
|
+
for (const c of [_value, _start, _end, today]) if (visible(c)) return c;
|
|
331
|
+
return firstOfMonth(currentView);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
function dayButton(iso: IsoDate): HTMLButtonElement | null {
|
|
335
|
+
if (!el) return null;
|
|
336
|
+
return (
|
|
337
|
+
el.querySelector<HTMLButtonElement>(
|
|
338
|
+
`button[data-iso="${iso}"]:not([data-outside])`
|
|
339
|
+
) ?? el.querySelector<HTMLButtonElement>(`button[data-iso="${iso}"]`)
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function focusDay(iso: IsoDate) {
|
|
344
|
+
iso = clampIso(iso, _min, _max);
|
|
345
|
+
focusedIso = iso;
|
|
346
|
+
ensureVisible(iso);
|
|
347
|
+
await tick();
|
|
348
|
+
dayButton(iso)?.focus();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function onDayKeydown(e: KeyboardEvent) {
|
|
352
|
+
if (disabled || e.altKey || e.ctrlKey || e.metaKey) return;
|
|
353
|
+
const current = (e.currentTarget as HTMLButtonElement).dataset.iso ?? tabbableIso;
|
|
354
|
+
let next: IsoDate;
|
|
355
|
+
switch (e.key) {
|
|
356
|
+
case "ArrowLeft":
|
|
357
|
+
next = addDaysIso(current, -1);
|
|
358
|
+
break;
|
|
359
|
+
case "ArrowRight":
|
|
360
|
+
next = addDaysIso(current, 1);
|
|
361
|
+
break;
|
|
362
|
+
case "ArrowUp":
|
|
363
|
+
next = addDaysIso(current, -7);
|
|
364
|
+
break;
|
|
365
|
+
case "ArrowDown":
|
|
366
|
+
next = addDaysIso(current, 7);
|
|
367
|
+
break;
|
|
368
|
+
case "Home":
|
|
369
|
+
next = addDaysIso(current, -weekdayColumn(current, weekStartsOn));
|
|
370
|
+
break;
|
|
371
|
+
case "End":
|
|
372
|
+
next = addDaysIso(current, 6 - weekdayColumn(current, weekStartsOn));
|
|
373
|
+
break;
|
|
374
|
+
case "PageUp":
|
|
375
|
+
next = addMonthsIso(current, e.shiftKey ? -12 : -1);
|
|
376
|
+
break;
|
|
377
|
+
case "PageDown":
|
|
378
|
+
next = addMonthsIso(current, e.shiftKey ? 12 : 1);
|
|
379
|
+
break;
|
|
380
|
+
default:
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
e.preventDefault();
|
|
384
|
+
focusDay(next);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Dialog use: the grid has to become visible (showModal) before it can take focus.
|
|
388
|
+
$effect(() => {
|
|
389
|
+
if (!focusOnMount) return;
|
|
390
|
+
let cancelled = false;
|
|
391
|
+
let frames = 0;
|
|
392
|
+
const attempt = () => {
|
|
393
|
+
if (cancelled) return;
|
|
394
|
+
const btn = el?.querySelector<HTMLElement>('button[data-iso][tabindex="0"]');
|
|
395
|
+
const visible =
|
|
396
|
+
!!btn &&
|
|
397
|
+
(typeof btn.checkVisibility === "function" ? btn.checkVisibility() : true);
|
|
398
|
+
if (btn && visible) {
|
|
399
|
+
btn.focus();
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (++frames < 30) requestAnimationFrame(attempt);
|
|
403
|
+
};
|
|
404
|
+
requestAnimationFrame(attempt);
|
|
405
|
+
return () => {
|
|
406
|
+
cancelled = true;
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
// ---- selection --------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
function isDayDisabled(cell: DayCell): boolean {
|
|
413
|
+
return (
|
|
414
|
+
disabled ||
|
|
415
|
+
!isWithinBounds(cell.iso, _min, _max) ||
|
|
416
|
+
!!isDateDisabled?.(cell.iso, cell)
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function selectDay(cell: DayCell) {
|
|
421
|
+
if (isDayDisabled(cell)) return;
|
|
422
|
+
const iso = cell.iso;
|
|
423
|
+
focusedIso = iso;
|
|
424
|
+
hoverIso = null;
|
|
425
|
+
if (mode === "single") {
|
|
426
|
+
value = iso;
|
|
427
|
+
onSelect?.(iso);
|
|
428
|
+
} else if (anchor) {
|
|
429
|
+
const [s, e] = orderedRange(anchor, iso);
|
|
430
|
+
start = s;
|
|
431
|
+
end = e;
|
|
432
|
+
onRangeChange?.({ start: s, end: e, complete: true });
|
|
433
|
+
} else {
|
|
434
|
+
start = iso;
|
|
435
|
+
end = null;
|
|
436
|
+
onRangeChange?.({ start: iso, end: null, complete: false });
|
|
437
|
+
}
|
|
438
|
+
// a filler day of an adjacent month: bring that month on screen and keep focus
|
|
439
|
+
if (!cell.isCurrentMonth) focusDay(iso);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function clearSelection() {
|
|
443
|
+
if (disabled) return;
|
|
444
|
+
if (mode === "single") {
|
|
445
|
+
value = null;
|
|
446
|
+
onSelect?.(null);
|
|
447
|
+
} else {
|
|
448
|
+
start = null;
|
|
449
|
+
end = null;
|
|
450
|
+
onRangeChange?.({ start: null, end: null, complete: false });
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Range preview: anchor → hovered (mouse) or focused (keyboard) day.
|
|
455
|
+
let previewRange = $derived.by((): [IsoDate, IsoDate] | null => {
|
|
456
|
+
if (!anchor) return null;
|
|
457
|
+
const other = hoverIso ?? focusedIso;
|
|
458
|
+
if (!other || other === anchor) return null;
|
|
459
|
+
return orderedRange(anchor, other);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
function dayState(cell: DayCell): CalendarDayState {
|
|
463
|
+
const iso = cell.iso;
|
|
464
|
+
const rangeStart = mode === "range" && _start === iso;
|
|
465
|
+
const rangeEnd = mode === "range" && _end === iso;
|
|
466
|
+
const selected = mode === "single" ? _value === iso : rangeStart || rangeEnd;
|
|
467
|
+
const inRange =
|
|
468
|
+
mode === "range" &&
|
|
469
|
+
!!_start &&
|
|
470
|
+
!!_end &&
|
|
471
|
+
!selected &&
|
|
472
|
+
isWithinRange(iso, _start, _end);
|
|
473
|
+
const preview =
|
|
474
|
+
!!previewRange && isWithinRange(iso, previewRange[0], previewRange[1]);
|
|
475
|
+
return {
|
|
476
|
+
iso,
|
|
477
|
+
day: cell.date.day,
|
|
478
|
+
cell,
|
|
479
|
+
selected,
|
|
480
|
+
rangeStart,
|
|
481
|
+
rangeEnd,
|
|
482
|
+
inRange,
|
|
483
|
+
preview,
|
|
484
|
+
today: cell.isToday,
|
|
485
|
+
outside: !cell.isCurrentMonth,
|
|
486
|
+
weekend: cell.isWeekend,
|
|
487
|
+
disabled: isDayDisabled(cell),
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// The continuous band painted on the cells: the committed range, or the preview.
|
|
492
|
+
// `bandStart` / `bandEnd` round its outer corners.
|
|
493
|
+
function bandState(iso: IsoDate) {
|
|
494
|
+
const committed = !!_start && !!_end && isWithinRange(iso, _start, _end);
|
|
495
|
+
const preview =
|
|
496
|
+
!!previewRange && isWithinRange(iso, previewRange[0], previewRange[1]);
|
|
497
|
+
const range = committed ? [_start!, _end!] : previewRange;
|
|
498
|
+
return {
|
|
499
|
+
committed,
|
|
500
|
+
preview,
|
|
501
|
+
bandStart: !!range && range[0] === iso,
|
|
502
|
+
bandEnd: !!range && range[1] === iso,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function dayLabel(s: CalendarDayState): string {
|
|
507
|
+
const base = formatIsoDateLong(s.iso, locale);
|
|
508
|
+
return s.today ? `${base}, ${t("day_today")}` : base;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
let liveMessage = $derived.by(() => {
|
|
512
|
+
if (mode === "range") {
|
|
513
|
+
if (anchor) return t("select_end");
|
|
514
|
+
if (_start && _end) {
|
|
515
|
+
return t("selected_range", {
|
|
516
|
+
start: formatIsoDateLong(_start, locale),
|
|
517
|
+
end: formatIsoDateLong(_end, locale),
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return "";
|
|
521
|
+
}
|
|
522
|
+
return _value ? t("selected_date", { value: formatIsoDateLong(_value, locale) }) : "";
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
// ---- imperative API ---------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
/** Focus the day that is currently in the tab order. */
|
|
528
|
+
export function focus(): void {
|
|
529
|
+
(
|
|
530
|
+
dayButton(tabbableIso) ??
|
|
531
|
+
el?.querySelector<HTMLElement>('button[data-iso][tabindex="0"]')
|
|
532
|
+
)?.focus();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Show the previous month. */
|
|
536
|
+
export function prevMonth(): void {
|
|
537
|
+
if (!prevDisabled) setView(addMonths(currentView, -1));
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Show the next month. */
|
|
541
|
+
export function nextMonth(): void {
|
|
542
|
+
if (!nextDisabled) setView(addMonths(currentView, 1));
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Show a specific month (1–12). */
|
|
546
|
+
export function goToMonth(year: number, month: number): void {
|
|
547
|
+
setView({ year, month });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Bring the month containing `iso` on screen. */
|
|
551
|
+
export function goToDate(iso: IsoDate): void {
|
|
552
|
+
const n = normalizeIsoDate(iso);
|
|
553
|
+
if (n) ensureVisible(n);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Navigate to and focus today (does not select it). */
|
|
557
|
+
export function goToToday(): void {
|
|
558
|
+
if (disabled) return;
|
|
559
|
+
const iso = todayIso(zone);
|
|
560
|
+
setView(yearMonthOf(iso));
|
|
561
|
+
focusDay(iso);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** The first displayed month. */
|
|
565
|
+
export function getView(): YearMonth {
|
|
566
|
+
return currentView;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// ---- classes ----------------------------------------------------------------
|
|
570
|
+
|
|
571
|
+
let _class = $derived(unstyled ? classProp : twMerge("stuic-calendar", classProp));
|
|
572
|
+
let _classMonth = $derived(
|
|
573
|
+
unstyled ? classMonthProp : twMerge("stuic-calendar-month", classMonthProp)
|
|
574
|
+
);
|
|
575
|
+
let _classHeader = $derived(
|
|
576
|
+
unstyled ? classHeaderProp : twMerge("stuic-calendar-header", classHeaderProp)
|
|
577
|
+
);
|
|
578
|
+
let _classCaption = $derived(
|
|
579
|
+
unstyled ? classCaptionProp : twMerge("stuic-calendar-caption", classCaptionProp)
|
|
580
|
+
);
|
|
581
|
+
let _classGrid = $derived(
|
|
582
|
+
unstyled ? classGridProp : twMerge("stuic-calendar-grid", classGridProp)
|
|
583
|
+
);
|
|
584
|
+
let _classWeekday = $derived(
|
|
585
|
+
unstyled ? classWeekdayProp : twMerge("stuic-calendar-weekday", classWeekdayProp)
|
|
586
|
+
);
|
|
587
|
+
let _classCell = $derived(
|
|
588
|
+
unstyled ? classCellProp : twMerge("stuic-calendar-cell", classCellProp)
|
|
589
|
+
);
|
|
590
|
+
let _classDay = $derived(
|
|
591
|
+
unstyled ? classDayProp : twMerge("stuic-calendar-day", classDayProp)
|
|
592
|
+
);
|
|
593
|
+
let _classFooter = $derived(
|
|
594
|
+
unstyled ? classFooterProp : twMerge("stuic-calendar-footer", classFooterProp)
|
|
595
|
+
);
|
|
596
|
+
const sub = (name: string) => (unstyled ? undefined : `stuic-calendar-${name}`);
|
|
597
|
+
</script>
|
|
598
|
+
|
|
599
|
+
<div
|
|
600
|
+
bind:this={el}
|
|
601
|
+
class={_class}
|
|
602
|
+
role="group"
|
|
603
|
+
data-mode={mode}
|
|
604
|
+
data-months={_months}
|
|
605
|
+
data-disabled={disabled ? "" : undefined}
|
|
606
|
+
aria-disabled={disabled ? "true" : undefined}
|
|
607
|
+
{...rest}
|
|
608
|
+
>
|
|
609
|
+
<div class={sub("months")}>
|
|
610
|
+
{#each monthViews as mv, i (i)}
|
|
611
|
+
<div class={_classMonth}>
|
|
612
|
+
<div class={_classHeader}>
|
|
613
|
+
{#if i === 0}
|
|
614
|
+
<Button
|
|
615
|
+
type="button"
|
|
616
|
+
variant="ghost"
|
|
617
|
+
size="sm"
|
|
618
|
+
class={sub("nav")}
|
|
619
|
+
nav={{ direction: "prev", icon: iconChevronLeft({ size: 20 }) }}
|
|
620
|
+
aria-label={t("prev_month")}
|
|
621
|
+
tooltip={t("prev_month")}
|
|
622
|
+
disabled={prevDisabled}
|
|
623
|
+
onclick={prevMonth}
|
|
624
|
+
/>
|
|
625
|
+
{:else}
|
|
626
|
+
<span class={sub("nav-placeholder")} aria-hidden="true"></span>
|
|
627
|
+
{/if}
|
|
628
|
+
|
|
629
|
+
<div class={_classCaption} id={mv.captionId}>
|
|
630
|
+
{#if captionLayout === "dropdown" && i === 0}
|
|
631
|
+
<label class="sr-only" for="{uid}-month">{t("month")}</label>
|
|
632
|
+
<select
|
|
633
|
+
id="{uid}-month"
|
|
634
|
+
class={sub("select")}
|
|
635
|
+
{disabled}
|
|
636
|
+
onchange={(e) =>
|
|
637
|
+
setView({
|
|
638
|
+
year: currentView.year,
|
|
639
|
+
month: Number(e.currentTarget.value),
|
|
640
|
+
})}
|
|
641
|
+
>
|
|
642
|
+
{#each monthNames as name, m (m)}
|
|
643
|
+
<option value={m + 1} selected={m + 1 === currentView.month}>
|
|
644
|
+
{name}
|
|
645
|
+
</option>
|
|
646
|
+
{/each}
|
|
647
|
+
</select>
|
|
648
|
+
<label class="sr-only" for="{uid}-year">{t("year")}</label>
|
|
649
|
+
<select
|
|
650
|
+
id="{uid}-year"
|
|
651
|
+
class={sub("select")}
|
|
652
|
+
{disabled}
|
|
653
|
+
onchange={(e) =>
|
|
654
|
+
setView({
|
|
655
|
+
year: Number(e.currentTarget.value),
|
|
656
|
+
month: currentView.month,
|
|
657
|
+
})}
|
|
658
|
+
>
|
|
659
|
+
{#each yearOptions as y (y)}
|
|
660
|
+
<option value={y} selected={y === currentView.year}>{y}</option>
|
|
661
|
+
{/each}
|
|
662
|
+
</select>
|
|
663
|
+
{:else}
|
|
664
|
+
{mv.label}
|
|
665
|
+
{/if}
|
|
666
|
+
</div>
|
|
667
|
+
|
|
668
|
+
{#if i === _months - 1}
|
|
669
|
+
<Button
|
|
670
|
+
type="button"
|
|
671
|
+
variant="ghost"
|
|
672
|
+
size="sm"
|
|
673
|
+
class={sub("nav")}
|
|
674
|
+
nav={{ direction: "next", icon: iconChevronRight({ size: 20 }) }}
|
|
675
|
+
aria-label={t("next_month")}
|
|
676
|
+
tooltip={t("next_month")}
|
|
677
|
+
disabled={nextDisabled}
|
|
678
|
+
onclick={nextMonth}
|
|
679
|
+
/>
|
|
680
|
+
{:else}
|
|
681
|
+
<span class={sub("nav-placeholder")} aria-hidden="true"></span>
|
|
682
|
+
{/if}
|
|
683
|
+
</div>
|
|
684
|
+
|
|
685
|
+
<table class={_classGrid} role="grid" aria-labelledby={mv.captionId}>
|
|
686
|
+
<thead>
|
|
687
|
+
<tr>
|
|
688
|
+
{#if showWeekNumbers}
|
|
689
|
+
<th scope="col" class={_classWeekday}>{t("week")}</th>
|
|
690
|
+
{/if}
|
|
691
|
+
{#each weekdayShort as name, d (d)}
|
|
692
|
+
<th scope="col" class={_classWeekday} aria-label={weekdayLong[d]}>
|
|
693
|
+
{name}
|
|
694
|
+
</th>
|
|
695
|
+
{/each}
|
|
696
|
+
</tr>
|
|
697
|
+
</thead>
|
|
698
|
+
<tbody>
|
|
699
|
+
{#each mv.grid as week (week[0].iso)}
|
|
700
|
+
<tr>
|
|
701
|
+
{#if showWeekNumbers}
|
|
702
|
+
<th
|
|
703
|
+
scope="row"
|
|
704
|
+
class={sub("week-number")}
|
|
705
|
+
aria-label={t("week_number", { value: week[0].weekNumber })}
|
|
706
|
+
>
|
|
707
|
+
{week[0].weekNumber}
|
|
708
|
+
</th>
|
|
709
|
+
{/if}
|
|
710
|
+
{#each week as cell (cell.iso)}
|
|
711
|
+
{@const s = dayState(cell)}
|
|
712
|
+
{@const band = bandState(cell.iso)}
|
|
713
|
+
{@const hidden = s.outside && !showOutsideDays}
|
|
714
|
+
<td
|
|
715
|
+
role="gridcell"
|
|
716
|
+
class={_classCell}
|
|
717
|
+
aria-selected={!hidden && s.selected ? "true" : undefined}
|
|
718
|
+
data-in-range={!hidden && band.committed ? "" : undefined}
|
|
719
|
+
data-preview={!hidden && band.preview ? "" : undefined}
|
|
720
|
+
data-band-start={!hidden && band.bandStart ? "" : undefined}
|
|
721
|
+
data-band-end={!hidden && band.bandEnd ? "" : undefined}
|
|
722
|
+
data-outside={s.outside ? "" : undefined}
|
|
723
|
+
>
|
|
724
|
+
{#if !hidden}
|
|
725
|
+
<button
|
|
726
|
+
type="button"
|
|
727
|
+
class={_classDay}
|
|
728
|
+
tabindex={cell.isCurrentMonth && cell.iso === tabbableIso
|
|
729
|
+
? 0
|
|
730
|
+
: -1}
|
|
731
|
+
aria-label={dayLabel(s)}
|
|
732
|
+
aria-disabled={s.disabled ? "true" : undefined}
|
|
733
|
+
aria-current={s.today ? "date" : undefined}
|
|
734
|
+
data-iso={s.iso}
|
|
735
|
+
data-selected={s.selected ? "" : undefined}
|
|
736
|
+
data-range-start={s.rangeStart ? "" : undefined}
|
|
737
|
+
data-range-end={s.rangeEnd ? "" : undefined}
|
|
738
|
+
data-in-range={s.inRange ? "" : undefined}
|
|
739
|
+
data-preview={s.preview ? "" : undefined}
|
|
740
|
+
data-today={s.today ? "" : undefined}
|
|
741
|
+
data-outside={s.outside ? "" : undefined}
|
|
742
|
+
data-weekend={s.weekend ? "" : undefined}
|
|
743
|
+
onclick={() => selectDay(cell)}
|
|
744
|
+
onkeydown={onDayKeydown}
|
|
745
|
+
onfocus={() => (focusedIso = cell.iso)}
|
|
746
|
+
onpointerenter={(e) => {
|
|
747
|
+
if (e.pointerType === "mouse") hoverIso = cell.iso;
|
|
748
|
+
}}
|
|
749
|
+
onpointerleave={() => {
|
|
750
|
+
if (hoverIso === cell.iso) hoverIso = null;
|
|
751
|
+
}}
|
|
752
|
+
>
|
|
753
|
+
{#if renderDay}
|
|
754
|
+
{@render renderDay(s)}
|
|
755
|
+
{:else}
|
|
756
|
+
{s.day}
|
|
757
|
+
{/if}
|
|
758
|
+
</button>
|
|
759
|
+
{/if}
|
|
760
|
+
</td>
|
|
761
|
+
{/each}
|
|
762
|
+
</tr>
|
|
763
|
+
{/each}
|
|
764
|
+
</tbody>
|
|
765
|
+
</table>
|
|
766
|
+
</div>
|
|
767
|
+
{/each}
|
|
768
|
+
</div>
|
|
769
|
+
|
|
770
|
+
{#if showToday || showClear || footer}
|
|
771
|
+
<div class={_classFooter}>
|
|
772
|
+
<div class={sub("footer-actions")}>
|
|
773
|
+
{#if showToday}
|
|
774
|
+
<Button type="button" variant="ghost" size="sm" {disabled} onclick={goToToday}>
|
|
775
|
+
{t("today")}
|
|
776
|
+
</Button>
|
|
777
|
+
{/if}
|
|
778
|
+
{#if showClear}
|
|
779
|
+
<Button
|
|
780
|
+
type="button"
|
|
781
|
+
variant="ghost"
|
|
782
|
+
size="sm"
|
|
783
|
+
disabled={disabled || !hasSelection}
|
|
784
|
+
onclick={clearSelection}
|
|
785
|
+
>
|
|
786
|
+
{t("clear")}
|
|
787
|
+
</Button>
|
|
788
|
+
{/if}
|
|
789
|
+
</div>
|
|
790
|
+
{#if footer}
|
|
791
|
+
<div class={sub("footer-actions")}>
|
|
792
|
+
{@render footer()}
|
|
793
|
+
</div>
|
|
794
|
+
{/if}
|
|
795
|
+
</div>
|
|
796
|
+
{/if}
|
|
797
|
+
|
|
798
|
+
<div class="sr-only" aria-live="polite" aria-atomic="true">{liveMessage}</div>
|
|
799
|
+
</div>
|