@juspay/svelte-ui-components 2.68.0 → 2.69.1

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.
@@ -6,6 +6,16 @@
6
6
  import Button from '../Button/Button.svelte';
7
7
  import chevronDownSvg from '../assets/chevron-down.svg?raw';
8
8
  import checkmarkSvg from '../assets/checkmark.svg?raw';
9
+ import chevronRightSvg from '../assets/chevron-right.svg?raw';
10
+ import {
11
+ TIME_DISPLAY_PATTERN,
12
+ applyTimeDisplay,
13
+ formatTimeDisplay,
14
+ toMinutesOfDay
15
+ } from './timeUtils';
16
+
17
+ const clockSvg =
18
+ '<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="8" cy="8" r="6.25" stroke="currentColor" stroke-width="1.5"/><path d="M8 4.5V8l2.25 1.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
9
19
 
10
20
  let {
11
21
  rangeStart = $bindable(null),
@@ -18,6 +28,8 @@
18
28
  maxRangeDays = null,
19
29
  presets = null,
20
30
  presetCheckmark = false,
31
+ showDateInputs = false,
32
+ showTimeSelection = false,
21
33
  placeholder = 'Select date',
22
34
  dualMonth,
23
35
  timePicker,
@@ -58,6 +70,14 @@
58
70
  // made a custom calendar selection (or before any selection is made).
59
71
  let selectedPresetLabel: string | null = $state(null);
60
72
 
73
+ // Built-in time-of-day selection (opt-in via showTimeSelection). Display strings
74
+ // are 12-hour ("02:30 PM"); they seed from the draft dates when the picker opens
75
+ // and are combined back onto the committed range in handleApply. showTimeRow drives
76
+ // the collapsible time inputs, toggled by the clock button in the date-input row.
77
+ let startTimeDisplay: string = $state('12:00 AM');
78
+ let endTimeDisplay: string = $state('11:59 PM');
79
+ let showTimeRow: boolean = $state(false);
80
+
61
81
  const MS_PER_DAY = 24 * 60 * 60 * 1000;
62
82
 
63
83
  function isBaseDisabledDate(date: Date): boolean {
@@ -170,6 +190,29 @@
170
190
  return d.toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' });
171
191
  }
172
192
 
193
+ // Read-only date-input box contents (opt-in via showDateInputs), reflecting the draft.
194
+ const draftStartDateLabel: string = $derived(draftStart !== null ? formatDate(draftStart) : '');
195
+ const draftEndDateLabel: string = $derived(draftEnd !== null ? formatDate(draftEnd) : '');
196
+
197
+ const isStartTimeValid: boolean = $derived(TIME_DISPLAY_PATTERN.test(startTimeDisplay.trim()));
198
+ const isEndTimeValid: boolean = $derived(TIME_DISPLAY_PATTERN.test(endTimeDisplay.trim()));
199
+ // Apply is blocked while a time is malformed, or while both ends fall on the same
200
+ // calendar day and the start time is after the end time. Different days are fine.
201
+ const isTimeRangeValid: boolean = $derived.by(() => {
202
+ if (!showTimeSelection) {
203
+ return true;
204
+ }
205
+ if (!isStartTimeValid || !isEndTimeValid) {
206
+ return false;
207
+ }
208
+ if (draftStart === null || draftEnd === null || !isSameDay(draftStart, draftEnd)) {
209
+ return true;
210
+ }
211
+ const startMinutes = toMinutesOfDay(startTimeDisplay);
212
+ const endMinutes = toMinutesOfDay(endTimeDisplay);
213
+ return startMinutes === null || endMinutes === null || startMinutes <= endMinutes;
214
+ });
215
+
173
216
  const triggerLabel: string = $derived.by(() => {
174
217
  // If an initial preset label is active (seeded on mount, not yet overridden), show it
175
218
  if (activePresetLabel !== null) {
@@ -206,6 +249,14 @@
206
249
  // by label. A direct calendar click later clears this, reverting to date matching.
207
250
  selectedPresetLabel = committedPresetLabel;
208
251
 
252
+ // Seed the time inputs from the committed range's time-of-day, and collapse the
253
+ // time row so each open starts from the date view.
254
+ if (showTimeSelection) {
255
+ startTimeDisplay = draftStart !== null ? formatTimeDisplay(draftStart) : '12:00 AM';
256
+ endTimeDisplay = draftEnd !== null ? formatTimeDisplay(draftEnd) : '11:59 PM';
257
+ showTimeRow = false;
258
+ }
259
+
209
260
  // Navigate left calendar so it shows the committed start month (or today)
210
261
  const anchor = rangeStart !== null ? rangeStart : value !== null ? value : now;
211
262
  leftYear = anchor.getFullYear();
@@ -221,6 +272,17 @@
221
272
  onopentoggle?.({ open: false });
222
273
  }
223
274
 
275
+ // The trigger toggles: clicking it while the panel is already open dismisses
276
+ // the picker instead of re-opening it onto itself. The open-only handler left
277
+ // a second click feeling dead — the panel could only be closed by clicking away.
278
+ function togglePicker(): void {
279
+ if (isOpen) {
280
+ closePicker();
281
+ } else {
282
+ openPicker();
283
+ }
284
+ }
285
+
224
286
  function openComparePicker(): void {
225
287
  compareFocusReturnEl =
226
288
  document.activeElement instanceof HTMLElement ? document.activeElement : null;
@@ -273,10 +335,21 @@
273
335
  activePresetLabel = null;
274
336
  if (mode === 'range') {
275
337
  if (draftStart !== null && draftEnd !== null) {
276
- rangeStart = draftStart;
277
- rangeEnd = draftEnd;
338
+ // Fold the time-of-day inputs onto the committed dates when time selection is on.
339
+ const appliedStart = showTimeSelection
340
+ ? (applyTimeDisplay(draftStart, startTimeDisplay) ?? draftStart)
341
+ : draftStart;
342
+ const appliedEnd = showTimeSelection
343
+ ? (applyTimeDisplay(draftEnd, endTimeDisplay) ?? draftEnd)
344
+ : draftEnd;
345
+ rangeStart = appliedStart;
346
+ rangeEnd = appliedEnd;
278
347
  committedPresetLabel = selectedPresetLabel;
279
- onapply?.({ rangeStart: draftStart, rangeEnd: draftEnd, presetLabel: selectedPresetLabel });
348
+ onapply?.({
349
+ rangeStart: appliedStart,
350
+ rangeEnd: appliedEnd,
351
+ presetLabel: selectedPresetLabel
352
+ });
280
353
  }
281
354
  if (
282
355
  typeof compareCalendar === 'function' &&
@@ -389,7 +462,7 @@
389
462
 
390
463
  const canApply: boolean = $derived.by(() => {
391
464
  if (mode === 'range') {
392
- return draftStart !== null && draftEnd !== null;
465
+ return draftStart !== null && draftEnd !== null && isTimeRangeValid;
393
466
  }
394
467
  return draftValue !== null;
395
468
  });
@@ -442,7 +515,7 @@
442
515
  <!-- Trigger wrapper — bind:this here so outside-click detection works -->
443
516
  <div bind:this={triggerRef} class="drp-trigger-wrapper">
444
517
  <Button
445
- onclick={openPicker}
518
+ onclick={togglePicker}
446
519
  ariaLabel="Open date picker"
447
520
  classes="drp-trigger {isOpen ? 'drp-trigger-open' : ''}"
448
521
  >
@@ -578,6 +651,68 @@
578
651
 
579
652
  <!-- Calendar area -->
580
653
  <div class="drp-calendars">
654
+ {#if (showDateInputs || showTimeSelection) && mode === 'range'}
655
+ <div class="drp-datetime-header">
656
+ <div class="drp-date-input-row">
657
+ <div class="drp-date-input" data-pw={testId ? `${testId}-start-date` : null}>
658
+ <span class="drp-date-input-value">{draftStartDateLabel || 'Start date'}</span>
659
+ </div>
660
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
661
+ <span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
662
+ <div class="drp-date-input" data-pw={testId ? `${testId}-end-date` : null}>
663
+ <span class="drp-date-input-value">{draftEndDateLabel || 'End date'}</span>
664
+ </div>
665
+ {#if showTimeSelection}
666
+ <button
667
+ type="button"
668
+ class="drp-time-toggle"
669
+ class:drp-time-toggle-active={showTimeRow}
670
+ aria-label="Toggle time selection"
671
+ aria-pressed={showTimeRow}
672
+ data-pw={testId ? `${testId}-time-toggle` : null}
673
+ onclick={() => (showTimeRow = !showTimeRow)}
674
+ >
675
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
676
+ <span class="drp-time-toggle-icon" aria-hidden="true">{@html clockSvg}</span>
677
+ </button>
678
+ {/if}
679
+ </div>
680
+ {#if showTimeSelection && showTimeRow}
681
+ <div class="drp-time-input-row">
682
+ <div class="drp-time-input" class:drp-time-input-invalid={!isStartTimeValid}>
683
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
684
+ <span class="drp-time-input-icon" aria-hidden="true">{@html clockSvg}</span>
685
+ <input
686
+ type="text"
687
+ class="drp-time-field"
688
+ bind:value={startTimeDisplay}
689
+ maxlength="8"
690
+ placeholder="12:00 AM"
691
+ aria-label="Start time"
692
+ aria-invalid={!isStartTimeValid}
693
+ data-pw={testId ? `${testId}-start-time` : null}
694
+ />
695
+ </div>
696
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
697
+ <span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
698
+ <div class="drp-time-input" class:drp-time-input-invalid={!isEndTimeValid}>
699
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
700
+ <span class="drp-time-input-icon" aria-hidden="true">{@html clockSvg}</span>
701
+ <input
702
+ type="text"
703
+ class="drp-time-field"
704
+ bind:value={endTimeDisplay}
705
+ maxlength="8"
706
+ placeholder="11:59 PM"
707
+ aria-label="End time"
708
+ aria-invalid={!isEndTimeValid}
709
+ data-pw={testId ? `${testId}-end-time` : null}
710
+ />
711
+ </div>
712
+ </div>
713
+ {/if}
714
+ </div>
715
+ {/if}
581
716
  {#if isDualMonth}
582
717
  <!-- Dual-month layout with shared nav -->
583
718
  <div class="drp-dual-header">
@@ -930,6 +1065,132 @@
930
1065
  flex-wrap: wrap;
931
1066
  }
932
1067
 
1068
+ /* ── Built-in date + time inputs (showDateInputs / showTimeSelection) ── */
1069
+ .drp-datetime-header {
1070
+ display: flex;
1071
+ flex-direction: column;
1072
+ gap: var(--drp-datetime-gap, 8px);
1073
+ padding-bottom: var(--drp-datetime-padding-bottom, 12px);
1074
+ margin-bottom: var(--drp-datetime-margin-bottom, 4px);
1075
+ border-bottom: var(--drp-datetime-divider, 1px solid #e8e8e8);
1076
+ }
1077
+
1078
+ .drp-date-input-row,
1079
+ .drp-time-input-row {
1080
+ display: flex;
1081
+ align-items: center;
1082
+ gap: var(--drp-datetime-row-gap, 12px);
1083
+ }
1084
+
1085
+ .drp-date-input {
1086
+ flex: 1;
1087
+ min-width: 0;
1088
+ padding: var(--drp-date-input-padding, 10px 14px);
1089
+ border: var(--drp-date-input-border, 1px solid #d4d4d4);
1090
+ border-radius: var(--drp-date-input-radius, 8px);
1091
+ background: var(--drp-date-input-background, #ffffff);
1092
+ }
1093
+
1094
+ .drp-date-input-value {
1095
+ display: block;
1096
+ color: var(--drp-date-input-color, #333333);
1097
+ font-size: var(--drp-date-input-font-size, 13px);
1098
+ white-space: nowrap;
1099
+ overflow: hidden;
1100
+ text-overflow: ellipsis;
1101
+ }
1102
+
1103
+ .drp-datetime-arrow {
1104
+ flex-shrink: 0;
1105
+ display: inline-flex;
1106
+ width: var(--drp-datetime-arrow-size, 16px);
1107
+ height: var(--drp-datetime-arrow-size, 16px);
1108
+ color: var(--drp-datetime-arrow-color, #888888);
1109
+ }
1110
+
1111
+ .drp-datetime-arrow :global(svg) {
1112
+ width: 100%;
1113
+ height: 100%;
1114
+ }
1115
+
1116
+ .drp-time-toggle {
1117
+ flex-shrink: 0;
1118
+ display: inline-flex;
1119
+ align-items: center;
1120
+ justify-content: center;
1121
+ width: var(--drp-time-toggle-size, 40px);
1122
+ height: var(--drp-time-toggle-size, 40px);
1123
+ padding: 0;
1124
+ border: var(--drp-time-toggle-border, 1px solid #d4d4d4);
1125
+ border-radius: var(--drp-time-toggle-radius, 8px);
1126
+ background: var(--drp-time-toggle-background, #f6f7f9);
1127
+ color: var(--drp-time-toggle-color, #555555);
1128
+ cursor: pointer;
1129
+ }
1130
+
1131
+ .drp-time-toggle-active {
1132
+ border-color: var(--drp-time-toggle-active-border, currentColor);
1133
+ color: var(--drp-time-toggle-active-color, #1b85ff);
1134
+ }
1135
+
1136
+ .drp-time-toggle-icon {
1137
+ display: inline-flex;
1138
+ width: var(--drp-time-toggle-icon-size, 16px);
1139
+ height: var(--drp-time-toggle-icon-size, 16px);
1140
+ }
1141
+
1142
+ .drp-time-toggle-icon :global(svg) {
1143
+ width: 100%;
1144
+ height: 100%;
1145
+ }
1146
+
1147
+ .drp-time-input {
1148
+ position: relative;
1149
+ flex: 1;
1150
+ min-width: 0;
1151
+ display: flex;
1152
+ align-items: center;
1153
+ border: var(--drp-time-input-border, 1px solid #d4d4d4);
1154
+ border-radius: var(--drp-time-input-radius, 8px);
1155
+ background: var(--drp-time-input-background, #ffffff);
1156
+ }
1157
+
1158
+ .drp-time-input-invalid {
1159
+ border-color: var(--drp-time-input-invalid-border, #e5484d);
1160
+ }
1161
+
1162
+ .drp-time-input-icon {
1163
+ display: inline-flex;
1164
+ flex-shrink: 0;
1165
+ width: var(--drp-time-input-icon-size, 16px);
1166
+ height: var(--drp-time-input-icon-size, 16px);
1167
+ margin-left: var(--drp-time-input-icon-gap, 12px);
1168
+ color: var(--drp-time-input-icon-color, #888888);
1169
+ pointer-events: none;
1170
+ }
1171
+
1172
+ .drp-time-input-icon :global(svg) {
1173
+ width: 100%;
1174
+ height: 100%;
1175
+ }
1176
+
1177
+ .drp-time-field {
1178
+ flex: 1;
1179
+ min-width: 0;
1180
+ width: 100%;
1181
+ padding: var(--drp-time-field-padding, 10px 14px 10px 8px);
1182
+ border: none;
1183
+ outline: none;
1184
+ background: transparent;
1185
+ color: var(--drp-time-field-color, #333333);
1186
+ font-size: var(--drp-time-field-font-size, 13px);
1187
+ font-family: inherit;
1188
+ }
1189
+
1190
+ .drp-time-field::placeholder {
1191
+ color: var(--drp-time-field-placeholder-color, #aaaaaa);
1192
+ }
1193
+
933
1194
  /* ── Compare calendar slot ── */
934
1195
  .drp-compare-section {
935
1196
  display: flex;
@@ -40,6 +40,18 @@ export type OptionalDateRangePickerProperties = {
40
40
  * its highlighted background regardless of this flag. Default: false.
41
41
  */
42
42
  presetCheckmark?: boolean;
43
+ /**
44
+ * Show read-only start/end date boxes at the top of the calendar area (range mode),
45
+ * reflecting the current draft selection. Opt-in. Default: false.
46
+ */
47
+ showDateInputs?: boolean;
48
+ /**
49
+ * Show built-in time-of-day selection (range mode): a clock toggle in the date-input
50
+ * row that reveals start/end time inputs ("HH:MM AM/PM"). On Apply the entered times
51
+ * are folded onto the committed range's start/end. Implies the date-input row.
52
+ * Opt-in. Default: false.
53
+ */
54
+ showTimeSelection?: boolean;
43
55
  /** Placeholder shown when no range is selected. */
44
56
  placeholder?: string;
45
57
  /** Show two months side by side. Defaults to true for range mode, false for single. */
@@ -0,0 +1,13 @@
1
+ /** Matches a 12-hour clock display such as "2:30 PM" or "02:05 am". */
2
+ export declare const TIME_DISPLAY_PATTERN: RegExp;
3
+ /** Parse a 12-hour display string into 24-hour hours/minutes, or null when invalid. */
4
+ export declare const parseTimeDisplay: (display: string) => {
5
+ hours: number;
6
+ minutes: number;
7
+ } | null;
8
+ /** Format a Date's time-of-day as a 12-hour display string, e.g. "02:30 PM". */
9
+ export declare const formatTimeDisplay: (date: Date) => string;
10
+ /** Return a new Date with the time-of-day from a 12-hour display string applied, or null when invalid. */
11
+ export declare const applyTimeDisplay: (date: Date, display: string) => Date | null;
12
+ /** Minutes-since-midnight for ordering comparisons, or null when the display is invalid. */
13
+ export declare const toMinutesOfDay: (display: string) => number | null;
@@ -0,0 +1,43 @@
1
+ // Time-of-day helpers for the DateRangePicker's built-in date + time inputs.
2
+ /** Matches a 12-hour clock display such as "2:30 PM" or "02:05 am". */
3
+ export const TIME_DISPLAY_PATTERN = /^(1[0-2]|0?[1-9]):([0-5][0-9])\s?(AM|PM)$/i;
4
+ const pad = (n) => n.toString().padStart(2, '0');
5
+ /** Parse a 12-hour display string into 24-hour hours/minutes, or null when invalid. */
6
+ export const parseTimeDisplay = (display) => {
7
+ const match = TIME_DISPLAY_PATTERN.exec(display.trim());
8
+ if (match === null) {
9
+ return null;
10
+ }
11
+ let hours = Number(match[1]);
12
+ const minutes = Number(match[2]);
13
+ const meridiem = match[3].toUpperCase();
14
+ if (meridiem === 'PM' && hours !== 12) {
15
+ hours += 12;
16
+ }
17
+ else if (meridiem === 'AM' && hours === 12) {
18
+ hours = 0;
19
+ }
20
+ return { hours, minutes };
21
+ };
22
+ /** Format a Date's time-of-day as a 12-hour display string, e.g. "02:30 PM". */
23
+ export const formatTimeDisplay = (date) => {
24
+ const hours24 = date.getHours();
25
+ const meridiem = hours24 >= 12 ? 'PM' : 'AM';
26
+ const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
27
+ return `${pad(hours12)}:${pad(date.getMinutes())} ${meridiem}`;
28
+ };
29
+ /** Return a new Date with the time-of-day from a 12-hour display string applied, or null when invalid. */
30
+ export const applyTimeDisplay = (date, display) => {
31
+ const parsed = parseTimeDisplay(display);
32
+ if (parsed === null) {
33
+ return null;
34
+ }
35
+ const next = new Date(date.getTime());
36
+ next.setHours(parsed.hours, parsed.minutes, 0, 0);
37
+ return next;
38
+ };
39
+ /** Minutes-since-midnight for ordering comparisons, or null when the display is invalid. */
40
+ export const toMinutesOfDay = (display) => {
41
+ const parsed = parseTimeDisplay(display);
42
+ return parsed === null ? null : parsed.hours * 60 + parsed.minutes;
43
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.68.0",
3
+ "version": "2.69.1",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",