@xmesh/system-design 0.0.1 → 0.0.3
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/LICENSE +201 -0
- package/README.md +6 -0
- package/dist/lit/components/date-range/index.css +324 -0
- package/dist/lit/components/date-range/index.d.ts +57 -0
- package/dist/lit/components/date-range/index.js +702 -0
- package/dist/lit/components/popover/index.css +34 -0
- package/dist/lit/components/popover/index.d.ts +29 -0
- package/dist/lit/components/popover/index.js +204 -0
- package/dist/lit/components/table/index.js +4 -9
- package/dist/lit/index.d.ts +2 -0
- package/dist/lit/index.js +2 -0
- package/package.json +19 -1
|
@@ -0,0 +1,702 @@
|
|
|
1
|
+
/*
|
|
2
|
+
date-range/index.ts — <xm-date-range>
|
|
3
|
+
|
|
4
|
+
A field-styled trigger that opens an anchored popover holding a vertical preset
|
|
5
|
+
rail + an accessible calendar grid for selecting a date range. Emits ISO
|
|
6
|
+
yyyy-mm-dd strings.
|
|
7
|
+
|
|
8
|
+
Composes the xm-overlay foundation (Story 1.4) for anchored positioning,
|
|
9
|
+
top-layer stacking, Esc dismissal, and focus-restore — it does NOT hand-roll
|
|
10
|
+
z-index, anchoring math, or the close-on-Esc path. The overlay runs in the
|
|
11
|
+
`menu` tier and is driven through its PUBLIC API only (mode / tier / placement /
|
|
12
|
+
.anchor / .opener / show / hide / xm-overlay-close); we never reach into its
|
|
13
|
+
shadow root (AD-12). Because the overlay's non-modal popover is
|
|
14
|
+
`popover="manual"` (no native light-dismiss), outside-click dismissal is owned
|
|
15
|
+
here, and the panel keeps its own Tab focus-cycle (role="dialog").
|
|
16
|
+
|
|
17
|
+
Properties:
|
|
18
|
+
from string | null ISO yyyy-mm-dd start (controlled)
|
|
19
|
+
to string | null ISO yyyy-mm-dd end (controlled)
|
|
20
|
+
placeholder string trigger placeholder (default "Date range")
|
|
21
|
+
open boolean reflected open state (also a preview hook)
|
|
22
|
+
|
|
23
|
+
Events (AD-8 / AD-8a, tier (b)):
|
|
24
|
+
xm-date-range-change detail { from, to } — ISO yyyy-mm-dd strings.
|
|
25
|
+
Presets emit IMMEDIATELY and close; the calendar emits only on explicit
|
|
26
|
+
Apply; Clear emits { null, null }. bubbles:true, composed:true.
|
|
27
|
+
|
|
28
|
+
Behaviour:
|
|
29
|
+
- Presets (Today / Last 7 days / Last 30 days / This month) apply on click,
|
|
30
|
+
emit, and close. "Custom" just reveals the calendar (no emit).
|
|
31
|
+
- Calendar: click an endpoint, then a second to complete the range; Apply
|
|
32
|
+
commits. Clear resets.
|
|
33
|
+
|
|
34
|
+
A11y (critical):
|
|
35
|
+
- panel role="dialog" + aria-labelledby; initial focus = current start
|
|
36
|
+
endpoint (or today if unset); on close focus → trigger (via overlay.opener).
|
|
37
|
+
- calendar role="grid", roving tabindex (one day tabindex=0, rest -1),
|
|
38
|
+
aria-current="date" on today, aria-selected on chosen days, each day's
|
|
39
|
+
accessible name is the FULL date (e.g. "Tuesday, April 14, 2026").
|
|
40
|
+
- PageUp/PageDown change month and announce the new month/year via a polite
|
|
41
|
+
live region inside the component.
|
|
42
|
+
- preset rail role="radiogroup" with aria-checked.
|
|
43
|
+
- invalid range (from>to): role="alert" message + Apply aria-disabled
|
|
44
|
+
(still focusable, NOT hard-disabled).
|
|
45
|
+
|
|
46
|
+
Shadow DOM. Depends on xm-overlay + tokens (AD-12). Lit is a bare `import`.
|
|
47
|
+
*/
|
|
48
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
49
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
50
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
51
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
52
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
53
|
+
};
|
|
54
|
+
import { LitElement, html, svg, nothing } from "lit";
|
|
55
|
+
import { customElement, property, state, query } from "lit/decorators.js";
|
|
56
|
+
const DR_CSS = new URL("../date-range/index.css", import.meta.url).href;
|
|
57
|
+
let drSeq = 0;
|
|
58
|
+
const PRESETS = [
|
|
59
|
+
{ id: "today", label: "Today" },
|
|
60
|
+
{ id: "last7", label: "Last 7 days" },
|
|
61
|
+
{ id: "last30", label: "Last 30 days" },
|
|
62
|
+
{ id: "month", label: "This month" },
|
|
63
|
+
{ id: "custom", label: "Custom" },
|
|
64
|
+
];
|
|
65
|
+
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
66
|
+
const CAL_ICON = () => svg `
|
|
67
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
68
|
+
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
|
|
69
|
+
stroke-linejoin="round" aria-hidden="true">
|
|
70
|
+
<rect x="3" y="4" width="18" height="18" rx="2" />
|
|
71
|
+
<path d="M16 2v4M8 2v4M3 10h18" />
|
|
72
|
+
</svg>
|
|
73
|
+
`;
|
|
74
|
+
const CHEVRON_LEFT = () => svg `
|
|
75
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
|
76
|
+
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
|
|
77
|
+
stroke-linejoin="round" aria-hidden="true">
|
|
78
|
+
<polyline points="15 18 9 12 15 6" />
|
|
79
|
+
</svg>
|
|
80
|
+
`;
|
|
81
|
+
const CHEVRON_RIGHT = () => svg `
|
|
82
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none"
|
|
83
|
+
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
|
|
84
|
+
stroke-linejoin="round" aria-hidden="true">
|
|
85
|
+
<polyline points="9 18 15 12 9 6" />
|
|
86
|
+
</svg>
|
|
87
|
+
`;
|
|
88
|
+
const ALERT_ICON = () => svg `
|
|
89
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
90
|
+
stroke="currentColor" stroke-width="1.8" stroke-linecap="round"
|
|
91
|
+
stroke-linejoin="round" aria-hidden="true">
|
|
92
|
+
<path d="M12 9v4M12 17h.01" />
|
|
93
|
+
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />
|
|
94
|
+
</svg>
|
|
95
|
+
`;
|
|
96
|
+
/* ---------- date utilities (all local, no Intl-day-arithmetic) ---------- */
|
|
97
|
+
function pad(n) {
|
|
98
|
+
return n < 10 ? `0${n}` : `${n}`;
|
|
99
|
+
}
|
|
100
|
+
function toISO(d) {
|
|
101
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
102
|
+
}
|
|
103
|
+
function fromISO(s) {
|
|
104
|
+
if (!s)
|
|
105
|
+
return null;
|
|
106
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
|
107
|
+
if (!m)
|
|
108
|
+
return null;
|
|
109
|
+
const y = Number(m[1]);
|
|
110
|
+
const mo = Number(m[2]) - 1;
|
|
111
|
+
const da = Number(m[3]);
|
|
112
|
+
const d = new Date(y, mo, da);
|
|
113
|
+
if (Number.isNaN(d.getTime()))
|
|
114
|
+
return null;
|
|
115
|
+
// new Date normalizes overflow (Feb 30 → Mar 2), so a structurally-valid but
|
|
116
|
+
// non-existent date would silently round-trip out as a different ISO string.
|
|
117
|
+
// Reject it by checking the components survived the round-trip.
|
|
118
|
+
if (d.getFullYear() !== y || d.getMonth() !== mo || d.getDate() !== da) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
return d;
|
|
122
|
+
}
|
|
123
|
+
function sameDay(a, b) {
|
|
124
|
+
return (a.getFullYear() === b.getFullYear() &&
|
|
125
|
+
a.getMonth() === b.getMonth() &&
|
|
126
|
+
a.getDate() === b.getDate());
|
|
127
|
+
}
|
|
128
|
+
function startOfDay(d) {
|
|
129
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
130
|
+
}
|
|
131
|
+
function addDays(d, n) {
|
|
132
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
|
|
133
|
+
}
|
|
134
|
+
const MONTH_FMT = new Intl.DateTimeFormat(undefined, {
|
|
135
|
+
month: "long",
|
|
136
|
+
year: "numeric",
|
|
137
|
+
});
|
|
138
|
+
const FULL_FMT = new Intl.DateTimeFormat(undefined, {
|
|
139
|
+
weekday: "long",
|
|
140
|
+
month: "long",
|
|
141
|
+
day: "numeric",
|
|
142
|
+
year: "numeric",
|
|
143
|
+
});
|
|
144
|
+
const SHORT_FMT = new Intl.DateTimeFormat(undefined, {
|
|
145
|
+
month: "short",
|
|
146
|
+
day: "numeric",
|
|
147
|
+
year: "numeric",
|
|
148
|
+
});
|
|
149
|
+
let XmDateRange = class XmDateRange extends LitElement {
|
|
150
|
+
constructor() {
|
|
151
|
+
super(...arguments);
|
|
152
|
+
this.from = null;
|
|
153
|
+
this.to = null;
|
|
154
|
+
this.placeholder = "Date range";
|
|
155
|
+
this.open = false;
|
|
156
|
+
this._id = `xm-date-range-${++drSeq}`;
|
|
157
|
+
// Draft selection inside the calendar (not committed until Apply).
|
|
158
|
+
this._draftFrom = null;
|
|
159
|
+
this._draftTo = null;
|
|
160
|
+
// The month currently displayed in the calendar (first of month).
|
|
161
|
+
this._view = startOfDay(new Date());
|
|
162
|
+
// The day that owns roving tabindex=0.
|
|
163
|
+
this._focusDay = startOfDay(new Date());
|
|
164
|
+
this._liveMsg = "";
|
|
165
|
+
this._activePreset = null;
|
|
166
|
+
// Guards re-entrancy between our hide() and the overlay's xm-overlay-close.
|
|
167
|
+
this._closing = false;
|
|
168
|
+
this._onDocPointer = (e) => {
|
|
169
|
+
if (!this.open)
|
|
170
|
+
return;
|
|
171
|
+
if (!e.composedPath().includes(this))
|
|
172
|
+
this._close(false);
|
|
173
|
+
};
|
|
174
|
+
this._onOverlayClose = () => {
|
|
175
|
+
// Overlay self-dismissed (Esc). It already restored focus to the trigger
|
|
176
|
+
// via .opener; just sync our state.
|
|
177
|
+
if (this.open && !this._closing) {
|
|
178
|
+
this.open = false;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
connectedCallback() {
|
|
183
|
+
super.connectedCallback();
|
|
184
|
+
document.addEventListener("pointerdown", this._onDocPointer, true);
|
|
185
|
+
}
|
|
186
|
+
disconnectedCallback() {
|
|
187
|
+
super.disconnectedCallback();
|
|
188
|
+
document.removeEventListener("pointerdown", this._onDocPointer, true);
|
|
189
|
+
}
|
|
190
|
+
/* ---------- open / close ---------- */
|
|
191
|
+
_initDraft() {
|
|
192
|
+
const start = fromISO(this.from);
|
|
193
|
+
const end = fromISO(this.to);
|
|
194
|
+
this._draftFrom = start;
|
|
195
|
+
this._draftTo = end;
|
|
196
|
+
const anchor = start ?? startOfDay(new Date());
|
|
197
|
+
this._view = new Date(anchor.getFullYear(), anchor.getMonth(), 1);
|
|
198
|
+
this._focusDay = anchor;
|
|
199
|
+
this._activePreset = null;
|
|
200
|
+
this._liveMsg = "";
|
|
201
|
+
}
|
|
202
|
+
_toggle() {
|
|
203
|
+
if (this.open)
|
|
204
|
+
this._close(false);
|
|
205
|
+
else
|
|
206
|
+
this.open = true;
|
|
207
|
+
}
|
|
208
|
+
_close(restoreFocus) {
|
|
209
|
+
if (!this.open)
|
|
210
|
+
return;
|
|
211
|
+
this._closing = true;
|
|
212
|
+
this.open = false;
|
|
213
|
+
const ov = this._overlay;
|
|
214
|
+
if (ov?.open)
|
|
215
|
+
ov.hide("api");
|
|
216
|
+
this._closing = false;
|
|
217
|
+
if (restoreFocus)
|
|
218
|
+
requestAnimationFrame(() => this._trigger?.focus());
|
|
219
|
+
}
|
|
220
|
+
_focusRovingDay() {
|
|
221
|
+
const el = this._panel?.querySelector('.date-range__day[tabindex="0"]');
|
|
222
|
+
el?.focus();
|
|
223
|
+
}
|
|
224
|
+
/* ---------- presets ---------- */
|
|
225
|
+
_applyPreset(id) {
|
|
226
|
+
if (id === "custom") {
|
|
227
|
+
this._activePreset = "custom";
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const today = startOfDay(new Date());
|
|
231
|
+
let from;
|
|
232
|
+
let to;
|
|
233
|
+
switch (id) {
|
|
234
|
+
case "today":
|
|
235
|
+
from = today;
|
|
236
|
+
to = today;
|
|
237
|
+
break;
|
|
238
|
+
case "last7":
|
|
239
|
+
from = addDays(today, -6);
|
|
240
|
+
to = today;
|
|
241
|
+
break;
|
|
242
|
+
case "last30":
|
|
243
|
+
from = addDays(today, -29);
|
|
244
|
+
to = today;
|
|
245
|
+
break;
|
|
246
|
+
case "month":
|
|
247
|
+
from = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
248
|
+
to = today;
|
|
249
|
+
break;
|
|
250
|
+
default:
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
this._emit(from, to);
|
|
254
|
+
this._close(true);
|
|
255
|
+
}
|
|
256
|
+
/* ---------- calendar selection ---------- */
|
|
257
|
+
_pickDay(day) {
|
|
258
|
+
this._activePreset = "custom";
|
|
259
|
+
if (!this._draftFrom || (this._draftFrom && this._draftTo)) {
|
|
260
|
+
// start a fresh range
|
|
261
|
+
this._draftFrom = day;
|
|
262
|
+
this._draftTo = null;
|
|
263
|
+
this._liveMsg = `Start date ${FULL_FMT.format(day)} selected, choose end date`;
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
// completing the range; order them
|
|
267
|
+
if (day < this._draftFrom) {
|
|
268
|
+
this._draftTo = this._draftFrom;
|
|
269
|
+
this._draftFrom = day;
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
this._draftTo = day;
|
|
273
|
+
}
|
|
274
|
+
this._liveMsg = `Range ${FULL_FMT.format(this._draftFrom)} to ${FULL_FMT.format(this._draftTo)} selected`;
|
|
275
|
+
}
|
|
276
|
+
this._focusDay = day;
|
|
277
|
+
// Picking a muted leading/trailing cell scrolls the calendar to that month
|
|
278
|
+
// so the header + roving tabindex don't strand on an out-of-month day.
|
|
279
|
+
if (day.getMonth() !== this._view.getMonth() ||
|
|
280
|
+
day.getFullYear() !== this._view.getFullYear()) {
|
|
281
|
+
this._view = new Date(day.getFullYear(), day.getMonth(), 1);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
get _invalid() {
|
|
285
|
+
return !!(this._draftFrom &&
|
|
286
|
+
this._draftTo &&
|
|
287
|
+
this._draftFrom > this._draftTo);
|
|
288
|
+
}
|
|
289
|
+
_apply() {
|
|
290
|
+
if (!this._draftFrom || !this._draftTo || this._invalid)
|
|
291
|
+
return;
|
|
292
|
+
this._emit(this._draftFrom, this._draftTo);
|
|
293
|
+
this._close(true);
|
|
294
|
+
}
|
|
295
|
+
_clear() {
|
|
296
|
+
this._draftFrom = null;
|
|
297
|
+
this._draftTo = null;
|
|
298
|
+
this._activePreset = null;
|
|
299
|
+
// Only emit when there was actually a committed range to clear, so a Clear
|
|
300
|
+
// on an already-empty picker doesn't fire a no-op change.
|
|
301
|
+
if (this.from !== null || this.to !== null) {
|
|
302
|
+
this._emit(null, null);
|
|
303
|
+
}
|
|
304
|
+
this._close(true);
|
|
305
|
+
}
|
|
306
|
+
_emit(from, to) {
|
|
307
|
+
const detail = {
|
|
308
|
+
from: from ? toISO(from) : null,
|
|
309
|
+
to: to ? toISO(to) : null,
|
|
310
|
+
};
|
|
311
|
+
this.from = detail.from;
|
|
312
|
+
this.to = detail.to;
|
|
313
|
+
this.dispatchEvent(new CustomEvent("xm-date-range-change", {
|
|
314
|
+
detail,
|
|
315
|
+
bubbles: true,
|
|
316
|
+
composed: true,
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
/* ---------- month nav ---------- */
|
|
320
|
+
_shiftMonth(delta) {
|
|
321
|
+
this._view = new Date(this._view.getFullYear(), this._view.getMonth() + delta, 1);
|
|
322
|
+
this._liveMsg = MONTH_FMT.format(this._view);
|
|
323
|
+
}
|
|
324
|
+
/* ---------- keyboard (roving grid + Tab-cycle + PageUp/Down) ---------- */
|
|
325
|
+
_onGridKey(e) {
|
|
326
|
+
let next = null;
|
|
327
|
+
switch (e.key) {
|
|
328
|
+
case "ArrowRight":
|
|
329
|
+
next = addDays(this._focusDay, 1);
|
|
330
|
+
break;
|
|
331
|
+
case "ArrowLeft":
|
|
332
|
+
next = addDays(this._focusDay, -1);
|
|
333
|
+
break;
|
|
334
|
+
case "ArrowDown":
|
|
335
|
+
next = addDays(this._focusDay, 7);
|
|
336
|
+
break;
|
|
337
|
+
case "ArrowUp":
|
|
338
|
+
next = addDays(this._focusDay, -7);
|
|
339
|
+
break;
|
|
340
|
+
case "Home":
|
|
341
|
+
next = addDays(this._focusDay, -((this._focusDay.getDay() + 6) % 7));
|
|
342
|
+
break;
|
|
343
|
+
case "End":
|
|
344
|
+
next = addDays(this._focusDay, 6 - ((this._focusDay.getDay() + 6) % 7));
|
|
345
|
+
break;
|
|
346
|
+
case "PageUp":
|
|
347
|
+
e.preventDefault();
|
|
348
|
+
this._shiftMonth(-1);
|
|
349
|
+
next = new Date(this._view.getFullYear(), this._view.getMonth(), Math.min(this._focusDay.getDate(), this._daysInMonth(this._view)));
|
|
350
|
+
break;
|
|
351
|
+
case "PageDown":
|
|
352
|
+
e.preventDefault();
|
|
353
|
+
this._shiftMonth(1);
|
|
354
|
+
next = new Date(this._view.getFullYear(), this._view.getMonth(), Math.min(this._focusDay.getDate(), this._daysInMonth(this._view)));
|
|
355
|
+
break;
|
|
356
|
+
// Enter / Space are handled by the focused day's native <button>
|
|
357
|
+
// (activation → @click → _pickDay). Intercepting them here too would
|
|
358
|
+
// double-fire on Space (button activates on keyup, after this keydown).
|
|
359
|
+
default:
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (next) {
|
|
363
|
+
e.preventDefault();
|
|
364
|
+
this._focusDay = next;
|
|
365
|
+
if (next.getMonth() !== this._view.getMonth() ||
|
|
366
|
+
next.getFullYear() !== this._view.getFullYear()) {
|
|
367
|
+
this._view = new Date(next.getFullYear(), next.getMonth(), 1);
|
|
368
|
+
}
|
|
369
|
+
requestAnimationFrame(() => this._focusRovingDay());
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
_panelFocusables() {
|
|
373
|
+
if (!this._panel)
|
|
374
|
+
return [];
|
|
375
|
+
const sel = 'button:not([disabled]),[role="radio"],[tabindex]:not([tabindex="-1"])';
|
|
376
|
+
return Array.from(this._panel.querySelectorAll(sel)).filter((el) => el.getClientRects().length > 0);
|
|
377
|
+
}
|
|
378
|
+
// Esc is owned by the overlay (innermost-only). Here we only keep Tab inside
|
|
379
|
+
// the role="dialog" panel — the non-modal popover does not trap natively.
|
|
380
|
+
_onPanelKey(e) {
|
|
381
|
+
if (e.key !== "Tab")
|
|
382
|
+
return;
|
|
383
|
+
const items = this._panelFocusables();
|
|
384
|
+
if (items.length === 0) {
|
|
385
|
+
e.preventDefault();
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const first = items[0];
|
|
389
|
+
const last = items[items.length - 1];
|
|
390
|
+
const active = this.shadowRoot?.activeElement;
|
|
391
|
+
if (e.shiftKey && (active === first || active === this._panel)) {
|
|
392
|
+
e.preventDefault();
|
|
393
|
+
last.focus();
|
|
394
|
+
}
|
|
395
|
+
else if (!e.shiftKey && active === last) {
|
|
396
|
+
e.preventDefault();
|
|
397
|
+
first.focus();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
_daysInMonth(view) {
|
|
401
|
+
return new Date(view.getFullYear(), view.getMonth() + 1, 0).getDate();
|
|
402
|
+
}
|
|
403
|
+
/* ---------- overlay reconcile ---------- */
|
|
404
|
+
willUpdate(changed) {
|
|
405
|
+
// Initialise the draft from the committed from/to whenever we open — covers
|
|
406
|
+
// both trigger-open and an externally set `open` (stories / preview hook).
|
|
407
|
+
if (changed.has("open") && this.open)
|
|
408
|
+
this._initDraft();
|
|
409
|
+
}
|
|
410
|
+
updated(changed) {
|
|
411
|
+
if (changed.has("open")) {
|
|
412
|
+
const ov = this._overlay;
|
|
413
|
+
const trigger = this._trigger;
|
|
414
|
+
if (this.open && ov && trigger) {
|
|
415
|
+
ov.anchor = trigger;
|
|
416
|
+
ov.opener = trigger;
|
|
417
|
+
ov.show();
|
|
418
|
+
requestAnimationFrame(() => {
|
|
419
|
+
if (this.open)
|
|
420
|
+
this._focusRovingDay();
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
else if (!this.open && ov?.open) {
|
|
424
|
+
ov.hide("api");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/* ---------- render ---------- */
|
|
429
|
+
render() {
|
|
430
|
+
const labelId = `${this._id}-title`;
|
|
431
|
+
const triggerCls = `date-range__trigger${this.open ? " date-range__trigger--open" : ""}`;
|
|
432
|
+
return html `
|
|
433
|
+
<link rel="stylesheet" href="${DR_CSS}" />
|
|
434
|
+
<style>
|
|
435
|
+
:host { display: inline-block; }
|
|
436
|
+
:host([hidden]) { display: none; }
|
|
437
|
+
</style>
|
|
438
|
+
<button
|
|
439
|
+
type="button"
|
|
440
|
+
class="${triggerCls}"
|
|
441
|
+
aria-haspopup="dialog"
|
|
442
|
+
aria-expanded="${this.open ? "true" : "false"}"
|
|
443
|
+
@click=${() => this._toggle()}
|
|
444
|
+
>
|
|
445
|
+
<span class="date-range__icon">${CAL_ICON()}</span>
|
|
446
|
+
${this._renderTriggerValue()}
|
|
447
|
+
</button>
|
|
448
|
+
|
|
449
|
+
<xm-overlay
|
|
450
|
+
mode="non-modal"
|
|
451
|
+
tier="menu"
|
|
452
|
+
placement="bottom-start"
|
|
453
|
+
label="Date range"
|
|
454
|
+
@xm-overlay-close=${this._onOverlayClose}
|
|
455
|
+
>
|
|
456
|
+
${this.open ? this._renderPanel(labelId) : nothing}
|
|
457
|
+
</xm-overlay>
|
|
458
|
+
`;
|
|
459
|
+
}
|
|
460
|
+
_renderTriggerValue() {
|
|
461
|
+
const start = fromISO(this.from);
|
|
462
|
+
const end = fromISO(this.to);
|
|
463
|
+
if (start && end) {
|
|
464
|
+
const label = `${SHORT_FMT.format(start)} – ${SHORT_FMT.format(end)}`;
|
|
465
|
+
return html `<span class="date-range__value">${label}</span>`;
|
|
466
|
+
}
|
|
467
|
+
return html `<span class="date-range__value date-range__value--placeholder"
|
|
468
|
+
>${this.placeholder}</span
|
|
469
|
+
>`;
|
|
470
|
+
}
|
|
471
|
+
_renderPanel(labelId) {
|
|
472
|
+
return html `
|
|
473
|
+
<div
|
|
474
|
+
class="date-range__panel"
|
|
475
|
+
role="dialog"
|
|
476
|
+
aria-labelledby="${labelId}"
|
|
477
|
+
@keydown=${(e) => this._onPanelKey(e)}
|
|
478
|
+
>
|
|
479
|
+
<span id="${labelId}" class="date-range__sr-only">Select a date range</span>
|
|
480
|
+
<div class="date-range__layout">
|
|
481
|
+
${this._renderRail()} ${this._renderCalendar()}
|
|
482
|
+
</div>
|
|
483
|
+
${this._invalid
|
|
484
|
+
? html `<div
|
|
485
|
+
class="date-range__alert"
|
|
486
|
+
role="alert"
|
|
487
|
+
id="${labelId}-alert"
|
|
488
|
+
>
|
|
489
|
+
<span class="date-range__alert-icon">${ALERT_ICON()}</span>
|
|
490
|
+
<span>End date is before start date</span>
|
|
491
|
+
</div>`
|
|
492
|
+
: nothing}
|
|
493
|
+
${this._renderFooter(labelId)}
|
|
494
|
+
<span class="date-range__sr-only" aria-live="polite"
|
|
495
|
+
>${this._liveMsg}</span
|
|
496
|
+
>
|
|
497
|
+
</div>
|
|
498
|
+
`;
|
|
499
|
+
}
|
|
500
|
+
_renderRail() {
|
|
501
|
+
return html `
|
|
502
|
+
<div
|
|
503
|
+
class="date-range__rail"
|
|
504
|
+
role="radiogroup"
|
|
505
|
+
aria-label="Date range presets"
|
|
506
|
+
>
|
|
507
|
+
${PRESETS.map((p) => {
|
|
508
|
+
const active = this._activePreset === p.id;
|
|
509
|
+
const cls = `date-range__preset${active ? " date-range__preset--active" : ""}`;
|
|
510
|
+
return html `<button
|
|
511
|
+
type="button"
|
|
512
|
+
class="${cls}"
|
|
513
|
+
role="radio"
|
|
514
|
+
aria-checked="${active ? "true" : "false"}"
|
|
515
|
+
@click=${() => this._applyPreset(p.id)}
|
|
516
|
+
>
|
|
517
|
+
${p.label}
|
|
518
|
+
</button>`;
|
|
519
|
+
})}
|
|
520
|
+
</div>
|
|
521
|
+
`;
|
|
522
|
+
}
|
|
523
|
+
_renderCalendar() {
|
|
524
|
+
return html `
|
|
525
|
+
<div class="date-range__calendar">
|
|
526
|
+
<div class="date-range__cal-head">
|
|
527
|
+
<button
|
|
528
|
+
type="button"
|
|
529
|
+
class="date-range__nav"
|
|
530
|
+
aria-label="Previous month"
|
|
531
|
+
@click=${() => this._shiftMonth(-1)}
|
|
532
|
+
>
|
|
533
|
+
${CHEVRON_LEFT()}
|
|
534
|
+
</button>
|
|
535
|
+
<span class="date-range__month" aria-hidden="true"
|
|
536
|
+
>${MONTH_FMT.format(this._view)}</span
|
|
537
|
+
>
|
|
538
|
+
<button
|
|
539
|
+
type="button"
|
|
540
|
+
class="date-range__nav"
|
|
541
|
+
aria-label="Next month"
|
|
542
|
+
@click=${() => this._shiftMonth(1)}
|
|
543
|
+
>
|
|
544
|
+
${CHEVRON_RIGHT()}
|
|
545
|
+
</button>
|
|
546
|
+
</div>
|
|
547
|
+
<div
|
|
548
|
+
class="date-range__grid"
|
|
549
|
+
role="grid"
|
|
550
|
+
aria-label="${MONTH_FMT.format(this._view)}"
|
|
551
|
+
@keydown=${(e) => this._onGridKey(e)}
|
|
552
|
+
>
|
|
553
|
+
<div class="date-range__weekdays" role="row">
|
|
554
|
+
${WEEKDAYS.map((w) => html `<span
|
|
555
|
+
class="date-range__weekday"
|
|
556
|
+
role="columnheader"
|
|
557
|
+
aria-hidden="true"
|
|
558
|
+
>${w}</span
|
|
559
|
+
>`)}
|
|
560
|
+
</div>
|
|
561
|
+
${this._renderWeeks()}
|
|
562
|
+
</div>
|
|
563
|
+
</div>
|
|
564
|
+
`;
|
|
565
|
+
}
|
|
566
|
+
_renderWeeks() {
|
|
567
|
+
const year = this._view.getFullYear();
|
|
568
|
+
const month = this._view.getMonth();
|
|
569
|
+
const first = new Date(year, month, 1);
|
|
570
|
+
// Monday-first offset (JS getDay: 0=Sun).
|
|
571
|
+
const lead = (first.getDay() + 6) % 7;
|
|
572
|
+
const gridStart = addDays(first, -lead);
|
|
573
|
+
const today = startOfDay(new Date());
|
|
574
|
+
const weeks = [];
|
|
575
|
+
// Six role="row" weeks of seven gridcells — the ARIA grid requires rows
|
|
576
|
+
// between the grid and its cells (the row wrappers are display:contents so
|
|
577
|
+
// the 7-column CSS grid is unaffected).
|
|
578
|
+
for (let w = 0; w < 6; w++) {
|
|
579
|
+
const cells = [];
|
|
580
|
+
for (let d = 0; d < 7; d++) {
|
|
581
|
+
const day = addDays(gridStart, w * 7 + d);
|
|
582
|
+
cells.push(this._renderDay(day, month, today));
|
|
583
|
+
}
|
|
584
|
+
weeks.push(html `<div class="date-range__week" role="row">${cells}</div>`);
|
|
585
|
+
}
|
|
586
|
+
return weeks;
|
|
587
|
+
}
|
|
588
|
+
_renderDay(day, viewMonth, today) {
|
|
589
|
+
const inMonth = day.getMonth() === viewMonth;
|
|
590
|
+
const isToday = sameDay(day, today);
|
|
591
|
+
const isFocus = sameDay(day, this._focusDay);
|
|
592
|
+
const start = this._draftFrom;
|
|
593
|
+
const end = this._draftTo;
|
|
594
|
+
const isStart = start && sameDay(day, start);
|
|
595
|
+
const isEnd = end && sameDay(day, end);
|
|
596
|
+
const selected = !!(isStart || isEnd);
|
|
597
|
+
const inRange = !!(start && end && day > start && day < end);
|
|
598
|
+
const cellCls = [
|
|
599
|
+
"date-range__cell",
|
|
600
|
+
inRange && "date-range__cell--in-range",
|
|
601
|
+
isStart && end && "date-range__cell--start",
|
|
602
|
+
isEnd && start && "date-range__cell--end",
|
|
603
|
+
]
|
|
604
|
+
.filter(Boolean)
|
|
605
|
+
.join(" ");
|
|
606
|
+
const dayCls = [
|
|
607
|
+
"date-range__day",
|
|
608
|
+
!inMonth && "date-range__day--muted",
|
|
609
|
+
isToday && "date-range__day--today",
|
|
610
|
+
selected && "date-range__day--selected",
|
|
611
|
+
]
|
|
612
|
+
.filter(Boolean)
|
|
613
|
+
.join(" ");
|
|
614
|
+
return html `
|
|
615
|
+
<div
|
|
616
|
+
class="${cellCls}"
|
|
617
|
+
role="gridcell"
|
|
618
|
+
aria-selected="${selected ? "true" : "false"}"
|
|
619
|
+
>
|
|
620
|
+
<button
|
|
621
|
+
type="button"
|
|
622
|
+
class="${dayCls}"
|
|
623
|
+
tabindex="${isFocus ? "0" : "-1"}"
|
|
624
|
+
aria-label="${FULL_FMT.format(day)}"
|
|
625
|
+
aria-current="${isToday ? "date" : nothing}"
|
|
626
|
+
@click=${() => this._pickDay(day)}
|
|
627
|
+
>
|
|
628
|
+
${day.getDate()}
|
|
629
|
+
</button>
|
|
630
|
+
</div>
|
|
631
|
+
`;
|
|
632
|
+
}
|
|
633
|
+
_renderFooter(labelId) {
|
|
634
|
+
const canApply = !!(this._draftFrom && this._draftTo) && !this._invalid;
|
|
635
|
+
return html `
|
|
636
|
+
<div class="date-range__footer">
|
|
637
|
+
<button
|
|
638
|
+
type="button"
|
|
639
|
+
class="date-range__btn"
|
|
640
|
+
@click=${() => this._clear()}
|
|
641
|
+
>
|
|
642
|
+
Clear
|
|
643
|
+
</button>
|
|
644
|
+
<button
|
|
645
|
+
type="button"
|
|
646
|
+
class="date-range__btn date-range__btn--apply"
|
|
647
|
+
aria-disabled="${canApply ? "false" : "true"}"
|
|
648
|
+
aria-describedby="${this._invalid ? `${labelId}-alert` : nothing}"
|
|
649
|
+
@click=${() => this._apply()}
|
|
650
|
+
>
|
|
651
|
+
Apply
|
|
652
|
+
</button>
|
|
653
|
+
</div>
|
|
654
|
+
`;
|
|
655
|
+
}
|
|
656
|
+
};
|
|
657
|
+
__decorate([
|
|
658
|
+
property({ type: String })
|
|
659
|
+
], XmDateRange.prototype, "from", void 0);
|
|
660
|
+
__decorate([
|
|
661
|
+
property({ type: String })
|
|
662
|
+
], XmDateRange.prototype, "to", void 0);
|
|
663
|
+
__decorate([
|
|
664
|
+
property({ type: String })
|
|
665
|
+
], XmDateRange.prototype, "placeholder", void 0);
|
|
666
|
+
__decorate([
|
|
667
|
+
property({ type: Boolean, reflect: true })
|
|
668
|
+
], XmDateRange.prototype, "open", void 0);
|
|
669
|
+
__decorate([
|
|
670
|
+
state()
|
|
671
|
+
], XmDateRange.prototype, "_id", void 0);
|
|
672
|
+
__decorate([
|
|
673
|
+
state()
|
|
674
|
+
], XmDateRange.prototype, "_draftFrom", void 0);
|
|
675
|
+
__decorate([
|
|
676
|
+
state()
|
|
677
|
+
], XmDateRange.prototype, "_draftTo", void 0);
|
|
678
|
+
__decorate([
|
|
679
|
+
state()
|
|
680
|
+
], XmDateRange.prototype, "_view", void 0);
|
|
681
|
+
__decorate([
|
|
682
|
+
state()
|
|
683
|
+
], XmDateRange.prototype, "_focusDay", void 0);
|
|
684
|
+
__decorate([
|
|
685
|
+
state()
|
|
686
|
+
], XmDateRange.prototype, "_liveMsg", void 0);
|
|
687
|
+
__decorate([
|
|
688
|
+
state()
|
|
689
|
+
], XmDateRange.prototype, "_activePreset", void 0);
|
|
690
|
+
__decorate([
|
|
691
|
+
query("xm-overlay")
|
|
692
|
+
], XmDateRange.prototype, "_overlay", void 0);
|
|
693
|
+
__decorate([
|
|
694
|
+
query(".date-range__trigger")
|
|
695
|
+
], XmDateRange.prototype, "_trigger", void 0);
|
|
696
|
+
__decorate([
|
|
697
|
+
query(".date-range__panel")
|
|
698
|
+
], XmDateRange.prototype, "_panel", void 0);
|
|
699
|
+
XmDateRange = __decorate([
|
|
700
|
+
customElement("xm-date-range")
|
|
701
|
+
], XmDateRange);
|
|
702
|
+
export { XmDateRange };
|