@marimo-team/islands 0.23.7-dev56 → 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.
@@ -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
+ };
@@ -17,23 +17,46 @@ import {
17
17
  SelectValue,
18
18
  } from "../ui/select";
19
19
  import { Button } from "../ui/button";
20
+ import { DateLikeInput, DateLikeRangeInput } from "./date-filter-inputs";
20
21
  import { FilterByValuesPicker } from "./filter-by-values-picker";
21
22
  import { RegexInput } from "./regex-input";
22
23
  import {
23
24
  type ColumnFilterValue,
25
+ DATETIME_OPS,
24
26
  Filter,
27
+ isDatetimeComparisonOp,
28
+ isNumberComparisonOp,
29
+ isTextScalarOp,
25
30
  MEMBERSHIP_OPS,
26
- NUMBER_COMPARISON_OPS,
27
- type NumberComparisonOp,
28
31
  NUMBER_OPS,
29
32
  TEXT_OPS,
30
- TEXT_SCALAR_OPS,
31
- type TextScalarOp,
32
33
  } from "./filters";
33
34
  import { OPERATOR_LABELS } from "./operator-labels";
34
35
  import { Tooltip } from "../ui/tooltip";
35
36
 
36
- type EditableFilterType = "number" | "text" | "boolean" | "select";
37
+ type EditableFilterType =
38
+ | "number"
39
+ | "text"
40
+ | "boolean"
41
+ | "select"
42
+ | "date"
43
+ | "datetime"
44
+ | "time";
45
+
46
+ type DateLikeEditableFilterType = Extract<
47
+ EditableFilterType,
48
+ "date" | "datetime" | "time"
49
+ >;
50
+
51
+ const DATE_LIKE_TYPES: ReadonlySet<EditableFilterType> = new Set([
52
+ "date",
53
+ "datetime",
54
+ "time",
55
+ ]);
56
+
57
+ const isDateLikeType = (
58
+ type: EditableFilterType,
59
+ ): type is DateLikeEditableFilterType => DATE_LIKE_TYPES.has(type);
37
60
 
38
61
  const BOOLEAN_OPS = ["is_true", "is_false", "is_null", "is_not_null"] as const;
39
62
  const SELECT_OPS = MEMBERSHIP_OPS;
@@ -46,6 +69,9 @@ const OPERATORS_BY_TYPE: Record<
46
69
  text: TEXT_OPS,
47
70
  boolean: BOOLEAN_OPS,
48
71
  select: SELECT_OPS,
72
+ date: DATETIME_OPS,
73
+ datetime: DATETIME_OPS,
74
+ time: DATETIME_OPS,
49
75
  };
50
76
 
51
77
  const DEFAULT_OPERATOR: Record<EditableFilterType, OperatorType> = {
@@ -53,6 +79,9 @@ const DEFAULT_OPERATOR: Record<EditableFilterType, OperatorType> = {
53
79
  text: "contains",
54
80
  boolean: "is_true",
55
81
  select: "in",
82
+ date: "between",
83
+ datetime: "between",
84
+ time: "between",
56
85
  };
57
86
 
58
87
  const OPERATORS_WITHOUT_VALUE = new Set<OperatorType>([
@@ -63,22 +92,14 @@ const OPERATORS_WITHOUT_VALUE = new Set<OperatorType>([
63
92
  "is_empty",
64
93
  ]);
65
94
 
66
- const NUMBER_COMPARISON_SET: ReadonlySet<OperatorType> = new Set(
67
- NUMBER_COMPARISON_OPS,
68
- );
69
- const TEXT_SCALAR_SET: ReadonlySet<OperatorType> = new Set(TEXT_SCALAR_OPS);
70
-
71
- const isNumberComparisonOp = (op: OperatorType): op is NumberComparisonOp =>
72
- NUMBER_COMPARISON_SET.has(op);
73
- const isTextScalarOp = (op: OperatorType): op is TextScalarOp =>
74
- TEXT_SCALAR_SET.has(op);
75
-
76
95
  type DraftValue =
77
96
  | { kind: "between"; min?: number; max?: number }
78
97
  | { kind: "single-number"; value?: number }
79
98
  | { kind: "single-text"; text?: string }
80
99
  | { kind: "multi-text"; values?: string[] }
81
100
  | { kind: "options"; options?: unknown[] }
101
+ | { kind: "date-between"; min?: Date; max?: Date }
102
+ | { kind: "date-single"; value?: Date }
82
103
  | { kind: "none" };
83
104
 
84
105
  interface Snapshot {
@@ -116,7 +137,13 @@ export const FilterPillEditor = <TData,>({
116
137
  const editableColumns = table.getAllColumns().filter((c) => {
117
138
  const ft = c.columnDef.meta?.filterType;
118
139
  return (
119
- ft === "number" || ft === "text" || ft === "boolean" || ft === "select"
140
+ ft === "number" ||
141
+ ft === "text" ||
142
+ ft === "boolean" ||
143
+ ft === "select" ||
144
+ ft === "date" ||
145
+ ft === "datetime" ||
146
+ ft === "time"
120
147
  );
121
148
  });
122
149
 
@@ -200,7 +227,7 @@ export const FilterPillEditor = <TData,>({
200
227
  const operatorTriggerRef = useRef<HTMLButtonElement>(null);
201
228
  useEffect(() => {
202
229
  const firstInput = valueSlotRef.current?.querySelector<HTMLElement>(
203
- 'input, [role="combobox"], button',
230
+ 'input, [role="spinbutton"], [role="combobox"], button',
204
231
  );
205
232
  if (firstInput) {
206
233
  firstInput.focus();
@@ -216,6 +243,11 @@ export const FilterPillEditor = <TData,>({
216
243
  e.preventDefault();
217
244
  handleApply();
218
245
  }}
246
+ onKeyDownCapture={(e) => {
247
+ if (e.key === "Tab") {
248
+ e.stopPropagation();
249
+ }
250
+ }}
219
251
  >
220
252
  <div className="flex flex-col gap-1">
221
253
  <label className="text-xs text-muted-foreground" htmlFor={columnId}>
@@ -426,6 +458,36 @@ const ValueSlot = <TData, TValue>({
426
458
  />
427
459
  );
428
460
  }
461
+ if (isDateLikeType(type) && operator === "between") {
462
+ const v =
463
+ value.kind === "date-between" ? value : { kind: "date-between" as const };
464
+ return (
465
+ <DateLikeRangeInput
466
+ key={`${column?.id ?? "_"}-${operator}`}
467
+ filterType={type}
468
+ min={v.min}
469
+ max={v.max}
470
+ onRangeChange={(min, max) =>
471
+ onChange({ kind: "date-between", min, max })
472
+ }
473
+ className="border-input"
474
+ />
475
+ );
476
+ }
477
+ if (isDateLikeType(type) && isDatetimeComparisonOp(operator)) {
478
+ const v =
479
+ value.kind === "date-single" ? value : { kind: "date-single" as const };
480
+ return (
481
+ <DateLikeInput
482
+ key={`${column?.id ?? "_"}-${operator}`}
483
+ filterType={type}
484
+ value={v.value}
485
+ onChange={(next) => onChange({ kind: "date-single", value: next })}
486
+ aria-label="value"
487
+ className="border-input"
488
+ />
489
+ );
490
+ }
429
491
  if (type === "select" && column) {
430
492
  const v = value.kind === "options" ? value : { kind: "options" as const };
431
493
  return (
@@ -443,19 +505,18 @@ const ValueSlot = <TData, TValue>({
443
505
  };
444
506
 
445
507
  function getEditableType(value: ColumnFilterValue): EditableFilterType {
446
- if (value.type === "number") {
447
- return "number";
448
- }
449
- if (value.type === "text") {
450
- return "text";
451
- }
452
- if (value.type === "boolean") {
453
- return "boolean";
454
- }
455
- if (value.type === "select") {
456
- return "select";
508
+ switch (value.type) {
509
+ case "number":
510
+ case "text":
511
+ case "boolean":
512
+ case "select":
513
+ case "date":
514
+ case "datetime":
515
+ case "time":
516
+ return value.type;
517
+ default:
518
+ return "text";
457
519
  }
458
- return "text";
459
520
  }
460
521
 
461
522
  function toDraftValue(value: ColumnFilterValue): DraftValue {
@@ -486,6 +547,21 @@ function toDraftValue(value: ColumnFilterValue): DraftValue {
486
547
  if (value.type === "select") {
487
548
  return { kind: "options", options: [...value.options] };
488
549
  }
550
+ if (
551
+ value.type === "date" ||
552
+ value.type === "datetime" ||
553
+ value.type === "time"
554
+ ) {
555
+ switch (value.operator) {
556
+ case "between":
557
+ return { kind: "date-between", min: value.min, max: value.max };
558
+ case "is_null":
559
+ case "is_not_null":
560
+ return { kind: "none" };
561
+ default:
562
+ return { kind: "date-single", value: value.value };
563
+ }
564
+ }
489
565
  return { kind: "none" };
490
566
  }
491
567
 
@@ -509,6 +585,11 @@ function emptyDraftFor(
509
585
  if (type === "select") {
510
586
  return { kind: "options", options: [] };
511
587
  }
588
+ if (isDateLikeType(type)) {
589
+ return operator === "between"
590
+ ? { kind: "date-between" }
591
+ : { kind: "date-single" };
592
+ }
512
593
  return { kind: "none" };
513
594
  }
514
595
 
@@ -516,7 +597,7 @@ function getMissingValueMessage(
516
597
  type: EditableFilterType,
517
598
  operator: OperatorType,
518
599
  ): string {
519
- if (type === "number" && operator === "between") {
600
+ if (operator === "between") {
520
601
  return "Min and max are required";
521
602
  }
522
603
  if (type === "text" && (operator === "in" || operator === "not_in")) {
@@ -611,5 +692,33 @@ function buildFilterValue({
611
692
  operator: operator === "not_in" ? "not_in" : "in",
612
693
  });
613
694
  }
695
+ if (isDateLikeType(type)) {
696
+ const factory =
697
+ type === "date"
698
+ ? Filter.date
699
+ : type === "datetime"
700
+ ? Filter.datetime
701
+ : Filter.time;
702
+ if (operator === "is_null" || operator === "is_not_null") {
703
+ return factory({ operator });
704
+ }
705
+ if (operator === "between") {
706
+ if (
707
+ draft.kind !== "date-between" ||
708
+ draft.min === undefined ||
709
+ draft.max === undefined
710
+ ) {
711
+ return undefined;
712
+ }
713
+ return factory({ operator: "between", min: draft.min, max: draft.max });
714
+ }
715
+ if (!isDatetimeComparisonOp(operator)) {
716
+ return undefined;
717
+ }
718
+ if (draft.kind !== "date-single" || draft.value === undefined) {
719
+ return undefined;
720
+ }
721
+ return factory({ operator, value: draft.value });
722
+ }
614
723
  return undefined;
615
724
  }