@marimo-team/islands 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 (29) hide show
  1. package/dist/{chat-ui-DCyW3OUK.js → chat-ui-D3XBept8.js} +3 -3
  2. package/dist/{code-visibility-CJ7U5FE0.js → code-visibility-PjV7HUDZ.js} +10624 -1451
  3. package/dist/{formats-CpgZM9BM.js → formats-Dsy9kkZu.js} +1 -1
  4. package/dist/{html-to-image-40ZXSWP-.js → html-to-image-CpggM7u1.js} +1 -1
  5. package/dist/main.js +1353 -9989
  6. package/dist/{process-output-CCeeXIBd.js → process-output-X8TR20AK.js} +1 -1
  7. package/dist/{reveal-component-Bopa1DsA.js → reveal-component-Phd-LTXq.js} +3 -3
  8. package/dist/{toDate-CJWlVNGD.js → toDate-CIpC_34u.js} +30 -17
  9. package/dist/{vega-component-BtvQ-Kc4.js → vega-component-cSdqoAxe.js} +2 -2
  10. package/package.json +1 -1
  11. package/src/components/data-table/__tests__/column-header.test.tsx +106 -1
  12. package/src/components/data-table/__tests__/filter-pill-editor.test.tsx +88 -2
  13. package/src/components/data-table/__tests__/filters.test.ts +84 -13
  14. package/src/components/data-table/column-header.tsx +152 -26
  15. package/src/components/data-table/date-filter-inputs.tsx +325 -0
  16. package/src/components/data-table/filter-pill-editor.tsx +139 -30
  17. package/src/components/data-table/filter-pills.tsx +31 -57
  18. package/src/components/data-table/filters.ts +88 -66
  19. package/src/components/editor/chrome/wrapper/footer-items/backend-status.tsx +1 -1
  20. package/src/core/runtime/__tests__/runtime.test.ts +38 -17
  21. package/src/core/runtime/runtime.ts +57 -34
  22. package/src/core/websocket/__tests__/useMarimoKernelConnection.hook.test.tsx +5 -4
  23. package/src/core/websocket/__tests__/useMarimoKernelConnection.test.ts +18 -54
  24. package/src/core/websocket/transports/__tests__/ws.test.ts +125 -0
  25. package/src/core/websocket/transports/basic.ts +1 -3
  26. package/src/core/websocket/transports/transport.ts +0 -1
  27. package/src/core/websocket/transports/ws.ts +96 -0
  28. package/src/core/websocket/useMarimoKernelConnection.tsx +30 -26
  29. package/src/core/websocket/useWebSocket.tsx +3 -18
@@ -1,6 +1,6 @@
1
1
  import { s as __toESM } from "./chunk-BNovOVIE.js";
2
2
  import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
3
- import { it as parseHtmlContent, rt as ansiToPlainText } from "./html-to-image-40ZXSWP-.js";
3
+ import { it as parseHtmlContent, rt as ansiToPlainText } from "./html-to-image-CpggM7u1.js";
4
4
  import { u as createLucideIcon } from "./dist-D3ZI9nhS.js";
5
5
  import { t as Strings } from "./strings-BiIhGaI8.js";
6
6
  import { t as require_jsx_runtime } from "./jsx-runtime-COBk7ree.js";
@@ -6,11 +6,11 @@ import { s as __toESM } from "./chunk-BNovOVIE.js";
6
6
  import { _ as Logger, g as cn, h as Events, l as useEventListener, t as Button } from "./button-Dj4BTre0.js";
7
7
  import { t as require_react } from "./react-DA-nE2FX.js";
8
8
  import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
9
- import "./html-to-image-40ZXSWP-.js";
9
+ import "./html-to-image-CpggM7u1.js";
10
10
  import "./chunk-5FQGJX7Z-BOg95xG5.js";
11
- import { Ft as Code, Mt as Expand, a as DEFAULT_SLIDE_TYPE, c as Slide, i as DEFAULT_DECK_TRANSITION, jt as EyeOff, s as SlideSidebar, t as useNotebookCodeAvailable } from "./code-visibility-CJ7U5FE0.js";
11
+ import { Ft as EyeOff, It as Expand, a as DEFAULT_SLIDE_TYPE, c as Slide, i as DEFAULT_DECK_TRANSITION, s as SlideSidebar, t as useNotebookCodeAvailable, zt as Code } from "./code-visibility-PjV7HUDZ.js";
12
12
  import "./input-D4kjoQUB.js";
13
- import "./toDate-CJWlVNGD.js";
13
+ import "./toDate-CIpC_34u.js";
14
14
  import "./react-dom-BWRJ_g_k.js";
15
15
  import { t as require_jsx_runtime } from "./jsx-runtime-COBk7ree.js";
16
16
  import "./zod-BWkcDORu.js";
@@ -512,15 +512,21 @@ var RuntimeManager = class {
512
512
  get isSameOrigin() {
513
513
  return this.httpURL.origin === window.location.origin;
514
514
  }
515
- formatHttpURL(e, b, x = true) {
516
- e || (e = "");
515
+ get isServerless() {
516
+ return isWasm() || isIslands() || isStaticNotebook();
517
+ }
518
+ formatHttpURL({ path: e = "", searchParams: b, restrictToKnownQueryParams: x = true }) {
517
519
  let S = this.httpURL, w = new URLSearchParams(window.location.search);
518
520
  if (b) for (let [e2, x2] of b.entries()) S.searchParams.set(e2, x2);
519
521
  for (let [e2, b2] of w.entries()) x && !Object.values(KnownQueryParams).includes(e2) || S.searchParams.set(e2, b2);
520
522
  return S.pathname = `${S.pathname.replace(/\/$/, "")}/${e.replace(/^\//, "")}`, S.hash = "", S;
521
523
  }
522
524
  formatWsURL(e, b) {
523
- let x = this.formatHttpURL(e, b, false);
525
+ let x = this.formatHttpURL({
526
+ path: e,
527
+ searchParams: b,
528
+ restrictToKnownQueryParams: false
529
+ });
524
530
  return !this.isSameOrigin && this.config.authToken && x.searchParams.set(KnownQueryParams.accessToken, this.config.authToken), asWsUrl(x.toString());
525
531
  }
526
532
  getWsURL(e) {
@@ -546,26 +552,33 @@ var RuntimeManager = class {
546
552
  return this.formatWsURL(`/lsp/${e}`);
547
553
  }
548
554
  getAiURL(e) {
549
- return this.formatHttpURL(`/api/ai/${e}`);
555
+ return this.formatHttpURL({ path: `/api/ai/${e}` });
550
556
  }
551
557
  healthURL() {
552
- return this.formatHttpURL("/health");
558
+ return this.formatHttpURL({ path: "/health" });
553
559
  }
554
- async isHealthy() {
555
- if (isWasm() || isIslands() || isStaticNotebook()) return true;
560
+ async fetchHealth() {
556
561
  try {
557
- let e = await fetch(this.healthURL().toString());
558
- if (e.redirected) {
559
- Logger.debug(`Runtime redirected to ${e.url}`);
560
- let x2 = new URL(e.url);
561
- x2.pathname = x2.pathname.replace(/\/health$/, ""), this.config.url = x2.toString();
562
- }
563
- let x = e.ok;
564
- return x && this.setDOMBaseUri(this.config.url), x;
562
+ return await fetch(this.healthURL().toString());
565
563
  } catch (e) {
566
- return Logger.error(`Failed to check health: ${e instanceof Error ? e.message : "Unknown error"}`, { cause: e }), false;
564
+ return Logger.error(`Failed to check health: ${e instanceof Error ? e.message : "Unknown error"}`, { cause: e }), null;
567
565
  }
568
566
  }
567
+ async reconcileFromHealth() {
568
+ if (this.isServerless) return true;
569
+ let e = await this.fetchHealth();
570
+ if (!e) return false;
571
+ if (e.redirected) {
572
+ Logger.debug(`Runtime redirected to ${e.url}`);
573
+ let x = new URL(e.url);
574
+ x.pathname = x.pathname.replace(/\/health$/, ""), this.config.url = x.toString();
575
+ }
576
+ return e.ok && this.setDOMBaseUri(this.config.url), e.ok;
577
+ }
578
+ async probeHealth() {
579
+ var _a;
580
+ return this.isServerless ? true : ((_a = await this.fetchHealth()) == null ? void 0 : _a.ok) ?? false;
581
+ }
569
582
  setDOMBaseUri(e) {
570
583
  e = e.split("?", 1)[0], e.endsWith("/") || (e += "/");
571
584
  let b = document.querySelector("base");
@@ -574,7 +587,7 @@ var RuntimeManager = class {
574
587
  async init(e) {
575
588
  Logger.debug("Initializing runtime...");
576
589
  let x = 0;
577
- for (; !await this.isHealthy(); ) {
590
+ for (; !await this.reconcileFromHealth(); ) {
578
591
  if (x >= 25) {
579
592
  Logger.error("Failed to connect after 25 retries"), this.initialHealthyCheck.reject(/* @__PURE__ */ Error("Failed to connect after 25 retries"));
580
593
  return;
@@ -2,7 +2,7 @@ import { s as __toESM } from "./chunk-BNovOVIE.js";
2
2
  import { _ as Logger, c as Objects, g as cn, h as Events } from "./button-Dj4BTre0.js";
3
3
  import { t as require_react } from "./react-DA-nE2FX.js";
4
4
  import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
5
- import { c as asRemoteURL, v as CircleQuestionMark } from "./toDate-CJWlVNGD.js";
5
+ import { c as asRemoteURL, v as CircleQuestionMark } from "./toDate-CIpC_34u.js";
6
6
  import "./react-dom-BWRJ_g_k.js";
7
7
  import { t as require_jsx_runtime } from "./jsx-runtime-COBk7ree.js";
8
8
  import "./zod-BWkcDORu.js";
@@ -11,7 +11,7 @@ import { t as Tooltip } from "./tooltip-DRaMBu06.js";
11
11
  import { i as debounce_default } from "./constants-D0gkYoE2.js";
12
12
  import { n as useTheme, w as useEvent_default } from "./useTheme-DykuNHR2.js";
13
13
  import { s as uniq } from "./arrays-CldYf7p7.js";
14
- import { a as isValid, i as AlertTitle, n as Alert, t as arrow } from "./formats-CpgZM9BM.js";
14
+ import { a as isValid, i as AlertTitle, n as Alert, t as arrow } from "./formats-Dsy9kkZu.js";
15
15
  import { n as formats } from "./vega-loader.browser-3_z8GoFC.js";
16
16
  import { a as getContainerWidth, n as vegaLoadData, s as tooltipHandler } from "./loader-Dr8Qem8p.js";
17
17
  import { t as useAsyncData } from "./useAsyncData-C56Khv_R.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.7-dev55",
3
+ "version": "0.23.7-dev57",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -2,7 +2,11 @@
2
2
  import type { Column } from "@tanstack/react-table";
3
3
  import { fireEvent, render, screen, within } from "@testing-library/react";
4
4
  import { beforeAll, describe, expect, it, vi } from "vitest";
5
- import { NumberFilterMenu, TextFilterMenu } from "../column-header";
5
+ import {
6
+ DateFilterMenu,
7
+ NumberFilterMenu,
8
+ TextFilterMenu,
9
+ } from "../column-header";
6
10
  import { Filter } from "../filters";
7
11
 
8
12
  beforeAll(() => {
@@ -201,3 +205,104 @@ describe("TextFilterMenu", () => {
201
205
  expect(screen.getByRole("button", { name: /apply/i })).not.toBeDisabled();
202
206
  });
203
207
  });
208
+
209
+ type DateFilterValue = ReturnType<typeof Filter.date>;
210
+
211
+ function mockDateColumn(
212
+ filterType: "date" | "datetime" | "time" = "date",
213
+ initial?: DateFilterValue,
214
+ ): Column<unknown, unknown> & {
215
+ setFilterValue: ReturnType<typeof vi.fn>;
216
+ } {
217
+ let filterValue = initial;
218
+ const setFilterValue = vi.fn((next) => {
219
+ filterValue = next;
220
+ });
221
+ return {
222
+ id: "when",
223
+ columnDef: { meta: { dataType: filterType, filterType } },
224
+ getFilterValue: () => filterValue,
225
+ setFilterValue,
226
+ } as unknown as Column<unknown, unknown> & {
227
+ setFilterValue: ReturnType<typeof vi.fn>;
228
+ };
229
+ }
230
+
231
+ describe("DateFilterMenu", () => {
232
+ it("shows all expected operators in the dropdown", () => {
233
+ const column = mockDateColumn("date");
234
+ render(<DateFilterMenu column={column} filterType="date" />);
235
+ fireEvent.click(screen.getByRole("combobox"));
236
+ const listbox = screen.getByRole("listbox");
237
+ const labels = within(listbox)
238
+ .getAllByRole("option")
239
+ .map((o) => o.textContent);
240
+ expect(labels).toEqual([
241
+ "Between",
242
+ "Equals",
243
+ "Doesn't equal",
244
+ "Greater than",
245
+ "Greater than or equal",
246
+ "Less than",
247
+ "Less than or equal",
248
+ "Is null",
249
+ "Is not null",
250
+ ]);
251
+ });
252
+
253
+ it("defaults to between mode and disables Apply until both bounds set", () => {
254
+ const column = mockDateColumn("date");
255
+ render(<DateFilterMenu column={column} filterType="date" />);
256
+ expect(screen.getByLabelText("range")).toBeInTheDocument();
257
+ expect(screen.getByRole("button", { name: /apply/i })).toBeDisabled();
258
+ expect(screen.queryByLabelText("value")).not.toBeInTheDocument();
259
+ });
260
+
261
+ it("seeds between min/max from current filter", () => {
262
+ const column = mockDateColumn(
263
+ "date",
264
+ Filter.date({
265
+ operator: "between",
266
+ min: new Date("2024-01-01T00:00:00Z"),
267
+ max: new Date("2024-06-01T00:00:00Z"),
268
+ }),
269
+ );
270
+ render(<DateFilterMenu column={column} filterType="date" />);
271
+ expect(screen.getByLabelText("range")).toBeInTheDocument();
272
+ expect(screen.getByRole("button", { name: /apply/i })).not.toBeDisabled();
273
+ });
274
+
275
+ it("comparison operator swaps range for a single value picker", () => {
276
+ const column = mockDateColumn(
277
+ "date",
278
+ Filter.date({
279
+ operator: ">",
280
+ value: new Date("2024-01-01T00:00:00Z"),
281
+ }),
282
+ );
283
+ render(<DateFilterMenu column={column} filterType="date" />);
284
+ expect(screen.getByLabelText("value")).toBeInTheDocument();
285
+ expect(screen.queryByLabelText("range")).not.toBeInTheDocument();
286
+ });
287
+
288
+ it("time filter type renders two TimeFields for between", () => {
289
+ const column = mockDateColumn("time");
290
+ render(<DateFilterMenu column={column} filterType="time" />);
291
+ expect(screen.getByLabelText("min")).toBeInTheDocument();
292
+ expect(screen.getByLabelText("max")).toBeInTheDocument();
293
+ });
294
+
295
+ it("selecting a nullish operator hides value inputs and commits on Apply", () => {
296
+ const column = mockDateColumn("date");
297
+ render(<DateFilterMenu column={column} filterType="date" />);
298
+ fireEvent.click(screen.getByRole("combobox"));
299
+ const listbox = screen.getByRole("listbox");
300
+ fireEvent.click(within(listbox).getByText("Is null"));
301
+ expect(screen.queryByLabelText("range")).not.toBeInTheDocument();
302
+ expect(screen.queryByLabelText("value")).not.toBeInTheDocument();
303
+ fireEvent.click(screen.getByRole("button", { name: /apply/i }));
304
+ expect(column.setFilterValue).toHaveBeenCalledWith(
305
+ Filter.date({ operator: "is_null" }),
306
+ );
307
+ });
308
+ });
@@ -26,7 +26,14 @@ beforeAll(() => {
26
26
 
27
27
  function makeColumn(
28
28
  id: string,
29
- filterType: "text" | "number" | "boolean" | "select",
29
+ filterType:
30
+ | "text"
31
+ | "number"
32
+ | "boolean"
33
+ | "select"
34
+ | "date"
35
+ | "datetime"
36
+ | "time",
30
37
  ): Column<unknown, unknown> {
31
38
  return {
32
39
  id,
@@ -35,7 +42,13 @@ function makeColumn(
35
42
  }
36
43
 
37
44
  function mockTable(): Table<unknown> {
38
- const columns = [makeColumn("name", "text"), makeColumn("age", "number")];
45
+ const columns = [
46
+ makeColumn("name", "text"),
47
+ makeColumn("age", "number"),
48
+ makeColumn("when", "date"),
49
+ makeColumn("at", "datetime"),
50
+ makeColumn("clock", "time"),
51
+ ];
39
52
  return {
40
53
  getAllColumns: () => columns,
41
54
  getColumn: (id: string) => columns.find((c) => c.id === id),
@@ -144,6 +157,79 @@ describe("FilterPillEditor — snapshot rehydration", () => {
144
157
  });
145
158
  });
146
159
 
160
+ describe("FilterPillEditor — date/datetime/time", () => {
161
+ it("rehydrates a date between snapshot with the range picker", () => {
162
+ renderWithProviders(
163
+ <FilterPillEditor
164
+ snapshot={{
165
+ columnId: "when",
166
+ value: Filter.date({
167
+ operator: "between",
168
+ min: new Date("2024-01-01T00:00:00Z"),
169
+ max: new Date("2024-06-01T00:00:00Z"),
170
+ }),
171
+ }}
172
+ table={mockTable()}
173
+ onClose={vi.fn()}
174
+ />,
175
+ );
176
+ expect(screen.getByLabelText("range")).toBeInTheDocument();
177
+ expect(screen.queryByLabelText("value")).not.toBeInTheDocument();
178
+ });
179
+
180
+ it("rehydrates a datetime <= snapshot with a single value picker", () => {
181
+ renderWithProviders(
182
+ <FilterPillEditor
183
+ snapshot={{
184
+ columnId: "at",
185
+ value: Filter.datetime({
186
+ operator: "<=",
187
+ value: new Date("2024-06-01T12:00:00Z"),
188
+ }),
189
+ }}
190
+ table={mockTable()}
191
+ onClose={vi.fn()}
192
+ />,
193
+ );
194
+ expect(screen.getByLabelText("value")).toBeInTheDocument();
195
+ expect(screen.queryByLabelText("range")).not.toBeInTheDocument();
196
+ });
197
+
198
+ it("renders min/max TimeFields for time between", () => {
199
+ renderWithProviders(
200
+ <FilterPillEditor
201
+ snapshot={{
202
+ columnId: "clock",
203
+ value: Filter.time({
204
+ operator: "between",
205
+ min: new Date("2024-01-01T08:00:00Z"),
206
+ max: new Date("2024-01-01T17:00:00Z"),
207
+ }),
208
+ }}
209
+ table={mockTable()}
210
+ onClose={vi.fn()}
211
+ />,
212
+ );
213
+ expect(screen.getByLabelText("min")).toBeInTheDocument();
214
+ expect(screen.getByLabelText("max")).toBeInTheDocument();
215
+ });
216
+
217
+ it("hides the value slot for date is_null", () => {
218
+ renderWithProviders(
219
+ <FilterPillEditor
220
+ snapshot={{
221
+ columnId: "when",
222
+ value: Filter.date({ operator: "is_null" }),
223
+ }}
224
+ table={mockTable()}
225
+ onClose={vi.fn()}
226
+ />,
227
+ );
228
+ expect(screen.queryByText("Value")).not.toBeInTheDocument();
229
+ expect(screen.queryByLabelText("range")).not.toBeInTheDocument();
230
+ });
231
+ });
232
+
147
233
  describe("FilterPillEditor — apply", () => {
148
234
  it("commits a number > filter via setColumnFilters", () => {
149
235
  const table = mockTable();
@@ -215,22 +215,93 @@ describe("filterToFilterCondition", () => {
215
215
  ]);
216
216
  });
217
217
 
218
- it("handles date filter with min and max", () => {
219
- const min = new Date("2024-01-01");
220
- const max = new Date("2024-12-31");
218
+ it("handles date between filter", () => {
219
+ const min = new Date(2024, 0, 1);
220
+ const max = new Date(2024, 11, 31);
221
221
  const result = filterToFilterCondition(
222
222
  "created",
223
- Filter.date({ min, max }),
223
+ Filter.date({ operator: "between", min, max }),
224
224
  );
225
- expect(result).toHaveLength(2);
226
- expect(result[0]).toMatchObject({
227
- operator: ">=",
228
- value: min.toISOString(),
229
- });
230
- expect(result[1]).toMatchObject({
231
- operator: "<=",
232
- value: max.toISOString(),
233
- });
225
+ expect(result).toEqual([
226
+ {
227
+ column_id: "created",
228
+ operator: "between",
229
+ value: { min: "2024-01-01", max: "2024-12-31" },
230
+ type: "condition",
231
+ negate: false,
232
+ },
233
+ ]);
234
+ });
235
+
236
+ it("handles date comparison filter", () => {
237
+ const value = new Date(2024, 5, 15);
238
+ const result = filterToFilterCondition(
239
+ "created",
240
+ Filter.date({ operator: ">=", value }),
241
+ );
242
+ expect(result).toEqual([
243
+ {
244
+ column_id: "created",
245
+ operator: ">=",
246
+ value: "2024-06-15",
247
+ type: "condition",
248
+ negate: false,
249
+ },
250
+ ]);
251
+ });
252
+
253
+ it("handles datetime between filter as local ISO string without TZ", () => {
254
+ const min = new Date(2024, 0, 1, 0, 0, 0);
255
+ const max = new Date(2024, 11, 31, 23, 59, 59);
256
+ const result = filterToFilterCondition(
257
+ "created",
258
+ Filter.datetime({ operator: "between", min, max }),
259
+ );
260
+ expect(result).toEqual([
261
+ {
262
+ column_id: "created",
263
+ operator: "between",
264
+ value: {
265
+ min: "2024-01-01T00:00:00",
266
+ max: "2024-12-31T23:59:59",
267
+ },
268
+ type: "condition",
269
+ negate: false,
270
+ },
271
+ ]);
272
+ });
273
+
274
+ it("handles time between filter as HH:MM:SS", () => {
275
+ const min = new Date(2024, 0, 1, 9, 30, 0);
276
+ const max = new Date(2024, 0, 1, 17, 45, 15);
277
+ const result = filterToFilterCondition(
278
+ "start",
279
+ Filter.time({ operator: "between", min, max }),
280
+ );
281
+ expect(result).toEqual([
282
+ {
283
+ column_id: "start",
284
+ operator: "between",
285
+ value: { min: "09:30:00", max: "17:45:15" },
286
+ type: "condition",
287
+ negate: false,
288
+ },
289
+ ]);
290
+ });
291
+
292
+ it("handles date is_null filter", () => {
293
+ const result = filterToFilterCondition(
294
+ "created",
295
+ Filter.date({ operator: "is_null" }),
296
+ );
297
+ expect(result).toEqual([
298
+ {
299
+ column_id: "created",
300
+ operator: "is_null",
301
+ type: "condition",
302
+ negate: false,
303
+ },
304
+ ]);
234
305
  });
235
306
 
236
307
  it("every condition has type and negate fields", () => {
@@ -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,