@marimo-team/islands 0.23.2-dev33 → 0.23.2-dev37

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marimo-team/islands",
3
- "version": "0.23.2-dev33",
3
+ "version": "0.23.2-dev37",
4
4
  "main": "dist/main.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "type": "module",
@@ -0,0 +1,83 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { render } from "@testing-library/react";
4
+ import { describe, expect, it } from "vitest";
5
+ import { SentinelCell } from "../sentinel-cell";
6
+ import type { CellValueSentinel } from "../types";
7
+
8
+ function renderSentinel(sentinel: CellValueSentinel) {
9
+ const { container } = render(<SentinelCell sentinel={sentinel} />);
10
+ return container.querySelector("span")!;
11
+ }
12
+
13
+ describe("SentinelCell", () => {
14
+ it("renders null as None", () => {
15
+ const span = renderSentinel({ type: "null", value: null });
16
+ expect(span.textContent).toBe("None");
17
+ expect(span.getAttribute("aria-label")).toBe("None");
18
+ expect(span.className).toContain("italic");
19
+ expect(span.className).toContain("bg-muted");
20
+ });
21
+
22
+ it("renders empty string as <empty>", () => {
23
+ const span = renderSentinel({ type: "empty-string", value: "" });
24
+ expect(span.textContent).toBe("<empty>");
25
+ expect(span.getAttribute("aria-label")).toBe("empty string");
26
+ });
27
+
28
+ it("renders single space", () => {
29
+ const span = renderSentinel({ type: "whitespace", value: " " });
30
+ expect(span.textContent).toBe("\u2423");
31
+ expect(span.getAttribute("aria-label")).toBe("1 space");
32
+ });
33
+
34
+ it("renders multiple spaces", () => {
35
+ const span = renderSentinel({ type: "whitespace", value: " " });
36
+ expect(span.textContent).toBe("\u2423\u2423\u2423");
37
+ expect(span.getAttribute("aria-label")).toBe("3 spaces");
38
+ });
39
+
40
+ it("renders tab", () => {
41
+ const span = renderSentinel({ type: "whitespace", value: "\t" });
42
+ expect(span.textContent).toBe("\\t");
43
+ expect(span.getAttribute("aria-label")).toBe("1 tab");
44
+ });
45
+
46
+ it("renders newline", () => {
47
+ const span = renderSentinel({ type: "whitespace", value: "\n" });
48
+ expect(span.textContent).toBe("\\n");
49
+ expect(span.getAttribute("aria-label")).toBe("1 newline");
50
+ });
51
+
52
+ it("renders mixed whitespace", () => {
53
+ const span = renderSentinel({ type: "whitespace", value: "\t \n" });
54
+ expect(span.textContent).toBe("\\t\u2423\\n");
55
+ expect(span.getAttribute("aria-label")).toBe("1 tab, 1 space, 1 newline");
56
+ });
57
+
58
+ it("renders NaN", () => {
59
+ const span = renderSentinel({ type: "nan", value: Number.NaN });
60
+ expect(span.textContent).toBe("NaN");
61
+ });
62
+
63
+ it("renders inf", () => {
64
+ const span = renderSentinel({ type: "positive-infinity", value: Infinity });
65
+ expect(span.textContent).toBe("inf");
66
+ expect(span.getAttribute("title")).toBe("Infinity");
67
+ });
68
+
69
+ it("renders -inf", () => {
70
+ const span = renderSentinel({
71
+ type: "negative-infinity",
72
+ value: -Infinity,
73
+ });
74
+ expect(span.textContent).toBe("-inf");
75
+ expect(span.getAttribute("title")).toBe("-Infinity");
76
+ });
77
+
78
+ it("renders NaT", () => {
79
+ const span = renderSentinel({ type: "nat", value: "NaT" });
80
+ expect(span.textContent).toBe("NaT");
81
+ expect(span.getAttribute("title")).toBe("NaT (Not a Time)");
82
+ });
83
+ });
@@ -3,6 +3,7 @@
3
3
  import type { Table } from "@tanstack/react-table";
4
4
  import { describe, expect, it } from "vitest";
5
5
  import {
6
+ detectSentinel,
6
7
  getClipboardContent,
7
8
  getPageIndexForRow,
8
9
  getRawValue,
@@ -186,6 +187,133 @@ describe("getClipboardContent", () => {
186
187
  });
187
188
  });
188
189
 
190
+ describe("detectSentinel", () => {
191
+ it("should detect null and undefined", () => {
192
+ expect(detectSentinel(null, undefined)).toEqual({
193
+ type: "null",
194
+ value: null,
195
+ });
196
+ expect(detectSentinel(undefined, undefined)).toEqual({
197
+ type: "null",
198
+ value: undefined,
199
+ });
200
+ });
201
+
202
+ it("should detect empty string", () => {
203
+ expect(detectSentinel("", "string")).toEqual({
204
+ type: "empty-string",
205
+ value: "",
206
+ });
207
+ });
208
+
209
+ it("should detect whitespace-only strings", () => {
210
+ expect(detectSentinel(" ", "string")).toEqual({
211
+ type: "whitespace",
212
+ value: " ",
213
+ });
214
+ expect(detectSentinel(" ", "string")).toEqual({
215
+ type: "whitespace",
216
+ value: " ",
217
+ });
218
+ expect(detectSentinel("\t", "string")).toEqual({
219
+ type: "whitespace",
220
+ value: "\t",
221
+ });
222
+ expect(detectSentinel("\n", "string")).toEqual({
223
+ type: "whitespace",
224
+ value: "\n",
225
+ });
226
+ expect(detectSentinel("\t \n", "string")).toEqual({
227
+ type: "whitespace",
228
+ value: "\t \n",
229
+ });
230
+ });
231
+
232
+ it("should detect NaN", () => {
233
+ expect(detectSentinel(Number.NaN, "number")).toEqual({
234
+ type: "nan",
235
+ value: Number.NaN,
236
+ });
237
+ });
238
+
239
+ it("should detect Infinity", () => {
240
+ expect(detectSentinel(Number.POSITIVE_INFINITY, "number")).toEqual({
241
+ type: "positive-infinity",
242
+ value: Number.POSITIVE_INFINITY,
243
+ });
244
+ expect(detectSentinel(Number.NEGATIVE_INFINITY, "number")).toEqual({
245
+ type: "negative-infinity",
246
+ value: Number.NEGATIVE_INFINITY,
247
+ });
248
+ });
249
+
250
+ it("should return null for normal values", () => {
251
+ expect(detectSentinel("hello", "string")).toBeNull();
252
+ expect(detectSentinel(42, "number")).toBeNull();
253
+ expect(detectSentinel(0, "number")).toBeNull();
254
+ expect(detectSentinel(-1.5, "number")).toBeNull();
255
+ expect(detectSentinel(true, "boolean")).toBeNull();
256
+ expect(detectSentinel(false, "boolean")).toBeNull();
257
+ expect(detectSentinel({}, "unknown")).toBeNull();
258
+ expect(detectSentinel([], "unknown")).toBeNull();
259
+ });
260
+
261
+ it("should not match literal null-like strings", () => {
262
+ expect(detectSentinel("null", "string")).toBeNull();
263
+ expect(detectSentinel("NULL", "string")).toBeNull();
264
+ expect(detectSentinel("None", "string")).toBeNull();
265
+ });
266
+
267
+ it("should not match string NaN/Infinity in non-numeric columns", () => {
268
+ expect(detectSentinel("NaN", "string")).toBeNull();
269
+ expect(detectSentinel("Infinity", "string")).toBeNull();
270
+ expect(detectSentinel("-Infinity", "string")).toBeNull();
271
+ });
272
+
273
+ it("should match string NaN/Infinity in numeric columns", () => {
274
+ expect(detectSentinel("NaN", "number")).toEqual({
275
+ type: "nan",
276
+ value: "NaN",
277
+ });
278
+ expect(detectSentinel("Infinity", "number")).toEqual({
279
+ type: "positive-infinity",
280
+ value: "Infinity",
281
+ });
282
+ expect(detectSentinel("-Infinity", "number")).toEqual({
283
+ type: "negative-infinity",
284
+ value: "-Infinity",
285
+ });
286
+ expect(detectSentinel("inf", "number")).toEqual({
287
+ type: "positive-infinity",
288
+ value: "inf",
289
+ });
290
+ expect(detectSentinel("-inf", "number")).toEqual({
291
+ type: "negative-infinity",
292
+ value: "-inf",
293
+ });
294
+ });
295
+
296
+ it("should still not match normal strings in numeric columns", () => {
297
+ expect(detectSentinel("hello", "number")).toBeNull();
298
+ expect(detectSentinel("42", "number")).toBeNull();
299
+ });
300
+
301
+ it("should not match NaT in non-temporal columns", () => {
302
+ expect(detectSentinel("NaT", "string")).toBeNull();
303
+ });
304
+
305
+ it("should match NaT in temporal columns", () => {
306
+ expect(detectSentinel("NaT", "datetime")).toEqual({
307
+ type: "nat",
308
+ value: "NaT",
309
+ });
310
+ expect(detectSentinel("NaT", "date")).toEqual({
311
+ type: "nat",
312
+ value: "NaT",
313
+ });
314
+ });
315
+ });
316
+
189
317
  function createMockTableWithMeta<TData>(rawData?: TData[]): Table<TData> {
190
318
  return {
191
319
  options: {
@@ -53,6 +53,7 @@ import {
53
53
  SelectValue,
54
54
  } from "../ui/select";
55
55
  import { type ColumnFilterForType, Filter } from "./filters";
56
+ import { SentinelCell } from "./sentinel-cell";
56
57
  import {
57
58
  ClearFilterMenuItem,
58
59
  FilterButtons,
@@ -65,7 +66,7 @@ import {
65
66
  renderSortFilterIcon,
66
67
  renderSorts,
67
68
  } from "./header-items";
68
- import { stringifyUnknownValue } from "./utils";
69
+ import { detectSentinel, stringifyUnknownValue } from "./utils";
69
70
 
70
71
  const TOP_K_ROWS = 30;
71
72
 
@@ -647,6 +648,10 @@ const PopoverFilterByValues = <TData, TValue>({
647
648
  {filteredData.map(([value, count], rowIndex) => {
648
649
  const isSelected = chosenValues.has(value);
649
650
  const valueString = stringifyUnknownValue({ value });
651
+ const sentinel = detectSentinel(
652
+ value,
653
+ column.columnDef.meta?.dataType,
654
+ );
650
655
 
651
656
  return (
652
657
  <CommandItem
@@ -661,7 +666,11 @@ const PopoverFilterByValues = <TData, TValue>({
661
666
  className="mr-3 h-3.5 w-3.5"
662
667
  />
663
668
  <span className="flex-1 overflow-hidden max-h-20 line-clamp-3">
664
- {valueString}
669
+ {sentinel ? (
670
+ <SentinelCell sentinel={sentinel} />
671
+ ) : (
672
+ valueString
673
+ )}
665
674
  </span>
666
675
  <span className="ml-3">{count}</span>
667
676
  </CommandItem>
@@ -37,7 +37,10 @@ import {
37
37
  extractTimezone,
38
38
  type FieldTypesWithExternalType,
39
39
  INDEX_COLUMN_NAME,
40
+ isNumericType,
40
41
  } from "./types";
42
+ import { SentinelCell } from "./sentinel-cell";
43
+ import { detectSentinel } from "./utils";
41
44
  import { uniformSample } from "./uniformSample";
42
45
  import { MarkdownUrlDetector, UrlDetector } from "./url-detector";
43
46
 
@@ -163,7 +166,7 @@ export function generateColumns<T>({
163
166
  }
164
167
  // Auto right-align numeric columns
165
168
  const dataType = getMeta(key).dataType;
166
- if (dataType === "number" || dataType === "integer") {
169
+ if (isNumericType(dataType)) {
167
170
  return "right";
168
171
  }
169
172
  return undefined;
@@ -269,7 +272,7 @@ export function generateColumns<T>({
269
272
  !isCellSelected;
270
273
 
271
274
  const dataType = column.columnDef.meta?.dataType;
272
- const isNumeric = dataType === "number" || dataType === "integer";
275
+ const isNumeric = isNumericType(dataType);
273
276
  const cellStyles = getCellStyleClass({
274
277
  justify,
275
278
  wrapped,
@@ -522,6 +525,17 @@ export function renderCellValue<TData, TValue>({
522
525
 
523
526
  const isWrapped = column.getColumnWrapping?.() === "wrap";
524
527
 
528
+ // Sentinel values (null, whitespace, NaN, Infinity, NaT) rendered specially.
529
+ // Empty strings are left as-is
530
+ const sentinel = detectSentinel(value, dataType);
531
+ if (sentinel && sentinel.type !== "empty-string") {
532
+ return (
533
+ <div onClick={selectCell} className={cellStyles}>
534
+ <SentinelCell sentinel={sentinel} />
535
+ </div>
536
+ );
537
+ }
538
+
525
539
  if (dataType === "datetime" && typeof value === "string") {
526
540
  try {
527
541
  if (!isValid(value)) {
@@ -0,0 +1,90 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { CellValueSentinel, CellValueSentinelType } from "./types";
4
+
5
+ const WHITESPACE_CHARS: Record<string, { marker: string; name: string }> = {
6
+ " ": { marker: "\u2423", name: "space" }, // open box (space symbol)
7
+ "\t": { marker: "\\t", name: "tab" },
8
+ "\n": { marker: "\\n", name: "newline" },
9
+ "\r": { marker: "\\r", name: "newline" },
10
+ };
11
+
12
+ function renderWhitespaceMarkers(str: string): string {
13
+ return [...str].map((ch) => WHITESPACE_CHARS[ch]?.marker ?? ch).join("");
14
+ }
15
+
16
+ function describeWhitespace(str: string): string {
17
+ const counts: Record<string, number> = {};
18
+ for (const ch of str) {
19
+ const name = WHITESPACE_CHARS[ch]?.name ?? "character";
20
+ counts[name] = (counts[name] ?? 0) + 1;
21
+ }
22
+ return Object.entries(counts)
23
+ .map(([name, count]) => `${count} ${name}${count > 1 ? "s" : ""}`)
24
+ .join(", ");
25
+ }
26
+
27
+ interface SentinelConfig {
28
+ label: (value: CellValueSentinel["value"]) => string;
29
+ tooltip: (value: CellValueSentinel["value"]) => string;
30
+ ariaLabel: (value: CellValueSentinel["value"]) => string;
31
+ }
32
+
33
+ const SENTINEL_CONFIG: Record<CellValueSentinelType, SentinelConfig> = {
34
+ null: {
35
+ label: () => "None",
36
+ tooltip: () => "None",
37
+ ariaLabel: () => "None",
38
+ },
39
+ "empty-string": {
40
+ label: () => "<empty>",
41
+ tooltip: () => "<empty>",
42
+ ariaLabel: () => "empty string",
43
+ },
44
+ whitespace: {
45
+ label: (value) => renderWhitespaceMarkers(String(value)),
46
+ tooltip: (value) => describeWhitespace(String(value)),
47
+ ariaLabel: (value) => describeWhitespace(String(value)),
48
+ },
49
+ nan: {
50
+ label: () => "NaN",
51
+ tooltip: () => "NaN",
52
+ ariaLabel: () => "NaN",
53
+ },
54
+ "positive-infinity": {
55
+ label: () => "inf",
56
+ tooltip: () => "Infinity",
57
+ ariaLabel: () => "infinity",
58
+ },
59
+ "negative-infinity": {
60
+ label: () => "-inf",
61
+ tooltip: () => "-Infinity",
62
+ ariaLabel: () => "negative infinity",
63
+ },
64
+ nat: {
65
+ label: () => "NaT",
66
+ tooltip: () => "NaT (Not a Time)",
67
+ ariaLabel: () => "Not a Time",
68
+ },
69
+ };
70
+
71
+ export function SentinelCell({
72
+ sentinel,
73
+ }: {
74
+ sentinel: CellValueSentinel;
75
+ }): React.ReactElement {
76
+ const config = SENTINEL_CONFIG[sentinel.type];
77
+ const label = config.label(sentinel.value);
78
+ const tooltip = config.tooltip(sentinel.value);
79
+ const ariaLabel = config.ariaLabel(sentinel.value);
80
+
81
+ return (
82
+ <span
83
+ className="italic text-muted-foreground bg-muted rounded px-1"
84
+ aria-label={ariaLabel}
85
+ title={tooltip}
86
+ >
87
+ <span className="opacity-70">{label}</span>
88
+ </span>
89
+ );
90
+ }
@@ -95,6 +95,29 @@ export type DataTableSelection =
95
95
  | "multi-cell"
96
96
  | null;
97
97
 
98
+ export type CellValueSentinel =
99
+ | { type: "null"; value: null | undefined }
100
+ | { type: "empty-string"; value: string }
101
+ | { type: "whitespace"; value: string }
102
+ | { type: "nan"; value: number | string }
103
+ | { type: "positive-infinity"; value: number | string }
104
+ | { type: "negative-infinity"; value: number | string }
105
+ | { type: "nat"; value: string };
106
+
107
+ export type CellValueSentinelType = CellValueSentinel["type"];
108
+
109
+ export function isNumericType(
110
+ dataType: DataType | undefined,
111
+ ): dataType is "number" | "integer" {
112
+ return dataType === "number" || dataType === "integer";
113
+ }
114
+
115
+ export function isTemporalType(
116
+ dataType: DataType | undefined,
117
+ ): dataType is "date" | "datetime" | "time" {
118
+ return dataType === "date" || dataType === "datetime" || dataType === "time";
119
+ }
120
+
98
121
  export function extractTimezone(dtype: string | undefined): string | undefined {
99
122
  if (!dtype) {
100
123
  return undefined;
@@ -5,7 +5,15 @@ import type { TableData } from "@/plugins/impl/DataTablePlugin";
5
5
  import { vegaLoadData } from "@/plugins/impl/vega/loader";
6
6
  import { jsonParseWithSpecialChar } from "@/utils/json/json-parser";
7
7
  import { getMimeValues } from "./mime-cell";
8
- import { INDEX_COLUMN_NAME } from "./types";
8
+ import type { DataType } from "@/core/kernel/messages";
9
+ import {
10
+ type CellValueSentinel,
11
+ INDEX_COLUMN_NAME,
12
+ isNumericType,
13
+ isTemporalType,
14
+ } from "./types";
15
+
16
+ const WHITESPACE_ONLY_RE = /^[\s]+$/;
9
17
 
10
18
  /**
11
19
  * Convenience function to load table data.
@@ -85,6 +93,71 @@ export function getPageIndexForRow(
85
93
  return null;
86
94
  }
87
95
 
96
+ // String representations of numeric special values.
97
+ // Only matched when the caller indicates the column is numeric.
98
+ type StringValueSentinelType = Extract<
99
+ CellValueSentinel,
100
+ { value: number | string }
101
+ >["type"];
102
+
103
+ const NUMERIC_STRING_SPECIALS: Record<string, StringValueSentinelType> = {
104
+ NaN: "nan",
105
+ Infinity: "positive-infinity",
106
+ "-Infinity": "negative-infinity",
107
+ inf: "positive-infinity",
108
+ "-inf": "negative-infinity",
109
+ };
110
+
111
+ /**
112
+ * Detect if a cell value is a sentinel (null, empty string, whitespace,
113
+ * NaN, infinity, NaT). Column-type-dependent sentinels (string "NaN",
114
+ * "NaT", etc.) are matched based on `dataType`.
115
+ */
116
+ export function detectSentinel(
117
+ value: unknown,
118
+ dataType: DataType | undefined,
119
+ ): CellValueSentinel | null {
120
+ if (value == null) {
121
+ return { type: "null", value };
122
+ }
123
+
124
+ if (typeof value === "string") {
125
+ if (value === "") {
126
+ return { type: "empty-string", value };
127
+ }
128
+ if (WHITESPACE_ONLY_RE.test(value)) {
129
+ return { type: "whitespace", value };
130
+ }
131
+ // String "NaN"/"Infinity" in a numeric column = actual special float value
132
+ if (isNumericType(dataType)) {
133
+ const type = NUMERIC_STRING_SPECIALS[value];
134
+ if (type) {
135
+ return { type, value };
136
+ }
137
+ }
138
+ // String "NaT" in a temporal column = pandas Not-a-Time sentinel
139
+ if (isTemporalType(dataType) && value === "NaT") {
140
+ return { type: "nat", value };
141
+ }
142
+ return null;
143
+ }
144
+
145
+ if (typeof value === "number") {
146
+ if (Number.isNaN(value)) {
147
+ return { type: "nan", value };
148
+ }
149
+ if (value === Number.POSITIVE_INFINITY) {
150
+ return { type: "positive-infinity", value };
151
+ }
152
+ if (value === Number.NEGATIVE_INFINITY) {
153
+ return { type: "negative-infinity", value };
154
+ }
155
+ return null;
156
+ }
157
+
158
+ return null;
159
+ }
160
+
88
161
  /**
89
162
  * Stringify an unknown value. Converts objects to JSON strings.
90
163
  * @param opts.value - The value to stringify.
@@ -24,32 +24,41 @@ const ToastViewport = React.forwardRef<
24
24
  ));
25
25
  ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
26
26
 
27
+ const VARIANT_CLASSES = {
28
+ default: "bg-background border",
29
+ danger:
30
+ "group destructive text-error border-destructive bg-(--red-1) shadow-md-solid shadow-error",
31
+ } as const satisfies Record<string, string>;
32
+
33
+ type ToastVariant = keyof typeof VARIANT_CLASSES;
34
+
27
35
  const toastVariants = cva(
28
36
  "data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-(--radix-toast-swipe-move-x) data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-(--radix-toast-swipe-end-x) data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full sm:data-[state=open]:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full",
29
37
  {
30
38
  variants: {
31
- variant: {
32
- default: "bg-background border",
33
- danger:
34
- "group destructive text-error border-destructive bg-(--red-1) shadow-md-solid shadow-error",
35
- },
39
+ variant: VARIANT_CLASSES,
36
40
  },
37
41
  defaultVariants: {
38
- variant: "default",
42
+ variant: "default" satisfies ToastVariant,
39
43
  },
40
44
  },
41
45
  );
42
46
 
47
+ function isToastVariant(value: unknown): value is ToastVariant {
48
+ return typeof value === "string" && value in VARIANT_CLASSES;
49
+ }
50
+
43
51
  const Toast = React.forwardRef<
44
52
  React.ElementRef<typeof ToastPrimitives.Root>,
45
53
  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
46
54
  VariantProps<typeof toastVariants>
47
55
  >(({ className, variant, ...props }, ref) => {
56
+ const resolvedVariant = isToastVariant(variant) ? variant : "default";
48
57
  return (
49
58
  <ToastPrimitives.Root
50
59
  ref={ref}
51
60
  className={cn(
52
- toastVariants({ variant: variant || "default" }),
61
+ toastVariants({ variant: resolvedVariant }),
53
62
  "print:hidden",
54
63
  className,
55
64
  )}