@juspay/svelte-ui-components 4.38.0 → 4.39.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.
@@ -45,6 +45,8 @@
45
45
  dualMonth,
46
46
  timePicker,
47
47
  align = 'left',
48
+ presetsPosition = 'side',
49
+ responsiveLayout = false,
48
50
  compareStart = $bindable(null),
49
51
  compareEnd = $bindable(null),
50
52
  compareCalendar,
@@ -70,6 +72,24 @@
70
72
  typeof dualMonth === 'boolean' ? dualMonth : mode === 'range'
71
73
  );
72
74
 
75
+ // A media query cannot read a custom property, so the breakpoints are literals. The
76
+ // layout classes and the month count both follow these two queries, which keeps the
77
+ // JS-driven layout and the CSS that styles it from disagreeing about where it flips.
78
+ const SHEET_QUERY = '(max-width: 1023px)';
79
+ const NARROW_QUERY = '(max-width: 688px)';
80
+
81
+ let isWithinSheetWidth: boolean = $state(false);
82
+ let isWithinNarrowWidth: boolean = $state(false);
83
+
84
+ const isNarrow: boolean = $derived(responsiveLayout && isWithinNarrowWidth);
85
+ // A single-month picker is already compact, so it only needs the sheet once it is
86
+ // narrow; a range picker needs it as soon as two months no longer fit beside presets.
87
+ const isSheet: boolean = $derived(
88
+ responsiveLayout && (mode === 'range' ? isWithinSheetWidth : isWithinNarrowWidth)
89
+ );
90
+ const showDualMonth: boolean = $derived(isDualMonth && !isNarrow);
91
+ const isPresetsTop: boolean = $derived(presetsPosition === 'top' || isSheet);
92
+
73
93
  // Draft state — only committed on Apply
74
94
  let draftStart: Date | null = $state(null);
75
95
  let draftEnd: Date | null = $state(null);
@@ -380,6 +400,12 @@
380
400
  if (panelNode === null || triggerRef === null || typeof window === 'undefined') {
381
401
  return;
382
402
  }
403
+ // A sheet is pinned to the viewport rather than to the trigger, so there is no
404
+ // side of the trigger to flip to.
405
+ if (isSheet) {
406
+ opensUpward = false;
407
+ return;
408
+ }
383
409
  const trigger = triggerRef.getBoundingClientRect();
384
410
  const panelHeight = panelNode.offsetHeight;
385
411
  const offset = 6;
@@ -404,6 +430,70 @@
404
430
  };
405
431
  }
406
432
 
433
+ // Reads and subscribes only while responsiveLayout is on, so a picker that never asks
434
+ // for the layout never touches matchMedia. Every access is guarded: matchMedia is
435
+ // absent during SSR and in jsdom, and a partial stand-in can lack addEventListener;
436
+ // in both cases the unconfined layout is the right answer.
437
+ function trackViewportQuery(query: string, apply: (matches: boolean) => void): () => void {
438
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
439
+ return () => {};
440
+ }
441
+ const mediaQueryList: MediaQueryList | null = window.matchMedia(query) ?? null;
442
+ if (mediaQueryList === null) {
443
+ return () => {};
444
+ }
445
+ apply(mediaQueryList.matches === true);
446
+ if (typeof mediaQueryList.addEventListener !== 'function') {
447
+ return () => {};
448
+ }
449
+ const handleChange = (event: MediaQueryListEvent): void => {
450
+ // A flip can destroy the focused calendar day (two months become one), which
451
+ // drops focus to the body and leaves the panel's Tab trap with nothing to catch.
452
+ const hadFocusInPanel = panelRef !== null && panelRef.contains(getActiveElement(panelRef));
453
+ apply(event.matches);
454
+ tick().then(() => {
455
+ // The panel changes between anchored and fixed with the layout, so the
456
+ // above/below decision made for the old layout no longer holds.
457
+ positionPanel();
458
+ if (
459
+ hadFocusInPanel &&
460
+ panelRef !== null &&
461
+ !panelRef.contains(getActiveElement(panelRef))
462
+ ) {
463
+ focusFirstElement(panelRef);
464
+ }
465
+ });
466
+ };
467
+ mediaQueryList.addEventListener('change', handleChange);
468
+ return () => mediaQueryList.removeEventListener('change', handleChange);
469
+ }
470
+
471
+ function followViewportWhileEnabled(
472
+ _node: HTMLElement,
473
+ enabled: boolean
474
+ ): { update: (nextEnabled: boolean) => void; destroy: () => void } {
475
+ let stopTracking: () => void = () => {};
476
+ const sync = (shouldFollow: boolean): void => {
477
+ stopTracking();
478
+ stopTracking = () => {};
479
+ if (!shouldFollow) {
480
+ return;
481
+ }
482
+ const stopSheetQuery = trackViewportQuery(SHEET_QUERY, (matches) => {
483
+ isWithinSheetWidth = matches;
484
+ });
485
+ const stopNarrowQuery = trackViewportQuery(NARROW_QUERY, (matches) => {
486
+ isWithinNarrowWidth = matches;
487
+ });
488
+ stopTracking = () => {
489
+ stopSheetQuery();
490
+ stopNarrowQuery();
491
+ };
492
+ };
493
+ sync(enabled);
494
+ return { update: sync, destroy: () => sync(false) };
495
+ }
496
+
407
497
  // The trigger toggles: clicking it while the panel is already open dismisses
408
498
  // the picker instead of re-opening it onto itself. The open-only handler left
409
499
  // a second click feeling dead — the panel could only be closed by clicking away.
@@ -700,7 +790,7 @@
700
790
  // re-navigation/remount when a typed date is already on screen.
701
791
  function isMonthVisible(date: Date): boolean {
702
792
  const isLeftMonthVisible = date.getFullYear() === leftYear && date.getMonth() === leftMonth;
703
- if (!isDualMonth) {
793
+ if (!showDualMonth) {
704
794
  return isLeftMonthVisible;
705
795
  }
706
796
  const rightMonth = new SvelteDate(leftYear, leftMonth + 1, 1);
@@ -709,6 +799,13 @@
709
799
  return isLeftMonthVisible || isRightMonthVisible;
710
800
  }
711
801
 
802
+ // The single-month Calendar navigates itself, so its month is mirrored back here:
803
+ // isMonthVisible and every later remount seed from leftYear/leftMonth.
804
+ function handleSingleMonthChange(event: { year: number; month: number }): void {
805
+ leftYear = event.year;
806
+ leftMonth = event.month;
807
+ }
808
+
712
809
  function navigateCalendarTo(date: Date): void {
713
810
  if (isMonthVisible(date)) {
714
811
  return;
@@ -899,7 +996,13 @@
899
996
  {/if}
900
997
  {/snippet}
901
998
 
902
- <div class="drp-root {classes ?? ''}" bind:this={drpRootEl} data-pw={testId} testID={testId}>
999
+ <div
1000
+ class="drp-root {classes ?? ''}"
1001
+ bind:this={drpRootEl}
1002
+ use:followViewportWhileEnabled={responsiveLayout}
1003
+ data-pw={testId}
1004
+ testID={testId}
1005
+ >
903
1006
  <!-- Trigger wrapper — bind:this here for panel positioning and focus-return -->
904
1007
  <div bind:this={triggerRef} class="drp-trigger-wrapper">
905
1008
  <Button
@@ -980,6 +1083,9 @@
980
1083
  class:drp-panel-above={opensUpward}
981
1084
  class:drp-panel-align-left={align === 'left'}
982
1085
  class:drp-panel-align-right={align === 'right'}
1086
+ class:drp-panel-presets-top={isPresetsTop}
1087
+ class:drp-panel-sheet={isSheet}
1088
+ class:drp-panel-narrow={isNarrow}
983
1089
  role="dialog"
984
1090
  aria-label="Date range picker"
985
1091
  aria-modal="true"
@@ -1244,7 +1350,7 @@
1244
1350
  {/if}
1245
1351
  </div>
1246
1352
  {/if}
1247
- {#if isDualMonth}
1353
+ {#if showDualMonth}
1248
1354
  <!-- Dual-month layout with shared nav -->
1249
1355
  <div class="drp-dual-header">
1250
1356
  <button
@@ -1303,20 +1409,24 @@
1303
1409
  </div>
1304
1410
  {:else}
1305
1411
  <!-- Single month -->
1306
- <Calendar
1307
- mode={mode === 'range' ? 'range' : 'single'}
1308
- bind:rangeStart={draftStart}
1309
- bind:rangeEnd={draftEnd}
1310
- bind:value={draftValue}
1311
- {weekStartsOn}
1312
- {locale}
1313
- {minDate}
1314
- {maxDate}
1315
- {disabledDates}
1316
- onrangeselect={handleRangeSelect}
1317
- onselect={handleSingleSelect}
1318
- classes="drp-calendar-embedded"
1319
- />
1412
+ {#key calendarKey}
1413
+ <Calendar
1414
+ mode={mode === 'range' ? 'range' : 'single'}
1415
+ bind:rangeStart={draftStart}
1416
+ bind:rangeEnd={draftEnd}
1417
+ bind:value={draftValue}
1418
+ {weekStartsOn}
1419
+ {locale}
1420
+ {minDate}
1421
+ {maxDate}
1422
+ disabledDates={mode === 'range' ? rangeConstrainedDisabledDates : disabledDates}
1423
+ initialMonth={leftInitialMonth}
1424
+ onrangeselect={handleRangeSelect}
1425
+ onselect={handleSingleSelect}
1426
+ onmonthchange={handleSingleMonthChange}
1427
+ classes="drp-calendar-embedded"
1428
+ />
1429
+ {/key}
1320
1430
  {/if}
1321
1431
 
1322
1432
  <!-- Time picker slot: consumer controls all time UI -->
@@ -1435,6 +1545,9 @@
1435
1545
  max-width: var(--drp-panel-max-width, 760px);
1436
1546
  max-height: var(--drp-panel-max-height, calc(100dvh - 80px));
1437
1547
  overflow: hidden;
1548
+ /* A host may disable pointer events on an ancestor (a collapsing sidebar, say) and
1549
+ an open aria-modal panel must stay usable, as Modal's content does. */
1550
+ pointer-events: auto;
1438
1551
  }
1439
1552
 
1440
1553
  /* Set only when the panel would not fit below the trigger — see positionPanel(). */
@@ -1453,6 +1566,23 @@
1453
1566
  left: auto;
1454
1567
  }
1455
1568
 
1569
+ /* Opt-in sheet (responsiveLayout): confine the panel to the viewport instead of the
1570
+ trigger. The insets are tokens because the free area differs per host (a fixed
1571
+ sidebar, an embedding shell, safe-area insets); the defaults suit a bare page. */
1572
+ .drp-panel.drp-panel-sheet {
1573
+ position: fixed;
1574
+ top: var(--drp-sheet-top, auto);
1575
+ right: var(--drp-sheet-right, 16px);
1576
+ bottom: var(--drp-sheet-bottom, 16px);
1577
+ left: var(--drp-sheet-left, 16px);
1578
+ z-index: var(--drp-sheet-z-index, var(--drp-panel-z-index, 1000));
1579
+ width: auto;
1580
+ min-width: 0;
1581
+ max-width: var(--drp-sheet-max-width, 48rem);
1582
+ max-height: var(--drp-sheet-max-height, calc(100dvh - 2rem));
1583
+ margin-inline: auto;
1584
+ }
1585
+
1456
1586
  .drp-panel-inner {
1457
1587
  display: flex;
1458
1588
  flex-direction: row;
@@ -1461,6 +1591,57 @@
1461
1591
  overflow-y: auto;
1462
1592
  }
1463
1593
 
1594
+ /* ── Presets above the calendars (presetsPosition="top", and every sheet) ── */
1595
+ .drp-panel-presets-top .drp-panel-inner {
1596
+ flex-direction: column;
1597
+ }
1598
+
1599
+ .drp-panel-presets-top .drp-sidebar {
1600
+ flex: none;
1601
+ flex-direction: row;
1602
+ flex-wrap: wrap;
1603
+ min-width: 0;
1604
+ max-height: none;
1605
+ overflow-y: visible;
1606
+ border-right: 0;
1607
+ border-bottom: var(--drp-sidebar-border, 1px solid #e8e8e8);
1608
+ }
1609
+
1610
+ .drp-panel-presets-top .drp-preset-item {
1611
+ width: auto;
1612
+ }
1613
+
1614
+ .drp-panel-presets-top .drp-preset-divider {
1615
+ display: none;
1616
+ }
1617
+
1618
+ .drp-panel-presets-top .drp-calendars {
1619
+ flex: none;
1620
+ }
1621
+
1622
+ .drp-panel-presets-top .drp-months-row {
1623
+ justify-content: center;
1624
+ }
1625
+
1626
+ /* ── Narrow layout (responsiveLayout, <= 688px): one month, stacked inputs ── */
1627
+ .drp-panel-narrow .drp-calendars {
1628
+ padding: var(--drp-calendars-padding-narrow, 16px 8px);
1629
+ }
1630
+
1631
+ .drp-panel-narrow :global(.drp-calendar-embedded) {
1632
+ max-width: 100%;
1633
+ }
1634
+
1635
+ .drp-panel-narrow .drp-date-input-row,
1636
+ .drp-panel-narrow .drp-time-input-row {
1637
+ flex-direction: column;
1638
+ align-items: stretch;
1639
+ }
1640
+
1641
+ .drp-panel-narrow .drp-datetime-arrow {
1642
+ display: none;
1643
+ }
1644
+
1464
1645
  /* ── Sidebar ── */
1465
1646
  .drp-sidebar {
1466
1647
  display: flex;
@@ -1914,6 +2095,7 @@
1914
2095
  flex-direction: column;
1915
2096
  min-width: var(--drp-compare-panel-min-width, 280px);
1916
2097
  overflow: hidden;
2098
+ pointer-events: auto;
1917
2099
  }
1918
2100
 
1919
2101
  .drp-compare-panel-body {
@@ -15,6 +15,7 @@ export type DateRangePreset = {
15
15
  };
16
16
  export type DateRangePickerMode = 'range' | 'single';
17
17
  export type DateRangePickerAlign = 'left' | 'right';
18
+ export type DateRangePickerPresetsPosition = 'side' | 'top';
18
19
  export type DateRangePickerTimeLayout = 'toggle' | 'inline';
19
20
  export type TimeDisplayBoundary = 'start' | 'end';
20
21
  export type OptionalDateRangePickerProperties = {
@@ -80,6 +81,21 @@ export type OptionalDateRangePickerProperties = {
80
81
  dualMonth?: boolean;
81
82
  /** Align the dropdown panel to the left or right edge of the trigger. Default: 'left'. */
82
83
  align?: DateRangePickerAlign;
84
+ /**
85
+ * Where the presets sit. 'side' (default) puts them in a column beside the calendars;
86
+ * 'top' lays them out as a wrapping row above the calendars, which suits a panel that
87
+ * cannot be wide. Group dividers are hidden while the presets are on top.
88
+ */
89
+ presetsPosition?: DateRangePickerPresetsPosition;
90
+ /**
91
+ * Opt-in viewport awareness. At or below 1023px in range mode, or 688px in single
92
+ * mode, the panel becomes a fixed sheet confined to the viewport (inset by the
93
+ * --drp-sheet-* tokens) with the presets on top. At or below 688px it also shows one
94
+ * month and stacks the date and time inputs. The breakpoints are fixed because a media
95
+ * query cannot read a custom property. Default: false, which leaves the panel exactly
96
+ * as it was.
97
+ */
98
+ responsiveLayout?: boolean;
83
99
  /** Snippet rendered in the time-picker slot. Consumer controls all time UI. */
84
100
  timePicker?: Snippet;
85
101
  /** Start of compare range (bindable). Used when compareCalendar snippet is provided. */