@juspay/svelte-ui-components 2.67.0 → 2.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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();
@@ -273,10 +324,21 @@
273
324
  activePresetLabel = null;
274
325
  if (mode === 'range') {
275
326
  if (draftStart !== null && draftEnd !== null) {
276
- rangeStart = draftStart;
277
- rangeEnd = draftEnd;
327
+ // Fold the time-of-day inputs onto the committed dates when time selection is on.
328
+ const appliedStart = showTimeSelection
329
+ ? (applyTimeDisplay(draftStart, startTimeDisplay) ?? draftStart)
330
+ : draftStart;
331
+ const appliedEnd = showTimeSelection
332
+ ? (applyTimeDisplay(draftEnd, endTimeDisplay) ?? draftEnd)
333
+ : draftEnd;
334
+ rangeStart = appliedStart;
335
+ rangeEnd = appliedEnd;
278
336
  committedPresetLabel = selectedPresetLabel;
279
- onapply?.({ rangeStart: draftStart, rangeEnd: draftEnd, presetLabel: selectedPresetLabel });
337
+ onapply?.({
338
+ rangeStart: appliedStart,
339
+ rangeEnd: appliedEnd,
340
+ presetLabel: selectedPresetLabel
341
+ });
280
342
  }
281
343
  if (
282
344
  typeof compareCalendar === 'function' &&
@@ -389,7 +451,7 @@
389
451
 
390
452
  const canApply: boolean = $derived.by(() => {
391
453
  if (mode === 'range') {
392
- return draftStart !== null && draftEnd !== null;
454
+ return draftStart !== null && draftEnd !== null && isTimeRangeValid;
393
455
  }
394
456
  return draftValue !== null;
395
457
  });
@@ -578,6 +640,68 @@
578
640
 
579
641
  <!-- Calendar area -->
580
642
  <div class="drp-calendars">
643
+ {#if (showDateInputs || showTimeSelection) && mode === 'range'}
644
+ <div class="drp-datetime-header">
645
+ <div class="drp-date-input-row">
646
+ <div class="drp-date-input" data-pw={testId ? `${testId}-start-date` : null}>
647
+ <span class="drp-date-input-value">{draftStartDateLabel || 'Start date'}</span>
648
+ </div>
649
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
650
+ <span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
651
+ <div class="drp-date-input" data-pw={testId ? `${testId}-end-date` : null}>
652
+ <span class="drp-date-input-value">{draftEndDateLabel || 'End date'}</span>
653
+ </div>
654
+ {#if showTimeSelection}
655
+ <button
656
+ type="button"
657
+ class="drp-time-toggle"
658
+ class:drp-time-toggle-active={showTimeRow}
659
+ aria-label="Toggle time selection"
660
+ aria-pressed={showTimeRow}
661
+ data-pw={testId ? `${testId}-time-toggle` : null}
662
+ onclick={() => (showTimeRow = !showTimeRow)}
663
+ >
664
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
665
+ <span class="drp-time-toggle-icon" aria-hidden="true">{@html clockSvg}</span>
666
+ </button>
667
+ {/if}
668
+ </div>
669
+ {#if showTimeSelection && showTimeRow}
670
+ <div class="drp-time-input-row">
671
+ <div class="drp-time-input" class:drp-time-input-invalid={!isStartTimeValid}>
672
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
673
+ <span class="drp-time-input-icon" aria-hidden="true">{@html clockSvg}</span>
674
+ <input
675
+ type="text"
676
+ class="drp-time-field"
677
+ bind:value={startTimeDisplay}
678
+ maxlength="8"
679
+ placeholder="12:00 AM"
680
+ aria-label="Start time"
681
+ aria-invalid={!isStartTimeValid}
682
+ data-pw={testId ? `${testId}-start-time` : null}
683
+ />
684
+ </div>
685
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
686
+ <span class="drp-datetime-arrow" aria-hidden="true">{@html chevronRightSvg}</span>
687
+ <div class="drp-time-input" class:drp-time-input-invalid={!isEndTimeValid}>
688
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
689
+ <span class="drp-time-input-icon" aria-hidden="true">{@html clockSvg}</span>
690
+ <input
691
+ type="text"
692
+ class="drp-time-field"
693
+ bind:value={endTimeDisplay}
694
+ maxlength="8"
695
+ placeholder="11:59 PM"
696
+ aria-label="End time"
697
+ aria-invalid={!isEndTimeValid}
698
+ data-pw={testId ? `${testId}-end-time` : null}
699
+ />
700
+ </div>
701
+ </div>
702
+ {/if}
703
+ </div>
704
+ {/if}
581
705
  {#if isDualMonth}
582
706
  <!-- Dual-month layout with shared nav -->
583
707
  <div class="drp-dual-header">
@@ -930,6 +1054,132 @@
930
1054
  flex-wrap: wrap;
931
1055
  }
932
1056
 
1057
+ /* ── Built-in date + time inputs (showDateInputs / showTimeSelection) ── */
1058
+ .drp-datetime-header {
1059
+ display: flex;
1060
+ flex-direction: column;
1061
+ gap: var(--drp-datetime-gap, 8px);
1062
+ padding-bottom: var(--drp-datetime-padding-bottom, 12px);
1063
+ margin-bottom: var(--drp-datetime-margin-bottom, 4px);
1064
+ border-bottom: var(--drp-datetime-divider, 1px solid #e8e8e8);
1065
+ }
1066
+
1067
+ .drp-date-input-row,
1068
+ .drp-time-input-row {
1069
+ display: flex;
1070
+ align-items: center;
1071
+ gap: var(--drp-datetime-row-gap, 12px);
1072
+ }
1073
+
1074
+ .drp-date-input {
1075
+ flex: 1;
1076
+ min-width: 0;
1077
+ padding: var(--drp-date-input-padding, 10px 14px);
1078
+ border: var(--drp-date-input-border, 1px solid #d4d4d4);
1079
+ border-radius: var(--drp-date-input-radius, 8px);
1080
+ background: var(--drp-date-input-background, #ffffff);
1081
+ }
1082
+
1083
+ .drp-date-input-value {
1084
+ display: block;
1085
+ color: var(--drp-date-input-color, #333333);
1086
+ font-size: var(--drp-date-input-font-size, 13px);
1087
+ white-space: nowrap;
1088
+ overflow: hidden;
1089
+ text-overflow: ellipsis;
1090
+ }
1091
+
1092
+ .drp-datetime-arrow {
1093
+ flex-shrink: 0;
1094
+ display: inline-flex;
1095
+ width: var(--drp-datetime-arrow-size, 16px);
1096
+ height: var(--drp-datetime-arrow-size, 16px);
1097
+ color: var(--drp-datetime-arrow-color, #888888);
1098
+ }
1099
+
1100
+ .drp-datetime-arrow :global(svg) {
1101
+ width: 100%;
1102
+ height: 100%;
1103
+ }
1104
+
1105
+ .drp-time-toggle {
1106
+ flex-shrink: 0;
1107
+ display: inline-flex;
1108
+ align-items: center;
1109
+ justify-content: center;
1110
+ width: var(--drp-time-toggle-size, 40px);
1111
+ height: var(--drp-time-toggle-size, 40px);
1112
+ padding: 0;
1113
+ border: var(--drp-time-toggle-border, 1px solid #d4d4d4);
1114
+ border-radius: var(--drp-time-toggle-radius, 8px);
1115
+ background: var(--drp-time-toggle-background, #f6f7f9);
1116
+ color: var(--drp-time-toggle-color, #555555);
1117
+ cursor: pointer;
1118
+ }
1119
+
1120
+ .drp-time-toggle-active {
1121
+ border-color: var(--drp-time-toggle-active-border, currentColor);
1122
+ color: var(--drp-time-toggle-active-color, #1b85ff);
1123
+ }
1124
+
1125
+ .drp-time-toggle-icon {
1126
+ display: inline-flex;
1127
+ width: var(--drp-time-toggle-icon-size, 16px);
1128
+ height: var(--drp-time-toggle-icon-size, 16px);
1129
+ }
1130
+
1131
+ .drp-time-toggle-icon :global(svg) {
1132
+ width: 100%;
1133
+ height: 100%;
1134
+ }
1135
+
1136
+ .drp-time-input {
1137
+ position: relative;
1138
+ flex: 1;
1139
+ min-width: 0;
1140
+ display: flex;
1141
+ align-items: center;
1142
+ border: var(--drp-time-input-border, 1px solid #d4d4d4);
1143
+ border-radius: var(--drp-time-input-radius, 8px);
1144
+ background: var(--drp-time-input-background, #ffffff);
1145
+ }
1146
+
1147
+ .drp-time-input-invalid {
1148
+ border-color: var(--drp-time-input-invalid-border, #e5484d);
1149
+ }
1150
+
1151
+ .drp-time-input-icon {
1152
+ display: inline-flex;
1153
+ flex-shrink: 0;
1154
+ width: var(--drp-time-input-icon-size, 16px);
1155
+ height: var(--drp-time-input-icon-size, 16px);
1156
+ margin-left: var(--drp-time-input-icon-gap, 12px);
1157
+ color: var(--drp-time-input-icon-color, #888888);
1158
+ pointer-events: none;
1159
+ }
1160
+
1161
+ .drp-time-input-icon :global(svg) {
1162
+ width: 100%;
1163
+ height: 100%;
1164
+ }
1165
+
1166
+ .drp-time-field {
1167
+ flex: 1;
1168
+ min-width: 0;
1169
+ width: 100%;
1170
+ padding: var(--drp-time-field-padding, 10px 14px 10px 8px);
1171
+ border: none;
1172
+ outline: none;
1173
+ background: transparent;
1174
+ color: var(--drp-time-field-color, #333333);
1175
+ font-size: var(--drp-time-field-font-size, 13px);
1176
+ font-family: inherit;
1177
+ }
1178
+
1179
+ .drp-time-field::placeholder {
1180
+ color: var(--drp-time-field-placeholder-color, #aaaaaa);
1181
+ }
1182
+
933
1183
  /* ── Compare calendar slot ── */
934
1184
  .drp-compare-section {
935
1185
  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
+ };
@@ -335,6 +335,19 @@
335
335
  --cursor: var(--modal-footer-secondary-button-cursor, pointer);
336
336
  --opacity: var(--modal-footer-secondary-button-opacity, 1);
337
337
  --button-border: var(--modal-footer-secondary-button-border, none);
338
+ --disabled-background-color: var(
339
+ --modal-footer-secondary-button-disabled-color,
340
+ var(--modal-footer-secondary-button-color, #3a4550)
341
+ );
342
+ --disabled-text-color: var(
343
+ --modal-footer-secondary-button-disabled-text-color,
344
+ var(--modal-footer-secondary-button-text-color, white)
345
+ );
346
+ --disabled-border: var(
347
+ --modal-footer-secondary-button-disabled-border,
348
+ var(--modal-footer-secondary-button-border, none)
349
+ );
350
+ --disabled-opacity: var(--modal-footer-secondary-button-disabled-opacity, 0.4);
338
351
  --button-justify-content: var(--modal-footer-secondary-button-justify-content, center);
339
352
  --button-content-flex-direction: var(
340
353
  --modal-footer-secondary-button-content-flex-direction,
@@ -363,6 +376,19 @@
363
376
  --cursor: var(--modal-footer-primary-button-cursor, pointer);
364
377
  --opacity: var(--modal-footer-primary-button-opacity, 1);
365
378
  --button-border: var(--modal-footer-primary-button-border, none);
379
+ --disabled-background-color: var(
380
+ --modal-footer-primary-button-disabled-color,
381
+ var(--modal-footer-primary-button-color, #3a4550)
382
+ );
383
+ --disabled-text-color: var(
384
+ --modal-footer-primary-button-disabled-text-color,
385
+ var(--modal-footer-primary-button-text-color, white)
386
+ );
387
+ --disabled-border: var(
388
+ --modal-footer-primary-button-disabled-border,
389
+ var(--modal-footer-primary-button-border, none)
390
+ );
391
+ --disabled-opacity: var(--modal-footer-primary-button-disabled-opacity, 0.4);
366
392
  --button-justify-content: var(--modal-footer-primary-button-justify-content, center);
367
393
  --button-content-flex-direction: var(--modal-footer-primary-button-content-flex-direction, row);
368
394
  --button-content-gap: var(--modal-footer-primary-button-content-gap, 16px);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juspay/svelte-ui-components",
3
- "version": "2.67.0",
3
+ "version": "2.69.0",
4
4
  "description": "A themeable Svelte 5 UI component library with CSS custom property driven styling",
5
5
  "keywords": [
6
6
  "svelte",