@agent-native/core 0.124.0 → 0.124.1

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 (37) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +16 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/templates/analytics/server/lib/dashboard-report-render.ts +6 -3
  5. package/corpus/templates/analytics/server/lib/report-chart-svg.ts +18 -5
  6. package/corpus/templates/crm/actions/_crm-list-utils.ts +178 -0
  7. package/corpus/templates/crm/actions/add-crm-record-to-list.ts +23 -2
  8. package/corpus/templates/crm/actions/create-crm-list.ts +229 -43
  9. package/corpus/templates/crm/actions/create-crm-record.ts +13 -5
  10. package/corpus/templates/crm/actions/update-crm-list-entry.ts +1 -1
  11. package/corpus/templates/crm/app/components/crm/RecordGrid.tsx +22 -2
  12. package/corpus/templates/crm/app/components/crm/RecordWorkspace.tsx +143 -32
  13. package/corpus/templates/crm/app/components/crm/board/CrmBoard.tsx +190 -75
  14. package/corpus/templates/crm/app/components/crm/grid/CrmGrid.tsx +267 -115
  15. package/corpus/templates/crm/app/components/crm/grid/GridCell.tsx +265 -131
  16. package/corpus/templates/crm/app/components/crm/record/AttributePanel.tsx +79 -100
  17. package/corpus/templates/crm/app/components/crm/record/RecordHeader.tsx +12 -8
  18. package/corpus/templates/crm/app/components/crm/record/RecordLists.tsx +27 -31
  19. package/corpus/templates/crm/app/components/crm/record/RecordTabs.tsx +16 -13
  20. package/corpus/templates/crm/app/components/crm/record/attribute-row.tsx +108 -0
  21. package/corpus/templates/crm/app/components/crm/record/field-editors.tsx +201 -9
  22. package/corpus/templates/crm/app/components/crm/record/panel-resize.ts +61 -0
  23. package/corpus/templates/crm/app/components/crm/record/record-data.ts +5 -4
  24. package/corpus/templates/crm/app/components/crm/shared/README-tokens.md +103 -0
  25. package/corpus/templates/crm/app/components/crm/shared/RecordReferencePicker.tsx +117 -0
  26. package/corpus/templates/crm/app/components/crm/shared/attribute-value.ts +84 -0
  27. package/corpus/templates/crm/app/components/crm/shared/ui-tokens.ts +89 -0
  28. package/corpus/templates/crm/app/global.css +170 -0
  29. package/corpus/templates/crm/app/i18n/en-US.ts +1 -0
  30. package/corpus/templates/crm/changelog/2026-07-26-a-half-typed-number-or-date-no-longer-saves-as-empty-the-fie.md +6 -0
  31. package/corpus/templates/crm/changelog/2026-07-26-a-new-pipeline-board-now-opens-with-stage-columns-a-summabl.md +6 -0
  32. package/corpus/templates/crm/changelog/2026-07-26-record-reference-fields-like-account-can-now-be-linked-from-.md +6 -0
  33. package/corpus/templates/crm/changelog/2026-07-26-retyping-a-record-field-now-replaces-the-old-value-instead-o.md +6 -0
  34. package/dist/collab/routes.d.ts +1 -1
  35. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  36. package/dist/observability/routes.d.ts +1 -1
  37. package/package.json +1 -1
@@ -1,13 +1,28 @@
1
- import { useActionQuery } from "@agent-native/core/client/hooks";
2
1
  import { useT } from "@agent-native/core/client/i18n";
3
2
  import {
3
+ IconAbc,
4
4
  IconAlertTriangle,
5
+ IconCalendar,
5
6
  IconCheck,
7
+ IconClock,
6
8
  IconClockExclamation,
7
9
  IconCopy,
10
+ IconCurrencyDollar,
8
11
  IconExternalLink,
12
+ IconId,
13
+ IconLink,
14
+ IconMail,
15
+ IconMapPin,
16
+ IconMessage,
17
+ IconNumbers,
18
+ IconPhone,
19
+ IconProgressCheck,
20
+ IconSelector,
21
+ IconSquareCheck,
9
22
  IconStar,
10
23
  IconStarFilled,
24
+ IconUser,
25
+ IconWorld,
11
26
  } from "@tabler/icons-react";
12
27
  import { useEffect, useRef, useState } from "react";
13
28
  import { toast } from "sonner";
@@ -25,16 +40,20 @@ import {
25
40
  } from "@/components/ui/popover";
26
41
  import { cn } from "@/lib/utils";
27
42
 
43
+ import type { CrmAttributeType } from "../../../../shared/crm-attributes";
28
44
  import {
29
45
  activeOptions,
30
46
  attributeInputValue,
47
+ editorInputType,
31
48
  RATING_MAX,
49
+ referenceMembers,
50
+ referenceSearchKind,
32
51
  valueTokens,
52
+ type CrmValueToken,
33
53
  } from "../shared/attribute-value";
34
- import {
35
- AttributeOptionChip,
36
- AttributeRating,
37
- } from "../shared/AttributeValueParts";
54
+ import { AttributeRating } from "../shared/AttributeValueParts";
55
+ import { RecordReferencePicker } from "../shared/RecordReferencePicker";
56
+ import { overlayProps } from "../shared/ui-tokens";
38
57
  import {
39
58
  cellSpecFor,
40
59
  formatCell,
@@ -49,6 +68,86 @@ type Translate = ReturnType<typeof useT>;
49
68
 
50
69
  const EMPTY = "—";
51
70
 
71
+ // ---------------------------------------------------------------------------
72
+ // Header affordances
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /** The leading glyph on a column header: the attribute's type, not its name. */
76
+ const TYPE_ICONS: Record<
77
+ CrmAttributeType,
78
+ React.ComponentType<{ className?: string }>
79
+ > = {
80
+ text: IconAbc,
81
+ number: IconNumbers,
82
+ checkbox: IconSquareCheck,
83
+ currency: IconCurrencyDollar,
84
+ date: IconCalendar,
85
+ timestamp: IconClock,
86
+ rating: IconStar,
87
+ status: IconProgressCheck,
88
+ select: IconSelector,
89
+ "record-reference": IconLink,
90
+ "actor-reference": IconUser,
91
+ location: IconMapPin,
92
+ domain: IconWorld,
93
+ "email-address": IconMail,
94
+ "phone-number": IconPhone,
95
+ interaction: IconMessage,
96
+ "personal-name": IconId,
97
+ };
98
+
99
+ export function AttributeTypeIcon({
100
+ type,
101
+ className,
102
+ }: {
103
+ type: CrmAttributeType;
104
+ className?: string;
105
+ }) {
106
+ const Icon = TYPE_ICONS[type] ?? IconAbc;
107
+ return <Icon className={className} />;
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Avatars
112
+ // ---------------------------------------------------------------------------
113
+
114
+ export type CrmAvatarShape = "person" | "company";
115
+
116
+ function initials(name: string): string {
117
+ const words = name.trim().split(/\s+/).filter(Boolean);
118
+ if (!words.length) return "";
119
+ const first = words[0]?.[0] ?? "";
120
+ const second = words.length > 1 ? (words[words.length - 1]?.[0] ?? "") : "";
121
+ return `${first}${second}`;
122
+ }
123
+
124
+ /**
125
+ * A record's avatar. Shape carries the object type — a round avatar is a
126
+ * person, a squircle is an organisation — so the two never have to be labelled.
127
+ */
128
+ export function RecordAvatar({
129
+ name,
130
+ shape,
131
+ className,
132
+ }: {
133
+ name: string;
134
+ shape: CrmAvatarShape;
135
+ className?: string;
136
+ }) {
137
+ return (
138
+ <span
139
+ aria-hidden
140
+ className={cn(
141
+ "inline-flex size-5 shrink-0 items-center justify-center bg-muted text-[10px] font-medium uppercase leading-none text-content-secondary ring-1 ring-inset ring-hairline",
142
+ shape === "person" ? "rounded-full" : "rounded-avatar-company",
143
+ className,
144
+ )}
145
+ >
146
+ {initials(name)}
147
+ </span>
148
+ );
149
+ }
150
+
52
151
  // ---------------------------------------------------------------------------
53
152
  // Provenance
54
153
  // ---------------------------------------------------------------------------
@@ -166,39 +265,122 @@ function copyToClipboard(text: string, t: Translate) {
166
265
  .catch(() => toast.error(t("grid.copyFailed")));
167
266
  }
168
267
 
268
+ /**
269
+ * A record reference. Hover tint rides the shared overlay rather than a
270
+ * background swap so it composites over a tinted row exactly like every other
271
+ * hover in the app.
272
+ */
169
273
  function Chip({
170
274
  children,
275
+ avatar,
171
276
  onCopy,
172
277
  href,
173
278
  }: {
174
279
  children: React.ReactNode;
280
+ avatar?: React.ReactNode;
175
281
  onCopy?: () => void;
176
282
  href?: string;
177
283
  }) {
178
284
  return (
179
- <span className="inline-flex max-w-full items-center gap-1 rounded-full border border-border/70 bg-muted/40 py-0.5 pl-1.5 pr-2 text-xs">
180
- <span className="truncate">{children}</span>
285
+ <span
286
+ {...overlayProps({
287
+ className:
288
+ "inline-flex max-w-full items-center gap-1.5 self-start rounded-chip py-0.5 pl-1 pr-1.5 text-sm before:duration-[var(--motion-breezy)]",
289
+ })}
290
+ >
291
+ {avatar}
292
+ <span className="min-w-0 truncate">{children}</span>
181
293
  {href ? (
182
294
  <a
183
295
  href={href}
184
296
  target="_blank"
185
297
  rel="noreferrer"
186
- className="shrink-0 text-muted-foreground hover:text-foreground"
298
+ className="shrink-0 text-content-ghost hover:text-foreground"
187
299
  onClick={(event) => event.stopPropagation()}
188
300
  >
189
- <IconExternalLink className="size-3" />
301
+ <IconExternalLink className="size-3.5" />
190
302
  </a>
191
303
  ) : null}
192
304
  {onCopy ? (
193
305
  <button
194
306
  type="button"
195
- className="shrink-0 cursor-pointer text-muted-foreground hover:text-foreground"
307
+ className="shrink-0 cursor-pointer text-content-ghost hover:text-foreground"
196
308
  onClick={(event) => {
197
309
  event.stopPropagation();
198
310
  onCopy();
199
311
  }}
200
312
  >
201
- <IconCopy className="size-3" />
313
+ <IconCopy className="size-3.5" />
314
+ </button>
315
+ ) : null}
316
+ </span>
317
+ );
318
+ }
319
+
320
+ /**
321
+ * An option renders as a *tint* of its own colour, never a saturated fill: a
322
+ * grid of filled chips reads as decoration, and the colour stops carrying
323
+ * information. `color-mix` rather than an appended hex alpha — an option colour
324
+ * may be any CSS colour, and `#0a0` + "22" is not a colour at all.
325
+ */
326
+ function OptionPill({ token }: { token: CrmValueToken }) {
327
+ return (
328
+ <span
329
+ className="inline-flex max-w-full items-center rounded-badge bg-foreground/[0.06] px-1.5 py-0.5 text-xs font-medium text-content-secondary"
330
+ style={
331
+ token.color
332
+ ? {
333
+ backgroundColor: `color-mix(in srgb, ${token.color} 14%, transparent)`,
334
+ color: token.color,
335
+ }
336
+ : undefined
337
+ }
338
+ >
339
+ <span className="min-w-0 truncate">{token.label}</span>
340
+ </span>
341
+ );
342
+ }
343
+
344
+ /** A value that is a destination: underlined, with a decoration soft enough
345
+ * that a column of them does not read as a stack of rules. */
346
+ function LinkValue({
347
+ text,
348
+ href,
349
+ onCopy,
350
+ }: {
351
+ text: string;
352
+ href?: string;
353
+ onCopy?: () => void;
354
+ }) {
355
+ const underline =
356
+ "min-w-0 truncate underline decoration-content-ghost underline-offset-[0.14em] hover:decoration-content-secondary";
357
+ return (
358
+ <span className="inline-flex min-w-0 max-w-full items-center gap-1">
359
+ {href ? (
360
+ <a
361
+ href={href}
362
+ // A `mailto:` opened in a new tab leaves an empty one behind.
363
+ {...(href.startsWith("http")
364
+ ? { target: "_blank", rel: "noreferrer" }
365
+ : {})}
366
+ className={underline}
367
+ onClick={(event) => event.stopPropagation()}
368
+ >
369
+ {text}
370
+ </a>
371
+ ) : (
372
+ <span className={underline}>{text}</span>
373
+ )}
374
+ {onCopy ? (
375
+ <button
376
+ type="button"
377
+ className="shrink-0 cursor-pointer text-content-ghost opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/row:opacity-100"
378
+ onClick={(event) => {
379
+ event.stopPropagation();
380
+ onCopy();
381
+ }}
382
+ >
383
+ <IconCopy className="size-3.5" />
202
384
  </button>
203
385
  ) : null}
204
386
  </span>
@@ -218,10 +400,10 @@ function StatusPill({
218
400
  const token = valueTokens(attribute, value)[0];
219
401
  // A status whose option was deleted still shows its stored value: "—" would
220
402
  // claim the record has no stage at all.
221
- if (!token) return <span className="text-muted-foreground">{EMPTY}</span>;
403
+ if (!token) return <span className="text-content-ghost">{EMPTY}</span>;
222
404
  return (
223
405
  <span className="inline-flex items-center gap-1.5">
224
- <AttributeOptionChip token={token} />
406
+ <OptionPill token={token} />
225
407
  {overrun !== null ? (
226
408
  <IconClockExclamation
227
409
  className="size-3.5 shrink-0 text-amber-600 dark:text-amber-500"
@@ -274,12 +456,12 @@ export function CellDisplay({
274
456
  if (type === "select") {
275
457
  const tokens = valueTokens(attribute, value);
276
458
  if (!tokens.length) {
277
- return <span className="text-muted-foreground">{EMPTY}</span>;
459
+ return <span className="text-content-ghost">{EMPTY}</span>;
278
460
  }
279
461
  return (
280
- <span className="flex flex-wrap items-center gap-1">
462
+ <span className="flex items-center gap-1 overflow-hidden">
281
463
  {tokens.map((token, index) => (
282
- <AttributeOptionChip key={`${token.label}:${index}`} token={token} />
464
+ <OptionPill key={`${token.label}:${index}`} token={token} />
283
465
  ))}
284
466
  </span>
285
467
  );
@@ -287,22 +469,25 @@ export function CellDisplay({
287
469
  if (type === "email-address" || type === "domain") {
288
470
  const values = Array.isArray(value) ? value : value === null ? [] : [value];
289
471
  if (!values.length) {
290
- return <span className="text-muted-foreground">{EMPTY}</span>;
472
+ return <span className="text-content-ghost">{EMPTY}</span>;
291
473
  }
292
474
  return (
293
- <span className="flex flex-wrap items-center gap-1">
475
+ <span className="flex items-center gap-2 overflow-hidden">
294
476
  {values.map((entry) => {
295
477
  const text = String(entry);
296
478
  return (
297
- <Chip
479
+ <LinkValue
298
480
  key={text}
481
+ text={text}
299
482
  onCopy={() => copyToClipboard(text, t)}
300
- {...(type === "domain"
301
- ? { href: text.startsWith("http") ? text : `https://${text}` }
302
- : {})}
303
- >
304
- {text}
305
- </Chip>
483
+ href={
484
+ type === "domain"
485
+ ? text.startsWith("http")
486
+ ? text
487
+ : `https://${text}`
488
+ : `mailto:${text}`
489
+ }
490
+ />
306
491
  );
307
492
  })}
308
493
  </span>
@@ -310,13 +495,27 @@ export function CellDisplay({
310
495
  }
311
496
  if (type === "record-reference" || type === "actor-reference") {
312
497
  const text = formatCell(attribute, value);
313
- if (!text) return <span className="text-muted-foreground">{EMPTY}</span>;
314
- return <Chip>{text}</Chip>;
498
+ if (!text) return <span className="text-content-ghost">{EMPTY}</span>;
499
+ return (
500
+ <Chip
501
+ avatar={
502
+ <RecordAvatar
503
+ name={text}
504
+ // A reference carries no object type, so only an actor — always a
505
+ // person — can claim the round shape.
506
+ shape={type === "actor-reference" ? "person" : "company"}
507
+ className="size-4 text-[9px]"
508
+ />
509
+ }
510
+ >
511
+ {text}
512
+ </Chip>
513
+ );
315
514
  }
316
515
 
317
516
  const text = formatCell(attribute, value);
318
- if (!text) return <span className="text-muted-foreground">{EMPTY}</span>;
319
- return <span className="block truncate">{text}</span>;
517
+ if (!text) return <span className="text-content-ghost">{EMPTY}</span>;
518
+ return <span className="block min-w-0 truncate">{text}</span>;
320
519
  }
321
520
 
322
521
  // ---------------------------------------------------------------------------
@@ -324,7 +523,7 @@ export function CellDisplay({
324
523
  // ---------------------------------------------------------------------------
325
524
 
326
525
  const INPUT_CLASS =
327
- "h-full w-full border-0 bg-transparent px-2 text-sm outline-none ring-0 placeholder:text-muted-foreground/60";
526
+ "h-full w-full border-0 bg-transparent px-3 text-sm outline-none ring-0 placeholder:text-content-ghost";
328
527
 
329
528
  function useAutoFocus<T extends HTMLElement>() {
330
529
  const ref = useRef<T>(null);
@@ -335,76 +534,6 @@ function useAutoFocus<T extends HTMLElement>() {
335
534
  return ref;
336
535
  }
337
536
 
338
- function ReferencePicker({
339
- attribute,
340
- onPick,
341
- onCancel,
342
- }: {
343
- attribute: CrmGridAttribute;
344
- onPick: (value: string) => void;
345
- onCancel: () => void;
346
- }) {
347
- const t = useT();
348
- const [search, setSearch] = useState("");
349
- const inputRef = useAutoFocus<HTMLInputElement>();
350
- const results = useActionQuery<{
351
- records?: Array<{ id: string; displayName: string; subtitle?: string }>;
352
- }>(
353
- "list-crm-records" as never,
354
- { query: search.trim() || undefined, limit: 8 } as never,
355
- { enabled: search.trim().length > 1 } as never,
356
- );
357
- const records = results.data?.records ?? [];
358
- return (
359
- <div className="w-64">
360
- <input
361
- ref={inputRef}
362
- value={search}
363
- onChange={(event) => setSearch(event.target.value)}
364
- onKeyDown={(event) => {
365
- if (event.key === "Escape") onCancel();
366
- }}
367
- placeholder={t("grid.searchRecords")}
368
- className="w-full border-b border-border/70 bg-transparent px-3 py-2 text-sm outline-none"
369
- />
370
- <div className="max-h-56 overflow-y-auto py-1">
371
- {search.trim().length <= 1 ? (
372
- <p className="px-3 py-2 text-xs text-muted-foreground">
373
- {t("grid.searchToLink")}
374
- </p>
375
- ) : results.isError ? (
376
- <p className="px-3 py-2 text-xs text-destructive">
377
- {t("grid.searchFailed")}
378
- </p>
379
- ) : records.length === 0 ? (
380
- <p className="px-3 py-2 text-xs text-muted-foreground">
381
- {results.isLoading ? t("grid.searching") : t("grid.noMatches")}
382
- </p>
383
- ) : (
384
- records.map((record) => (
385
- <button
386
- key={record.id}
387
- type="button"
388
- className="flex w-full cursor-pointer flex-col items-start px-3 py-1.5 text-left text-sm hover:bg-muted"
389
- onClick={() => onPick(record.displayName)}
390
- >
391
- <span className="truncate font-medium">{record.displayName}</span>
392
- {record.subtitle ? (
393
- <span className="truncate text-xs text-muted-foreground">
394
- {record.subtitle}
395
- </span>
396
- ) : null}
397
- </button>
398
- ))
399
- )}
400
- </div>
401
- <p className="border-t border-border/60 px-3 py-1.5 text-[11px] text-muted-foreground">
402
- {t("grid.referenceHint", { attribute: attribute.label })}
403
- </p>
404
- </div>
405
- );
406
- }
407
-
408
537
  function OptionPicker({
409
538
  attribute,
410
539
  value,
@@ -446,10 +575,13 @@ function OptionPicker({
446
575
  onClick={() => toggle(option.value)}
447
576
  >
448
577
  <span
449
- className="size-2 shrink-0 rounded-full"
450
- style={{
451
- backgroundColor: option.color ?? "var(--muted-foreground)",
452
- }}
578
+ className={cn(
579
+ "size-3 shrink-0 rounded-full",
580
+ // An uncolored option is a ring, not a grey dot: a filled grey
581
+ // reads as "colored grey" next to real option colours.
582
+ option.color ? "" : "ring-1 ring-inset ring-hairline",
583
+ )}
584
+ style={option.color ? { backgroundColor: option.color } : undefined}
453
585
  />
454
586
  <span className="flex-1 truncate">{option.title}</span>
455
587
  {selected.has(option.value) ? (
@@ -495,7 +627,7 @@ export function CellEditor({
495
627
 
496
628
  if (spec.editor === "checkbox") {
497
629
  return (
498
- <span className="flex h-full items-center justify-center">
630
+ <span className="flex h-full items-start justify-center pt-2">
499
631
  <Checkbox
500
632
  autoFocus
501
633
  checked={value === true}
@@ -509,23 +641,25 @@ export function CellEditor({
509
641
  if (spec.editor === "rating") {
510
642
  const current = typeof value === "number" ? value : 0;
511
643
  return (
512
- <span className="flex h-full items-center gap-0.5 px-2">
513
- {Array.from({ length: RATING_MAX }, (_, index) => index + 1).map(
514
- (step) => (
515
- <button
516
- key={step}
517
- type="button"
518
- className="cursor-pointer"
519
- onClick={() => onCommit(step === current ? null : step, false)}
520
- >
521
- {step <= current ? (
522
- <IconStarFilled className="size-4 text-amber-500" />
523
- ) : (
524
- <IconStar className="size-4 text-muted-foreground/50 hover:text-amber-500" />
525
- )}
526
- </button>
527
- ),
528
- )}
644
+ <span className="flex h-full items-start px-3 pt-2">
645
+ <span className="flex items-center gap-0.5 rounded-lg px-1.5 py-0.5 ring-1 ring-inset ring-transparent transition-[box-shadow] duration-[var(--motion-comfortable)] hover:ring-hairline">
646
+ {Array.from({ length: RATING_MAX }, (_, index) => index + 1).map(
647
+ (step) => (
648
+ <button
649
+ key={step}
650
+ type="button"
651
+ className="cursor-pointer"
652
+ onClick={() => onCommit(step === current ? null : step, false)}
653
+ >
654
+ {step <= current ? (
655
+ <IconStarFilled className="size-3.5 text-amber-500" />
656
+ ) : (
657
+ <IconStar className="size-3.5 text-content-ghost hover:text-amber-500" />
658
+ )}
659
+ </button>
660
+ ),
661
+ )}
662
+ </span>
529
663
  </span>
530
664
  );
531
665
  }
@@ -539,7 +673,7 @@ export function CellEditor({
539
673
  }}
540
674
  >
541
675
  <PopoverTrigger asChild>
542
- <span className="flex h-full items-center px-2 text-sm">
676
+ <span className="flex h-full items-start px-3 pt-2 text-sm">
543
677
  <CellDisplay attribute={attribute} value={value} />
544
678
  </span>
545
679
  </PopoverTrigger>
@@ -563,13 +697,15 @@ export function CellEditor({
563
697
  }}
564
698
  >
565
699
  <PopoverTrigger asChild>
566
- <span className="flex h-full items-center px-2 text-sm">
700
+ <span className="flex h-full items-start px-3 pt-2 text-sm">
567
701
  <CellDisplay attribute={attribute} value={value} />
568
702
  </span>
569
703
  </PopoverTrigger>
570
704
  <PopoverContent align="start" className="p-0">
571
- <ReferencePicker
572
- attribute={attribute}
705
+ <RecordReferencePicker
706
+ label={attribute.label}
707
+ kind={referenceSearchKind(attribute)}
708
+ selected={referenceMembers(value)}
573
709
  onPick={(next) => onCommit(next, true)}
574
710
  onCancel={onCancel}
575
711
  />
@@ -578,16 +714,14 @@ export function CellEditor({
578
714
  );
579
715
  }
580
716
 
581
- // The grid overrides the registry's `number` input type: spinner arrows in a
582
- // 34px cell steal the scroll wheel while the sheet is being scrolled.
583
- const inputType = spec.inputType === "number" ? "text" : spec.inputType;
717
+ const input = editorInputType(attribute);
584
718
 
585
719
  return (
586
720
  <input
587
721
  ref={inputRef}
588
- type={inputType}
722
+ type={input.type}
589
723
  defaultValue={text}
590
- inputMode={spec.editor === "number" ? "decimal" : undefined}
724
+ {...(input.inputMode ? { inputMode: input.inputMode } : {})}
591
725
  onChange={(event) => setText(event.target.value)}
592
726
  onKeyDown={(event) => {
593
727
  const intent = resolveGridKey(event, { editing: true });