@juspay/svelte-ui-components 2.111.1 → 2.111.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.
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
DateRangePickerProperties,
|
|
4
|
+
DateRangePreset,
|
|
5
|
+
TimeDisplayBoundary
|
|
6
|
+
} from './properties';
|
|
3
7
|
import { tick, untrack } from 'svelte';
|
|
4
8
|
import { SvelteDate } from 'svelte/reactivity';
|
|
5
9
|
import Calendar from '../Calendar/Calendar.svelte';
|
|
@@ -11,6 +15,7 @@
|
|
|
11
15
|
TIME_DISPLAY_PATTERN,
|
|
12
16
|
applyTimeDisplay,
|
|
13
17
|
formatTimeDisplay,
|
|
18
|
+
parseDateDisplay,
|
|
14
19
|
toMinutesOfDay
|
|
15
20
|
} from './timeUtils';
|
|
16
21
|
|
|
@@ -80,6 +85,23 @@
|
|
|
80
85
|
let endTimeDisplay: string = $state('11:59 PM');
|
|
81
86
|
let showTimeRow: boolean = $state(false);
|
|
82
87
|
|
|
88
|
+
// Typed text for the built-in date inputs (showDateInputs, range mode). The displayed
|
|
89
|
+
// text is *derived* from draftStart/draftEnd — the single source of truth — so it can
|
|
90
|
+
// never go stale relative to a draft change made elsewhere (preset pick, calendar
|
|
91
|
+
// click, the initialPresetLabel seed effect, ...). While the user is actively editing
|
|
92
|
+
// a field, its edit buffer holds the in-progress text instead (a half-typed string
|
|
93
|
+
// must never reach the calendar grid or Apply's canApply check); the buffer resets to
|
|
94
|
+
// null — falling back to the canonical formatted date — on every successful or
|
|
95
|
+
// rejected commit, see commitTypedDate.
|
|
96
|
+
let startDateEditBuffer: string | null = $state(null);
|
|
97
|
+
let endDateEditBuffer: string | null = $state(null);
|
|
98
|
+
const startDateInputText: string = $derived(
|
|
99
|
+
startDateEditBuffer ?? (draftStart !== null ? formatDate(draftStart) : '')
|
|
100
|
+
);
|
|
101
|
+
const endDateInputText: string = $derived(
|
|
102
|
+
endDateEditBuffer ?? (draftEnd !== null ? formatDate(draftEnd) : '')
|
|
103
|
+
);
|
|
104
|
+
|
|
83
105
|
// Inline time layout (timeSelectionLayout="inline"): the start/end time inputs
|
|
84
106
|
// render beside their date inputs on the same row, always visible — no clock
|
|
85
107
|
// toggle and no collapsible row. All seeding/validation/fold logic is shared
|
|
@@ -99,11 +121,14 @@
|
|
|
99
121
|
|
|
100
122
|
// When maxRangeDays is set, disable any date whose span from the in-progress
|
|
101
123
|
// start would exceed the limit (inclusive of both ends), until the range is
|
|
102
|
-
// completed. Falls back to the raw disabledDates otherwise.
|
|
124
|
+
// completed. Falls back to the raw disabledDates otherwise. Always returns a
|
|
125
|
+
// callable predicate (rather than sometimes the raw Date[]/predicate union) so
|
|
126
|
+
// it can be called directly — by both the Calendar grid and isDateSelectable's
|
|
127
|
+
// typed-date validation — without a union-type call-signature error.
|
|
103
128
|
const rangeConstrainedDisabledDates = $derived.by(() => {
|
|
104
129
|
const limit = maxRangeDays;
|
|
105
130
|
if (limit === null) {
|
|
106
|
-
return
|
|
131
|
+
return (date: Date): boolean => isBaseDisabledDate(date);
|
|
107
132
|
}
|
|
108
133
|
const anchor = draftStart;
|
|
109
134
|
const selecting = anchor !== null && draftEnd === null;
|
|
@@ -200,10 +225,6 @@
|
|
|
200
225
|
return d.toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
201
226
|
}
|
|
202
227
|
|
|
203
|
-
// Read-only date-input box contents (opt-in via showDateInputs), reflecting the draft.
|
|
204
|
-
const draftStartDateLabel: string = $derived(draftStart !== null ? formatDate(draftStart) : '');
|
|
205
|
-
const draftEndDateLabel: string = $derived(draftEnd !== null ? formatDate(draftEnd) : '');
|
|
206
|
-
|
|
207
228
|
const isStartTimeValid: boolean = $derived(TIME_DISPLAY_PATTERN.test(startTimeDisplay.trim()));
|
|
208
229
|
const isEndTimeValid: boolean = $derived(TIME_DISPLAY_PATTERN.test(endTimeDisplay.trim()));
|
|
209
230
|
// Apply is blocked while a time is malformed, or while both ends fall on the same
|
|
@@ -259,6 +280,13 @@
|
|
|
259
280
|
// by label. A direct calendar click later clears this, reverting to date matching.
|
|
260
281
|
selectedPresetLabel = committedPresetLabel;
|
|
261
282
|
|
|
283
|
+
// Discard any in-progress typed text from a previous session — the date-input
|
|
284
|
+
// display text is derived from draftStart/draftEnd above, so it already reflects
|
|
285
|
+
// whatever draft this open seeds (including a later re-seed from initialPresetLabel,
|
|
286
|
+
// which runs in an $effect.pre after this synchronous assignment).
|
|
287
|
+
startDateEditBuffer = null;
|
|
288
|
+
endDateEditBuffer = null;
|
|
289
|
+
|
|
262
290
|
// Seed the time inputs from the committed range's time-of-day, and collapse the
|
|
263
291
|
// time row so each open starts from the date view.
|
|
264
292
|
if (showTimeSelection) {
|
|
@@ -407,6 +435,8 @@
|
|
|
407
435
|
draftEnd = rangeEnd;
|
|
408
436
|
draftValue = value;
|
|
409
437
|
selectedPresetLabel = committedPresetLabel;
|
|
438
|
+
startDateEditBuffer = null;
|
|
439
|
+
endDateEditBuffer = null;
|
|
410
440
|
const anchor = rangeStart !== null ? rangeStart : value !== null ? value : now;
|
|
411
441
|
leftYear = anchor.getFullYear();
|
|
412
442
|
leftMonth = anchor.getMonth();
|
|
@@ -420,6 +450,10 @@
|
|
|
420
450
|
if (mode === 'range') {
|
|
421
451
|
draftStart = start;
|
|
422
452
|
draftEnd = end;
|
|
453
|
+
// Discard any in-progress typed text — the date-input display text is derived
|
|
454
|
+
// from draftStart/draftEnd, so it already reflects the preset's own start/end.
|
|
455
|
+
startDateEditBuffer = null;
|
|
456
|
+
endDateEditBuffer = null;
|
|
423
457
|
// Reseed the time-of-day inputs from the preset's own start/end so a
|
|
424
458
|
// time-bearing preset (e.g. "Last 30 minutes" / "Last 12 Hours") is not
|
|
425
459
|
// silently overwritten by a stale display string in handleApply's
|
|
@@ -443,6 +477,9 @@
|
|
|
443
477
|
draftEnd = event.rangeEnd;
|
|
444
478
|
// A direct calendar click is not a preset selection.
|
|
445
479
|
selectedPresetLabel = null;
|
|
480
|
+
// Discard any in-progress typed text in favor of the calendar's own selection.
|
|
481
|
+
startDateEditBuffer = null;
|
|
482
|
+
endDateEditBuffer = null;
|
|
446
483
|
if (showTimeSelection) {
|
|
447
484
|
startTimeDisplay = '12:00 AM';
|
|
448
485
|
endTimeDisplay = isSameDay(event.rangeEnd, now) ? formatTimeDisplay(now) : '11:59 PM';
|
|
@@ -455,6 +492,177 @@
|
|
|
455
492
|
selectedPresetLabel = null;
|
|
456
493
|
}
|
|
457
494
|
|
|
495
|
+
// Whether a typed date passes the same minDate/maxDate/disabledDates/maxRangeDays
|
|
496
|
+
// constraints the calendar grid itself enforces — a typed date must never commit
|
|
497
|
+
// somewhere the grid would have refused to let the user click. Checks
|
|
498
|
+
// rangeConstrainedDisabledDates (not just the raw disabledDates) so a typed end date
|
|
499
|
+
// that would blow past maxRangeDays mid-selection is rejected exactly like a click on
|
|
500
|
+
// the same grid cell would be.
|
|
501
|
+
function isDateSelectable(date: Date): boolean {
|
|
502
|
+
if (minDate !== null) {
|
|
503
|
+
const normalizedMinDate = new SvelteDate(
|
|
504
|
+
minDate.getFullYear(),
|
|
505
|
+
minDate.getMonth(),
|
|
506
|
+
minDate.getDate()
|
|
507
|
+
);
|
|
508
|
+
if (date.getTime() < normalizedMinDate.getTime()) {
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
if (maxDate !== null) {
|
|
513
|
+
const normalizedMaxDate = new SvelteDate(
|
|
514
|
+
maxDate.getFullYear(),
|
|
515
|
+
maxDate.getMonth(),
|
|
516
|
+
maxDate.getDate()
|
|
517
|
+
);
|
|
518
|
+
if (date.getTime() > normalizedMaxDate.getTime()) {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return !rangeConstrainedDisabledDates(date);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// The single acceptance rule for a typed date, shared by the live invalid-border
|
|
526
|
+
// feedback and by commitTypedDate. Keeping one predicate is the point: while these
|
|
527
|
+
// were two separate expressions, the border could stay clean for a date that commit
|
|
528
|
+
// then silently rejected, leaving no cue as to why the field reverted.
|
|
529
|
+
//
|
|
530
|
+
// Three ways a parsed date is refused:
|
|
531
|
+
// 1. it isn't selectable at all (minDate/maxDate/disabled, and the maxRangeDays
|
|
532
|
+
// span while a range is mid-selection),
|
|
533
|
+
// 2. it crosses the opposite boundary (a start after the end, or vice versa),
|
|
534
|
+
// 3. it would stretch an *already-complete* range past maxRangeDays.
|
|
535
|
+
// Case 3 is not covered by isDateSelectable: rangeConstrainedDisabledDates only
|
|
536
|
+
// applies its span check while `selecting` is true (draftStart set, draftEnd still
|
|
537
|
+
// null). Once both boundaries exist that guard goes quiet, so retyping either one
|
|
538
|
+
// could otherwise commit a range longer than the calendar grid would ever allow.
|
|
539
|
+
function isTypedDateAcceptable(boundary: TimeDisplayBoundary, candidate: Date): boolean {
|
|
540
|
+
if (!isDateSelectable(candidate)) {
|
|
541
|
+
return false;
|
|
542
|
+
}
|
|
543
|
+
const otherBoundaryDate = boundary === 'start' ? draftEnd : draftStart;
|
|
544
|
+
if (otherBoundaryDate === null) {
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
const crossesOtherBoundary =
|
|
548
|
+
boundary === 'start'
|
|
549
|
+
? candidate.getTime() > otherBoundaryDate.getTime()
|
|
550
|
+
: candidate.getTime() < otherBoundaryDate.getTime();
|
|
551
|
+
if (crossesOtherBoundary) {
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
if (maxRangeDays !== null) {
|
|
555
|
+
const spanDays =
|
|
556
|
+
Math.abs(Math.round((candidate.getTime() - otherBoundaryDate.getTime()) / MS_PER_DAY)) + 1;
|
|
557
|
+
if (spanDays > maxRangeDays) {
|
|
558
|
+
return false;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return true;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Live validity feedback for the typed date-input text, mirroring isStartTimeValid /
|
|
565
|
+
// isEndTimeValid — the field is flagged invalid as soon as the typed text can't parse
|
|
566
|
+
// into a date this picker would accept, matching the time input's parse-as-you-type
|
|
567
|
+
// styling. Empty text is not flagged invalid; nothing has been typed yet.
|
|
568
|
+
const isStartDateValid: boolean = $derived.by(() => {
|
|
569
|
+
const trimmedStartDateText = startDateInputText.trim();
|
|
570
|
+
if (trimmedStartDateText === '') {
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
const parsedStartDate = parseDateDisplay(startDateInputText);
|
|
574
|
+
return parsedStartDate !== null && isTypedDateAcceptable('start', parsedStartDate);
|
|
575
|
+
});
|
|
576
|
+
const isEndDateValid: boolean = $derived.by(() => {
|
|
577
|
+
const trimmedEndDateText = endDateInputText.trim();
|
|
578
|
+
if (trimmedEndDateText === '') {
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
const parsedEndDate = parseDateDisplay(endDateInputText);
|
|
582
|
+
return parsedEndDate !== null && isTypedDateAcceptable('end', parsedEndDate);
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// True when date's month is already one of the two visible calendar months (or the
|
|
586
|
+
// single visible month outside dual-month mode) — used to avoid an unnecessary
|
|
587
|
+
// re-navigation/remount when a typed date is already on screen.
|
|
588
|
+
function isMonthVisible(date: Date): boolean {
|
|
589
|
+
const isLeftMonthVisible = date.getFullYear() === leftYear && date.getMonth() === leftMonth;
|
|
590
|
+
if (!isDualMonth) {
|
|
591
|
+
return isLeftMonthVisible;
|
|
592
|
+
}
|
|
593
|
+
const rightMonth = new SvelteDate(leftYear, leftMonth + 1, 1);
|
|
594
|
+
const isRightMonthVisible =
|
|
595
|
+
date.getFullYear() === rightMonth.getFullYear() && date.getMonth() === rightMonth.getMonth();
|
|
596
|
+
return isLeftMonthVisible || isRightMonthVisible;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function navigateCalendarTo(date: Date): void {
|
|
600
|
+
if (isMonthVisible(date)) {
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
leftYear = date.getFullYear();
|
|
604
|
+
leftMonth = date.getMonth();
|
|
605
|
+
calendarKey++;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Discard one boundary's in-progress edit buffer, falling back to the derived display
|
|
609
|
+
// of its current draft value. Used both to normalize a successful commit and to revert
|
|
610
|
+
// a rejected/unparseable one.
|
|
611
|
+
function clearDateEditBuffer(boundary: TimeDisplayBoundary): void {
|
|
612
|
+
if (boundary === 'start') {
|
|
613
|
+
startDateEditBuffer = null;
|
|
614
|
+
} else {
|
|
615
|
+
endDateEditBuffer = null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Record the in-progress typed text for one date-input boundary as the user types.
|
|
620
|
+
function handleDateInputChange(event: Event, boundary: TimeDisplayBoundary): void {
|
|
621
|
+
if (!(event.currentTarget instanceof HTMLInputElement)) {
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
if (boundary === 'start') {
|
|
625
|
+
startDateEditBuffer = event.currentTarget.value;
|
|
626
|
+
} else {
|
|
627
|
+
endDateEditBuffer = event.currentTarget.value;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Parse and commit the typed text for one date-input boundary (called on blur and on
|
|
632
|
+
// Enter). Invalid text (unparseable, outside minDate/maxDate, disabled, exceeding
|
|
633
|
+
// maxRangeDays, or crossing the other boundary) is rejected — the field reverts to the
|
|
634
|
+
// last valid value instead of committing garbage into draftStart/draftEnd. A no-op
|
|
635
|
+
// when the field was never edited (buffer already null — nothing to commit).
|
|
636
|
+
function commitTypedDate(boundary: TimeDisplayBoundary): void {
|
|
637
|
+
const editBuffer = boundary === 'start' ? startDateEditBuffer : endDateEditBuffer;
|
|
638
|
+
if (editBuffer === null) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const parsedDate = parseDateDisplay(editBuffer);
|
|
642
|
+
|
|
643
|
+
if (parsedDate === null || !isTypedDateAcceptable(boundary, parsedDate)) {
|
|
644
|
+
clearDateEditBuffer(boundary);
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (boundary === 'start') {
|
|
649
|
+
draftStart = parsedDate;
|
|
650
|
+
} else {
|
|
651
|
+
draftEnd = parsedDate;
|
|
652
|
+
}
|
|
653
|
+
// A typed date is a custom selection, same as a direct calendar click.
|
|
654
|
+
selectedPresetLabel = null;
|
|
655
|
+
navigateCalendarTo(parsedDate);
|
|
656
|
+
clearDateEditBuffer(boundary);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function handleDateInputKeyDown(event: KeyboardEvent, boundary: TimeDisplayBoundary): void {
|
|
660
|
+
if (event.key === 'Enter') {
|
|
661
|
+
event.preventDefault();
|
|
662
|
+
commitTypedDate(boundary);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
458
666
|
// Close on outside-click
|
|
459
667
|
function handleDocumentClick(event: MouseEvent): void {
|
|
460
668
|
if (!isOpen && !openCompare) {
|
|
@@ -705,13 +913,21 @@
|
|
|
705
913
|
<div class="drp-date-input-row">
|
|
706
914
|
{#if isInlineTime}
|
|
707
915
|
<div class="drp-date-time-group">
|
|
708
|
-
<div
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
916
|
+
<div class="drp-date-input" class:drp-date-input-invalid={!isStartDateValid}>
|
|
917
|
+
<input
|
|
918
|
+
type="text"
|
|
919
|
+
class="drp-date-input-value"
|
|
920
|
+
value={startDateInputText}
|
|
921
|
+
oninput={(event) => handleDateInputChange(event, 'start')}
|
|
922
|
+
maxlength="32"
|
|
923
|
+
placeholder="Start date"
|
|
924
|
+
aria-label="Start date"
|
|
925
|
+
aria-invalid={!isStartDateValid}
|
|
926
|
+
onblur={() => commitTypedDate('start')}
|
|
927
|
+
onkeydown={(event) => handleDateInputKeyDown(event, 'start')}
|
|
928
|
+
data-pw={testId ? `${testId}-start-date` : null}
|
|
929
|
+
testID={testId ? `${testId}-start-date` : null}
|
|
930
|
+
/>
|
|
715
931
|
</div>
|
|
716
932
|
<div
|
|
717
933
|
class="drp-time-input drp-time-input-inline"
|
|
@@ -735,12 +951,21 @@
|
|
|
735
951
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
736
952
|
<span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
|
|
737
953
|
<div class="drp-date-time-group">
|
|
738
|
-
<div
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
954
|
+
<div class="drp-date-input" class:drp-date-input-invalid={!isEndDateValid}>
|
|
955
|
+
<input
|
|
956
|
+
type="text"
|
|
957
|
+
class="drp-date-input-value"
|
|
958
|
+
value={endDateInputText}
|
|
959
|
+
oninput={(event) => handleDateInputChange(event, 'end')}
|
|
960
|
+
maxlength="32"
|
|
961
|
+
placeholder="End date"
|
|
962
|
+
aria-label="End date"
|
|
963
|
+
aria-invalid={!isEndDateValid}
|
|
964
|
+
onblur={() => commitTypedDate('end')}
|
|
965
|
+
onkeydown={(event) => handleDateInputKeyDown(event, 'end')}
|
|
966
|
+
data-pw={testId ? `${testId}-end-date` : null}
|
|
967
|
+
testID={testId ? `${testId}-end-date` : null}
|
|
968
|
+
/>
|
|
744
969
|
</div>
|
|
745
970
|
<div
|
|
746
971
|
class="drp-time-input drp-time-input-inline"
|
|
@@ -762,21 +987,39 @@
|
|
|
762
987
|
</div>
|
|
763
988
|
</div>
|
|
764
989
|
{:else}
|
|
765
|
-
<div
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
990
|
+
<div class="drp-date-input" class:drp-date-input-invalid={!isStartDateValid}>
|
|
991
|
+
<input
|
|
992
|
+
type="text"
|
|
993
|
+
class="drp-date-input-value"
|
|
994
|
+
value={startDateInputText}
|
|
995
|
+
oninput={(event) => handleDateInputChange(event, 'start')}
|
|
996
|
+
maxlength="32"
|
|
997
|
+
placeholder="Start date"
|
|
998
|
+
aria-label="Start date"
|
|
999
|
+
aria-invalid={!isStartDateValid}
|
|
1000
|
+
onblur={() => commitTypedDate('start')}
|
|
1001
|
+
onkeydown={(event) => handleDateInputKeyDown(event, 'start')}
|
|
1002
|
+
data-pw={testId ? `${testId}-start-date` : null}
|
|
1003
|
+
testID={testId ? `${testId}-start-date` : null}
|
|
1004
|
+
/>
|
|
771
1005
|
</div>
|
|
772
1006
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
773
1007
|
<span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
|
|
774
|
-
<div
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
1008
|
+
<div class="drp-date-input" class:drp-date-input-invalid={!isEndDateValid}>
|
|
1009
|
+
<input
|
|
1010
|
+
type="text"
|
|
1011
|
+
class="drp-date-input-value"
|
|
1012
|
+
value={endDateInputText}
|
|
1013
|
+
oninput={(event) => handleDateInputChange(event, 'end')}
|
|
1014
|
+
maxlength="32"
|
|
1015
|
+
placeholder="End date"
|
|
1016
|
+
aria-label="End date"
|
|
1017
|
+
aria-invalid={!isEndDateValid}
|
|
1018
|
+
onblur={() => commitTypedDate('end')}
|
|
1019
|
+
onkeydown={(event) => handleDateInputKeyDown(event, 'end')}
|
|
1020
|
+
data-pw={testId ? `${testId}-end-date` : null}
|
|
1021
|
+
testID={testId ? `${testId}-end-date` : null}
|
|
1022
|
+
/>
|
|
780
1023
|
</div>
|
|
781
1024
|
{#if showTimeSelection}
|
|
782
1025
|
<button
|
|
@@ -1218,15 +1461,30 @@
|
|
|
1218
1461
|
background: var(--drp-date-input-background, #ffffff);
|
|
1219
1462
|
}
|
|
1220
1463
|
|
|
1464
|
+
.drp-date-input-invalid {
|
|
1465
|
+
border-color: var(--drp-date-input-invalid-border, #e5484d);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1221
1468
|
.drp-date-input-value {
|
|
1222
1469
|
display: block;
|
|
1470
|
+
width: 100%;
|
|
1471
|
+
min-width: 0;
|
|
1472
|
+
padding: 0;
|
|
1473
|
+
border: none;
|
|
1474
|
+
outline: none;
|
|
1475
|
+
background: transparent;
|
|
1223
1476
|
color: var(--drp-date-input-color, #333333);
|
|
1224
1477
|
font-size: var(--drp-date-input-font-size, 13px);
|
|
1478
|
+
font-family: inherit;
|
|
1225
1479
|
white-space: nowrap;
|
|
1226
1480
|
overflow: hidden;
|
|
1227
1481
|
text-overflow: ellipsis;
|
|
1228
1482
|
}
|
|
1229
1483
|
|
|
1484
|
+
.drp-date-input-value::placeholder {
|
|
1485
|
+
color: var(--drp-date-input-placeholder-color, #aaaaaa);
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1230
1488
|
.drp-datetime-arrow {
|
|
1231
1489
|
flex-shrink: 0;
|
|
1232
1490
|
display: inline-flex;
|
|
@@ -43,8 +43,13 @@ export type OptionalDateRangePickerProperties = {
|
|
|
43
43
|
*/
|
|
44
44
|
presetCheckmark?: boolean;
|
|
45
45
|
/**
|
|
46
|
-
* Show
|
|
47
|
-
*
|
|
46
|
+
* Show typeable start/end date boxes at the top of the calendar area (range mode).
|
|
47
|
+
* Each box seeds from the current draft selection and can be edited directly by
|
|
48
|
+
* typing a date; the typed text is parsed and committed on blur or Enter. Accepts
|
|
49
|
+
* the component's own display format ("Jul 10, 2026"), numeric "M/D/YYYY", or ISO
|
|
50
|
+
* "YYYY-MM-DD". Unparseable, out-of-range (minDate/maxDate), disabled, or
|
|
51
|
+
* boundary-crossing (start after end, or vice versa) input is rejected — the field
|
|
52
|
+
* reverts to its last valid value instead of committing garbage. Opt-in. Default: false.
|
|
48
53
|
*/
|
|
49
54
|
showDateInputs?: boolean;
|
|
50
55
|
/**
|
|
@@ -12,3 +12,9 @@ export declare const formatTimeDisplay: (date: Date) => string;
|
|
|
12
12
|
export declare const applyTimeDisplay: (date: Date, display: string, boundary: TimeDisplayBoundary) => Date | null;
|
|
13
13
|
/** Minutes-since-midnight for ordering comparisons, or null when the display is invalid. */
|
|
14
14
|
export declare const toMinutesOfDay: (display: string) => number | null;
|
|
15
|
+
/**
|
|
16
|
+
* Parse a typed calendar-date string into a local-midnight Date, or null when the text is
|
|
17
|
+
* unparseable or names a calendar date that does not exist (e.g. "Feb 30, 2026"). Accepts the
|
|
18
|
+
* component's own display format ("Jul 10, 2026"), numeric "M/D/YYYY", and ISO "YYYY-MM-DD".
|
|
19
|
+
*/
|
|
20
|
+
export declare const parseDateDisplay: (display: string) => Date | null;
|
|
@@ -45,3 +45,74 @@ export const toMinutesOfDay = (display) => {
|
|
|
45
45
|
const parsed = parseTimeDisplay(display);
|
|
46
46
|
return parsed === null ? null : parsed.hours * 60 + parsed.minutes;
|
|
47
47
|
};
|
|
48
|
+
const SHORT_MONTH_NAMES = [
|
|
49
|
+
'jan',
|
|
50
|
+
'feb',
|
|
51
|
+
'mar',
|
|
52
|
+
'apr',
|
|
53
|
+
'may',
|
|
54
|
+
'jun',
|
|
55
|
+
'jul',
|
|
56
|
+
'aug',
|
|
57
|
+
'sep',
|
|
58
|
+
'oct',
|
|
59
|
+
'nov',
|
|
60
|
+
'dec'
|
|
61
|
+
];
|
|
62
|
+
/** Matches the DateRangePicker's own display format, e.g. "Jul 10, 2026" or "Jul 10 2026". */
|
|
63
|
+
const MONTH_NAME_DATE_PATTERN = /^([A-Za-z]{3,})\.?\s+(\d{1,2}),?\s+(\d{4})$/;
|
|
64
|
+
/** Matches numeric "M/D/YYYY" or "M-D-YYYY" input. */
|
|
65
|
+
const NUMERIC_SLASH_DATE_PATTERN = /^(\d{1,2})[/-](\d{1,2})[/-](\d{4})$/;
|
|
66
|
+
/** Matches ISO "YYYY-MM-DD" input. */
|
|
67
|
+
const ISO_DATE_PATTERN = /^(\d{4})-(\d{1,2})-(\d{1,2})$/;
|
|
68
|
+
/**
|
|
69
|
+
* Build a local-midnight Date for the given calendar parts.
|
|
70
|
+
*
|
|
71
|
+
* `new Date(year, ...)` maps years 0-99 onto 1900-1999, so a literal the parser accepts
|
|
72
|
+
* as four digits — "0050-01-01" — would silently land in 1950. setFullYear has no such
|
|
73
|
+
* remapping, so the date is constructed neutrally and then stamped.
|
|
74
|
+
*/
|
|
75
|
+
const localMidnightFromParts = (year, monthIndex, day) => {
|
|
76
|
+
const date = new Date();
|
|
77
|
+
date.setFullYear(year, monthIndex, day);
|
|
78
|
+
date.setHours(0, 0, 0, 0);
|
|
79
|
+
return date;
|
|
80
|
+
};
|
|
81
|
+
const daysInMonth = (year, monthIndex) =>
|
|
82
|
+
// Day 0 of the next month is the last day of this one.
|
|
83
|
+
localMidnightFromParts(year, monthIndex + 1, 0).getDate();
|
|
84
|
+
/** Build a local-midnight Date from year/monthIndex/day, or null when the calendar date does not exist. */
|
|
85
|
+
const buildDateFromParts = (year, monthIndex, day) => {
|
|
86
|
+
if (monthIndex < 0 || monthIndex > 11 || day < 1 || day > daysInMonth(year, monthIndex)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
return localMidnightFromParts(year, monthIndex, day);
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Parse a typed calendar-date string into a local-midnight Date, or null when the text is
|
|
93
|
+
* unparseable or names a calendar date that does not exist (e.g. "Feb 30, 2026"). Accepts the
|
|
94
|
+
* component's own display format ("Jul 10, 2026"), numeric "M/D/YYYY", and ISO "YYYY-MM-DD".
|
|
95
|
+
*/
|
|
96
|
+
export const parseDateDisplay = (display) => {
|
|
97
|
+
const trimmedDisplay = display.trim();
|
|
98
|
+
if (trimmedDisplay === '') {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const isoMatch = ISO_DATE_PATTERN.exec(trimmedDisplay);
|
|
102
|
+
if (isoMatch !== null) {
|
|
103
|
+
return buildDateFromParts(Number(isoMatch[1]), Number(isoMatch[2]) - 1, Number(isoMatch[3]));
|
|
104
|
+
}
|
|
105
|
+
const monthNameMatch = MONTH_NAME_DATE_PATTERN.exec(trimmedDisplay);
|
|
106
|
+
if (monthNameMatch !== null) {
|
|
107
|
+
const monthIndex = SHORT_MONTH_NAMES.indexOf(monthNameMatch[1].slice(0, 3).toLowerCase());
|
|
108
|
+
if (monthIndex === -1) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return buildDateFromParts(Number(monthNameMatch[3]), monthIndex, Number(monthNameMatch[2]));
|
|
112
|
+
}
|
|
113
|
+
const slashMatch = NUMERIC_SLASH_DATE_PATTERN.exec(trimmedDisplay);
|
|
114
|
+
if (slashMatch !== null) {
|
|
115
|
+
return buildDateFromParts(Number(slashMatch[3]), Number(slashMatch[1]) - 1, Number(slashMatch[2]));
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
};
|
package/dist/Tabs/Tabs.svelte
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { tick } from 'svelte';
|
|
2
3
|
import type { TabItem, TabsProperties } from './properties';
|
|
3
4
|
import Img from '../Img/Img.svelte';
|
|
4
5
|
import chevronLeftSvg from '../assets/chevron-left.svg?raw';
|
|
@@ -149,11 +150,49 @@
|
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
|
|
153
|
+
// After a keyboard-driven selection the active item changes (directly in
|
|
154
|
+
// string mode; via the consumer's activeKey update in object mode), and the
|
|
155
|
+
// roving tabindex re-roves with it. Move DOM focus onto the newly active tab
|
|
156
|
+
// so the user can keep arrowing — without this, focus is stranded on a
|
|
157
|
+
// tabindex="-1" item and the roving pattern breaks down.
|
|
158
|
+
function moveFocusToActiveTab(): void {
|
|
159
|
+
void tick().then(() => {
|
|
160
|
+
const activeTab = scrollContainer?.querySelector<HTMLElement>('[role="tab"][tabindex="0"]');
|
|
161
|
+
activeTab?.focus();
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// WAI-ARIA APG tablist keyboard contract with activation-follows-focus:
|
|
166
|
+
// Arrow keys (orientation-aware) move selection to the adjacent tab with
|
|
167
|
+
// wrap-around, Home/End jump to the first/last tab. Without this, the roving
|
|
168
|
+
// tabindex makes every non-active tab keyboard-unreachable.
|
|
152
169
|
function handleKeydown(event: KeyboardEvent, index: number): void {
|
|
153
170
|
if (event.key === 'Enter' || event.key === ' ') {
|
|
154
171
|
event.preventDefault();
|
|
155
172
|
handleTabClick(index);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const nextKey = isVertical ? 'ArrowDown' : 'ArrowRight';
|
|
176
|
+
const previousKey = isVertical ? 'ArrowUp' : 'ArrowLeft';
|
|
177
|
+
let targetIndex: number | null = null;
|
|
178
|
+
if (event.key === nextKey) {
|
|
179
|
+
targetIndex = (index + 1) % items.length;
|
|
180
|
+
} else if (event.key === previousKey) {
|
|
181
|
+
targetIndex = (index - 1 + items.length) % items.length;
|
|
182
|
+
} else if (event.key === 'Home') {
|
|
183
|
+
targetIndex = 0;
|
|
184
|
+
} else if (event.key === 'End') {
|
|
185
|
+
targetIndex = items.length - 1;
|
|
186
|
+
}
|
|
187
|
+
if (targetIndex === null) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
if (disabled || targetIndex === index) {
|
|
192
|
+
return;
|
|
156
193
|
}
|
|
194
|
+
handleTabClick(targetIndex);
|
|
195
|
+
moveFocusToActiveTab();
|
|
157
196
|
}
|
|
158
197
|
|
|
159
198
|
function initOverflow(node: HTMLDivElement): () => void {
|
|
@@ -209,6 +248,7 @@
|
|
|
209
248
|
class:fade-top={canScrollUp}
|
|
210
249
|
class:fade-bottom={canScrollDown}
|
|
211
250
|
role="tablist"
|
|
251
|
+
aria-orientation={isVertical ? 'vertical' : null}
|
|
212
252
|
{@attach initOverflow}
|
|
213
253
|
onscroll={updateOverflow}
|
|
214
254
|
>
|