@reliability-design/web 1.4.5 → 1.4.6
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/components/rel-alert/rel-alert.ts +1 -1
- package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
- package/dist/components/rel-badge/rel-badge.ts +6 -1
- package/dist/components/rel-calendar/rel-calendar.ts +64 -18
- package/dist/components/rel-carousel/rel-carousel.ts +25 -2
- package/dist/components/rel-chip/rel-chip.ts +31 -20
- package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
- package/dist/components/rel-date-picker/date-parse.ts +178 -0
- package/dist/components/rel-date-picker/rel-date-picker.ts +529 -90
- package/dist/components/rel-drawer/rel-drawer.ts +6 -3
- package/dist/components/rel-menu/rel-menu.ts +6 -0
- package/dist/components/rel-otp/rel-otp.ts +12 -2
- package/dist/components/rel-pagination/rel-pagination.ts +13 -0
- package/dist/components/rel-popover/rel-popover.ts +33 -2
- package/dist/components/rel-progress/rel-progress.ts +13 -4
- package/dist/components/rel-radio/README.md +6 -3
- package/dist/components/rel-radio/rel-radio.ts +236 -60
- package/dist/components/rel-range-picker/rel-range-picker.ts +452 -95
- package/dist/components/rel-segmented-button/rel-segmented-button.ts +5 -1
- package/dist/components/rel-select/rel-select.ts +44 -13
- package/dist/components/rel-sheet/rel-sheet.ts +163 -18
- package/dist/components/rel-sidebar/rel-sidebar.ts +7 -1
- package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
- package/dist/components/rel-slider/rel-slider.ts +6 -3
- package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
- package/dist/components/rel-stepper/rel-stepper.ts +3 -1
- package/dist/components/rel-switch/rel-switch.ts +3 -1
- package/dist/components/rel-textfield/rel-textfield.ts +263 -75
- package/dist/components/rel-time-picker/rel-time-picker.ts +450 -94
- package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
- package/dist/components/rel-time-picker/time-parse.ts +85 -0
- package/dist/components/rel-toast/rel-toast.ts +4 -2
- package/dist/components/rel-tooltip/README.md +13 -5
- package/dist/components/rel-tooltip/rel-tooltip.ts +3 -0
- package/dist/components/rel-upload/rel-upload.ts +25 -0
- package/dist/index.d.mts +241 -11
- package/dist/index.d.ts +241 -11
- package/dist/index.js +2573 -598
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2614 -640
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { LitElement, html, css, nothing } from "lit";
|
|
2
2
|
import { customElement, property, state, query } from "lit/decorators.js";
|
|
3
|
+
import { live } from "lit/directives/live.js";
|
|
3
4
|
import { formatDatePattern } from "./date-format";
|
|
5
|
+
import { parseDateInput, isDayFirstPattern, isDayFirstLocale } from "./date-parse";
|
|
4
6
|
import { getDeepActiveElement, trapTabKey } from "../../utils/focus.js";
|
|
7
|
+
import { announce } from "../../utils/announce";
|
|
5
8
|
|
|
6
9
|
interface CalendarDay {
|
|
7
10
|
date: Date;
|
|
@@ -75,8 +78,20 @@ export class RelDatePicker extends LitElement {
|
|
|
75
78
|
@state() private focusedDate: Date | null = null;
|
|
76
79
|
@state() private yearDropdownOpen = false;
|
|
77
80
|
@state() private monthDropdownOpen = false;
|
|
81
|
+
/** Uncommitted typed text; null → the input shows the committed value. */
|
|
82
|
+
@state() private inputText: string | null = null;
|
|
83
|
+
/** Parse/range error message for the typed text (cleared on next input). */
|
|
84
|
+
@state() private inputError: string | null = null;
|
|
78
85
|
|
|
79
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Whether the next popover open should move focus into the calendar grid.
|
|
88
|
+
* Icon-click / ArrowDown / external `open` do (previous behavior); opening
|
|
89
|
+
* by clicking into the text input must NOT, or typing would be impossible.
|
|
90
|
+
*/
|
|
91
|
+
private focusPanelOnOpen = true;
|
|
92
|
+
|
|
93
|
+
@query(".trigger-input") private triggerInput?: HTMLInputElement;
|
|
94
|
+
@query(".trigger-button") private triggerButton?: HTMLElement;
|
|
80
95
|
@query(".popover") private popoverElement?: HTMLElement;
|
|
81
96
|
@query(".calendar-grid") private calendarGrid?: HTMLElement;
|
|
82
97
|
|
|
@@ -153,9 +168,14 @@ export class RelDatePicker extends LitElement {
|
|
|
153
168
|
color: var(--rel-text-danger);
|
|
154
169
|
}
|
|
155
170
|
|
|
171
|
+
/* Persistent live region — out of flow while empty so it adds no gap. */
|
|
172
|
+
.date-picker-support.empty {
|
|
173
|
+
position: absolute;
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
.trigger-button {
|
|
157
177
|
width: 100%;
|
|
158
|
-
min-width: 200px;
|
|
178
|
+
min-width: min(200px, 100%);
|
|
159
179
|
display: flex;
|
|
160
180
|
align-items: center;
|
|
161
181
|
justify-content: space-between;
|
|
@@ -175,28 +195,48 @@ export class RelDatePicker extends LitElement {
|
|
|
175
195
|
letter-spacing: var(--rel-body-md-regular-letter-spacing);
|
|
176
196
|
}
|
|
177
197
|
|
|
178
|
-
|
|
198
|
+
:host(:not([disabled])) .trigger-button:hover {
|
|
179
199
|
border-color: var(--rel-border-strong);
|
|
180
200
|
}
|
|
181
201
|
|
|
182
|
-
.trigger-button:focus-
|
|
202
|
+
.trigger-button:focus-within {
|
|
183
203
|
outline: 2px solid var(--rel-border-focus);
|
|
184
204
|
outline-offset: 2px;
|
|
185
205
|
border-color: var(--rel-border-focus);
|
|
186
206
|
}
|
|
187
207
|
|
|
188
|
-
.trigger-button
|
|
208
|
+
:host([disabled]) .trigger-button {
|
|
189
209
|
cursor: not-allowed;
|
|
190
210
|
background: var(--rel-bg-surface-elevated);
|
|
191
211
|
}
|
|
192
212
|
|
|
213
|
+
/* The editable field itself — the wrapper carries the box styles. */
|
|
214
|
+
.trigger-input {
|
|
215
|
+
flex: 1;
|
|
216
|
+
min-width: 0;
|
|
217
|
+
border: none;
|
|
218
|
+
background: transparent;
|
|
219
|
+
padding: 0;
|
|
220
|
+
margin: 0;
|
|
221
|
+
outline: none;
|
|
222
|
+
color: var(--rel-text-default);
|
|
223
|
+
font: inherit;
|
|
224
|
+
letter-spacing: inherit;
|
|
225
|
+
cursor: inherit;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.trigger-input::placeholder {
|
|
229
|
+
color: var(--rel-text-placeholder);
|
|
230
|
+
opacity: 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
193
233
|
/* Error state */
|
|
194
234
|
.trigger-button.error,
|
|
195
|
-
.trigger-button.error:hover
|
|
235
|
+
:host(:not([disabled])) .trigger-button.error:hover {
|
|
196
236
|
border-color: var(--rel-border-danger);
|
|
197
237
|
}
|
|
198
238
|
|
|
199
|
-
.trigger-button.error:focus-
|
|
239
|
+
.trigger-button.error:focus-within {
|
|
200
240
|
outline-color: var(--rel-border-danger);
|
|
201
241
|
border-color: var(--rel-border-danger);
|
|
202
242
|
}
|
|
@@ -220,15 +260,6 @@ export class RelDatePicker extends LitElement {
|
|
|
220
260
|
letter-spacing: var(--rel-body-lg-regular-letter-spacing);
|
|
221
261
|
}
|
|
222
262
|
|
|
223
|
-
.trigger-content {
|
|
224
|
-
flex: 1;
|
|
225
|
-
color: var(--rel-text-default);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
.trigger-content.placeholder {
|
|
229
|
-
color: var(--rel-text-placeholder);
|
|
230
|
-
}
|
|
231
|
-
|
|
232
263
|
.trigger-icons {
|
|
233
264
|
display: flex;
|
|
234
265
|
align-items: center;
|
|
@@ -259,16 +290,34 @@ export class RelDatePicker extends LitElement {
|
|
|
259
290
|
}
|
|
260
291
|
|
|
261
292
|
.popover {
|
|
293
|
+
/* Reset native [popover] UA styles first (the panel is promoted to
|
|
294
|
+
the top layer so a drawer/sheet/modal ancestor — transformed,
|
|
295
|
+
overflow:hidden, or an equal-z-index sibling panel — can't clip it
|
|
296
|
+
or paint over it). While in the top layer, JS overrides the
|
|
297
|
+
anchored position below with inline fixed coordinates; the CSS
|
|
298
|
+
anchoring is the no-Popover-API fallback. */
|
|
299
|
+
margin: 0;
|
|
300
|
+
width: auto;
|
|
301
|
+
height: auto;
|
|
302
|
+
inset: auto;
|
|
303
|
+
color: inherit;
|
|
262
304
|
position: absolute;
|
|
263
305
|
top: calc(100% + 4px);
|
|
264
306
|
left: 0;
|
|
265
|
-
z-index:
|
|
307
|
+
z-index: 2147483647;
|
|
266
308
|
background: var(--rel-bg-surface);
|
|
267
309
|
border: 1px solid var(--rel-border-default);
|
|
268
310
|
border-radius: var(--rel-border-radius-sm);
|
|
269
311
|
box-shadow: var(--rel-shadows-shadow-300);
|
|
270
312
|
padding: var(--rel-spacing-md);
|
|
271
|
-
|
|
313
|
+
/* Never wider or taller than the viewport (small screens). border-box
|
|
314
|
+
so the caps include border+padding; 314px = the previous 280px
|
|
315
|
+
content-box min-width, keeping desktop rendering unchanged. */
|
|
316
|
+
box-sizing: border-box;
|
|
317
|
+
min-width: min(314px, calc(100vw - 16px));
|
|
318
|
+
max-width: calc(100vw - 16px);
|
|
319
|
+
max-height: min(600px, calc(100vh - 16px));
|
|
320
|
+
overflow-y: auto;
|
|
272
321
|
display: none;
|
|
273
322
|
}
|
|
274
323
|
|
|
@@ -276,6 +325,56 @@ export class RelDatePicker extends LitElement {
|
|
|
276
325
|
display: block;
|
|
277
326
|
}
|
|
278
327
|
|
|
328
|
+
/* Typed-shortcut hints (T → Today, …); each chip applies its value. */
|
|
329
|
+
.shortcut-row {
|
|
330
|
+
display: flex;
|
|
331
|
+
flex-wrap: wrap;
|
|
332
|
+
gap: var(--rel-spacing-2xs);
|
|
333
|
+
padding-bottom: var(--rel-spacing-xs);
|
|
334
|
+
margin-bottom: var(--rel-spacing-xs);
|
|
335
|
+
border-bottom: 1px solid var(--rel-border-subtle);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.shortcut-chip {
|
|
339
|
+
display: inline-flex;
|
|
340
|
+
align-items: center;
|
|
341
|
+
gap: var(--rel-spacing-2xs);
|
|
342
|
+
border: none;
|
|
343
|
+
background: transparent;
|
|
344
|
+
border-radius: var(--rel-border-radius-xs);
|
|
345
|
+
padding: var(--rel-spacing-3xs) var(--rel-spacing-2xs);
|
|
346
|
+
cursor: pointer;
|
|
347
|
+
color: var(--rel-text-default);
|
|
348
|
+
transition: background 0.15s ease;
|
|
349
|
+
font-family: var(--rel-body-xs-regular-font-family);
|
|
350
|
+
font-weight: var(--rel-body-xs-regular-font-weight);
|
|
351
|
+
font-size: var(--rel-body-xs-regular-font-size);
|
|
352
|
+
line-height: var(--rel-body-xs-regular-line-height);
|
|
353
|
+
letter-spacing: var(--rel-body-xs-regular-letter-spacing);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
.shortcut-chip:hover {
|
|
357
|
+
background: var(--rel-bg-neutral-elevated-1);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.shortcut-chip:focus-visible {
|
|
361
|
+
outline: 2px solid var(--rel-border-focus);
|
|
362
|
+
outline-offset: -2px;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.shortcut-chip kbd {
|
|
366
|
+
border: 1px solid var(--rel-border-default);
|
|
367
|
+
border-bottom-width: 2px;
|
|
368
|
+
border-radius: var(--rel-border-radius-xs);
|
|
369
|
+
background: var(--rel-bg-surface-elevated);
|
|
370
|
+
padding: 0 var(--rel-spacing-3xs);
|
|
371
|
+
min-width: 18px;
|
|
372
|
+
text-align: center;
|
|
373
|
+
font-family: inherit;
|
|
374
|
+
font-size: inherit;
|
|
375
|
+
color: var(--rel-text-label);
|
|
376
|
+
}
|
|
377
|
+
|
|
279
378
|
.calendar-header {
|
|
280
379
|
display: flex;
|
|
281
380
|
align-items: center;
|
|
@@ -414,6 +513,13 @@ export class RelDatePicker extends LitElement {
|
|
|
414
513
|
gap: var(--rel-spacing-3xs);
|
|
415
514
|
}
|
|
416
515
|
|
|
516
|
+
.calendar-week {
|
|
517
|
+
grid-column: 1 / -1;
|
|
518
|
+
display: grid;
|
|
519
|
+
grid-template-columns: repeat(7, 1fr);
|
|
520
|
+
gap: var(--rel-spacing-3xs);
|
|
521
|
+
}
|
|
522
|
+
|
|
417
523
|
.day-button {
|
|
418
524
|
aspect-ratio: 1;
|
|
419
525
|
border: none;
|
|
@@ -496,6 +602,9 @@ export class RelDatePicker extends LitElement {
|
|
|
496
602
|
super.disconnectedCallback();
|
|
497
603
|
document.removeEventListener("click", this.handleClickOutside);
|
|
498
604
|
document.removeEventListener("keydown", this.handleEscapeKey);
|
|
605
|
+
window.removeEventListener("resize", this.onReposition);
|
|
606
|
+
window.removeEventListener("scroll", this.onReposition, true);
|
|
607
|
+
this.hideTopLayer();
|
|
499
608
|
}
|
|
500
609
|
|
|
501
610
|
private handleClickOutside = (e: MouseEvent) => {
|
|
@@ -503,7 +612,8 @@ export class RelDatePicker extends LitElement {
|
|
|
503
612
|
|
|
504
613
|
const path = e.composedPath();
|
|
505
614
|
if (!path.includes(this)) {
|
|
506
|
-
|
|
615
|
+
// Don't refocus the field — the user clicked somewhere else on purpose.
|
|
616
|
+
this.closeCalendarPopover(false);
|
|
507
617
|
}
|
|
508
618
|
};
|
|
509
619
|
|
|
@@ -529,22 +639,118 @@ export class RelDatePicker extends LitElement {
|
|
|
529
639
|
}
|
|
530
640
|
}
|
|
531
641
|
|
|
532
|
-
|
|
642
|
+
// `open` is a public reflected property (consumers may set it directly,
|
|
643
|
+
// not only via the trigger), so top-layer promotion syncs here rather
|
|
644
|
+
// than inside toggle/close.
|
|
645
|
+
if (changedProperties.has("open")) {
|
|
646
|
+
if (!this.open) {
|
|
647
|
+
window.removeEventListener("resize", this.onReposition);
|
|
648
|
+
window.removeEventListener("scroll", this.onReposition, true);
|
|
649
|
+
this.hideTopLayer();
|
|
650
|
+
this.focusPanelOnOpen = true;
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
this.showTopLayer();
|
|
654
|
+
window.addEventListener("resize", this.onReposition);
|
|
655
|
+
window.addEventListener("scroll", this.onReposition, true);
|
|
533
656
|
// Focus the roving cell (selected day, else today, else first
|
|
534
|
-
// selectable) when opening — not blindly the first of the month
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
);
|
|
543
|
-
target?.focus();
|
|
544
|
-
}, 100);
|
|
657
|
+
// selectable) when opening — not blindly the first of the month —
|
|
658
|
+
// unless the open came from clicking into the text input (typing must
|
|
659
|
+
// keep working).
|
|
660
|
+
const focusPanel = this.focusPanelOnOpen;
|
|
661
|
+
this.focusPanelOnOpen = true;
|
|
662
|
+
if (focusPanel) {
|
|
663
|
+
setTimeout(() => this.focusPanelSelection(), 100);
|
|
664
|
+
}
|
|
545
665
|
}
|
|
546
666
|
}
|
|
547
667
|
|
|
668
|
+
/* A drawer/sheet/modal ancestor breaks the anchored panel two ways: a
|
|
669
|
+
* transform makes it the containing block for position:fixed descendants
|
|
670
|
+
* (and overflow:hidden clips absolute ones), and an equal-z-index panel
|
|
671
|
+
* later in the DOM paints over the calendar. Promoting the panel to the
|
|
672
|
+
* browser top layer sidesteps both — same fix as rel-popover /
|
|
673
|
+
* rel-autocomplete / rel-select / rel-range-picker. `manual` keeps
|
|
674
|
+
* outside-click and Escape handling under this component's own control.
|
|
675
|
+
* While in the top layer the CSS anchoring is meaningless, so the panel
|
|
676
|
+
* gets inline fixed coordinates from updatePopoverPosition(). */
|
|
677
|
+
|
|
678
|
+
private showTopLayer() {
|
|
679
|
+
const el = this.popoverElement;
|
|
680
|
+
if (el && typeof el.showPopover === "function") {
|
|
681
|
+
try {
|
|
682
|
+
if (!el.matches(":popover-open")) el.showPopover();
|
|
683
|
+
} catch {
|
|
684
|
+
/* older engines / detached element — CSS fallback applies */
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
requestAnimationFrame(() => this.updatePopoverPosition());
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
private hideTopLayer() {
|
|
691
|
+
const el = this.popoverElement;
|
|
692
|
+
if (!el) return;
|
|
693
|
+
if (typeof el.hidePopover === "function") {
|
|
694
|
+
try {
|
|
695
|
+
if (el.matches(":popover-open")) el.hidePopover();
|
|
696
|
+
} catch {
|
|
697
|
+
/* already hidden or unsupported — nothing to do */
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
el.style.position = "";
|
|
701
|
+
el.style.top = "";
|
|
702
|
+
el.style.left = "";
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
private onReposition = () => {
|
|
706
|
+
if (this.open) this.updatePopoverPosition();
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
private updatePopoverPosition = () => {
|
|
710
|
+
const panel = this.popoverElement;
|
|
711
|
+
const anchor = this.triggerButton;
|
|
712
|
+
if (!panel || !anchor || !this.open) return;
|
|
713
|
+
|
|
714
|
+
const t = anchor.getBoundingClientRect();
|
|
715
|
+
const c = panel.getBoundingClientRect();
|
|
716
|
+
|
|
717
|
+
// Below the trigger; flip above when there's no room, clamp to the
|
|
718
|
+
// viewport with an 8px margin on every edge.
|
|
719
|
+
let top = t.bottom + 4;
|
|
720
|
+
if (top + c.height > window.innerHeight - 8) {
|
|
721
|
+
top = Math.max(8, t.top - c.height - 4);
|
|
722
|
+
}
|
|
723
|
+
let left = t.left;
|
|
724
|
+
if (left + c.width > window.innerWidth - 8) {
|
|
725
|
+
left = window.innerWidth - c.width - 8;
|
|
726
|
+
}
|
|
727
|
+
left = Math.max(8, left);
|
|
728
|
+
|
|
729
|
+
// Without top-layer support a transformed ancestor may be the containing
|
|
730
|
+
// block for position:fixed, shifting the panel away from the viewport
|
|
731
|
+
// coordinates computed above. Apply, re-measure, and cancel the offset.
|
|
732
|
+
panel.style.position = "fixed";
|
|
733
|
+
panel.style.top = `${top}px`;
|
|
734
|
+
panel.style.left = `${left}px`;
|
|
735
|
+
const applied = panel.getBoundingClientRect();
|
|
736
|
+
top -= applied.top - top;
|
|
737
|
+
left -= applied.left - left;
|
|
738
|
+
panel.style.top = `${top}px`;
|
|
739
|
+
panel.style.left = `${left}px`;
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
/** Focus the roving day cell (selected day, else today, else first). */
|
|
743
|
+
private focusPanelSelection() {
|
|
744
|
+
const target =
|
|
745
|
+
this.calendarGrid?.querySelector<HTMLButtonElement>(
|
|
746
|
+
'.day-button[tabindex="0"]:not(:disabled)'
|
|
747
|
+
) ??
|
|
748
|
+
this.calendarGrid?.querySelector<HTMLButtonElement>(
|
|
749
|
+
".day-button:not(:disabled)"
|
|
750
|
+
);
|
|
751
|
+
target?.focus();
|
|
752
|
+
}
|
|
753
|
+
|
|
548
754
|
private parseISODate(iso: string): Date | null {
|
|
549
755
|
const match = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
550
756
|
if (!match) return null;
|
|
@@ -688,6 +894,26 @@ export class RelDatePicker extends LitElement {
|
|
|
688
894
|
return formatter.format(new Date(this.viewYear, this.viewMonth, 1));
|
|
689
895
|
}
|
|
690
896
|
|
|
897
|
+
private dayLabelFormatter: Intl.DateTimeFormat | null = null;
|
|
898
|
+
private dayLabelLocale = "";
|
|
899
|
+
|
|
900
|
+
private formatDayLabel(date: Date): string {
|
|
901
|
+
if (!this.dayLabelFormatter || this.dayLabelLocale !== this.locale) {
|
|
902
|
+
this.dayLabelLocale = this.locale;
|
|
903
|
+
this.dayLabelFormatter = new Intl.DateTimeFormat(this.locale, {
|
|
904
|
+
weekday: "long",
|
|
905
|
+
month: "long",
|
|
906
|
+
day: "numeric",
|
|
907
|
+
year: "numeric",
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
return this.dayLabelFormatter.format(date);
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
private announceMonth() {
|
|
914
|
+
announce(`${this.getMonthName()} ${this.viewYear}`);
|
|
915
|
+
}
|
|
916
|
+
|
|
691
917
|
private getMonthsList(): { value: number; label: string }[] {
|
|
692
918
|
const months = [];
|
|
693
919
|
for (let i = 0; i < 12; i++) {
|
|
@@ -732,11 +958,11 @@ export class RelDatePicker extends LitElement {
|
|
|
732
958
|
);
|
|
733
959
|
}
|
|
734
960
|
|
|
735
|
-
private closeCalendarPopover() {
|
|
961
|
+
private closeCalendarPopover(refocus = true) {
|
|
736
962
|
this.open = false;
|
|
737
963
|
this.monthDropdownOpen = false;
|
|
738
964
|
this.yearDropdownOpen = false;
|
|
739
|
-
this.
|
|
965
|
+
if (refocus) this.triggerInput?.focus();
|
|
740
966
|
this.dispatchEvent(
|
|
741
967
|
new CustomEvent("rel-open-change", {
|
|
742
968
|
detail: { open: false },
|
|
@@ -753,6 +979,7 @@ export class RelDatePicker extends LitElement {
|
|
|
753
979
|
} else {
|
|
754
980
|
this.viewMonth--;
|
|
755
981
|
}
|
|
982
|
+
this.announceMonth();
|
|
756
983
|
}
|
|
757
984
|
|
|
758
985
|
private nextMonth() {
|
|
@@ -762,33 +989,40 @@ export class RelDatePicker extends LitElement {
|
|
|
762
989
|
} else {
|
|
763
990
|
this.viewMonth++;
|
|
764
991
|
}
|
|
992
|
+
this.announceMonth();
|
|
765
993
|
}
|
|
766
994
|
|
|
767
995
|
private selectDay(day: CalendarDay) {
|
|
768
996
|
if (day.isDisabled || this.disabled || this.readonly) return;
|
|
997
|
+
this.commitDate(day.date);
|
|
998
|
+
if (this.closeOnSelect) {
|
|
999
|
+
this.closeCalendarPopover();
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
769
1002
|
|
|
770
|
-
|
|
1003
|
+
/** Commit a date: set value, reset typed text/error, fire rel-change. */
|
|
1004
|
+
private commitDate(date: Date) {
|
|
1005
|
+
const newValue = this.formatISODate(date);
|
|
771
1006
|
this.value = newValue;
|
|
772
|
-
|
|
1007
|
+
this.inputText = null;
|
|
1008
|
+
this.inputError = null;
|
|
773
1009
|
this.dispatchEvent(
|
|
774
1010
|
new CustomEvent("rel-change", {
|
|
775
1011
|
detail: {
|
|
776
1012
|
value: newValue,
|
|
777
|
-
date: new Date(
|
|
1013
|
+
date: new Date(date),
|
|
778
1014
|
},
|
|
779
1015
|
bubbles: true,
|
|
780
1016
|
composed: true,
|
|
781
1017
|
})
|
|
782
1018
|
);
|
|
783
|
-
|
|
784
|
-
if (this.closeOnSelect) {
|
|
785
|
-
this.closeCalendarPopover();
|
|
786
|
-
}
|
|
787
1019
|
}
|
|
788
1020
|
|
|
789
1021
|
private clearValue(e: Event) {
|
|
790
1022
|
e.stopPropagation();
|
|
791
1023
|
this.value = null;
|
|
1024
|
+
this.inputText = null;
|
|
1025
|
+
this.inputError = null;
|
|
792
1026
|
|
|
793
1027
|
this.dispatchEvent(
|
|
794
1028
|
new CustomEvent("rel-change", {
|
|
@@ -802,15 +1036,148 @@ export class RelDatePicker extends LitElement {
|
|
|
802
1036
|
);
|
|
803
1037
|
}
|
|
804
1038
|
|
|
805
|
-
|
|
806
|
-
|
|
1039
|
+
// ── Typed keyboard entry ──
|
|
1040
|
+
|
|
1041
|
+
/** Ambiguous "a/b" day/month order: display format wins, then locale. */
|
|
1042
|
+
private get dayFirst(): boolean {
|
|
1043
|
+
if (this.displayFormat) {
|
|
1044
|
+
const fromPattern = isDayFirstPattern(this.displayFormat);
|
|
1045
|
+
if (fromPattern !== null) return fromPattern;
|
|
1046
|
+
}
|
|
1047
|
+
return isDayFirstLocale(this.locale);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
/**
|
|
1051
|
+
* Parse and commit the typed text (Enter / blur). Invalid or out-of-range
|
|
1052
|
+
* text keeps the field in an error state with the text preserved for
|
|
1053
|
+
* correction; the committed value is untouched (Escape reverts the text).
|
|
1054
|
+
*/
|
|
1055
|
+
private commitTypedValue() {
|
|
1056
|
+
if (this.inputText === null) return; // nothing typed since last commit
|
|
1057
|
+
const text = this.inputText.trim();
|
|
1058
|
+
|
|
1059
|
+
if (!text) {
|
|
1060
|
+
// Field cleared by keyboard — behaves like the clear button.
|
|
1061
|
+
this.inputText = null;
|
|
1062
|
+
this.inputError = null;
|
|
1063
|
+
if (this.value !== null) {
|
|
1064
|
+
this.value = null;
|
|
1065
|
+
this.dispatchEvent(
|
|
1066
|
+
new CustomEvent("rel-change", {
|
|
1067
|
+
detail: { value: null, date: null },
|
|
1068
|
+
bubbles: true,
|
|
1069
|
+
composed: true,
|
|
1070
|
+
})
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
if (this.open) this.closeCalendarPopover();
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const parsed = parseDateInput(text, {
|
|
1078
|
+
referenceDate: new Date(),
|
|
1079
|
+
dayFirst: this.dayFirst,
|
|
1080
|
+
locale: this.locale,
|
|
1081
|
+
});
|
|
1082
|
+
if (!parsed) {
|
|
1083
|
+
this.inputError = "Invalid date";
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
// Midnight-anchored like the calendar grid, so min/max compares match.
|
|
1087
|
+
const date = new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
|
|
1088
|
+
if (this.isDateDisabledInternal(date)) {
|
|
1089
|
+
this.inputError = "Date not available";
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
this.commitDate(date);
|
|
1094
|
+
if (this.open) this.closeCalendarPopover();
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/** Today ± offset days, committed directly (T shortcuts / popover chips). */
|
|
1098
|
+
private applyDayOffsetShortcut(offsetDays: number) {
|
|
1099
|
+
const now = new Date();
|
|
1100
|
+
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offsetDays);
|
|
1101
|
+
if (this.isDateDisabledInternal(date)) {
|
|
1102
|
+
this.inputError = "Date not available";
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
this.commitDate(date);
|
|
1106
|
+
if (this.open) this.closeCalendarPopover();
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
private handleInputInput(e: Event) {
|
|
1110
|
+
this.inputText = (e.target as HTMLInputElement).value;
|
|
1111
|
+
if (this.inputError) this.inputError = null;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
private handleInputKeydown(e: KeyboardEvent) {
|
|
1115
|
+
if (e.key === "Enter") {
|
|
1116
|
+
e.preventDefault();
|
|
1117
|
+
if (this.inputText === null) {
|
|
1118
|
+
// Nothing typed — Enter opens the popover (previous trigger behavior).
|
|
1119
|
+
if (!this.open) this.toggleCalendarPopover();
|
|
1120
|
+
} else {
|
|
1121
|
+
this.commitTypedValue();
|
|
1122
|
+
}
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
if (e.key === "ArrowDown") {
|
|
807
1126
|
e.preventDefault();
|
|
808
1127
|
if (!this.open) {
|
|
809
1128
|
this.toggleCalendarPopover();
|
|
1129
|
+
} else {
|
|
1130
|
+
this.focusPanelSelection();
|
|
810
1131
|
}
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
if (e.key === "Escape" && this.inputText !== null) {
|
|
1135
|
+
// Revert the typed text; the document listener closes the popover.
|
|
1136
|
+
this.inputText = null;
|
|
1137
|
+
this.inputError = null;
|
|
811
1138
|
}
|
|
812
1139
|
}
|
|
813
1140
|
|
|
1141
|
+
/** Clicking anywhere on the field opens the popover, keeping focus typed-in. */
|
|
1142
|
+
private handleFieldClick() {
|
|
1143
|
+
if (this.open || this.disabled || this.readonly) return;
|
|
1144
|
+
this.focusPanelOnOpen = false;
|
|
1145
|
+
this.toggleCalendarPopover();
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/** The calendar icon toggles the popover with the previous focus behavior. */
|
|
1149
|
+
private handleIconClick(e: Event) {
|
|
1150
|
+
e.stopPropagation();
|
|
1151
|
+
if (this.open) {
|
|
1152
|
+
this.closeCalendarPopover();
|
|
1153
|
+
} else {
|
|
1154
|
+
this.toggleCalendarPopover();
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** Commit typed text when focus leaves the component entirely. */
|
|
1159
|
+
private handleInputBlur = (e: FocusEvent) => {
|
|
1160
|
+
this.dispatchEvent(new CustomEvent("rel-blur", { bubbles: true, composed: true }));
|
|
1161
|
+
const related = e.relatedTarget as Node | null;
|
|
1162
|
+
if (related && (this.renderRoot.contains(related) || this.contains(related))) {
|
|
1163
|
+
return; // focus moved into the panel/icons — their own handlers commit
|
|
1164
|
+
}
|
|
1165
|
+
this.commitTypedValue();
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
/** T inside the panel (grid/header focus) applies Today directly. */
|
|
1169
|
+
private handlePanelKeydown = (e: KeyboardEvent) => {
|
|
1170
|
+
if (
|
|
1171
|
+
(e.key === "t" || e.key === "T") &&
|
|
1172
|
+
!e.ctrlKey &&
|
|
1173
|
+
!e.metaKey &&
|
|
1174
|
+
!e.altKey
|
|
1175
|
+
) {
|
|
1176
|
+
e.preventDefault();
|
|
1177
|
+
this.applyDayOffsetShortcut(0);
|
|
1178
|
+
}
|
|
1179
|
+
};
|
|
1180
|
+
|
|
814
1181
|
private handleDayKeydown(e: KeyboardEvent, day: CalendarDay, index: number) {
|
|
815
1182
|
const days = this.getCalendarDays();
|
|
816
1183
|
let targetIndex = index;
|
|
@@ -844,6 +1211,7 @@ export class RelDatePicker extends LitElement {
|
|
|
844
1211
|
e.preventDefault();
|
|
845
1212
|
if (e.shiftKey) {
|
|
846
1213
|
this.viewYear--;
|
|
1214
|
+
this.announceMonth();
|
|
847
1215
|
} else {
|
|
848
1216
|
this.previousMonth();
|
|
849
1217
|
}
|
|
@@ -852,6 +1220,7 @@ export class RelDatePicker extends LitElement {
|
|
|
852
1220
|
e.preventDefault();
|
|
853
1221
|
if (e.shiftKey) {
|
|
854
1222
|
this.viewYear++;
|
|
1223
|
+
this.announceMonth();
|
|
855
1224
|
} else {
|
|
856
1225
|
this.nextMonth();
|
|
857
1226
|
}
|
|
@@ -975,6 +1344,7 @@ export class RelDatePicker extends LitElement {
|
|
|
975
1344
|
?.querySelector<HTMLElement>(".dropdown-trigger");
|
|
976
1345
|
this.viewMonth = month;
|
|
977
1346
|
this.monthDropdownOpen = false;
|
|
1347
|
+
this.announceMonth();
|
|
978
1348
|
// The focused item just went display:none — keep keyboard users anchored.
|
|
979
1349
|
this.updateComplete.then(() => trigger?.focus());
|
|
980
1350
|
}
|
|
@@ -986,6 +1356,7 @@ export class RelDatePicker extends LitElement {
|
|
|
986
1356
|
?.querySelector<HTMLElement>(".dropdown-trigger");
|
|
987
1357
|
this.viewYear = year;
|
|
988
1358
|
this.yearDropdownOpen = false;
|
|
1359
|
+
this.announceMonth();
|
|
989
1360
|
this.updateComplete.then(() => trigger?.focus());
|
|
990
1361
|
}
|
|
991
1362
|
|
|
@@ -1015,10 +1386,17 @@ export class RelDatePicker extends LitElement {
|
|
|
1015
1386
|
const months = this.getMonthsList();
|
|
1016
1387
|
const years = this.getYearsList();
|
|
1017
1388
|
const rovingIndex = this.getRovingIndex(days);
|
|
1389
|
+
const weeks: CalendarDay[][] = [];
|
|
1390
|
+
for (let i = 0; i < days.length; i += 7) {
|
|
1391
|
+
weeks.push(days.slice(i, i + 7));
|
|
1392
|
+
}
|
|
1018
1393
|
|
|
1019
|
-
const isError = this.error;
|
|
1020
|
-
const belowText = isError
|
|
1394
|
+
const isError = this.error || this.inputError !== null;
|
|
1395
|
+
const belowText = isError
|
|
1396
|
+
? this.errorMessage ?? this.inputError ?? this.helperText
|
|
1397
|
+
: this.helperText;
|
|
1021
1398
|
const describedById = belowText ? "date-picker-desc" : undefined;
|
|
1399
|
+
const displayText = this.value ? this.formatDisplayDate(this.value) : "";
|
|
1022
1400
|
const wrapperClasses = [
|
|
1023
1401
|
"date-picker-wrapper",
|
|
1024
1402
|
this.size,
|
|
@@ -1042,24 +1420,35 @@ export class RelDatePicker extends LitElement {
|
|
|
1042
1420
|
? html`<input type="hidden" name=${this.name} .value=${this.value ?? ""} />`
|
|
1043
1421
|
: nothing}
|
|
1044
1422
|
|
|
1045
|
-
<
|
|
1423
|
+
<div
|
|
1424
|
+
part="trigger"
|
|
1046
1425
|
class="trigger-button ${isError ? "error" : ""}"
|
|
1047
|
-
|
|
1048
|
-
?disabled=${this.disabled}
|
|
1049
|
-
@click=${this.toggleCalendarPopover}
|
|
1050
|
-
@keydown=${this.handleTriggerKeydown}
|
|
1051
|
-
aria-label=${this.label || "Select date"}
|
|
1052
|
-
aria-haspopup="dialog"
|
|
1053
|
-
aria-expanded=${this.open}
|
|
1054
|
-
aria-required=${this.required}
|
|
1055
|
-
aria-invalid=${isError}
|
|
1056
|
-
aria-readonly=${this.readonly}
|
|
1057
|
-
aria-describedby=${describedById ?? nothing}
|
|
1058
|
-
aria-labelledby=${this.label ? "date-picker-label" : nothing}
|
|
1426
|
+
@click=${this.handleFieldClick}
|
|
1059
1427
|
>
|
|
1060
|
-
<
|
|
1061
|
-
|
|
1062
|
-
|
|
1428
|
+
<input
|
|
1429
|
+
part="value"
|
|
1430
|
+
class="trigger-input"
|
|
1431
|
+
type="text"
|
|
1432
|
+
.value=${live(this.inputText ?? displayText)}
|
|
1433
|
+
placeholder=${this.placeholder}
|
|
1434
|
+
?disabled=${this.disabled}
|
|
1435
|
+
?readonly=${this.readonly}
|
|
1436
|
+
autocomplete="off"
|
|
1437
|
+
spellcheck="false"
|
|
1438
|
+
enterkeyhint="done"
|
|
1439
|
+
@input=${this.handleInputInput}
|
|
1440
|
+
@keydown=${this.handleInputKeydown}
|
|
1441
|
+
@blur=${this.handleInputBlur}
|
|
1442
|
+
role="combobox"
|
|
1443
|
+
aria-label=${this.label || "Select date"}
|
|
1444
|
+
aria-haspopup="dialog"
|
|
1445
|
+
aria-expanded=${this.open}
|
|
1446
|
+
aria-controls="date-picker-panel"
|
|
1447
|
+
aria-required=${this.required}
|
|
1448
|
+
aria-invalid=${isError}
|
|
1449
|
+
aria-describedby=${describedById ?? nothing}
|
|
1450
|
+
aria-labelledby=${this.label ? "date-picker-label" : nothing}
|
|
1451
|
+
/>
|
|
1063
1452
|
<span class="trigger-icons">
|
|
1064
1453
|
${this.clearable && this.value
|
|
1065
1454
|
? html`
|
|
@@ -1080,15 +1469,57 @@ export class RelDatePicker extends LitElement {
|
|
|
1080
1469
|
</button>
|
|
1081
1470
|
`
|
|
1082
1471
|
: nothing}
|
|
1083
|
-
<
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1472
|
+
<button
|
|
1473
|
+
class="icon-button"
|
|
1474
|
+
type="button"
|
|
1475
|
+
aria-label="Open calendar"
|
|
1476
|
+
aria-expanded=${this.open}
|
|
1477
|
+
?disabled=${this.disabled}
|
|
1478
|
+
@click=${this.handleIconClick}
|
|
1479
|
+
>
|
|
1480
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
|
1481
|
+
<path
|
|
1482
|
+
d="M4 2h1V1h2v1h2V1h2v1h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 3H4v7h8V5z"
|
|
1483
|
+
/>
|
|
1484
|
+
</svg>
|
|
1485
|
+
</button>
|
|
1088
1486
|
</span>
|
|
1089
|
-
</
|
|
1487
|
+
</div>
|
|
1090
1488
|
|
|
1091
|
-
<div
|
|
1489
|
+
<div
|
|
1490
|
+
id="date-picker-panel"
|
|
1491
|
+
class="popover ${this.open ? "open" : ""}"
|
|
1492
|
+
popover="manual"
|
|
1493
|
+
role="dialog"
|
|
1494
|
+
aria-label="Choose date"
|
|
1495
|
+
@keydown=${this.handlePanelKeydown}
|
|
1496
|
+
>
|
|
1497
|
+
<div part="shortcuts" class="shortcut-row">
|
|
1498
|
+
<button
|
|
1499
|
+
class="shortcut-chip"
|
|
1500
|
+
type="button"
|
|
1501
|
+
title="Type T in the field for today"
|
|
1502
|
+
@click=${() => this.applyDayOffsetShortcut(0)}
|
|
1503
|
+
>
|
|
1504
|
+
<kbd aria-hidden="true">T</kbd><span>Today</span>
|
|
1505
|
+
</button>
|
|
1506
|
+
<button
|
|
1507
|
+
class="shortcut-chip"
|
|
1508
|
+
type="button"
|
|
1509
|
+
title="Type T+1 in the field for tomorrow"
|
|
1510
|
+
@click=${() => this.applyDayOffsetShortcut(1)}
|
|
1511
|
+
>
|
|
1512
|
+
<kbd aria-hidden="true">T+1</kbd><span>Tomorrow</span>
|
|
1513
|
+
</button>
|
|
1514
|
+
<button
|
|
1515
|
+
class="shortcut-chip"
|
|
1516
|
+
type="button"
|
|
1517
|
+
title="Type T-1 in the field for yesterday"
|
|
1518
|
+
@click=${() => this.applyDayOffsetShortcut(-1)}
|
|
1519
|
+
>
|
|
1520
|
+
<kbd aria-hidden="true">T-1</kbd><span>Yesterday</span>
|
|
1521
|
+
</button>
|
|
1522
|
+
</div>
|
|
1092
1523
|
<div class="calendar-header">
|
|
1093
1524
|
<button
|
|
1094
1525
|
class="nav-button"
|
|
@@ -1203,34 +1634,42 @@ export class RelDatePicker extends LitElement {
|
|
|
1203
1634
|
)}
|
|
1204
1635
|
</div>
|
|
1205
1636
|
|
|
1206
|
-
<div class="calendar-grid" role="grid">
|
|
1207
|
-
${
|
|
1208
|
-
(
|
|
1209
|
-
<
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1637
|
+
<div class="calendar-grid" role="grid" aria-label="${this.getMonthName()} ${this.viewYear}">
|
|
1638
|
+
${weeks.map(
|
|
1639
|
+
(week, weekIndex) => html`
|
|
1640
|
+
<div class="calendar-week" role="row">
|
|
1641
|
+
${week.map((day, dayIndex) => {
|
|
1642
|
+
const index = weekIndex * 7 + dayIndex;
|
|
1643
|
+
return html`
|
|
1644
|
+
<button
|
|
1645
|
+
class="day-button ${day.isSelected ? "selected" : ""} ${day.isToday
|
|
1646
|
+
? "today"
|
|
1647
|
+
: ""} ${!day.isCurrentMonth ? "other-month" : ""}"
|
|
1648
|
+
role="gridcell"
|
|
1649
|
+
type="button"
|
|
1650
|
+
aria-label="${this.formatDayLabel(day.date)}"
|
|
1651
|
+
aria-selected="${day.isSelected}"
|
|
1652
|
+
aria-current=${day.isToday ? "date" : nothing}
|
|
1653
|
+
?disabled=${day.isDisabled}
|
|
1654
|
+
@click=${() => this.selectDay(day)}
|
|
1655
|
+
@keydown=${(e: KeyboardEvent) => this.handleDayKeydown(e, day, index)}
|
|
1656
|
+
tabindex="${index === rovingIndex ? 0 : -1}"
|
|
1657
|
+
>
|
|
1658
|
+
${day.day}
|
|
1659
|
+
</button>
|
|
1660
|
+
`;
|
|
1661
|
+
})}
|
|
1662
|
+
</div>
|
|
1224
1663
|
`
|
|
1225
1664
|
)}
|
|
1226
1665
|
</div>
|
|
1227
1666
|
</div>
|
|
1228
1667
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1668
|
+
<div
|
|
1669
|
+
class="date-picker-support ${isError ? "error" : ""} ${belowText ? "" : "empty"}"
|
|
1670
|
+
id="date-picker-desc"
|
|
1671
|
+
aria-live="polite"
|
|
1672
|
+
>${belowText ?? nothing}</div>
|
|
1234
1673
|
</div>
|
|
1235
1674
|
`;
|
|
1236
1675
|
}
|