@marimo-team/islands 0.23.4-dev9 → 0.23.5-dev1

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.
@@ -15,7 +15,7 @@ import { a as isValid, i as AlertTitle, n as Alert, t as arrow } from "./formats
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-BvW0-YWZ.js";
17
17
  import { t as useAsyncData } from "./useAsyncData-CKYzhCis.js";
18
- import { t as j } from "./react-vega-C2Rtgjb4.js";
18
+ import { t as j } from "./react-vega-k9ODWPlI.js";
19
19
  import "./defaultLocale-BpsHxBd7.js";
20
20
  import "./defaultLocale-DoeErsX2.js";
21
21
  import { t as useDeepCompareMemoize } from "./useDeepCompareMemoize-je76AJS_.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.4-dev9",
3
+ "version": "0.23.5-dev1",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -155,7 +155,7 @@
155
155
  "typescript-memoize": "^1.1.1",
156
156
  "use-acp": "0.2.6",
157
157
  "use-resize-observer": "^9.1.0",
158
- "vega-lite": "6.3.1",
158
+ "vega-lite": "6.4.2",
159
159
  "vega-loader": "^5.1.0",
160
160
  "vega-parser": "^7.1.0",
161
161
  "vega-tooltip": "^1.1.0",
@@ -0,0 +1,63 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import { seedFromFilter } from "../column-header";
5
+ import { Filter } from "../filters";
6
+
7
+ describe("seedFromFilter", () => {
8
+ it("returns empty defaults when there is no filter", () => {
9
+ expect(seedFromFilter(undefined)).toEqual({
10
+ values: [],
11
+ operator: "in",
12
+ });
13
+ });
14
+
15
+ it("seeds values from an `in` select filter", () => {
16
+ const filter = Filter.select({
17
+ options: ["Flying", "Ground"],
18
+ operator: "in",
19
+ });
20
+ expect(seedFromFilter(filter)).toEqual({
21
+ values: ["Flying", "Ground"],
22
+ operator: "in",
23
+ });
24
+ });
25
+
26
+ it("preserves `not_in` so reapplying does not silently flip to `in`", () => {
27
+ const filter = Filter.select({
28
+ options: ["Fire"],
29
+ operator: "not_in",
30
+ });
31
+ expect(seedFromFilter(filter)).toEqual({
32
+ values: ["Fire"],
33
+ operator: "not_in",
34
+ });
35
+ });
36
+
37
+ it("returns a fresh array (callers may mutate without affecting the filter)", () => {
38
+ const options = ["a", "b"];
39
+ const filter = Filter.select({ options, operator: "in" });
40
+ const seeded = seedFromFilter(filter);
41
+ seeded.values.push("c");
42
+ expect(options).toEqual(["a", "b"]);
43
+ });
44
+
45
+ it("ignores non-select filters and falls back to defaults", () => {
46
+ expect(
47
+ seedFromFilter(Filter.text({ text: "abc", operator: "equals" })),
48
+ ).toEqual({
49
+ values: [],
50
+ operator: "in",
51
+ });
52
+ expect(seedFromFilter(Filter.number({ min: 0, max: 10 }))).toEqual({
53
+ values: [],
54
+ operator: "in",
55
+ });
56
+ expect(
57
+ seedFromFilter(Filter.boolean({ value: true, operator: "is_true" })),
58
+ ).toEqual({
59
+ values: [],
60
+ operator: "in",
61
+ });
62
+ });
63
+ });
@@ -312,7 +312,7 @@ describe("generateColumns", () => {
312
312
  expect(cell?.props.className).toContain("center");
313
313
  });
314
314
 
315
- it("should always left-align column headers regardless of text justification", () => {
315
+ it("should align column headers to match textJustifyColumns", () => {
316
316
  const columns = generateColumns({
317
317
  rowHeaders: [],
318
318
  selection: null,
@@ -330,7 +330,8 @@ describe("generateColumns", () => {
330
330
  columnDef: { meta: col.meta },
331
331
  });
332
332
 
333
- // Even with right justification, header is left-aligned with sort + menu buttons
333
+ // Right-justified column: outer summary wrapper aligns to end, and the
334
+ // header row uses flex-row-reverse so the title sits at the right edge.
334
335
  const { container: rightContainer } = render(
335
336
  <TooltipProvider>
336
337
  {/* oxlint-disable-next-line typescript/no-explicit-any */}
@@ -345,12 +346,11 @@ describe("generateColumns", () => {
345
346
  "[data-testid='data-table-column-menu-button']",
346
347
  ),
347
348
  ).toBeTruthy();
348
- // No flex-row-reverse or items-end on header
349
- const rightWrapper = rightContainer.firstElementChild;
350
- expect(rightWrapper?.className).not.toContain("items-end");
351
- expect(rightWrapper?.className).not.toContain("flex-row-reverse");
349
+ expect(rightContainer.firstElementChild?.className).toContain("items-end");
350
+ expect(rightContainer.querySelector(".flex-row-reverse")).toBeTruthy();
352
351
 
353
- // Same for center-justified column
352
+ // Center-justified column: outer summary wrapper centers; header row
353
+ // keeps natural order.
354
354
  const { container: centerContainer } = render(
355
355
  <TooltipProvider>
356
356
  {/* oxlint-disable-next-line typescript/no-explicit-any */}
@@ -365,9 +365,39 @@ describe("generateColumns", () => {
365
365
  "[data-testid='data-table-column-menu-button']",
366
366
  ),
367
367
  ).toBeTruthy();
368
- const centerWrapper = centerContainer.firstElementChild;
369
- expect(centerWrapper?.className).not.toContain("items-center");
370
- expect(centerWrapper?.className).not.toContain("flex-row-reverse");
368
+ expect(centerContainer.firstElementChild?.className).toContain(
369
+ "items-center",
370
+ );
371
+ expect(centerContainer.querySelector(".flex-row-reverse")).toBeNull();
372
+ });
373
+
374
+ it("should not auto-align numeric column headers without explicit override", () => {
375
+ const columns = generateColumns({
376
+ rowHeaders: [],
377
+ selection: null,
378
+ fieldTypes,
379
+ });
380
+
381
+ const mockColumn = (col: (typeof columns)[number]) => ({
382
+ id: col.id,
383
+ getCanSort: () => true,
384
+ getCanFilter: () => false,
385
+ getIsSorted: () => false,
386
+ getSortIndex: () => -1,
387
+ getFilterValue: () => undefined,
388
+ columnDef: { meta: col.meta },
389
+ });
390
+
391
+ // "age" is numeric: cells auto right-align, but the header stays
392
+ // left-aligned unless the user explicitly opts in via text_justify_columns.
393
+ const { container } = render(
394
+ <TooltipProvider>
395
+ {/* oxlint-disable-next-line typescript/no-explicit-any */}
396
+ {(columns[1].header as any)({ column: mockColumn(columns[1]) })}
397
+ </TooltipProvider>,
398
+ );
399
+ expect(container.firstElementChild?.className).not.toContain("items-end");
400
+ expect(container.querySelector(".flex-row-reverse")).toBeNull();
371
401
  });
372
402
 
373
403
  it("should cycle sort button through asc, desc, and clear on clicks", () => {
@@ -9,7 +9,7 @@ import {
9
9
  TextIcon,
10
10
  XIcon,
11
11
  } from "lucide-react";
12
- import { useMemo, useRef, useState } from "react";
12
+ import { useRef, useState } from "react";
13
13
  import { useLocale } from "react-aria";
14
14
  import {
15
15
  DropdownMenu,
@@ -22,24 +22,12 @@ import {
22
22
  DropdownMenuSubTrigger,
23
23
  DropdownMenuTrigger,
24
24
  } from "@/components/ui/dropdown-menu";
25
- import { useAsyncData } from "@/hooks/useAsyncData";
26
- import { ErrorBanner } from "@/plugins/impl/common/error-banner";
27
25
  import type { CalculateTopKRows } from "@/plugins/impl/DataTablePlugin";
28
26
  import type { OperatorType } from "@/plugins/impl/data-frames/utils/operators";
29
27
  import { logNever } from "@/utils/assertNever";
30
28
  import { cn } from "@/utils/cn";
31
- import { Logger } from "@/utils/Logger";
32
29
  import { capitalize } from "@/utils/strings";
33
- import { Spinner } from "../icons/spinner";
34
30
  import { Button } from "../ui/button";
35
- import { Checkbox } from "../ui/checkbox";
36
- import {
37
- Command,
38
- CommandEmpty,
39
- CommandInput,
40
- CommandItem,
41
- CommandList,
42
- } from "../ui/command";
43
31
  import { DraggablePopover } from "../ui/draggable-popover";
44
32
  import { Input } from "../ui/input";
45
33
  import { NumberField } from "../ui/number-field";
@@ -52,8 +40,12 @@ import {
52
40
  SelectTrigger,
53
41
  SelectValue,
54
42
  } from "../ui/select";
55
- import { type ColumnFilterForType, Filter } from "./filters";
56
- import { SentinelCell } from "./sentinel-cell";
43
+ import { FilterByValuesList } from "./filter-by-values-picker";
44
+ import {
45
+ type ColumnFilterForType,
46
+ type ColumnFilterValue,
47
+ Filter,
48
+ } from "./filters";
57
49
  import {
58
50
  ClearFilterMenuItem,
59
51
  FilterButtons,
@@ -66,9 +58,6 @@ import {
66
58
  renderSortFilterIcon,
67
59
  renderSorts,
68
60
  } from "./header-items";
69
- import { detectSentinel, stringifyUnknownValue } from "./utils";
70
-
71
- const TOP_K_ROWS = 30;
72
61
 
73
62
  interface DataTableColumnHeaderProps<
74
63
  TData,
@@ -77,6 +66,7 @@ interface DataTableColumnHeaderProps<
77
66
  column: Column<TData, TValue>;
78
67
  header: React.ReactNode;
79
68
  subheader?: React.ReactNode;
69
+ justify?: "left" | "center" | "right";
80
70
  calculateTopKRows?: CalculateTopKRows;
81
71
  table?: Table<TData>;
82
72
  }
@@ -85,6 +75,7 @@ export const DataTableColumnHeader = <TData, TValue>({
85
75
  column,
86
76
  header,
87
77
  subheader,
78
+ justify,
88
79
  className,
89
80
  calculateTopKRows,
90
81
  table,
@@ -100,7 +91,13 @@ export const DataTableColumnHeader = <TData, TValue>({
100
91
  // No sorting or filtering
101
92
  if (!column.getCanSort() && !column.getCanFilter()) {
102
93
  return (
103
- <div className={cn(className)}>
94
+ <div
95
+ className={cn(
96
+ justify === "center" && "text-center",
97
+ justify === "right" && "text-right",
98
+ className,
99
+ )}
100
+ >
104
101
  {header}
105
102
  {subheader}
106
103
  </div>
@@ -114,9 +111,24 @@ export const DataTableColumnHeader = <TData, TValue>({
114
111
  <div
115
112
  className={cn("group flex flex-col my-1 w-full select-none", className)}
116
113
  >
117
- <div className="flex items-center gap-1">
118
- <span>{header}</span>
119
- {column.getCanSort() && <SortButton column={column} />}
114
+ <div
115
+ className={cn(
116
+ "flex items-center gap-1",
117
+ justify === "right" && "flex-row-reverse",
118
+ justify === "center" && "mx-auto",
119
+ )}
120
+ >
121
+ {justify === "center" ? (
122
+ <>
123
+ {column.getCanSort() && <SortButton column={column} />}
124
+ <span>{header}</span>
125
+ </>
126
+ ) : (
127
+ <>
128
+ <span>{header}</span>
129
+ {column.getCanSort() && <SortButton column={column} />}
130
+ </>
131
+ )}
120
132
  <DropdownMenu modal={false}>
121
133
  <DropdownMenuTrigger asChild={true}>
122
134
  <button
@@ -534,6 +546,26 @@ const TextFilter = <TData, TValue>({
534
546
  );
535
547
  };
536
548
 
549
+ /**
550
+ * Seed the filter-by-values picker from a column's existing filter value.
551
+ *
552
+ * Reopening the picker should reflect what's already applied. Only `select`
553
+ * filters carry checkbox-style values; other filter shapes (number, text,
554
+ * etc.) seed an empty list.
555
+ */
556
+ export function seedFromFilter(value: ColumnFilterValue | undefined): {
557
+ values: unknown[];
558
+ operator: Extract<OperatorType, "in" | "not_in">;
559
+ } {
560
+ if (value && "type" in value && value.type === "select") {
561
+ return {
562
+ values: [...value.options],
563
+ operator: value.operator === "not_in" ? "not_in" : "in",
564
+ };
565
+ }
566
+ return { values: [], operator: "in" };
567
+ }
568
+
537
569
  const PopoverFilterByValues = <TData, TValue>({
538
570
  setIsFilterValueOpen,
539
571
  calculateTopKRows,
@@ -543,69 +575,13 @@ const PopoverFilterByValues = <TData, TValue>({
543
575
  calculateTopKRows?: CalculateTopKRows;
544
576
  column: Column<TData, TValue>;
545
577
  }) => {
546
- const [chosenValues, setChosenValues] = useState<Set<unknown>>(new Set());
547
- const [query, setQuery] = useState<string>("");
548
-
549
- const { data, isPending, error } = useAsyncData(async () => {
550
- if (!calculateTopKRows) {
551
- return null;
552
- }
553
- const res = await calculateTopKRows({ column: column.id, k: TOP_K_ROWS });
554
- return res.data;
555
- }, []);
556
-
557
- const filteredData = useMemo(() => {
558
- if (!data) {
559
- return [];
560
- }
561
-
562
- try {
563
- return data.filter(([value, _count]) => {
564
- // Check if value exists and can be converted to string
565
- // Keep null values for filtering
566
- return value === undefined
567
- ? false
568
- : String(value).toLowerCase().includes(query.toLowerCase());
569
- });
570
- } catch (error_) {
571
- Logger.error("Error filtering data", error_);
572
- return [];
573
- }
574
- }, [data, query]);
575
-
576
- let dataTable: React.ReactNode;
577
-
578
- if (isPending) {
579
- dataTable = <Spinner size="medium" className="mx-auto mt-12 mb-10" />;
580
- }
581
-
582
- if (error) {
583
- dataTable = <ErrorBanner error={error} className="my-10 mx-4" />;
584
- }
585
-
586
- const handleToggle = (value: unknown) => {
587
- setChosenValues((prev) => {
588
- const checked = prev.has(value);
589
- const newSet = new Set(prev);
590
- if (checked) {
591
- newSet.delete(value);
592
- } else {
593
- newSet.add(value);
594
- }
595
- return newSet;
596
- });
597
- };
578
+ const seed = seedFromFilter(
579
+ column.getFilterValue() as ColumnFilterValue | undefined,
580
+ );
598
581
 
599
- const handleToggleAll = (checked: boolean) => {
600
- if (!data) {
601
- return;
602
- }
603
- if (checked) {
604
- setChosenValues(new Set(filteredData.map(([value]) => value)));
605
- } else {
606
- setChosenValues(new Set());
607
- }
608
- };
582
+ const [chosenValues, setChosenValues] = useState<Set<unknown>>(
583
+ () => new Set(seed.values),
584
+ );
609
585
 
610
586
  const handleApply = () => {
611
587
  if (chosenValues.size === 0) {
@@ -613,87 +589,13 @@ const PopoverFilterByValues = <TData, TValue>({
613
589
  return;
614
590
  }
615
591
  column.setFilterValue(
616
- Filter.select({ options: [...chosenValues], operator: "in" }),
592
+ Filter.select({
593
+ options: [...chosenValues],
594
+ operator: seed.operator,
595
+ }),
617
596
  );
618
597
  };
619
598
 
620
- if (data) {
621
- const allChecked = chosenValues.size === filteredData.length;
622
-
623
- dataTable = (
624
- <>
625
- <Command className="text-sm outline-hidden" shouldFilter={false}>
626
- <CommandInput
627
- placeholder={`Search among the top ${data.length} values`}
628
- autoFocus={true}
629
- onValueChange={(value) => setQuery(value.trim())}
630
- />
631
- <CommandEmpty>No results found.</CommandEmpty>
632
- <CommandList className="border-b">
633
- {filteredData.length > 0 && (
634
- <CommandItem
635
- value="__select-all__"
636
- className="border-b rounded-none px-3"
637
- onSelect={() => handleToggleAll(!allChecked)}
638
- >
639
- <Checkbox
640
- checked={chosenValues.size === filteredData.length}
641
- aria-label="Select all"
642
- className="mr-3 h-3.5 w-3.5"
643
- />
644
- <span className="font-bold flex-1">{column.id}</span>
645
- <span className="font-bold">Count</span>
646
- </CommandItem>
647
- )}
648
- {filteredData.map(([value, count], rowIndex) => {
649
- const isSelected = chosenValues.has(value);
650
- const valueString = stringifyUnknownValue({ value });
651
- const sentinel = detectSentinel(
652
- value,
653
- column.columnDef.meta?.dataType,
654
- );
655
-
656
- return (
657
- <CommandItem
658
- key={rowIndex}
659
- value={valueString}
660
- className="not-last:border-b rounded-none px-3"
661
- onSelect={() => handleToggle(value)}
662
- >
663
- <Checkbox
664
- checked={isSelected}
665
- aria-label="Select row"
666
- className="mr-3 h-3.5 w-3.5"
667
- />
668
- <span className="flex-1 overflow-hidden max-h-20 line-clamp-3">
669
- {sentinel ? (
670
- <SentinelCell sentinel={sentinel} />
671
- ) : (
672
- valueString
673
- )}
674
- </span>
675
- <span className="ml-3">{count}</span>
676
- </CommandItem>
677
- );
678
- })}
679
- </CommandList>
680
- {filteredData.length === TOP_K_ROWS && (
681
- <span className="text-xs text-muted-foreground mt-1.5 text-center">
682
- Only showing the top {TOP_K_ROWS} values
683
- </span>
684
- )}
685
- </Command>
686
- <FilterButtons
687
- onApply={handleApply}
688
- onClear={() => {
689
- setChosenValues(new Set());
690
- }}
691
- clearButtonDisabled={chosenValues.size === 0}
692
- />
693
- </>
694
- );
695
- }
696
-
697
599
  return (
698
600
  <DraggablePopover
699
601
  open={true}
@@ -709,7 +611,19 @@ const PopoverFilterByValues = <TData, TValue>({
709
611
  <XIcon className="h-4 w-4" />
710
612
  </Button>
711
613
  </PopoverClose>
712
- <div className="flex flex-col gap-1.5 py-2">{dataTable}</div>
614
+ <div className="flex flex-col gap-1.5 py-2">
615
+ <FilterByValuesList
616
+ column={column}
617
+ calculateTopKRows={calculateTopKRows}
618
+ chosenValues={chosenValues}
619
+ onChange={(values) => setChosenValues(new Set(values))}
620
+ />
621
+ <FilterButtons
622
+ onApply={handleApply}
623
+ onClear={() => setChosenValues(new Set())}
624
+ clearButtonDisabled={chosenValues.size === 0}
625
+ />
626
+ </div>
713
627
  </DraggablePopover>
714
628
  );
715
629
  };
@@ -197,9 +197,17 @@ export function generateColumns<T>({
197
197
  const stats = chartSpecModel?.getColumnStats(key);
198
198
  const dtype = column.columnDef.meta?.dtype;
199
199
  const headerTitle = headerTooltip?.[key];
200
+ const headerJustify = textJustifyColumns?.[key];
201
+
200
202
  const dtypeHeader =
201
203
  showDataTypes && dtype ? (
202
- <div className="flex flex-row gap-1">
204
+ <div
205
+ className={cn(
206
+ "flex flex-row gap-1",
207
+ headerJustify === "center" && "justify-center",
208
+ headerJustify === "right" && "justify-end",
209
+ )}
210
+ >
203
211
  <span className="text-xs text-muted-foreground">{dtype}</span>
204
212
  {stats && typeof stats.nulls === "number" && stats.nulls > 0 && (
205
213
  <span className="text-xs text-muted-foreground">
@@ -233,6 +241,7 @@ export function generateColumns<T>({
233
241
  header={headerWithTooltip}
234
242
  subheader={dtypeHeader}
235
243
  column={column}
244
+ justify={headerJustify}
236
245
  calculateTopKRows={calculateTopKRows}
237
246
  table={table}
238
247
  />
@@ -247,6 +256,8 @@ export function generateColumns<T>({
247
256
  <div
248
257
  className={cn(
249
258
  "flex flex-col h-full pt-0.5 pb-3 justify-between items-start",
259
+ headerJustify === "center" && "items-center",
260
+ headerJustify === "right" && "items-end",
250
261
  )}
251
262
  >
252
263
  {dataTableColumnHeader}
@@ -74,7 +74,7 @@ export const FilterByValuesPicker = <TData, TValue>({
74
74
  </Button>
75
75
  </PopoverTrigger>
76
76
  <PopoverContent className="w-80 p-0">
77
- <PickerBody
77
+ <FilterByValuesList
78
78
  column={column}
79
79
  calculateTopKRows={calculateTopKRows}
80
80
  chosenValues={chosenValuesSet}
@@ -85,19 +85,22 @@ export const FilterByValuesPicker = <TData, TValue>({
85
85
  );
86
86
  };
87
87
 
88
- interface PickerBodyProps<TData, TValue> {
88
+ interface FilterByValuesListProps<TData, TValue> {
89
89
  column: Column<TData, TValue>;
90
90
  calculateTopKRows?: CalculateTopKRows;
91
91
  chosenValues: Set<unknown>;
92
92
  onChange: (values: unknown[]) => void;
93
93
  }
94
94
 
95
- const PickerBody = <TData, TValue>({
95
+ /**
96
+ * Search + checkbox list that powers the "filter by values" picker.
97
+ */
98
+ export const FilterByValuesList = <TData, TValue>({
96
99
  column,
97
100
  calculateTopKRows,
98
101
  chosenValues,
99
102
  onChange,
100
- }: PickerBodyProps<TData, TValue>) => {
103
+ }: FilterByValuesListProps<TData, TValue>) => {
101
104
  const [query, setQuery] = useState<string>("");
102
105
 
103
106
  const { data, isPending, error } = useAsyncData(async () => {