@marimo-team/frontend 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.
- package/dist/assets/{ConnectedDataExplorerComponent-DgruBYR5.js → ConnectedDataExplorerComponent-BPVOWDZX.js} +1 -1
- package/dist/assets/JsonOutput-CsshJCMI.js +49 -0
- package/dist/assets/{add-connection-dialog-gd2yD9Qo.js → add-connection-dialog-BY0poJft.js} +1 -1
- package/dist/assets/{agent-panel-TetMUXWP.js → agent-panel-BvH5q5xP.js} +1 -1
- package/dist/assets/{cell-editor-87LAVsOR.js → cell-editor-B43gqTef.js} +1 -1
- package/dist/assets/{column-preview-_5uYiy74.js → column-preview-DUPpv2dz.js} +1 -1
- package/dist/assets/command-CXDOCJwp.js +1 -0
- package/dist/assets/{command-palette-DRkgETbF.js → command-palette-B5AX7Cgd.js} +1 -1
- package/dist/assets/{dependency-graph-panel-Yk1Ch_Gj.js → dependency-graph-panel-k1WQZ4m-.js} +1 -1
- package/dist/assets/{edit-page-DKd3R20z.js → edit-page-Lwy52pkS.js} +3 -3
- package/dist/assets/{file-explorer-panel-Dlwspu2Y.js → file-explorer-panel-DSahRBWr.js} +2 -2
- package/dist/assets/{form-Dr-_EXr8.js → form-0wZOuqB-.js} +1 -1
- package/dist/assets/{hooks-CNPv5U-N.js → hooks-BZOcKMEB.js} +1 -1
- package/dist/assets/{index-BruLKdo6.js → index-D48a2kB0.js} +8 -8
- package/dist/assets/{layout-DDh3d8oW.js → layout-DXt0aA4F.js} +3 -3
- package/dist/assets/{panels-CbRenLjk.js → panels-8fPdoW9_.js} +1 -1
- package/dist/assets/react-vega-BQXvtE5G.js +188 -0
- package/dist/assets/{react-vega-8VhWN23c.js → react-vega-BVATzlrQ.js} +1 -1
- package/dist/assets/{reveal-component-CLjsFHmT.js → reveal-component-CgCn23Mw.js} +1 -1
- package/dist/assets/{run-page-C1Wd8dr8.js → run-page-x3v_5tzv.js} +1 -1
- package/dist/assets/{scratchpad-panel-B8kRNql6.js → scratchpad-panel-CHFYLvso.js} +1 -1
- package/dist/assets/{session-panel-BvCIuxgU.js → session-panel-DMaf7Upn.js} +1 -1
- package/dist/assets/{slide-form-CXqvfwGg.js → slide-form-Dmi4fzLs.js} +1 -1
- package/dist/assets/{snippets-panel-COBTGJcr.js → snippets-panel-CnN632Ki.js} +1 -1
- package/dist/assets/{state-LyvUXW6A.js → state-CikXvgx3.js} +1 -1
- package/dist/assets/{tracing-DjZGgWTo.js → tracing-BZPLfSlB.js} +1 -1
- package/dist/assets/{tracing-panel-8eFzEjG0.js → tracing-panel-DBaQBraG.js} +2 -2
- package/dist/assets/{useDependencyPanelTab-DFZNQ3a8.js → useDependencyPanelTab-8WaMRWbo.js} +1 -1
- package/dist/assets/{useNotebookActions-DgOcTnrU.js → useNotebookActions-v4SgyM_0.js} +1 -1
- package/dist/assets/{vega-component-DTWLzrN1.js → vega-component-BnaMdpJB.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +2 -2
- package/src/components/data-table/__tests__/column-header.test.ts +63 -0
- package/src/components/data-table/__tests__/columns.test.tsx +40 -10
- package/src/components/data-table/column-header.tsx +77 -163
- package/src/components/data-table/columns.tsx +12 -1
- package/src/components/data-table/filter-by-values-picker.tsx +7 -4
- package/src/components/data-table/range-focus/__tests__/use-cell-range-selection.test.ts +135 -54
- package/src/components/data-table/range-focus/use-cell-range-selection.ts +36 -4
- package/dist/assets/JsonOutput-BVAcY5xS.js +0 -49
- package/dist/assets/command-CzIDL1VI.js +0 -1
- package/dist/assets/react-vega-Dprex0g5.js +0 -190
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
TextIcon,
|
|
10
10
|
XIcon,
|
|
11
11
|
} from "lucide-react";
|
|
12
|
-
import {
|
|
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 {
|
|
56
|
-
import {
|
|
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
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
547
|
-
|
|
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
|
|
600
|
-
|
|
601
|
-
|
|
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({
|
|
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">
|
|
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
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
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
|
-
}:
|
|
103
|
+
}: FilterByValuesListProps<TData, TValue>) => {
|
|
101
104
|
const [query, setQuery] = useState<string>("");
|
|
102
105
|
|
|
103
106
|
const { data, isPending, error } = useAsyncData(async () => {
|
|
@@ -1,117 +1,198 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
4
|
import { isInteractiveTarget } from "../use-cell-range-selection";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Dispatch a real `mousedown` from `target` (going through real DOM event
|
|
8
|
+
* dispatch so `composedPath()` traverses any open shadow roots) and call
|
|
9
|
+
* `isInteractiveTarget` from inside the listener, where the event's target
|
|
10
|
+
* and composed path are still live.
|
|
11
|
+
*/
|
|
12
|
+
function isInteractive(target: Element, cell: Element): boolean {
|
|
13
|
+
let result: boolean | undefined;
|
|
14
|
+
const handler = (event: Event) => {
|
|
15
|
+
const path = event.composedPath();
|
|
16
|
+
result = isInteractiveTarget({
|
|
17
|
+
target: event.target,
|
|
18
|
+
currentTarget: cell,
|
|
19
|
+
nativeEvent: {
|
|
20
|
+
composedPath: () => path,
|
|
21
|
+
},
|
|
22
|
+
} as unknown as React.MouseEvent);
|
|
23
|
+
};
|
|
24
|
+
cell.addEventListener("mousedown", handler);
|
|
25
|
+
target.dispatchEvent(
|
|
26
|
+
new MouseEvent("mousedown", { bubbles: true, composed: true }),
|
|
27
|
+
);
|
|
28
|
+
cell.removeEventListener("mousedown", handler);
|
|
29
|
+
if (result === undefined) {
|
|
30
|
+
throw new Error("mousedown did not bubble to the cell");
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let mounted: HTMLElement[] = [];
|
|
36
|
+
|
|
37
|
+
function makeCell(): HTMLTableCellElement {
|
|
38
|
+
const table = document.createElement("table");
|
|
39
|
+
const tbody = document.createElement("tbody");
|
|
40
|
+
const row = document.createElement("tr");
|
|
41
|
+
const cell = document.createElement("td");
|
|
42
|
+
row.append(cell);
|
|
43
|
+
tbody.append(row);
|
|
44
|
+
table.append(tbody);
|
|
45
|
+
document.body.append(table);
|
|
46
|
+
mounted.push(table);
|
|
47
|
+
return cell;
|
|
11
48
|
}
|
|
12
49
|
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
for (const el of mounted) {
|
|
52
|
+
el.remove();
|
|
53
|
+
}
|
|
54
|
+
mounted = [];
|
|
55
|
+
});
|
|
56
|
+
|
|
13
57
|
describe("isInteractiveTarget", () => {
|
|
14
58
|
it("returns false when target is the cell itself", () => {
|
|
15
|
-
const cell =
|
|
16
|
-
expect(
|
|
59
|
+
const cell = makeCell();
|
|
60
|
+
expect(isInteractive(cell, cell)).toBe(false);
|
|
17
61
|
});
|
|
18
62
|
|
|
19
63
|
it("returns false when clicking plain text inside a cell", () => {
|
|
20
|
-
const cell =
|
|
64
|
+
const cell = makeCell();
|
|
21
65
|
const span = document.createElement("span");
|
|
22
66
|
cell.append(span);
|
|
23
|
-
expect(
|
|
67
|
+
expect(isInteractive(span, cell)).toBe(false);
|
|
24
68
|
});
|
|
25
69
|
|
|
26
70
|
it.each(["input", "button", "select", "textarea"])(
|
|
27
71
|
"returns true when clicking a <%s>",
|
|
28
72
|
(tag) => {
|
|
29
|
-
const cell =
|
|
73
|
+
const cell = makeCell();
|
|
30
74
|
const el = document.createElement(tag);
|
|
31
75
|
cell.append(el);
|
|
32
|
-
expect(
|
|
76
|
+
expect(isInteractive(el, cell)).toBe(true);
|
|
33
77
|
},
|
|
34
78
|
);
|
|
35
79
|
|
|
36
80
|
it("returns true when clicking an <a> link", () => {
|
|
37
|
-
const cell =
|
|
81
|
+
const cell = makeCell();
|
|
38
82
|
const a = document.createElement("a");
|
|
39
83
|
a.href = "#";
|
|
40
84
|
cell.append(a);
|
|
41
|
-
expect(
|
|
85
|
+
expect(isInteractive(a, cell)).toBe(true);
|
|
42
86
|
});
|
|
43
87
|
|
|
44
88
|
it("returns true when clicking a <label>", () => {
|
|
45
|
-
const cell =
|
|
89
|
+
const cell = makeCell();
|
|
46
90
|
const label = document.createElement("label");
|
|
47
91
|
cell.append(label);
|
|
48
|
-
expect(
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('returns true for element with role="checkbox"', () => {
|
|
52
|
-
const cell = document.createElement("td");
|
|
53
|
-
const div = document.createElement("div");
|
|
54
|
-
div.setAttribute("role", "checkbox");
|
|
55
|
-
cell.append(div);
|
|
56
|
-
expect(isInteractiveTarget(createMouseEvent(div, cell))).toBe(true);
|
|
92
|
+
expect(isInteractive(label, cell)).toBe(true);
|
|
57
93
|
});
|
|
58
94
|
|
|
59
|
-
it(
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
95
|
+
it.each(["checkbox", "button"])(
|
|
96
|
+
'returns true for element with role="%s"',
|
|
97
|
+
(role) => {
|
|
98
|
+
const cell = makeCell();
|
|
99
|
+
const div = document.createElement("div");
|
|
100
|
+
div.setAttribute("role", role);
|
|
101
|
+
cell.append(div);
|
|
102
|
+
expect(isInteractive(div, cell)).toBe(true);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
66
105
|
|
|
67
106
|
it('returns true for contenteditable="true"', () => {
|
|
68
|
-
const cell =
|
|
107
|
+
const cell = makeCell();
|
|
69
108
|
const div = document.createElement("div");
|
|
70
109
|
div.setAttribute("contenteditable", "true");
|
|
71
110
|
cell.append(div);
|
|
72
|
-
expect(
|
|
111
|
+
expect(isInteractive(div, cell)).toBe(true);
|
|
73
112
|
});
|
|
74
113
|
|
|
75
114
|
it("returns true when clicking a child nested inside an interactive element", () => {
|
|
76
|
-
const cell =
|
|
115
|
+
const cell = makeCell();
|
|
77
116
|
const button = document.createElement("button");
|
|
78
117
|
const icon = document.createElement("span");
|
|
79
118
|
button.append(icon);
|
|
80
119
|
cell.append(button);
|
|
81
|
-
expect(
|
|
120
|
+
expect(isInteractive(icon, cell)).toBe(true);
|
|
82
121
|
});
|
|
83
122
|
|
|
84
|
-
it("returns true when clicking inside a marimo-ui-element", () => {
|
|
85
|
-
const cell =
|
|
123
|
+
it("returns true when clicking inside a marimo-ui-element wrapping a real widget", () => {
|
|
124
|
+
const cell = makeCell();
|
|
86
125
|
const marimoEl = document.createElement("marimo-ui-element");
|
|
87
|
-
const
|
|
88
|
-
marimoEl.append(
|
|
126
|
+
const widget = document.createElement("marimo-slider");
|
|
127
|
+
marimoEl.append(widget);
|
|
89
128
|
cell.append(marimoEl);
|
|
90
|
-
expect(
|
|
129
|
+
expect(isInteractive(widget, cell)).toBe(true);
|
|
130
|
+
expect(isInteractive(marimoEl, cell)).toBe(true);
|
|
91
131
|
});
|
|
92
132
|
|
|
93
|
-
it("
|
|
94
|
-
|
|
133
|
+
it.each(["marimo-lazy", "marimo-routes"])(
|
|
134
|
+
"returns false when clicking inside a passive content-wrapper UIElement (%s)",
|
|
135
|
+
(tag) => {
|
|
136
|
+
const cell = makeCell();
|
|
137
|
+
const marimoEl = document.createElement("marimo-ui-element");
|
|
138
|
+
const wrapper = document.createElement(tag);
|
|
139
|
+
const inner = document.createElement("div");
|
|
140
|
+
wrapper.append(inner);
|
|
141
|
+
marimoEl.append(wrapper);
|
|
142
|
+
cell.append(marimoEl);
|
|
143
|
+
expect(isInteractive(inner, cell)).toBe(false);
|
|
144
|
+
expect(isInteractive(wrapper, cell)).toBe(false);
|
|
145
|
+
},
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
it("returns false when clicking plain content rendered through mo.lazy's shadow DOM (#9189)", () => {
|
|
149
|
+
// Reproduces the structure marimo creates for mo.lazy(<plain html>):
|
|
150
|
+
// event.target gets retargeted to <marimo-lazy>, so closest() can't see
|
|
151
|
+
// into the shadow root. composedPath() must be used to confirm there's
|
|
152
|
+
// no genuinely interactive descendant.
|
|
153
|
+
const cell = makeCell();
|
|
95
154
|
const marimoEl = document.createElement("marimo-ui-element");
|
|
155
|
+
const lazy = document.createElement("marimo-lazy");
|
|
156
|
+
marimoEl.append(lazy);
|
|
96
157
|
cell.append(marimoEl);
|
|
97
|
-
|
|
158
|
+
|
|
159
|
+
const shadow = lazy.attachShadow({ mode: "open" });
|
|
160
|
+
const img = document.createElement("img");
|
|
161
|
+
shadow.append(img);
|
|
162
|
+
|
|
163
|
+
expect(isInteractive(img, cell)).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("returns true when clicking an interactive widget rendered inside a content wrapper's shadow DOM", () => {
|
|
167
|
+
// mo.lazy(mo.ui.slider(...)): the slider's <marimo-ui-element> lives
|
|
168
|
+
// inside marimo-lazy's shadow root, so closest() from the retargeted
|
|
169
|
+
// host couldn't see it. composedPath() does.
|
|
170
|
+
const cell = makeCell();
|
|
171
|
+
const outerUi = document.createElement("marimo-ui-element");
|
|
172
|
+
const lazy = document.createElement("marimo-lazy");
|
|
173
|
+
outerUi.append(lazy);
|
|
174
|
+
cell.append(outerUi);
|
|
175
|
+
|
|
176
|
+
const lazyShadow = lazy.attachShadow({ mode: "open" });
|
|
177
|
+
const innerUi = document.createElement("marimo-ui-element");
|
|
178
|
+
const slider = document.createElement("marimo-slider");
|
|
179
|
+
innerUi.append(slider);
|
|
180
|
+
lazyShadow.append(innerUi);
|
|
181
|
+
|
|
182
|
+
const sliderShadow = slider.attachShadow({ mode: "open" });
|
|
183
|
+
const input = document.createElement("input");
|
|
184
|
+
input.type = "range";
|
|
185
|
+
sliderShadow.append(input);
|
|
186
|
+
|
|
187
|
+
expect(isInteractive(input, cell)).toBe(true);
|
|
98
188
|
});
|
|
99
189
|
|
|
100
190
|
it("returns false when clicking a non-interactive div", () => {
|
|
101
|
-
const cell =
|
|
191
|
+
const cell = makeCell();
|
|
102
192
|
const wrapper = document.createElement("div");
|
|
103
193
|
const text = document.createElement("span");
|
|
104
194
|
wrapper.append(text);
|
|
105
195
|
cell.append(wrapper);
|
|
106
|
-
expect(
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("returns false when target is a non-Element (e.g. Text node)", () => {
|
|
110
|
-
const cell = document.createElement("td");
|
|
111
|
-
const textNode = document.createTextNode("hello");
|
|
112
|
-
cell.append(textNode);
|
|
113
|
-
expect(isInteractiveTarget(createMouseEvent(textNode as never, cell))).toBe(
|
|
114
|
-
false,
|
|
115
|
-
);
|
|
196
|
+
expect(isInteractive(text, cell)).toBe(false);
|
|
116
197
|
});
|
|
117
198
|
});
|