@marimo-team/frontend 0.23.7-dev55 → 0.23.7-dev57

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.
Files changed (116) hide show
  1. package/dist/assets/{CellStatus-DXNmZJpi.js → CellStatus-DGBvmSvq.js} +1 -1
  2. package/dist/assets/JsonOutput-BEbyS3oG.js +53 -0
  3. package/dist/assets/{LazyAnyLanguageCodeMirror-B2pl_WA3.js → LazyAnyLanguageCodeMirror-GdhQ07zA.js} +2 -2
  4. package/dist/assets/{MarimoErrorOutput-YGhIA85d.js → MarimoErrorOutput-XWqnhvJ6.js} +1 -1
  5. package/dist/assets/{RenderHTML-Dz1OIbOh.js → RenderHTML-B5r25cP5.js} +1 -1
  6. package/dist/assets/{RunButton-D1IZ1Yr0.js → RunButton-Dbak5hfa.js} +1 -1
  7. package/dist/assets/{add-cell-with-ai-hIPYl46r.js → add-cell-with-ai-BbZkMqv2.js} +1 -1
  8. package/dist/assets/{add-connection-dialog-CMR-c9XE.js → add-connection-dialog-Cw6_iYno.js} +1 -1
  9. package/dist/assets/{agent-panel-DnBAoLsM.js → agent-panel-StLA6gDR.js} +1 -1
  10. package/dist/assets/{ai-model-dropdown-zvokTxf_.js → ai-model-dropdown-CjhUqXgj.js} +1 -1
  11. package/dist/assets/{any-language-editor-BgxVFHQ8.js → any-language-editor-Bdhmwznp.js} +1 -1
  12. package/dist/assets/{app-config-button-BjFAqaTN.js → app-config-button-CCs8Jepz.js} +1 -1
  13. package/dist/assets/{cell-editor-Dak_jwhB.js → cell-editor-Cgyoqdi5.js} +1 -1
  14. package/dist/assets/{cell-link-pRI-YfIp.js → cell-link-PQYiMZw1.js} +1 -1
  15. package/dist/assets/{cells-DbE28H1u.js → cells-Dnu4nDoy.js} +1 -1
  16. package/dist/assets/{chat-display-D_nDPZek.js → chat-display-DetTBnqK.js} +1 -1
  17. package/dist/assets/{chat-panel-BktSpl2P.js → chat-panel-CEgw_vg0.js} +1 -1
  18. package/dist/assets/{chat-ui-C0_KcXrv.js → chat-ui-D-Y7p_cT.js} +1 -1
  19. package/dist/assets/{chunk-5FQGJX7Z-DILIU9Rm.js → chunk-5FQGJX7Z-BSzccEgu.js} +3 -3
  20. package/dist/assets/{code-block-37QAKDTI-Bgm-HPiB.js → code-block-37QAKDTI-U2R1jyOo.js} +1 -1
  21. package/dist/assets/{column-preview-CNeXQtKn.js → column-preview-DA6nf5_Q.js} +1 -1
  22. package/dist/assets/{command-palette-CcjZs_TG.js → command-palette-n_e11WBA.js} +1 -1
  23. package/dist/assets/{common-CWRr25jC.js → common-BaBE_ygg.js} +1 -1
  24. package/dist/assets/{components-DUd0ki0p.js → components-CvGaLA5d.js} +1 -1
  25. package/dist/assets/{components-Cj3Al1Y6.js → components-zB5yT_R8.js} +1 -1
  26. package/dist/assets/config-C2lTvbuU.js +1 -0
  27. package/dist/assets/{datasource-Prn_GWOB.js → datasource-I-LOgxeP.js} +1 -1
  28. package/dist/assets/{dependency-graph-panel-DUUCij85.js → dependency-graph-panel-BjOeXp74.js} +1 -1
  29. package/dist/assets/{dist-DxnNQmQo.js → dist-CW3rweKM.js} +1 -1
  30. package/dist/assets/{documentation-panel-CB8xalFX.js → documentation-panel-DMdFXuBf.js} +1 -1
  31. package/dist/assets/{download-DEJbA1IY.js → download-BO6T2USS.js} +1 -1
  32. package/dist/assets/{edit-page-BkHYt2if.js → edit-page-Dos8zz_9.js} +6 -6
  33. package/dist/assets/{error-panel-DG6AtqLR.js → error-panel-iXznkJZ1.js} +1 -1
  34. package/dist/assets/{file-explorer-panel-Co6MlwYD.js → file-explorer-panel-_77UepGi.js} +3 -3
  35. package/dist/assets/{file-icons-BjTIuMQg.js → file-icons-B6DaZdP0.js} +1 -1
  36. package/dist/assets/{file-name-input-CQVbWhL8.js → file-name-input-g2H2sY2h.js} +1 -1
  37. package/dist/assets/{floating-outline-uy6dAsIe.js → floating-outline-DbOtUfo-.js} +1 -1
  38. package/dist/assets/{focus-0RBjdtZw.js → focus-BaOnnMs-.js} +1 -1
  39. package/dist/assets/{form-DNa2VnwU.js → form-CM8vYbSt.js} +1 -1
  40. package/dist/assets/{globals-DI5QlXvl.js → globals-CpVAcN9Z.js} +1 -1
  41. package/dist/assets/{home-page-BSuXANlw.js → home-page-De1W6q6f.js} +1 -1
  42. package/dist/assets/{hooks-B7pYZHjF.js → hooks-7OHHugrQ.js} +1 -1
  43. package/dist/assets/{html-to-image-CIu-0LbU.js → html-to-image-D6SgvARi.js} +1 -1
  44. package/dist/assets/index-1EIgCVR_.js +38 -0
  45. package/dist/assets/{kiosk-mode-Ch75k65P.js → kiosk-mode-Czvj3vmL.js} +1 -1
  46. package/dist/assets/{layout-DFhJt7oJ.js → layout-DQGNHEpb.js} +5 -5
  47. package/dist/assets/{logs-panel-DR-1BC0S.js → logs-panel-BMAfoMJg.js} +1 -1
  48. package/dist/assets/{markdown-renderer-DGqYztXR.js → markdown-renderer-BQ-BQLiJ.js} +3 -3
  49. package/dist/assets/mermaid-4DMBBIKO-C0OyyVdo.js +1 -0
  50. package/dist/assets/{name-cell-input-DwfyLq31.js → name-cell-input-bwfAyC0i.js} +1 -1
  51. package/dist/assets/{outline-panel-CWunrooQ.js → outline-panel-CkZUQcZ1.js} +1 -1
  52. package/dist/assets/{packages-panel-BdcXUFQJ.js → packages-panel-B3dRYuRM.js} +1 -1
  53. package/dist/assets/panels-DvIOAb34.js +1 -0
  54. package/dist/assets/{process-output-CS4QGJvL.js → process-output-9W-JyYdE.js} +1 -1
  55. package/dist/assets/{radio-group-BS2PIEzV.js → radio-group-rsi1ibXY.js} +1 -1
  56. package/dist/assets/{readonly-python-code-C5JNX2fu.js → readonly-python-code-BKYj8PNf.js} +1 -1
  57. package/dist/assets/{reveal-component-PiSHIrbA.js → reveal-component-ClM8W-TD.js} +1 -1
  58. package/dist/assets/{run-page-BovrPK0f.js → run-page-DJOwAe2z.js} +1 -1
  59. package/dist/assets/{scratchpad-panel-CAWFveBD.js → scratchpad-panel-CpM3jVv7.js} +1 -1
  60. package/dist/assets/{secrets-panel-CEh4Wjfn.js → secrets-panel-DqHGq3V8.js} +1 -1
  61. package/dist/assets/{session-panel-BR9h5w96.js → session-panel-jVcSUURP.js} +1 -1
  62. package/dist/assets/{snippets-panel-Y2etH9Qg.js → snippets-panel-DFJd1ui5.js} +1 -1
  63. package/dist/assets/{state-Fa6RzVTL.js → state-BXNNuw9g.js} +1 -1
  64. package/dist/assets/{state-1SbOXCLX.js → state-D9EoHCkz.js} +1 -1
  65. package/dist/assets/{switch-CTn-kJzM.js → switch-BtkQp293.js} +1 -1
  66. package/dist/assets/{terminal-DI2XRUUH.js → terminal-BSE1Vg5d.js} +1 -1
  67. package/dist/assets/{textarea-wgoQLrBS.js → textarea-BBTcSr-i.js} +1 -1
  68. package/dist/assets/{tracing-C9PZ0Pr1.js → tracing-BQU8fBDM.js} +1 -1
  69. package/dist/assets/{tracing-panel-C20Rk6hU.js → tracing-panel-DEVpyGX3.js} +2 -2
  70. package/dist/assets/useBoolean-B8LMGUHl.js +1 -0
  71. package/dist/assets/{useCellActionButton-D_-iAhme.js → useCellActionButton-CpNJthj4.js} +1 -1
  72. package/dist/assets/{useDeleteCell-41mvwiyA.js → useDeleteCell-DFahVcdW.js} +1 -1
  73. package/dist/assets/{useDependencyPanelTab-ELdrL73c.js → useDependencyPanelTab-BNbEyT1o.js} +1 -1
  74. package/dist/assets/{useNotebookActions-CgN-58GN.js → useNotebookActions-DqlAe4Ea.js} +1 -1
  75. package/dist/assets/{useRunCells-CfHlqXY6.js → useRunCells-C0BPo9m1.js} +1 -1
  76. package/dist/assets/{useSplitCell-gHtyz873.js → useSplitCell-BN53wD86.js} +1 -1
  77. package/dist/assets/{vega-component-BPU1T-x7.js → vega-component-C9fDGx86.js} +1 -1
  78. package/dist/assets/{write-secret-modal-DjVzKit_.js → write-secret-modal-Liv_9MXS.js} +1 -1
  79. package/dist/index.html +40 -40
  80. package/package.json +1 -1
  81. package/src/components/data-table/__tests__/column-header.test.tsx +106 -1
  82. package/src/components/data-table/__tests__/filter-pill-editor.test.tsx +88 -2
  83. package/src/components/data-table/__tests__/filters.test.ts +84 -13
  84. package/src/components/data-table/column-header.tsx +152 -26
  85. package/src/components/data-table/date-filter-inputs.tsx +325 -0
  86. package/src/components/data-table/filter-pill-editor.tsx +139 -30
  87. package/src/components/data-table/filter-pills.tsx +31 -57
  88. package/src/components/data-table/filters.ts +88 -66
  89. package/src/components/editor/chrome/wrapper/footer-items/backend-status.tsx +1 -1
  90. package/src/core/runtime/__tests__/runtime.test.ts +38 -17
  91. package/src/core/runtime/runtime.ts +57 -34
  92. package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +5 -4
  93. package/src/core/websocket/__tests__/useMarimoKernelConnection.test.ts +18 -54
  94. package/src/core/websocket/transports/__tests__/ws.test.ts +125 -0
  95. package/src/core/websocket/transports/basic.ts +1 -3
  96. package/src/core/websocket/transports/transport.ts +0 -1
  97. package/src/core/websocket/transports/ws.ts +96 -0
  98. package/src/core/websocket/useMarimoKernelConnection.tsx +30 -26
  99. package/src/core/websocket/useWebSocket.tsx +3 -18
  100. package/dist/assets/JsonOutput-Dxol3ZtH.js +0 -49
  101. package/dist/assets/config-DGudsRYK.js +0 -1
  102. package/dist/assets/index-DySiGerD.js +0 -42
  103. package/dist/assets/mermaid-4DMBBIKO-B-uFGNnk.js +0 -1
  104. package/dist/assets/panels-CJ1t18_z.js +0 -1
  105. package/dist/assets/useBoolean-DP3412N2.js +0 -1
  106. /package/dist/assets/{bundle.esm-DjhGJy4I.js → bundle.esm-BXIlAZ6T.js} +0 -0
  107. /package/dist/assets/{esm-DLYpPRvw.js → esm-Cb2bnV6o.js} +0 -0
  108. /package/dist/assets/{field-CQGpbXj3.js → field-DNlzfMKW.js} +0 -0
  109. /package/dist/assets/{formats-CJQ67TPE.js → formats-BRq458WH.js} +0 -0
  110. /package/dist/assets/{icons-Ol38nIbL.js → icons-8tfAri2V.js} +0 -0
  111. /package/dist/assets/{micromark-factory-space-P--XWZhg.js → micromark-factory-space-BUQpMdx2.js} +0 -0
  112. /package/dist/assets/{react-resizable-panels.browser.esm-CgWOEYeG.js → react-resizable-panels.browser.esm-Ce2ksurd.js} +0 -0
  113. /package/dist/assets/{renderShortcut-D7FYCtYQ.js → renderShortcut-DK-VjfaX.js} +0 -0
  114. /package/dist/assets/{table-DUSsaCYD.js → table-DQE9hQzM.js} +0 -0
  115. /package/dist/assets/{tree-actions-D9i3o3Zk.js → tree-actions-DY8FUp3V.js} +0 -0
  116. /package/dist/assets/{useDeepCompareMemoize-zUHU--0D.js → useDeepCompareMemoize-CWcgQCbT.js} +0 -0
@@ -44,14 +44,19 @@ import { OPERATOR_LABELS } from "./operator-labels";
44
44
  import {
45
45
  type ColumnFilterForType,
46
46
  type ColumnFilterValue,
47
+ DATETIME_OPS,
47
48
  Filter,
48
- NUMBER_COMPARISON_OPS,
49
- type NumberComparisonOp,
49
+ isDatetimeComparisonOp,
50
+ isNumberComparisonOp,
51
+ isTextScalarOp,
50
52
  NUMBER_OPS,
51
53
  TEXT_OPS,
52
- TEXT_SCALAR_OPS,
53
- type TextScalarOp,
54
54
  } from "./filters";
55
+ import {
56
+ type DateLikeFilterType,
57
+ DateLikeInput,
58
+ DateLikeRangeInput,
59
+ } from "./date-filter-inputs";
55
60
  import {
56
61
  ClearFilterMenuItem,
57
62
  FilterButtons,
@@ -283,19 +288,21 @@ export function renderMenuItemFilter<TData, TValue>(
283
288
  return null;
284
289
  }
285
290
 
286
- if (filterType === "time") {
287
- // Not implemented
288
- return null;
289
- }
290
-
291
- if (filterType === "datetime") {
292
- // Not implemented
293
- return null;
294
- }
295
-
296
- if (filterType === "date") {
297
- // Not implemented
298
- return null;
291
+ if (
292
+ filterType === "date" ||
293
+ filterType === "datetime" ||
294
+ filterType === "time"
295
+ ) {
296
+ return (
297
+ <DropdownMenuSub>
298
+ {filterMenuItem}
299
+ <DropdownMenuPortal>
300
+ <DropdownMenuSubContent>
301
+ <DateFilterMenu column={column} filterType={filterType} />
302
+ </DropdownMenuSubContent>
303
+ </DropdownMenuPortal>
304
+ </DropdownMenuSub>
305
+ );
299
306
  }
300
307
 
301
308
  logNever(filterType);
@@ -369,12 +376,6 @@ const BooleanFilter = <TData, TValue>({
369
376
  );
370
377
  };
371
378
 
372
- const NUMBER_COMPARISON_SET: ReadonlySet<OperatorType> = new Set(
373
- NUMBER_COMPARISON_OPS,
374
- );
375
- const isNumberComparisonOp = (op: OperatorType): op is NumberComparisonOp =>
376
- NUMBER_COMPARISON_SET.has(op);
377
-
378
379
  type NumberComparisonFilter = Extract<
379
380
  ColumnFilterForType<"number">,
380
381
  { value: number }
@@ -485,9 +486,134 @@ export const NumberFilterMenu = <TData, TValue>({
485
486
  );
486
487
  };
487
488
 
488
- const TEXT_SCALAR_SET: ReadonlySet<OperatorType> = new Set(TEXT_SCALAR_OPS);
489
- const isTextScalarOp = (op: OperatorType): op is TextScalarOp =>
490
- TEXT_SCALAR_SET.has(op);
489
+ type DateComparisonFilter = Extract<
490
+ ColumnFilterForType<DateLikeFilterType>,
491
+ { value: Date }
492
+ >;
493
+ const isDateComparisonFilter = (
494
+ filter: ColumnFilterForType<DateLikeFilterType>,
495
+ ): filter is DateComparisonFilter => isDatetimeComparisonOp(filter.operator);
496
+
497
+ export const DateFilterMenu = <TData, TValue>({
498
+ column,
499
+ filterType,
500
+ }: {
501
+ column: Column<TData, TValue>;
502
+ filterType: DateLikeFilterType;
503
+ }) => {
504
+ const currentFilter = column.getFilterValue() as
505
+ | ColumnFilterForType<DateLikeFilterType>
506
+ | undefined;
507
+ const hasFilter = currentFilter !== undefined;
508
+
509
+ const [operator, setOperator] = useState<OperatorType>(
510
+ currentFilter?.operator ?? "between",
511
+ );
512
+ const [min, setMin] = useState<Date | undefined>(
513
+ currentFilter?.operator === "between" ? currentFilter.min : undefined,
514
+ );
515
+ const [max, setMax] = useState<Date | undefined>(
516
+ currentFilter?.operator === "between" ? currentFilter.max : undefined,
517
+ );
518
+ const [value, setValue] = useState<Date | undefined>(
519
+ currentFilter !== undefined && isDateComparisonFilter(currentFilter)
520
+ ? currentFilter.value
521
+ : undefined,
522
+ );
523
+
524
+ const isComparison = isDatetimeComparisonOp(operator);
525
+ const isNullish = operator === "is_null" || operator === "is_not_null";
526
+
527
+ const applyDisabled =
528
+ (operator === "between" && (min === undefined || max === undefined)) ||
529
+ (isComparison && value === undefined);
530
+
531
+ const buildFilter = (
532
+ opts: Parameters<typeof Filter.date>[0],
533
+ ): ColumnFilterForType<DateLikeFilterType> => {
534
+ switch (filterType) {
535
+ case "date":
536
+ return Filter.date(opts);
537
+ case "datetime":
538
+ return Filter.datetime(opts);
539
+ case "time":
540
+ return Filter.time(opts);
541
+ }
542
+ };
543
+
544
+ const handleApply = () => {
545
+ if (isNullish) {
546
+ column.setFilterValue(buildFilter({ operator }));
547
+ return;
548
+ }
549
+ if (operator === "between" && min !== undefined && max !== undefined) {
550
+ column.setFilterValue(buildFilter({ operator: "between", min, max }));
551
+ return;
552
+ }
553
+ if (isComparison && value !== undefined) {
554
+ column.setFilterValue(buildFilter({ operator, value }));
555
+ }
556
+ };
557
+
558
+ const [resetKey, setResetKey] = useState(0);
559
+ const handleClear = () => {
560
+ setMin(undefined);
561
+ setMax(undefined);
562
+ setValue(undefined);
563
+ setResetKey((k) => k + 1);
564
+ column.setFilterValue(undefined);
565
+ };
566
+
567
+ const handleOperatorChange = (next: OperatorType) => {
568
+ setOperator(next);
569
+ };
570
+
571
+ return (
572
+ <div
573
+ className="flex flex-col gap-1 pt-3 px-2"
574
+ onKeyDownCapture={(e) => {
575
+ if (e.key === "Tab") {
576
+ e.stopPropagation();
577
+ }
578
+ }}
579
+ >
580
+ <OperatorSelect
581
+ operator={operator}
582
+ options={DATETIME_OPS}
583
+ onChange={handleOperatorChange}
584
+ />
585
+ {operator === "between" && (
586
+ <DateLikeRangeInput
587
+ key={`${filterType}-${resetKey}`}
588
+ filterType={filterType}
589
+ min={min}
590
+ max={max}
591
+ onRangeChange={(nextMin, nextMax) => {
592
+ setMin(nextMin);
593
+ setMax(nextMax);
594
+ }}
595
+ className="shadow-none! border-border hover:shadow-none!"
596
+ />
597
+ )}
598
+ {isComparison && (
599
+ <DateLikeInput
600
+ key={`${filterType}-${resetKey}`}
601
+ filterType={filterType}
602
+ value={value}
603
+ onChange={setValue}
604
+ aria-label="value"
605
+ className="shadow-none! border-border hover:shadow-none!"
606
+ />
607
+ )}
608
+ <FilterButtons
609
+ onApply={handleApply}
610
+ onClear={handleClear}
611
+ clearButtonDisabled={!hasFilter}
612
+ applyButtonDisabled={applyDisabled}
613
+ />
614
+ </div>
615
+ );
616
+ };
491
617
 
492
618
  export const TextFilterMenu = <TData, TValue>({
493
619
  column,
@@ -0,0 +1,325 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+ import type {
3
+ CalendarDate,
4
+ CalendarDateTime,
5
+ Time,
6
+ } from "@internationalized/date";
7
+ import { parseDate, parseDateTime, parseTime } from "@internationalized/date";
8
+ import { MinusIcon } from "lucide-react";
9
+ import { useState } from "react";
10
+ import type { DateValue, TimeValue } from "react-aria-components";
11
+ import { TimeField } from "@/components/ui/date-input";
12
+ import { DatePicker, DateRangePicker } from "@/components/ui/date-picker";
13
+ import {
14
+ dateToISODate,
15
+ dateToISODateTime,
16
+ dateToISOTime,
17
+ type FilterType,
18
+ } from "./filters";
19
+
20
+ export type DateLikeFilterType = Extract<
21
+ FilterType,
22
+ "date" | "datetime" | "time"
23
+ >;
24
+
25
+ function dateToAria(
26
+ filterType: DateLikeFilterType,
27
+ d: Date,
28
+ ): DateValue | TimeValue {
29
+ switch (filterType) {
30
+ case "date":
31
+ return parseDate(dateToISODate(d));
32
+ case "datetime":
33
+ return parseDateTime(dateToISODateTime(d));
34
+ case "time":
35
+ return parseTime(dateToISOTime(d));
36
+ }
37
+ }
38
+
39
+ function ariaToDate(
40
+ filterType: DateLikeFilterType,
41
+ aria: DateValue | TimeValue,
42
+ ): Date {
43
+ if (filterType === "time") {
44
+ const t = aria as Time;
45
+ return new Date(1970, 0, 1, t.hour, t.minute, t.second, t.millisecond);
46
+ }
47
+ if (filterType === "date") {
48
+ const c = aria as CalendarDate;
49
+ return new Date(c.year, c.month - 1, c.day);
50
+ }
51
+ const c = aria as Partial<CalendarDateTime> & CalendarDate;
52
+ return new Date(
53
+ c.year,
54
+ c.month - 1,
55
+ c.day,
56
+ c.hour ?? 0,
57
+ c.minute ?? 0,
58
+ c.second ?? 0,
59
+ c.millisecond ?? 0,
60
+ );
61
+ }
62
+
63
+ // Parses a pasted string into a Date appropriate for the filter type.
64
+ // Accepts ISO, US, RFC formats via the Date constructor; time-only strings
65
+ // (HH:MM[:SS]) are handled explicitly since `new Date("12:30")` is invalid.
66
+ export function parsePastedDate(
67
+ filterType: DateLikeFilterType,
68
+ text: string,
69
+ ): Date | undefined {
70
+ const trimmed = text.trim();
71
+ if (!trimmed) {
72
+ return undefined;
73
+ }
74
+
75
+ const timeMatch =
76
+ filterType === "time"
77
+ ? trimmed.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?$/i)
78
+ : null;
79
+ if (timeMatch) {
80
+ const [, hStr, mStr, sStr, ampm] = timeMatch;
81
+ let hour = Number.parseInt(hStr, 10);
82
+ const minute = Number.parseInt(mStr, 10);
83
+ const second = sStr ? Number.parseInt(sStr, 10) : 0;
84
+ if (ampm) {
85
+ const isPm = ampm.toLowerCase() === "pm";
86
+ if (hour === 12) {
87
+ hour = isPm ? 12 : 0;
88
+ } else if (isPm) {
89
+ hour += 12;
90
+ }
91
+ }
92
+ return new Date(1970, 0, 1, hour, minute, second);
93
+ }
94
+
95
+ const dateOnlyMatch = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})$/);
96
+ if (dateOnlyMatch) {
97
+ const [, y, m, d] = dateOnlyMatch;
98
+ return new Date(
99
+ Number.parseInt(y, 10),
100
+ Number.parseInt(m, 10) - 1,
101
+ Number.parseInt(d, 10),
102
+ );
103
+ }
104
+
105
+ // Parse ISO datetimes as wall-clock to stay consistent with the picker's
106
+ // local-time basis. Trailing `Z` or offsets are stripped so that pasting
107
+ // `2024-01-15T08:30:00Z` displays `08:30` instead of being shifted by the
108
+ // viewer's timezone.
109
+ const isoMatch = trimmed.match(
110
+ /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/,
111
+ );
112
+ if (isoMatch) {
113
+ const [, y, mo, d, h, mi, s] = isoMatch;
114
+ return new Date(
115
+ Number.parseInt(y, 10),
116
+ Number.parseInt(mo, 10) - 1,
117
+ Number.parseInt(d, 10),
118
+ Number.parseInt(h, 10),
119
+ Number.parseInt(mi, 10),
120
+ s ? Number.parseInt(s, 10) : 0,
121
+ );
122
+ }
123
+
124
+ const parsed = new Date(trimmed);
125
+ if (Number.isNaN(parsed.getTime())) {
126
+ return undefined;
127
+ }
128
+ return parsed;
129
+ }
130
+
131
+ function parsePastedRange(
132
+ filterType: DateLikeFilterType,
133
+ text: string,
134
+ ): { min: Date; max: Date } | undefined {
135
+ const parts = text.split(/\s+(?:-|–|—|to)\s+/i);
136
+ if (parts.length === 2) {
137
+ const min = parsePastedDate(filterType, parts[0]);
138
+ const max = parsePastedDate(filterType, parts[1]);
139
+ if (min && max) {
140
+ return { min, max };
141
+ }
142
+ }
143
+ const single = parsePastedDate(filterType, text);
144
+ if (single) {
145
+ return { min: single, max: single };
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ interface DateLikeInputProps {
151
+ filterType: DateLikeFilterType;
152
+ value: Date | undefined;
153
+ onChange: (value: Date | undefined) => void;
154
+ "aria-label"?: string;
155
+ className?: string;
156
+ }
157
+
158
+ export const DateLikeInput = ({
159
+ filterType,
160
+ value,
161
+ onChange,
162
+ "aria-label": ariaLabel,
163
+ className,
164
+ }: DateLikeInputProps) => {
165
+ const [seedKey, setSeedKey] = useState(0);
166
+ const [seed, setSeed] = useState(value);
167
+
168
+ const handleChange = (next: DateValue | TimeValue | null) => {
169
+ if (next === null) {
170
+ return;
171
+ }
172
+ onChange(ariaToDate(filterType, next));
173
+ };
174
+
175
+ const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
176
+ const text = e.clipboardData.getData("text");
177
+ const parsed = parsePastedDate(filterType, text);
178
+ if (!parsed) {
179
+ return;
180
+ }
181
+ e.preventDefault();
182
+ onChange(parsed);
183
+ setSeed(parsed);
184
+ setSeedKey((k) => k + 1);
185
+ };
186
+
187
+ const seedValue =
188
+ seed === undefined ? undefined : dateToAria(filterType, seed);
189
+
190
+ return (
191
+ <div onPasteCapture={handlePaste} className="contents">
192
+ {filterType === "time" ? (
193
+ <TimeField<Time>
194
+ key={seedKey}
195
+ aria-label={ariaLabel}
196
+ defaultValue={seedValue as Time | undefined}
197
+ onChange={handleChange}
198
+ className={className}
199
+ />
200
+ ) : filterType === "date" ? (
201
+ <DatePicker<CalendarDate>
202
+ key={seedKey}
203
+ aria-label={ariaLabel}
204
+ defaultValue={seedValue as CalendarDate | undefined}
205
+ onChange={handleChange}
206
+ className={className}
207
+ />
208
+ ) : (
209
+ <DatePicker<CalendarDateTime>
210
+ key={seedKey}
211
+ aria-label={ariaLabel}
212
+ defaultValue={seedValue as CalendarDateTime | undefined}
213
+ granularity="second"
214
+ onChange={handleChange}
215
+ className={className}
216
+ />
217
+ )}
218
+ </div>
219
+ );
220
+ };
221
+
222
+ interface DateLikeRangeInputProps {
223
+ filterType: DateLikeFilterType;
224
+ min: Date | undefined;
225
+ max: Date | undefined;
226
+ onRangeChange: (min: Date | undefined, max: Date | undefined) => void;
227
+ className?: string;
228
+ }
229
+
230
+ export const DateLikeRangeInput = ({
231
+ filterType,
232
+ min,
233
+ max,
234
+ onRangeChange,
235
+ className,
236
+ }: DateLikeRangeInputProps) => {
237
+ const [seedKey, setSeedKey] = useState(0);
238
+ const [seedMin, setSeedMin] = useState(min);
239
+ const [seedMax, setSeedMax] = useState(max);
240
+
241
+ const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
242
+ const text = e.clipboardData.getData("text");
243
+ const parsed = parsePastedRange(filterType, text);
244
+ if (!parsed) {
245
+ return;
246
+ }
247
+ e.preventDefault();
248
+ e.stopPropagation();
249
+ onRangeChange(parsed.min, parsed.max);
250
+ setSeedMin(parsed.min);
251
+ setSeedMax(parsed.max);
252
+ setSeedKey((k) => k + 1);
253
+ };
254
+
255
+ if (filterType === "time") {
256
+ return (
257
+ <div onPasteCapture={handlePaste} className="flex gap-1 items-center">
258
+ <DateLikeInput
259
+ key={`min-${seedKey}`}
260
+ filterType="time"
261
+ value={seedMin}
262
+ onChange={(nextMin) => onRangeChange(nextMin, max)}
263
+ aria-label="min"
264
+ className={className}
265
+ />
266
+ <MinusIcon className="h-5 w-5 text-muted-foreground" />
267
+ <DateLikeInput
268
+ key={`max-${seedKey}`}
269
+ filterType="time"
270
+ value={seedMax}
271
+ onChange={(nextMax) => onRangeChange(min, nextMax)}
272
+ aria-label="max"
273
+ className={className}
274
+ />
275
+ </div>
276
+ );
277
+ }
278
+
279
+ const handleChange = (next: { start: DateValue; end: DateValue } | null) => {
280
+ if (next === null) {
281
+ return;
282
+ }
283
+ onRangeChange(
284
+ ariaToDate(filterType, next.start),
285
+ ariaToDate(filterType, next.end),
286
+ );
287
+ };
288
+
289
+ const seedRange =
290
+ seedMin === undefined || seedMax === undefined
291
+ ? undefined
292
+ : {
293
+ start: dateToAria(filterType, seedMin),
294
+ end: dateToAria(filterType, seedMax),
295
+ };
296
+
297
+ return (
298
+ <div onPasteCapture={handlePaste} className="contents">
299
+ {filterType === "date" ? (
300
+ <DateRangePicker<CalendarDate>
301
+ key={seedKey}
302
+ aria-label="range"
303
+ defaultValue={
304
+ seedRange as { start: CalendarDate; end: CalendarDate } | undefined
305
+ }
306
+ onChange={handleChange}
307
+ className={className}
308
+ />
309
+ ) : (
310
+ <DateRangePicker<CalendarDateTime>
311
+ key={seedKey}
312
+ aria-label="range"
313
+ defaultValue={
314
+ seedRange as
315
+ | { start: CalendarDateTime; end: CalendarDateTime }
316
+ | undefined
317
+ }
318
+ granularity="second"
319
+ onChange={handleChange}
320
+ className={className}
321
+ />
322
+ )}
323
+ </div>
324
+ );
325
+ };