@twoux/ui 0.0.0-stage → 0.1.0
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/LICENSE.md +61 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/NOTICE.md +64 -0
- package/SHADCN-DIFF.md +228 -0
- package/agent/AGENTS.md +358 -0
- package/agent/docs-md/accessibility.md +49 -0
- package/agent/docs-md/action-catalogue.md +81 -0
- package/agent/docs-md/actions.md +74 -0
- package/agent/docs-md/agent-activity-log.md +70 -0
- package/agent/docs-md/agent-permissions.md +82 -0
- package/agent/docs-md/agent-run.md +90 -0
- package/agent/docs-md/agents-on-behalf.md +74 -0
- package/agent/docs-md/ai-column.md +85 -0
- package/agent/docs-md/ai-origin-label.md +70 -0
- package/agent/docs-md/announcing-features.md +73 -0
- package/agent/docs-md/answer-actions.md +81 -0
- package/agent/docs-md/app-shell.md +114 -0
- package/agent/docs-md/artifact-card.md +89 -0
- package/agent/docs-md/ask-on-screen.md +72 -0
- package/agent/docs-md/assistant-page.md +100 -0
- package/agent/docs-md/assistant-panel.md +142 -0
- package/agent/docs-md/avatar.md +82 -0
- package/agent/docs-md/banner.md +106 -0
- package/agent/docs-md/board.md +163 -0
- package/agent/docs-md/breadcrumb.md +89 -0
- package/agent/docs-md/breakdown-table.md +97 -0
- package/agent/docs-md/bulk-action-bar.md +112 -0
- package/agent/docs-md/button.md +293 -0
- package/agent/docs-md/card.md +115 -0
- package/agent/docs-md/change-list.md +99 -0
- package/agent/docs-md/change-request.md +166 -0
- package/agent/docs-md/chart-area.md +87 -0
- package/agent/docs-md/chart-bar.md +100 -0
- package/agent/docs-md/chart-box-plot.md +88 -0
- package/agent/docs-md/chart-bullet.md +89 -0
- package/agent/docs-md/chart-candlestick.md +112 -0
- package/agent/docs-md/chart-card-line.md +120 -0
- package/agent/docs-md/chart-donut.md +90 -0
- package/agent/docs-md/chart-drill-down.md +72 -0
- package/agent/docs-md/chart-funnel.md +71 -0
- package/agent/docs-md/chart-heatmap.md +76 -0
- package/agent/docs-md/chart-histogram.md +81 -0
- package/agent/docs-md/chart-map.md +165 -0
- package/agent/docs-md/chart-mixed.md +88 -0
- package/agent/docs-md/chart-price.md +92 -0
- package/agent/docs-md/chart-radial-bars.md +109 -0
- package/agent/docs-md/chart-radial-lines.md +111 -0
- package/agent/docs-md/chart-sankey.md +104 -0
- package/agent/docs-md/chart-scatter.md +87 -0
- package/agent/docs-md/chart-share-bar.md +89 -0
- package/agent/docs-md/chart-treemap.md +98 -0
- package/agent/docs-md/chart-waterfall.md +93 -0
- package/agent/docs-md/checkbox.md +131 -0
- package/agent/docs-md/choosing-selection-control.md +71 -0
- package/agent/docs-md/citations-sources.md +79 -0
- package/agent/docs-md/citing-sources.md +63 -0
- package/agent/docs-md/clarify-before-acting.md +69 -0
- package/agent/docs-md/clarifying-question.md +92 -0
- package/agent/docs-md/code-block.md +174 -0
- package/agent/docs-md/colours.md +87 -0
- package/agent/docs-md/columns-menu.md +140 -0
- package/agent/docs-md/combobox.md +161 -0
- package/agent/docs-md/command-palette.md +126 -0
- package/agent/docs-md/confidence-note.md +87 -0
- package/agent/docs-md/configurable-dashboard.md +76 -0
- package/agent/docs-md/confirmation-dialog.md +93 -0
- package/agent/docs-md/connected-tools.md +80 -0
- package/agent/docs-md/connector-picker.md +127 -0
- package/agent/docs-md/consistency.md +53 -0
- package/agent/docs-md/content-density.md +61 -0
- package/agent/docs-md/conversation-history.md +78 -0
- package/agent/docs-md/create-record.md +80 -0
- package/agent/docs-md/csv-import.md +88 -0
- package/agent/docs-md/dashboard-items.md +78 -0
- package/agent/docs-md/data-export.md +61 -0
- package/agent/docs-md/data-grid.md +137 -0
- package/agent/docs-md/data-vis-colours.md +76 -0
- package/agent/docs-md/data-visualisation.md +76 -0
- package/agent/docs-md/date-picker.md +178 -0
- package/agent/docs-md/delete-record.md +75 -0
- package/agent/docs-md/dense-table.md +495 -0
- package/agent/docs-md/design-tokens.md +53 -0
- package/agent/docs-md/disabled-read-only.md +75 -0
- package/agent/docs-md/drag-and-drop.md +74 -0
- package/agent/docs-md/dropdown-menu.md +137 -0
- package/agent/docs-md/edit-record.md +73 -0
- package/agent/docs-md/empty-loading-error.md +78 -0
- package/agent/docs-md/empty-state.md +128 -0
- package/agent/docs-md/error-messages.md +75 -0
- package/agent/docs-md/error-state.md +123 -0
- package/agent/docs-md/export-menu.md +86 -0
- package/agent/docs-md/feedback-prompt.md +88 -0
- package/agent/docs-md/field-mapping.md +78 -0
- package/agent/docs-md/file-drop.md +110 -0
- package/agent/docs-md/filter-bar.md +138 -0
- package/agent/docs-md/filter-panel.md +103 -0
- package/agent/docs-md/filtering-search.md +79 -0
- package/agent/docs-md/follow-ups-starters.md +79 -0
- package/agent/docs-md/form-validation.md +95 -0
- package/agent/docs-md/generated-columns.md +65 -0
- package/agent/docs-md/generated-drafts-files.md +71 -0
- package/agent/docs-md/grouped-list.md +89 -0
- package/agent/docs-md/guide-cue.md +170 -0
- package/agent/docs-md/help-panel.md +103 -0
- package/agent/docs-md/help-system.md +74 -0
- package/agent/docs-md/iconography.md +49 -0
- package/agent/docs-md/inbox.md +108 -0
- package/agent/docs-md/inline-editing-validation.md +80 -0
- package/agent/docs-md/inline-input-in-answer.md +70 -0
- package/agent/docs-md/items-palette.md +116 -0
- package/agent/docs-md/keyboard-model.md +47 -0
- package/agent/docs-md/keyboard-navigation.md +68 -0
- package/agent/docs-md/keyboard-shortcut-hint.md +87 -0
- package/agent/docs-md/keyboard-shortcut-sheet.md +90 -0
- package/agent/docs-md/labelling-ai-output.md +66 -0
- package/agent/docs-md/layout.md +47 -0
- package/agent/docs-md/list-table.md +254 -0
- package/agent/docs-md/loading-skeleton.md +91 -0
- package/agent/docs-md/managing-agents.md +71 -0
- package/agent/docs-md/message-composer.md +105 -0
- package/agent/docs-md/migrating.md +49 -0
- package/agent/docs-md/motion.md +93 -0
- package/agent/docs-md/navigation.md +76 -0
- package/agent/docs-md/notification-channels.md +65 -0
- package/agent/docs-md/notifications-decision.md +100 -0
- package/agent/docs-md/onboarding-step.md +114 -0
- package/agent/docs-md/order-book.md +108 -0
- package/agent/docs-md/page-header.md +96 -0
- package/agent/docs-md/pagination.md +88 -0
- package/agent/docs-md/pane-layout.md +105 -0
- package/agent/docs-md/period-comparison.md +66 -0
- package/agent/docs-md/plan-card.md +120 -0
- package/agent/docs-md/popover-rules.md +88 -0
- package/agent/docs-md/prompt-editor.md +90 -0
- package/agent/docs-md/propose-apply.md +65 -0
- package/agent/docs-md/provider-logo.md +97 -0
- package/agent/docs-md/radio.md +83 -0
- package/agent/docs-md/ranked-list.md +102 -0
- package/agent/docs-md/reasoning-progress.md +72 -0
- package/agent/docs-md/record-detail.md +71 -0
- package/agent/docs-md/regenerating-answers.md +70 -0
- package/agent/docs-md/run-status-bar.md +94 -0
- package/agent/docs-md/saved-tasks-schedules.md +63 -0
- package/agent/docs-md/saved-views.md +149 -0
- package/agent/docs-md/saving-views.md +68 -0
- package/agent/docs-md/scheduler.md +258 -0
- package/agent/docs-md/secondary-panels.md +72 -0
- package/agent/docs-md/segmented-control.md +88 -0
- package/agent/docs-md/selection-bulk-actions.md +70 -0
- package/agent/docs-md/separator.md +75 -0
- package/agent/docs-md/setup-checklist.md +92 -0
- package/agent/docs-md/shape-elevation.md +55 -0
- package/agent/docs-md/sheet-grid.md +211 -0
- package/agent/docs-md/side-panel.md +134 -0
- package/agent/docs-md/sign-in.md +114 -0
- package/agent/docs-md/spacing.md +55 -0
- package/agent/docs-md/split-button.md +100 -0
- package/agent/docs-md/stat-compare.md +82 -0
- package/agent/docs-md/static-dashboard.md +75 -0
- package/agent/docs-md/status-badge.md +140 -0
- package/agent/docs-md/step-card.md +75 -0
- package/agent/docs-md/stepper.md +92 -0
- package/agent/docs-md/suggested-changes.md +91 -0
- package/agent/docs-md/switch.md +78 -0
- package/agent/docs-md/table-settings.md +81 -0
- package/agent/docs-md/tabs.md +85 -0
- package/agent/docs-md/tag-input.md +108 -0
- package/agent/docs-md/tag.md +79 -0
- package/agent/docs-md/task-builder.md +87 -0
- package/agent/docs-md/team-instructions.md +72 -0
- package/agent/docs-md/templates-gallery.md +85 -0
- package/agent/docs-md/text-input.md +160 -0
- package/agent/docs-md/thinking-steps.md +89 -0
- package/agent/docs-md/timeline.md +189 -0
- package/agent/docs-md/timestamp.md +84 -0
- package/agent/docs-md/timestamps.md +70 -0
- package/agent/docs-md/toast.md +117 -0
- package/agent/docs-md/tooltip.md +90 -0
- package/agent/docs-md/typography.md +62 -0
- package/agent/docs-md/undo-destructive.md +66 -0
- package/agent/docs-md/unsaved-changes.md +71 -0
- package/agent/docs-md/usage-meter.md +80 -0
- package/agent/docs-md/user-feedback.md +71 -0
- package/agent/docs-md/voice-and-tone.md +60 -0
- package/agent/docs-md/why-choice.md +93 -0
- package/agent/docs-md/workspace-onboarding.md +74 -0
- package/agent/docs-md/writing.md +48 -0
- package/agent/llms-full.txt +18369 -0
- package/agent/llms.txt +198 -0
- package/agent/skills/twoux-audit/SKILL.md +49 -0
- package/agent/skills/twoux-build/SKILL.md +39 -0
- package/agent/skills/twoux-design/SKILL.md +54 -0
- package/agent/skills/twoux-write/SKILL.md +32 -0
- package/bin/check.mjs +123 -0
- package/bin/docs.mjs +24 -0
- package/bin/guard.mjs +27 -0
- package/bin/init.mjs +113 -0
- package/bin/knowledge.mjs +87 -0
- package/bin/mcp.mjs +116 -0
- package/bin/theme.mjs +168 -0
- package/bin/twoux.mjs +25 -0
- package/package.json +80 -3
- package/registry/jobs.json +1028 -0
- package/registry/palette.json +770 -0
- package/registry/rules.json +913 -0
- package/src/components/agent-review.tsx +364 -0
- package/src/components/ai-basics.tsx +212 -0
- package/src/components/ai-work.tsx +304 -0
- package/src/components/ai.tsx +227 -0
- package/src/components/alert-dialog.tsx +203 -0
- package/src/components/alert.tsx +86 -0
- package/src/components/avatar.tsx +114 -0
- package/src/components/badge.tsx +59 -0
- package/src/components/board.tsx +397 -0
- package/src/components/breadcrumb.tsx +114 -0
- package/src/components/button-group.tsx +90 -0
- package/src/components/button.tsx +109 -0
- package/src/components/calendar.tsx +247 -0
- package/src/components/card.tsx +102 -0
- package/src/components/chart-map.tsx +336 -0
- package/src/components/charts-more.tsx +1331 -0
- package/src/components/charts-radial.tsx +328 -0
- package/src/components/charts.tsx +390 -0
- package/src/components/checkbox.tsx +40 -0
- package/src/components/code.tsx +139 -0
- package/src/components/collapsible.tsx +37 -0
- package/src/components/combobox.tsx +339 -0
- package/src/components/command-extras.tsx +11 -0
- package/src/components/command.tsx +196 -0
- package/src/components/data-grid.tsx +213 -0
- package/src/components/data-table.tsx +334 -0
- package/src/components/data-views.tsx +740 -0
- package/src/components/date-picker.tsx +686 -0
- package/src/components/dense-table.tsx +207 -0
- package/src/components/dialog.tsx +162 -0
- package/src/components/dropdown-menu.tsx +264 -0
- package/src/components/empty.tsx +114 -0
- package/src/components/feedback-prompt.tsx +80 -0
- package/src/components/field.tsx +254 -0
- package/src/components/fields.tsx +112 -0
- package/src/components/flow.tsx +840 -0
- package/src/components/guide-cue.tsx +273 -0
- package/src/components/inbox.tsx +113 -0
- package/src/components/input-group.tsx +176 -0
- package/src/components/input.tsx +26 -0
- package/src/components/kbd.tsx +34 -0
- package/src/components/label.tsx +27 -0
- package/src/components/labels.tsx +176 -0
- package/src/components/motion.tsx +136 -0
- package/src/components/onboarding.tsx +454 -0
- package/src/components/order-book.tsx +185 -0
- package/src/components/pane-layout.tsx +417 -0
- package/src/components/popover.tsx +96 -0
- package/src/components/provider-logos.data.ts +49 -0
- package/src/components/radio-group.tsx +52 -0
- package/src/components/scheduler.tsx +997 -0
- package/src/components/segmented-control.tsx +28 -0
- package/src/components/select.tsx +195 -0
- package/src/components/separator.tsx +31 -0
- package/src/components/sheet.tsx +148 -0
- package/src/components/shell.tsx +426 -0
- package/src/components/shortcut-sheet.tsx +83 -0
- package/src/components/skeleton.tsx +18 -0
- package/src/components/sonner.tsx +107 -0
- package/src/components/spinner.tsx +13 -0
- package/src/components/split-button.tsx +58 -0
- package/src/components/stepper.tsx +78 -0
- package/src/components/switch.tsx +43 -0
- package/src/components/table.tsx +126 -0
- package/src/components/tabs.tsx +118 -0
- package/src/components/tag-input.tsx +253 -0
- package/src/components/textarea.tsx +21 -0
- package/src/components/theme.tsx +80 -0
- package/src/components/timeline.tsx +408 -0
- package/src/components/toggle-group.tsx +87 -0
- package/src/components/toggle.tsx +54 -0
- package/src/components/tooltip.tsx +65 -0
- package/src/components/workspace-actions.tsx +294 -0
- package/src/components/workspace-import.tsx +465 -0
- package/src/components/workspace.tsx +1884 -0
- package/src/index.ts +80 -0
- package/src/lib/active-indicator.tsx +53 -0
- package/src/lib/chart-kit.tsx +72 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/shortcuts.tsx +61 -0
- package/src/lib/workspace.tsx +10 -0
- package/src/shadcn/alert-dialog.tsx +196 -0
- package/src/shadcn/alert.tsx +65 -0
- package/src/shadcn/avatar.tsx +108 -0
- package/src/shadcn/badge.tsx +47 -0
- package/src/shadcn/breadcrumb.tsx +108 -0
- package/src/shadcn/button-group.tsx +83 -0
- package/src/shadcn/button.tsx +63 -0
- package/src/shadcn/calendar.tsx +220 -0
- package/src/shadcn/card.tsx +91 -0
- package/src/shadcn/checkbox.tsx +31 -0
- package/src/shadcn/collapsible.tsx +33 -0
- package/src/shadcn/command.tsx +184 -0
- package/src/shadcn/dialog.tsx +158 -0
- package/src/shadcn/dropdown-menu.tsx +256 -0
- package/src/shadcn/empty.tsx +103 -0
- package/src/shadcn/field.tsx +248 -0
- package/src/shadcn/input-group.tsx +170 -0
- package/src/shadcn/input.tsx +20 -0
- package/src/shadcn/kbd.tsx +28 -0
- package/src/shadcn/label.tsx +23 -0
- package/src/shadcn/popover.tsx +88 -0
- package/src/shadcn/radio-group.tsx +44 -0
- package/src/shadcn/select.tsx +189 -0
- package/src/shadcn/separator.tsx +27 -0
- package/src/shadcn/sheet.tsx +142 -0
- package/src/shadcn/skeleton.tsx +13 -0
- package/src/shadcn/sonner.tsx +40 -0
- package/src/shadcn/switch.tsx +34 -0
- package/src/shadcn/table.tsx +115 -0
- package/src/shadcn/tabs.tsx +90 -0
- package/src/shadcn/textarea.tsx +17 -0
- package/src/shadcn/toggle-group.tsx +83 -0
- package/src/shadcn/toggle.tsx +46 -0
- package/src/shadcn/tooltip.tsx +56 -0
- package/src/styles/palette.css +127 -0
- package/src/styles/theme.css +35 -0
- package/src/styles/twoux.css +236 -0
- package/theme.json +14 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1884 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwoUX compositions for a data workspace: a sheet of records whose columns are typed, calculated by a formula,
|
|
3
|
+
* written by the assistant or looked up from another service, each run per row at a stated cost.
|
|
4
|
+
*
|
|
5
|
+
* SheetGrid: a spreadsheet keyboard after Excel and Google Sheets (one tab stop; arrows, Shift+arrows and drag select a
|
|
6
|
+
* range; copy and paste tab-separated values to and from Excel or Sheets, validated per column; Delete clears; fill
|
|
7
|
+
* down and right; undo and redo; every action announced in the status line), size="dense" for 28px rows,
|
|
8
|
+
* optional group rows with totals, filter row, row selection, row menu and Open button, plus typed columns with a type icon, a menu per column, row numbers, a status row for computed columns,
|
|
9
|
+
* computed cell states, Add row and Add 10 rows, Wrap text per column, pinned first columns.
|
|
10
|
+
* ColumnsMenu: search, show or hide all, eye toggles, reorder by drag, by keyboard (pick up, arrows, drop, Esc) or by
|
|
11
|
+
* Move up and Move down buttons. Optional Wrap long text and Rows per page for list tables. Applies at once.
|
|
12
|
+
* RunStatusBar: "Table up to date", or progress with the cost so far and Stop; run history; Auto-run with its cost per row.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
Activity,
|
|
16
|
+
ArrowDown,
|
|
17
|
+
ArrowLeftToLine,
|
|
18
|
+
ArrowRightToLine,
|
|
19
|
+
ArrowUp,
|
|
20
|
+
ArrowUpDown,
|
|
21
|
+
Calendar,
|
|
22
|
+
Check,
|
|
23
|
+
ChevronDown,
|
|
24
|
+
ChevronRight,
|
|
25
|
+
CircleAlert,
|
|
26
|
+
EllipsisVertical,
|
|
27
|
+
Eye,
|
|
28
|
+
EyeOff,
|
|
29
|
+
FoldHorizontal,
|
|
30
|
+
GripVertical,
|
|
31
|
+
Hash,
|
|
32
|
+
History,
|
|
33
|
+
ListFilter,
|
|
34
|
+
Loader2,
|
|
35
|
+
Lock,
|
|
36
|
+
type LucideIcon,
|
|
37
|
+
MoveHorizontal,
|
|
38
|
+
PanelRight,
|
|
39
|
+
Pencil,
|
|
40
|
+
Pin,
|
|
41
|
+
PinOff,
|
|
42
|
+
Plug,
|
|
43
|
+
Plus,
|
|
44
|
+
RefreshCw,
|
|
45
|
+
Search,
|
|
46
|
+
Settings2,
|
|
47
|
+
Sigma,
|
|
48
|
+
Square,
|
|
49
|
+
SquareChevronDown,
|
|
50
|
+
ToggleRight,
|
|
51
|
+
Trash2,
|
|
52
|
+
Type,
|
|
53
|
+
UnfoldHorizontal,
|
|
54
|
+
WrapText,
|
|
55
|
+
Columns3,
|
|
56
|
+
} from "lucide-react"
|
|
57
|
+
import * as React from "react"
|
|
58
|
+
import { toast } from "sonner"
|
|
59
|
+
import { cn } from "../lib/cn"
|
|
60
|
+
import { useWorkspace } from "../lib/workspace"
|
|
61
|
+
import { AiMark } from "./ai-basics"
|
|
62
|
+
import { AiCell, type AiCellState } from "./ai-work"
|
|
63
|
+
import { Badge } from "./badge"
|
|
64
|
+
import { Button } from "./button"
|
|
65
|
+
import { Checkbox } from "./checkbox"
|
|
66
|
+
import {
|
|
67
|
+
DropdownMenu,
|
|
68
|
+
DropdownMenuContent,
|
|
69
|
+
DropdownMenuItem,
|
|
70
|
+
DropdownMenuLabel,
|
|
71
|
+
DropdownMenuRadioGroup,
|
|
72
|
+
DropdownMenuRadioItem,
|
|
73
|
+
DropdownMenuSeparator,
|
|
74
|
+
DropdownMenuShortcut,
|
|
75
|
+
DropdownMenuSub,
|
|
76
|
+
DropdownMenuSubContent,
|
|
77
|
+
DropdownMenuSubTrigger,
|
|
78
|
+
DropdownMenuTrigger,
|
|
79
|
+
} from "./dropdown-menu"
|
|
80
|
+
import { Input } from "./input"
|
|
81
|
+
import { ShimmerText } from "./motion"
|
|
82
|
+
import { ProviderLogo } from "./onboarding"
|
|
83
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover"
|
|
84
|
+
import { RadioGroup, RadioGroupItem } from "./radio-group"
|
|
85
|
+
import type { ProviderId } from "./provider-logos.data"
|
|
86
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./select"
|
|
87
|
+
import type { ShortcutGroup } from "./shortcut-sheet"
|
|
88
|
+
import { Switch } from "./switch"
|
|
89
|
+
|
|
90
|
+
/* ---------------------------------------------------------------- types */
|
|
91
|
+
|
|
92
|
+
export type ColumnType = "text" | "number" | "date" | "select" | "boolean" | "formula" | "ai" | "enrichment"
|
|
93
|
+
|
|
94
|
+
export const COLUMN_TYPES: Record<ColumnType, { label: string; icon: LucideIcon | null; computed: boolean }> = {
|
|
95
|
+
text: { label: "Text", icon: Type, computed: false },
|
|
96
|
+
number: { label: "Number", icon: Hash, computed: false },
|
|
97
|
+
date: { label: "Date", icon: Calendar, computed: false },
|
|
98
|
+
select: { label: "Select", icon: SquareChevronDown, computed: false },
|
|
99
|
+
boolean: { label: "Yes or no", icon: ToggleRight, computed: false },
|
|
100
|
+
formula: { label: "Formula", icon: Sigma, computed: true },
|
|
101
|
+
ai: { label: "AI", icon: null, computed: true },
|
|
102
|
+
enrichment: { label: "Enrichment", icon: Plug, computed: true },
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Progress of a computed column, shown in the status row under its header. */
|
|
106
|
+
export type ColumnRunStatus = { done: number; total: number; errors?: number; running?: boolean; stale?: boolean }
|
|
107
|
+
|
|
108
|
+
export type SheetColumn = {
|
|
109
|
+
id: string
|
|
110
|
+
label: string
|
|
111
|
+
type: ColumnType
|
|
112
|
+
/** px */
|
|
113
|
+
width: number
|
|
114
|
+
pinned?: boolean
|
|
115
|
+
hidden?: boolean
|
|
116
|
+
wrap?: boolean
|
|
117
|
+
/** Always shown, can't be hidden or deleted: the column that names the record. */
|
|
118
|
+
locked?: boolean
|
|
119
|
+
/** Choices for a select column. */
|
|
120
|
+
options?: string[]
|
|
121
|
+
/** Shown in the column menu of a formula column, e.g. "=Seniority & Company". */
|
|
122
|
+
formula?: string
|
|
123
|
+
/** The service an enrichment column looks values up in. Its logo replaces the type icon. */
|
|
124
|
+
provider?: ProviderId
|
|
125
|
+
/** Credits per row, stated before every run and retry. */
|
|
126
|
+
costPerRow?: number
|
|
127
|
+
/** Return a message to block the save or the paste. */
|
|
128
|
+
validate?: (value: string) => string | undefined
|
|
129
|
+
status?: ColumnRunStatus
|
|
130
|
+
/** Number columns: an ISO currency code (GBP) formats values as money; pasted £, $ and € are understood. */
|
|
131
|
+
currency?: string
|
|
132
|
+
/** Show a total in the footer, and in each group row when rows are grouped. */
|
|
133
|
+
total?: "sum" | "average"
|
|
134
|
+
/** Yes or no columns: the word beside the switch, e.g. "Subscribed" and "Not subscribed". */
|
|
135
|
+
onLabel?: string
|
|
136
|
+
offLabel?: string
|
|
137
|
+
/** Rows where the column doesn't apply show "n/a" (read as "not applicable") and can't be edited or pasted into. */
|
|
138
|
+
notApplicable?: (row: SheetRow) => boolean
|
|
139
|
+
/** Custom display for a typed column, such as an avatar beside an owner's name. Editing still uses the column's type. */
|
|
140
|
+
render?: (value: SheetValue, row: SheetRow) => React.ReactNode
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type ComputedCellState = AiCellState | { state: "not-found" }
|
|
144
|
+
export type SheetValue = string | number | boolean | null | undefined
|
|
145
|
+
export type SheetRow = { id: string; values: Record<string, SheetValue>; computed?: Record<string, ComputedCellState> }
|
|
146
|
+
export type SheetSort = { columnId: string; direction: "asc" | "desc" } | null
|
|
147
|
+
|
|
148
|
+
const isComputed = (c: SheetColumn) => COLUMN_TYPES[c.type].computed
|
|
149
|
+
const fmtNum = (n: number) => n.toLocaleString("en-GB", { maximumFractionDigits: 2 })
|
|
150
|
+
export const formatCredits = (n: number) => `${n.toLocaleString("en-GB", { maximumFractionDigits: 1 })} ${n === 1 ? "credit" : "credits"}`
|
|
151
|
+
const fmtDate = (v: string) => {
|
|
152
|
+
const d = new Date(v)
|
|
153
|
+
return Number.isNaN(d.getTime()) ? v : new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", year: "numeric" }).format(d)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The type icon of a column: the provider's logo for enrichment, the origin mark for AI, otherwise a muted icon. */
|
|
157
|
+
export function ColumnTypeIcon({ type, provider, className }: { type: ColumnType; provider?: ProviderId; className?: string }) {
|
|
158
|
+
if (type === "enrichment" && provider) return <ProviderLogo provider={provider} size="sm" className={cn("size-4 p-px", className)} />
|
|
159
|
+
if (type === "ai") return <AiMark className={cn("size-3.5", className)} />
|
|
160
|
+
const Icon = COLUMN_TYPES[type].icon ?? Type
|
|
161
|
+
return <Icon aria-hidden="true" className={cn("size-3.5 shrink-0 text-ink-3", className)} />
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const SORT_WORDS: Record<string, [string, string]> = { number: ["Sort smallest first", "Sort largest first"], date: ["Sort oldest first", "Sort newest first"] }
|
|
165
|
+
|
|
166
|
+
function statusText(s: ColumnRunStatus) {
|
|
167
|
+
const pct = s.total ? Math.floor((s.done / s.total) * 100) : 100
|
|
168
|
+
const errs = s.errors ? `${s.errors} ${s.errors === 1 ? "error" : "errors"}` : ""
|
|
169
|
+
if (s.running) return { tone: "running" as const, text: `${pct}%`, errs }
|
|
170
|
+
if (s.stale) return { tone: "stale" as const, text: "Out of date", errs }
|
|
171
|
+
if (s.errors) return { tone: "error" as const, text: errs, errs: "" }
|
|
172
|
+
if (s.done < s.total) return { tone: "partial" as const, text: `${pct}% run`, errs }
|
|
173
|
+
return { tone: "ok" as const, text: "Up to date", errs }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function ColumnStatus({ status }: { status: ColumnRunStatus }) {
|
|
177
|
+
const s = statusText(status)
|
|
178
|
+
return (
|
|
179
|
+
<span className="inline-flex min-w-0 items-center gap-control text-sm whitespace-nowrap">
|
|
180
|
+
{s.tone === "running" && <Loader2 className="size-3 shrink-0 animate-spin text-ink-3" aria-hidden="true" />}
|
|
181
|
+
{s.tone === "ok" && <Check className="size-3 shrink-0 text-ok" aria-hidden="true" />}
|
|
182
|
+
{s.tone === "error" && <CircleAlert className="size-3 shrink-0 text-bad" aria-hidden="true" />}
|
|
183
|
+
{s.tone === "stale" && <RefreshCw className="size-3 shrink-0 text-warn" aria-hidden="true" />}
|
|
184
|
+
<span className={cn("tabular", s.tone === "error" ? "text-bad" : s.tone === "ok" ? "text-ink-2" : "text-ink-2")}>{s.text}</span>
|
|
185
|
+
{s.errs && <span className="tabular text-bad">· {s.errs}</span>}
|
|
186
|
+
</span>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function ComputedCell({ col, cell, wrap }: { col: SheetColumn; cell?: ComputedCellState; wrap?: boolean }) {
|
|
191
|
+
if (!cell) return <span className="text-ink-3"><span aria-hidden="true">–</span><span className="sr-only">Empty</span></span>
|
|
192
|
+
if (cell.state === "not-found") return <span className="text-sm text-ink-3">Not found</span>
|
|
193
|
+
if (col.type === "ai" && cell.state !== "done") return <AiCell cell={cell} />
|
|
194
|
+
if (cell.state === "queued") return <span className="text-sm text-ink-3">Waiting</span>
|
|
195
|
+
if (cell.state === "writing")
|
|
196
|
+
return <span role="status" className="inline-flex items-center gap-control text-sm text-ink-3"><Loader2 className="size-3 animate-spin" aria-hidden="true" />{col.type === "formula" ? "Calculating" : "Looking up"}</span>
|
|
197
|
+
if (cell.state === "failed")
|
|
198
|
+
return (
|
|
199
|
+
<span className="inline-flex min-w-0 items-center gap-control text-sm text-bad">
|
|
200
|
+
<CircleAlert className="size-3.5 shrink-0" aria-hidden="true" />
|
|
201
|
+
<span className={wrap ? "whitespace-normal" : "truncate"}>{cell.reason}</span>
|
|
202
|
+
</span>
|
|
203
|
+
)
|
|
204
|
+
return <span className={cn("block", wrap ? "whitespace-normal break-words" : "truncate")}>{cell.value}</span>
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/* ---------------------------------------------------------------- spreadsheet helpers */
|
|
208
|
+
|
|
209
|
+
type Cell = { r: number; c: number }
|
|
210
|
+
type Rect = { r1: number; c1: number; r2: number; c2: number }
|
|
211
|
+
type CellChange = { rowId: string; columnId: string; before: SheetValue; after: SheetValue }
|
|
212
|
+
type Batch = { label: string; changes: CellChange[] }
|
|
213
|
+
type Reject = { key: string; where: string; reason: string }
|
|
214
|
+
/** One cell written by a paste, fill, clear, undo or edit. */
|
|
215
|
+
export type SheetCellChange = { rowId: string; columnId: string; value: SheetValue }
|
|
216
|
+
|
|
217
|
+
const rectOf = (a: Cell, b: Cell): Rect => ({ r1: Math.min(a.r, b.r), c1: Math.min(a.c, b.c), r2: Math.max(a.r, b.r), c2: Math.max(a.c, b.c) })
|
|
218
|
+
const isBlank = (v: SheetValue) => v === null || v === undefined || v === ""
|
|
219
|
+
const detectMac = () => typeof navigator !== "undefined" && /Mac|iPhone|iPad|iPod/.test(navigator.platform || navigator.userAgent)
|
|
220
|
+
const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-GB")} ${n === 1 ? one : many}`
|
|
221
|
+
const TRUE_WORDS = ["true", "yes", "y", "1", "on", "x", "✓"]
|
|
222
|
+
const FALSE_WORDS = ["false", "no", "n", "0", "off"]
|
|
223
|
+
|
|
224
|
+
/** Tab-separated values, quoted the way Excel and Google Sheets quote them, so a copied range pastes into either. */
|
|
225
|
+
export function toTsv(cells: string[][]) {
|
|
226
|
+
return cells.map((row) => row.map((s) => (/[\t\n\r"]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s)).join("\t")).join("\n")
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Reads what Excel or Google Sheets put on the clipboard: tabs between cells, newlines between rows, quotes around a cell that holds either. */
|
|
230
|
+
export function parseTsv(text: string): string[][] {
|
|
231
|
+
const t = text.replace(/\r\n?/g, "\n").replace(/\n$/, "")
|
|
232
|
+
const out: string[][] = []
|
|
233
|
+
let row: string[] = []
|
|
234
|
+
let cell = ""
|
|
235
|
+
let quoted = false
|
|
236
|
+
for (let i = 0; i < t.length; i++) {
|
|
237
|
+
const ch = t[i]
|
|
238
|
+
if (quoted) {
|
|
239
|
+
if (ch === '"' && t[i + 1] === '"') { cell += '"'; i++ }
|
|
240
|
+
else if (ch === '"') quoted = false
|
|
241
|
+
else cell += ch
|
|
242
|
+
} else if (ch === '"' && cell === "") quoted = true
|
|
243
|
+
else if (ch === "\t") { row.push(cell); cell = "" }
|
|
244
|
+
else if (ch === "\n") { row.push(cell); out.push(row); row = []; cell = "" }
|
|
245
|
+
else cell += ch
|
|
246
|
+
}
|
|
247
|
+
row.push(cell)
|
|
248
|
+
out.push(row)
|
|
249
|
+
return out
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
253
|
+
/** 2025-03-14, 14/03/2025 (day first, as in the UK) or 14 Mar 2025, as an ISO date (2025-03-14), or null when it is not a real date. */
|
|
254
|
+
export function toIsoDate(v: string): string | null {
|
|
255
|
+
const uk = v.match(/^(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})$/)
|
|
256
|
+
const iso = v.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/)
|
|
257
|
+
let y: number, m: number, d: number
|
|
258
|
+
if (uk) [d, m, y] = [+uk[1], +uk[2], +uk[3]]
|
|
259
|
+
else if (iso) [y, m, d] = [+iso[1], +iso[2], +iso[3]]
|
|
260
|
+
else {
|
|
261
|
+
if (!/[a-z]/i.test(v)) return null
|
|
262
|
+
const t = new Date(v)
|
|
263
|
+
if (Number.isNaN(t.getTime())) return null
|
|
264
|
+
;[y, m, d] = [t.getFullYear(), t.getMonth() + 1, t.getDate()]
|
|
265
|
+
}
|
|
266
|
+
const t = new Date(Date.UTC(y, m - 1, d))
|
|
267
|
+
if (t.getUTCMonth() !== m - 1 || t.getUTCDate() !== d) return null
|
|
268
|
+
return `${y}-${pad(m)}-${pad(d)}`
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const computedHow = (col: SheetColumn) => (col.type === "formula" ? "calculated by a formula" : col.type === "ai" ? "written by the assistant" : "looked up")
|
|
272
|
+
|
|
273
|
+
/** Turns typed or pasted text into the column's value, or says why it doesn't fit. */
|
|
274
|
+
function coerce(col: SheetColumn, raw: string, row: SheetRow): { value: SheetValue } | { reason: string } {
|
|
275
|
+
if (isComputed(col)) return { reason: `${col.label} is ${computedHow(col)}, so it can't be typed or pasted into.` }
|
|
276
|
+
if (col.notApplicable?.(row)) return { reason: `${col.label} doesn't apply to this row.` }
|
|
277
|
+
const v = raw.trim()
|
|
278
|
+
if (col.type === "boolean") {
|
|
279
|
+
const w = v.toLowerCase()
|
|
280
|
+
if (TRUE_WORDS.includes(w) || (col.onLabel && w === col.onLabel.toLowerCase())) return { value: true }
|
|
281
|
+
if (w === "" || FALSE_WORDS.includes(w) || (col.offLabel && w === col.offLabel.toLowerCase())) return { value: false }
|
|
282
|
+
return { reason: `Enter yes or no${col.onLabel ? `, or ${col.onLabel}` : ""}.` }
|
|
283
|
+
}
|
|
284
|
+
const custom = col.validate?.(v)
|
|
285
|
+
if (custom) return { reason: custom }
|
|
286
|
+
if (v === "") return { value: "" }
|
|
287
|
+
if (col.type === "number") {
|
|
288
|
+
const n = Number(v.replace(/[,\s£$€]/g, "").replace(/^\((.*)\)$/, "-$1"))
|
|
289
|
+
return Number.isNaN(n) ? { reason: "Enter a number, such as 1200." } : { value: n }
|
|
290
|
+
}
|
|
291
|
+
if (col.type === "date") {
|
|
292
|
+
const d = toIsoDate(v)
|
|
293
|
+
return d ? { value: d } : { reason: "Enter a date, such as 2025-03-14 or 14/03/2025." }
|
|
294
|
+
}
|
|
295
|
+
if (col.type === "select") {
|
|
296
|
+
const o = (col.options ?? []).find((x) => x.toLowerCase() === v.toLowerCase())
|
|
297
|
+
return o ? { value: o } : { reason: `Choose one of ${(col.options ?? []).join(", ")}.` }
|
|
298
|
+
}
|
|
299
|
+
return { value: raw.replace(/\r/g, "") }
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const valueOf = (col: SheetColumn, row: SheetRow): SheetValue => {
|
|
303
|
+
if (isComputed(col)) { const c = row.computed?.[col.id]; return c?.state === "done" ? c.value : null }
|
|
304
|
+
return row.values[col.id]
|
|
305
|
+
}
|
|
306
|
+
/** What a cell puts on the clipboard: the raw value, so numbers stay numbers in Excel and Google Sheets. */
|
|
307
|
+
const copyText = (col: SheetColumn, row: SheetRow) => {
|
|
308
|
+
if (col.notApplicable?.(row)) return "n/a"
|
|
309
|
+
const v = valueOf(col, row)
|
|
310
|
+
if (col.type === "boolean") return v ? "TRUE" : "FALSE"
|
|
311
|
+
return isBlank(v) ? "" : String(v)
|
|
312
|
+
}
|
|
313
|
+
const fmtFor = (col: SheetColumn | undefined, n: number) =>
|
|
314
|
+
col?.currency ? n.toLocaleString("en-GB", { style: "currency", currency: col.currency, maximumFractionDigits: Number.isInteger(n) ? 0 : 2 }) : fmtNum(n)
|
|
315
|
+
const numbersOf = (col: SheetColumn, rows: SheetRow[]) =>
|
|
316
|
+
rows.map((r) => valueOf(col, r)).filter((v) => (typeof v === "number" ? true : typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)))).map(Number)
|
|
317
|
+
const aggregate = (col: SheetColumn, rows: SheetRow[]) => {
|
|
318
|
+
const nums = numbersOf(col, rows)
|
|
319
|
+
if (!nums.length) return null
|
|
320
|
+
const sum = nums.reduce((a, b) => a + b, 0)
|
|
321
|
+
return col.total === "average" ? sum / nums.length : sum
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
type Line = { kind: "row"; row: SheetRow; n: number } | { kind: "group"; key: string; label: string; rows: SheetRow[]; open: boolean }
|
|
325
|
+
|
|
326
|
+
/** The SheetGrid keys, for the ShortcutSheet. ⌘ on a Mac, Ctrl elsewhere. */
|
|
327
|
+
export function sheetGridShortcuts(mac = detectMac()): ShortcutGroup[] {
|
|
328
|
+
const M = mac ? "⌘" : "Ctrl"
|
|
329
|
+
return [
|
|
330
|
+
{
|
|
331
|
+
title: "Sheet: move and select",
|
|
332
|
+
items: [
|
|
333
|
+
{ keys: ["←", "→", "↑", "↓"], action: "Move one cell" },
|
|
334
|
+
{ keys: [M, "Arrow"], action: "Jump to the edge of the data" },
|
|
335
|
+
{ keys: ["Home", "End"], action: "First or last cell in the row" },
|
|
336
|
+
{ keys: [M, "Home", "End"], action: "First or last cell in the sheet" },
|
|
337
|
+
{ keys: ["Page Up", "Page Down"], action: "Move 10 rows" },
|
|
338
|
+
{ keys: ["⇧", "Arrow"], action: "Extend the selection" },
|
|
339
|
+
{ keys: [M, "⇧", "Arrow"], action: "Extend the selection to the edge of the data" },
|
|
340
|
+
{ keys: [M, "A"], action: "Select all cells" },
|
|
341
|
+
{ keys: ["Ctrl", "Space"], action: "Select the column" },
|
|
342
|
+
{ keys: ["⇧", "Space"], action: "Select or deselect the rows" },
|
|
343
|
+
{ keys: ["⇧", "F10"], action: "Open the row's actions" },
|
|
344
|
+
{ keys: [mac ? "⌥" : "Alt", "⇧", "← →"], action: "On a header: make the column narrower or wider" },
|
|
345
|
+
{ keys: ["Esc"], action: "Clear the selection" },
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
title: "Sheet: edit",
|
|
350
|
+
items: [
|
|
351
|
+
{ keys: ["Enter", "F2"], action: "Edit the cell; start typing to replace it" },
|
|
352
|
+
{ keys: ["Enter"], action: "Save and move down (⇧ Enter: up)" },
|
|
353
|
+
{ keys: ["Tab"], action: "Save and move right (⇧ Tab: left)" },
|
|
354
|
+
{ keys: [M, "Enter"], action: "Save the value into every selected cell" },
|
|
355
|
+
{ keys: ["Esc"], action: "Cancel the edit" },
|
|
356
|
+
{ keys: ["Delete"], action: "Clear the selected cells" },
|
|
357
|
+
{ keys: [M, "C"], action: "Copy, to paste here, in Excel or in Google Sheets" },
|
|
358
|
+
{ keys: [M, "X"], action: "Copy and clear" },
|
|
359
|
+
{ keys: [M, "V"], action: "Paste from here, Excel or Google Sheets" },
|
|
360
|
+
{ keys: [M, "D"], action: "Fill down" },
|
|
361
|
+
{ keys: [M, "R"], action: "Fill right" },
|
|
362
|
+
{ keys: [M, "Z"], action: "Undo" },
|
|
363
|
+
{ keys: mac ? [M, "⇧", "Z"] : ["Ctrl", "Y"], action: "Redo" },
|
|
364
|
+
{ keys: ["Space"], action: "Turn a yes or no cell on or off" },
|
|
365
|
+
{ keys: [M, ";"], action: "Put today's date in a date cell" },
|
|
366
|
+
{ keys: [M, "/"], action: "Show these shortcuts" },
|
|
367
|
+
],
|
|
368
|
+
},
|
|
369
|
+
]
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
type SheetRowApi = {
|
|
373
|
+
mouseDown: (e: React.MouseEvent, r: number, c: number, isEditing: boolean) => void
|
|
374
|
+
click: (e: React.MouseEvent) => void
|
|
375
|
+
mouseEnter: (e: React.MouseEvent, r: number, c: number) => void
|
|
376
|
+
doubleClick: () => void
|
|
377
|
+
contextMenu: (e: React.MouseEvent, r: number, c: number, row: SheetRow) => void
|
|
378
|
+
renderValue: (col: SheetColumn, row: SheetRow) => React.ReactNode
|
|
379
|
+
editor: (col: SheetColumn, row: SheetRow) => React.ReactNode
|
|
380
|
+
rowMenu: (row: SheetRow) => React.ReactNode
|
|
381
|
+
toggleRow: (row: SheetRow) => void
|
|
382
|
+
rowName: (row: SheetRow) => string
|
|
383
|
+
openRow: (id: string) => void
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const SheetBodyRow = React.memo(function SheetBodyRow({
|
|
387
|
+
api, row, n, r, rowIndex, visible, lefts, cellCls, dense, activeC, editing, error, selC1, selC2, rejected, checked, selectable, selecting, slot, hasRowMenu, menuOpen, openColId,
|
|
388
|
+
}: {
|
|
389
|
+
api: SheetRowApi
|
|
390
|
+
row: SheetRow
|
|
391
|
+
n: number
|
|
392
|
+
r: number
|
|
393
|
+
rowIndex: number
|
|
394
|
+
visible: SheetColumn[]
|
|
395
|
+
lefts: number[]
|
|
396
|
+
cellCls: string
|
|
397
|
+
dense: boolean
|
|
398
|
+
/** The active cell's column in this row, or -1. */
|
|
399
|
+
activeC: number
|
|
400
|
+
editing: string | null
|
|
401
|
+
error: string
|
|
402
|
+
/** The selected range's columns in this row when more than one cell is selected, or -1. */
|
|
403
|
+
selC1: number
|
|
404
|
+
selC2: number
|
|
405
|
+
rejected: Record<string, string>
|
|
406
|
+
checked: boolean
|
|
407
|
+
selectable: boolean
|
|
408
|
+
/** Some row is checked: every row shows its checkbox instead of its number. */
|
|
409
|
+
selecting: boolean
|
|
410
|
+
/** Width of the number or checkbox, px. */
|
|
411
|
+
slot: number
|
|
412
|
+
hasRowMenu: boolean
|
|
413
|
+
/** Re-renders the row when its menu opens or closes. */
|
|
414
|
+
menuOpen: boolean
|
|
415
|
+
openColId?: string
|
|
416
|
+
}) {
|
|
417
|
+
const name = api.rowName(row)
|
|
418
|
+
return (
|
|
419
|
+
<tr aria-rowindex={rowIndex} aria-selected={selectable ? checked : undefined} className="group/row">
|
|
420
|
+
<th scope="row" className={cn("sticky left-0 z-10 border-r border-b border-line px-1 text-sm font-normal", dense ? "h-7" : "h-9", activeC >= 0 ? "bg-primary-soft font-medium text-primary-ink" : checked ? "bg-primary-soft text-ink-3" : "bg-surface-2 text-ink-3 group-hover/row:bg-hover")}>
|
|
421
|
+
{/* The number and the checkbox share one slot: the checkbox shows on hover, on focus and while any row is checked. */}
|
|
422
|
+
<span className="flex items-center">
|
|
423
|
+
<span style={{ "--s": `${slot}px` } as React.CSSProperties} className="group/slot relative grid h-5 w-(--s) shrink-0 place-items-center">
|
|
424
|
+
<span className={cn("tabular w-full text-right", selectable && (checked || selecting ? "invisible" : "group-hover/row:invisible pointer-coarse:invisible"))}>{n}</span>
|
|
425
|
+
{selectable && (
|
|
426
|
+
<span className={cn("absolute inset-0 grid place-items-center", !(checked || selecting) && "opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100")}>
|
|
427
|
+
<Checkbox tabIndex={-1} aria-label={`Select ${name}`} checked={checked} onCheckedChange={() => api.toggleRow(row)} />
|
|
428
|
+
</span>
|
|
429
|
+
)}
|
|
430
|
+
</span>
|
|
431
|
+
{hasRowMenu && <span className={cn("flex", !menuOpen && "opacity-0 group-hover/row:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100")}>{api.rowMenu(row)}</span>}
|
|
432
|
+
</span>
|
|
433
|
+
</th>
|
|
434
|
+
{visible.map((c, ci) => {
|
|
435
|
+
const here = activeC === ci
|
|
436
|
+
const isEditing = here && editing !== null
|
|
437
|
+
const failed = row.computed?.[c.id]?.state === "failed"
|
|
438
|
+
const selected = selC1 >= 0 && ci >= selC1 && ci <= selC2
|
|
439
|
+
const why = rejected[`${row.id}:${c.id}`]
|
|
440
|
+
const openHere = openColId === c.id && !isEditing
|
|
441
|
+
const pinned = c.pinned
|
|
442
|
+
return (
|
|
443
|
+
<td
|
|
444
|
+
key={c.id}
|
|
445
|
+
role="gridcell"
|
|
446
|
+
data-cell={`${r}:${ci}`}
|
|
447
|
+
tabIndex={here && !isEditing ? 0 : -1}
|
|
448
|
+
aria-selected={selected || here}
|
|
449
|
+
aria-readonly={isComputed(c) || !!c.notApplicable?.(row) || undefined}
|
|
450
|
+
aria-invalid={failed || !!why || undefined}
|
|
451
|
+
title={why ? `Not pasted: ${why}` : undefined}
|
|
452
|
+
onMouseDown={(e) => api.mouseDown(e, r, ci, isEditing)}
|
|
453
|
+
onClick={(e) => api.click(e)}
|
|
454
|
+
onMouseEnter={(e) => api.mouseEnter(e, r, ci)}
|
|
455
|
+
onDoubleClick={() => api.doubleClick()}
|
|
456
|
+
onContextMenu={(e) => api.contextMenu(e, r, ci, row)}
|
|
457
|
+
style={pinned ? ({ "--l": `${lefts[ci]}px` } as React.CSSProperties) : undefined}
|
|
458
|
+
className={cn(
|
|
459
|
+
"group/cell scroll-mt-(--hh) scroll-mb-(--fh) scroll-ml-(--pl) border-b border-line",
|
|
460
|
+
cellCls,
|
|
461
|
+
c.wrap ? "py-2 align-top" : "whitespace-nowrap",
|
|
462
|
+
!isEditing && "select-none",
|
|
463
|
+
// The row being worked on is tinted; under the pointer, the row takes --color-hover and the cell --color-selected.
|
|
464
|
+
// The active cell stays on the surface with a 2px primary outline, so it reads at a glance.
|
|
465
|
+
selected ? "bg-primary-soft" : here ? "bg-surface" : activeC >= 0 ? "bg-active-row" : "bg-surface group-hover/row:bg-hover",
|
|
466
|
+
pinned && cn("sticky left-(--l) z-1", !visible[ci + 1]?.pinned && "border-r"),
|
|
467
|
+
c.type === "number" && "tabular text-right",
|
|
468
|
+
!isEditing && !selected && !here && "hover:bg-selected hover:shadow-[inset_0_0_0_1px_var(--color-line-strong)]",
|
|
469
|
+
why && !here && "outline-1 -outline-offset-1 outline-dashed outline-bad",
|
|
470
|
+
here ? "outline-2 -outline-offset-2 outline-solid" : "outline-none",
|
|
471
|
+
here && (isEditing && error ? "outline-bad" : "outline-primary")
|
|
472
|
+
)}
|
|
473
|
+
>
|
|
474
|
+
{isEditing ? api.editor(c, row) : openHere ? (
|
|
475
|
+
<span className="flex min-w-0 items-center gap-control">
|
|
476
|
+
<span className="min-w-0 flex-1">{api.renderValue(c, row)}</span>
|
|
477
|
+
<Button
|
|
478
|
+
variant="outline"
|
|
479
|
+
size="xs"
|
|
480
|
+
tabIndex={-1}
|
|
481
|
+
aria-label={`Open ${name}`}
|
|
482
|
+
onMouseDown={(e) => e.stopPropagation()}
|
|
483
|
+
onDoubleClick={(e) => e.stopPropagation()}
|
|
484
|
+
onClick={() => api.openRow(row.id)}
|
|
485
|
+
className="h-5 opacity-0 transition-opacity duration-(--duration-fast) group-hover/row:opacity-100 group-focus-within/row:opacity-100 pointer-coarse:opacity-100"
|
|
486
|
+
>
|
|
487
|
+
Open
|
|
488
|
+
</Button>
|
|
489
|
+
</span>
|
|
490
|
+
) : api.renderValue(c, row)}
|
|
491
|
+
{why && <span className="sr-only">Not pasted: {why}</span>}
|
|
492
|
+
</td>
|
|
493
|
+
)
|
|
494
|
+
})}
|
|
495
|
+
</tr>
|
|
496
|
+
)
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
/* ---------------------------------------------------------------- sheet grid */
|
|
500
|
+
|
|
501
|
+
export function SheetGrid({
|
|
502
|
+
columns,
|
|
503
|
+
onColumnsChange,
|
|
504
|
+
rows,
|
|
505
|
+
onCellChange,
|
|
506
|
+
onCellsChange,
|
|
507
|
+
sort = null,
|
|
508
|
+
onSortChange,
|
|
509
|
+
onFilterColumn,
|
|
510
|
+
onInsertColumn,
|
|
511
|
+
onEditColumn,
|
|
512
|
+
onRetryCell,
|
|
513
|
+
onRetryColumn,
|
|
514
|
+
onAddRows,
|
|
515
|
+
size = "default",
|
|
516
|
+
groupBy,
|
|
517
|
+
filterRow = false,
|
|
518
|
+
selectedRowIds,
|
|
519
|
+
onSelectedRowIdsChange,
|
|
520
|
+
rowActions,
|
|
521
|
+
onOpenRow,
|
|
522
|
+
"aria-label": label,
|
|
523
|
+
className,
|
|
524
|
+
}: {
|
|
525
|
+
columns: SheetColumn[]
|
|
526
|
+
/** Rename, type, wrap, freeze, hide, insert and delete all come back through here. Delete shows Undo. */
|
|
527
|
+
onColumnsChange: (columns: SheetColumn[]) => void
|
|
528
|
+
rows: SheetRow[]
|
|
529
|
+
/** Called once per cell. Use a functional state update: a paste or fill calls it for every cell it writes. */
|
|
530
|
+
onCellChange: (rowId: string, columnId: string, value: string | number | boolean) => void
|
|
531
|
+
/** Optional: receive a paste, fill, clear, undo or redo as one batch instead of one onCellChange per cell. */
|
|
532
|
+
onCellsChange?: (changes: SheetCellChange[]) => void
|
|
533
|
+
sort?: SheetSort
|
|
534
|
+
onSortChange?: (sort: SheetSort) => void
|
|
535
|
+
/** Adds a filter pill for the column to the page's FilterBar. */
|
|
536
|
+
onFilterColumn?: (columnId: string) => void
|
|
537
|
+
/** Insert a column at this index of `columns`. Without it, a text column is inserted and renamed in place. */
|
|
538
|
+
onInsertColumn?: (index: number) => void
|
|
539
|
+
/** Open the set-up of a computed column (prompt, inputs, provider). */
|
|
540
|
+
onEditColumn?: (columnId: string) => void
|
|
541
|
+
/** Run one failed cell again. Enter on a failed cell calls it; the status line states the cost. */
|
|
542
|
+
onRetryCell?: (rowId: string, columnId: string) => void
|
|
543
|
+
/** Run every failed cell in the column again. */
|
|
544
|
+
onRetryColumn?: (columnId: string) => void
|
|
545
|
+
onAddRows?: (count: number) => void
|
|
546
|
+
/** dense: 28px rows, only for spreadsheet-like data (inventories, ledgers, imports, mapping). Never a user preference. */
|
|
547
|
+
size?: "default" | "dense"
|
|
548
|
+
/** Group rows by this column's value, with a collapsible group row that shows the count and each total column's sum. */
|
|
549
|
+
groupBy?: string
|
|
550
|
+
/** A search field under each header that filters the rows by that column. */
|
|
551
|
+
filterRow?: boolean
|
|
552
|
+
/** Adds a checkbox to each row number. Shift+Space selects the rows of the selected cells. Pair with BulkActionBar. */
|
|
553
|
+
selectedRowIds?: string[]
|
|
554
|
+
onSelectedRowIdsChange?: (ids: string[]) => void
|
|
555
|
+
/** Items for the row's ⋮ menu (DropdownMenuItem). Opens from the row number, a right click, or ⇧F10. */
|
|
556
|
+
rowActions?: (row: SheetRow) => React.ReactNode
|
|
557
|
+
/** Adds Open to the row menu and an Open button in the name cell, shown on hover and focus. */
|
|
558
|
+
onOpenRow?: (rowId: string) => void
|
|
559
|
+
"aria-label": string
|
|
560
|
+
className?: string
|
|
561
|
+
}) {
|
|
562
|
+
const dense = size === "dense"
|
|
563
|
+
const isMac = React.useMemo(detectMac, [])
|
|
564
|
+
const M = isMac ? "⌘" : "Ctrl+"
|
|
565
|
+
const mod = (e: { metaKey: boolean; ctrlKey: boolean }) => (isMac ? e.metaKey : e.ctrlKey)
|
|
566
|
+
|
|
567
|
+
const visible = React.useMemo(() => {
|
|
568
|
+
const v = columns.filter((c) => !c.hidden)
|
|
569
|
+
return [...v.filter((c) => c.pinned), ...v.filter((c) => !c.pinned)]
|
|
570
|
+
}, [columns])
|
|
571
|
+
|
|
572
|
+
const [filters, setFilters] = React.useState<Record<string, string>>({})
|
|
573
|
+
const [collapsed, setCollapsed] = React.useState<Set<string>>(() => new Set())
|
|
574
|
+
|
|
575
|
+
const filteredRows = React.useMemo(() => {
|
|
576
|
+
const active = Object.entries(filters).filter(([, q]) => q.trim())
|
|
577
|
+
if (!active.length) return rows
|
|
578
|
+
return rows.filter((r) => active.every(([id, q]) => {
|
|
579
|
+
const col = columns.find((c) => c.id === id)
|
|
580
|
+
return !col || copyText(col, r).toLowerCase().includes(q.trim().toLowerCase())
|
|
581
|
+
}))
|
|
582
|
+
}, [rows, filters, columns])
|
|
583
|
+
|
|
584
|
+
const shownRows = React.useMemo(() => {
|
|
585
|
+
if (!sort) return filteredRows
|
|
586
|
+
const col = columns.find((c) => c.id === sort.columnId)
|
|
587
|
+
if (!col) return filteredRows
|
|
588
|
+
const dir = sort.direction === "asc" ? 1 : -1
|
|
589
|
+
return [...filteredRows].sort((a, b) => {
|
|
590
|
+
const x = valueOf(col, a), y = valueOf(col, b)
|
|
591
|
+
const ex = isBlank(x), ey = isBlank(y)
|
|
592
|
+
if (ex || ey) return ex === ey ? 0 : ex ? 1 : -1
|
|
593
|
+
if (col.type === "number") return (Number(x) - Number(y)) * dir
|
|
594
|
+
return String(x).localeCompare(String(y), "en-GB", { numeric: true }) * dir
|
|
595
|
+
})
|
|
596
|
+
}, [filteredRows, sort, columns])
|
|
597
|
+
|
|
598
|
+
const lines = React.useMemo<Line[]>(() => {
|
|
599
|
+
const gcol = groupBy ? columns.find((c) => c.id === groupBy) : undefined
|
|
600
|
+
if (!gcol) return shownRows.map((row, i) => ({ kind: "row" as const, row, n: i + 1 }))
|
|
601
|
+
const groups = new Map<string, SheetRow[]>()
|
|
602
|
+
for (const o of gcol.options ?? []) groups.set(o, [])
|
|
603
|
+
for (const row of shownRows) {
|
|
604
|
+
const k = copyText(gcol, row) || "(Empty)"
|
|
605
|
+
if (!groups.has(k)) groups.set(k, [])
|
|
606
|
+
groups.get(k)!.push(row)
|
|
607
|
+
}
|
|
608
|
+
// Rows are numbered in the order they show, counting collapsed groups too, so numbers don't shift on collapse.
|
|
609
|
+
const out: Line[] = []
|
|
610
|
+
let n = 0
|
|
611
|
+
for (const [key, rs] of groups) {
|
|
612
|
+
if (!rs.length) continue
|
|
613
|
+
const open = !collapsed.has(key)
|
|
614
|
+
out.push({ kind: "group", key, label: key, rows: rs, open })
|
|
615
|
+
for (const row of rs) { n++; if (open) out.push({ kind: "row", row, n }) }
|
|
616
|
+
}
|
|
617
|
+
return out
|
|
618
|
+
}, [shownRows, groupBy, columns, collapsed])
|
|
619
|
+
|
|
620
|
+
const rowAt = (r: number) => { const l = lines[r]; return l?.kind === "row" ? l.row : null }
|
|
621
|
+
const dataLines = (R: Rect) => {
|
|
622
|
+
const out: { r: number; row: SheetRow; n: number }[] = []
|
|
623
|
+
for (let r = Math.max(0, R.r1); r <= R.r2; r++) { const l = lines[r]; if (l?.kind === "row") out.push({ r, row: l.row, n: l.n }) }
|
|
624
|
+
return out
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const top = filterRow ? -2 : -1
|
|
628
|
+
const [pos, setPos] = React.useState<Cell>({ r: 0, c: 0 })
|
|
629
|
+
const [ext, setExt] = React.useState<Cell>({ r: 0, c: 0 })
|
|
630
|
+
const [selAll, setSelAll] = React.useState(false)
|
|
631
|
+
const [editing, setEditing] = React.useState<string | null>(null)
|
|
632
|
+
const [error, setError] = React.useState("")
|
|
633
|
+
const [status, setStatusText] = React.useState("")
|
|
634
|
+
const [addSaid, setAddSaid] = React.useState("")
|
|
635
|
+
const [tone, setTone] = React.useState<"plain" | "bad">("plain")
|
|
636
|
+
const [rejected, setRejected] = React.useState<Record<string, string>>({})
|
|
637
|
+
const [menuFor, setMenuFor] = React.useState<string | null>(null)
|
|
638
|
+
const [rowMenu, setRowMenu] = React.useState<string | null>(null)
|
|
639
|
+
const [renaming, setRenaming] = React.useState<string | null>(null)
|
|
640
|
+
const renameNext = React.useRef(false)
|
|
641
|
+
const grid = React.useRef<HTMLTableElement>(null)
|
|
642
|
+
const dragging = React.useRef(false)
|
|
643
|
+
const history = React.useRef<{ undo: Batch[]; redo: Batch[] }>({ undo: [], redo: [] })
|
|
644
|
+
const clip = React.useRef<{ kind: "copy" | "cut" | "paste"; handled: boolean } | null>(null)
|
|
645
|
+
const clickOpens = React.useRef(false)
|
|
646
|
+
/** Row count before Add row, so the grid can move to the first new row once it arrives. */
|
|
647
|
+
const adding = React.useRef<number | null>(null)
|
|
648
|
+
|
|
649
|
+
const say = (msg: string, t: "plain" | "bad" = "plain") => { setStatusText(msg); setTone(t) }
|
|
650
|
+
const setStatus = (msg: string) => say(msg)
|
|
651
|
+
|
|
652
|
+
const selection = new Set(selectedRowIds ?? [])
|
|
653
|
+
const selectable = !!onSelectedRowIdsChange
|
|
654
|
+
const hasRowMenu = !!rowActions || !!onOpenRow
|
|
655
|
+
const hasStatusRow = visible.some((c) => isComputed(c) && c.status)
|
|
656
|
+
const hasTotals = visible.some((c) => c.total)
|
|
657
|
+
const H = dense ? 28 : 36
|
|
658
|
+
// The row header is one slot (the number, or the checkbox in its place) plus the ⋮ menu: about 47px for 150 rows.
|
|
659
|
+
const SLOT = Math.max(16, String(Math.max(1, rows.length)).length * 7 + 2)
|
|
660
|
+
const ROWHEAD = 8 + SLOT + (hasRowMenu ? 16 : 0)
|
|
661
|
+
// Live width while a column edge is dragged; saved through onColumnsChange on release.
|
|
662
|
+
const [drag, setDrag] = React.useState<{ id: string; w: number } | null>(null)
|
|
663
|
+
const wOf = (c: SheetColumn) => (drag?.id === c.id ? drag.w : c.width)
|
|
664
|
+
// Column status (the run's progress) changes every tick but never shows in body cells, so it doesn't re-render rows.
|
|
665
|
+
// Frozen columns never take more than 60% of the grid's width, so a narrow screen can still scroll sideways.
|
|
666
|
+
const boxRef = React.useRef<HTMLDivElement>(null)
|
|
667
|
+
const [boxW, setBoxW] = React.useState(0)
|
|
668
|
+
React.useEffect(() => {
|
|
669
|
+
const el = boxRef.current
|
|
670
|
+
if (!el || typeof ResizeObserver === "undefined") return
|
|
671
|
+
const ro = new ResizeObserver(([e]) => setBoxW(Math.round(e.contentRect.width)))
|
|
672
|
+
ro.observe(el)
|
|
673
|
+
return () => ro.disconnect()
|
|
674
|
+
}, [])
|
|
675
|
+
const frozen = (() => {
|
|
676
|
+
const ids: string[] = []
|
|
677
|
+
let w = ROWHEAD
|
|
678
|
+
for (const c of visible) {
|
|
679
|
+
if (!c.pinned || (boxW && w + c.width > boxW * 0.6)) break
|
|
680
|
+
ids.push(c.id)
|
|
681
|
+
w += c.width
|
|
682
|
+
}
|
|
683
|
+
return ids.join("|")
|
|
684
|
+
})()
|
|
685
|
+
// Column status (the run's progress) changes every tick but never shows in body cells, so it doesn't re-render rows.
|
|
686
|
+
const bodySig = frozen + "#" + visible.map((c) => [c.id, c.label, c.type, c.width, c.wrap, c.currency, c.onLabel, c.offLabel, c.options?.join("/"), c.validate, c.render, c.notApplicable].map(String).join("~")).join("|")
|
|
687
|
+
const bodyCols = React.useMemo(() => {
|
|
688
|
+
const ids = new Set(frozen.split("|"))
|
|
689
|
+
return visible.map((c) => (!!c.pinned === ids.has(c.id) ? c : { ...c, pinned: ids.has(c.id) }))
|
|
690
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
691
|
+
}, [bodySig])
|
|
692
|
+
// Only a pinned column's drag moves the columns after it, so dragging an ordinary edge doesn't re-render the rows.
|
|
693
|
+
const pinDrag = drag && bodyCols.find((c) => c.id === drag.id)?.pinned ? drag : null
|
|
694
|
+
const lefts = React.useMemo(() => bodyCols.reduce<number[]>((acc, c, i) => {
|
|
695
|
+
const p = bodyCols[i - 1]
|
|
696
|
+
return [...acc, i === 0 ? ROWHEAD : acc[i - 1] + (p.pinned ? (pinDrag?.id === p.id ? pinDrag.w : p.width) : 0)]
|
|
697
|
+
}, []), [bodyCols, ROWHEAD, pinDrag])
|
|
698
|
+
const pinnedWidth = ROWHEAD + bodyCols.filter((c) => c.pinned).reduce((n, c) => n + wOf(c), 0)
|
|
699
|
+
const tableWidth = ROWHEAD + visible.reduce((n, c) => n + wOf(c), 0)
|
|
700
|
+
const headHeight = H + (filterRow ? H : 0) + (hasStatusRow ? 28 : 0)
|
|
701
|
+
const nameCol = columns.find((c) => c.locked) ?? visible[0]
|
|
702
|
+
const rowName = (r: SheetRow) => String(r.values[nameCol?.id ?? ""] ?? `row ${rows.indexOf(r) + 1}`)
|
|
703
|
+
const cellCls = dense ? "h-7 px-2" : "h-9 px-3"
|
|
704
|
+
|
|
705
|
+
const range: Rect | null = pos.r < 0 || !lines.length ? null : selAll ? { r1: 0, c1: 0, r2: lines.length - 1, c2: visible.length - 1 } : rectOf(pos, ext)
|
|
706
|
+
const inRange = (r: number, c: number) => !!range && r >= range.r1 && r <= range.r2 && c >= range.c1 && c <= range.c2
|
|
707
|
+
const rangeCells = range ? (range.r2 - range.r1 + 1) * (range.c2 - range.c1 + 1) : 0
|
|
708
|
+
|
|
709
|
+
const cellEl = (r: number, c: number) => grid.current?.querySelector<HTMLElement>(`[data-cell="${r}:${c}"]`)
|
|
710
|
+
const focusCell = React.useCallback(() => cellEl(pos.r, pos.c)?.focus(), [pos])
|
|
711
|
+
// Focus follows the active cell while the person is working in the grid, including after an edit or a menu closes.
|
|
712
|
+
const engaged = React.useRef(false)
|
|
713
|
+
React.useEffect(() => { if (editing === null && renaming === null && menuFor === null && rowMenu === null && engaged.current) focusCell() }, [pos, editing, renaming, menuFor, rowMenu, focusCell])
|
|
714
|
+
React.useEffect(() => { if (ext.r !== pos.r || ext.c !== pos.c) cellEl(ext.r, ext.c)?.scrollIntoView({ block: "nearest", inline: "nearest" }) }, [ext, pos])
|
|
715
|
+
React.useEffect(() => {
|
|
716
|
+
const clamp = (p: Cell) => ({ r: Math.max(top, Math.min(p.r, lines.length - 1)), c: Math.max(0, Math.min(p.c, visible.length - 1)) })
|
|
717
|
+
setPos(clamp)
|
|
718
|
+
setExt(clamp)
|
|
719
|
+
}, [lines.length, visible.length, top])
|
|
720
|
+
// After Add row: scroll to the bottom, where the rows went, and make the first new row's first cell active.
|
|
721
|
+
React.useEffect(() => {
|
|
722
|
+
const before = adding.current
|
|
723
|
+
if (before === null || lines.length <= before) return
|
|
724
|
+
adding.current = null
|
|
725
|
+
engaged.current = true
|
|
726
|
+
const box = boxRef.current
|
|
727
|
+
if (box) box.scrollTop = box.scrollHeight
|
|
728
|
+
const p = { r: before, c: 0 }
|
|
729
|
+
setPos(p); setExt(p); setSelAll(false)
|
|
730
|
+
}, [lines.length])
|
|
731
|
+
React.useEffect(() => {
|
|
732
|
+
const up = () => { dragging.current = false }
|
|
733
|
+
document.addEventListener("mouseup", up)
|
|
734
|
+
return () => document.removeEventListener("mouseup", up)
|
|
735
|
+
}, [])
|
|
736
|
+
|
|
737
|
+
const keyOf = (row: SheetRow, col: SheetColumn) => `${row.id}:${col.id}`
|
|
738
|
+
const clampBody = (p: Cell) => ({ r: Math.max(0, Math.min(lines.length - 1, p.r)), c: Math.max(0, Math.min(visible.length - 1, p.c)) })
|
|
739
|
+
const announceCell = (p: Cell) => {
|
|
740
|
+
const row = rowAt(p.r), col = visible[p.c]
|
|
741
|
+
const why = row && col && rejected[keyOf(row, col)]
|
|
742
|
+
if (why) say(`Not pasted into ${col.label} for ${rowName(row)}: ${why}`, "bad")
|
|
743
|
+
}
|
|
744
|
+
/** Header and filter rows included. */
|
|
745
|
+
const move = (r: number, c: number) => {
|
|
746
|
+
const p = { r: Math.max(top, Math.min(lines.length - 1, r)), c: Math.max(0, Math.min(visible.length - 1, c)) }
|
|
747
|
+
setPos(p); setExt(p); setSelAll(false); announceCell(p)
|
|
748
|
+
}
|
|
749
|
+
const go = (to: Cell) => { const p = clampBody(to); setPos(p); setExt(p); setSelAll(false); announceCell(p) }
|
|
750
|
+
const describe = (R: Rect) => {
|
|
751
|
+
const ls = dataLines(R)
|
|
752
|
+
if (!ls.length) return "No cells selected."
|
|
753
|
+
const nC = R.c2 - R.c1 + 1
|
|
754
|
+
const cols = nC > 1 ? `${visible[R.c1].label} to ${visible[R.c2].label}` : visible[R.c1].label
|
|
755
|
+
const rws = ls.length > 1 ? `rows ${ls[0].n} to ${ls[ls.length - 1].n}` : `row ${ls[0].n}`
|
|
756
|
+
return `${plural(ls.length * nC, "cell")} selected: ${cols}, ${rws}.`
|
|
757
|
+
}
|
|
758
|
+
const extendTo = (to: Cell) => {
|
|
759
|
+
const p = clampBody(to)
|
|
760
|
+
setExt(p); setSelAll(false)
|
|
761
|
+
say(describe(rectOf(pos, p)))
|
|
762
|
+
}
|
|
763
|
+
const edge = (from: Cell, dr: number, dc: number): Cell => {
|
|
764
|
+
const inb = (r: number, c: number) => r >= 0 && r < lines.length && c >= 0 && c < visible.length
|
|
765
|
+
const empty = (r: number, c: number) => { const row = rowAt(r); return !row || isBlank(valueOf(visible[c], row)) }
|
|
766
|
+
let { r, c } = from
|
|
767
|
+
if (!inb(r + dr, c + dc)) return from
|
|
768
|
+
if (!empty(r, c) && !empty(r + dr, c + dc)) { while (inb(r + dr, c + dc) && !empty(r + dr, c + dc)) { r += dr; c += dc } }
|
|
769
|
+
else { r += dr; c += dc; while (inb(r + dr, c + dc) && empty(r, c)) { r += dr; c += dc } }
|
|
770
|
+
return { r, c }
|
|
771
|
+
}
|
|
772
|
+
const nextData = (r: number, d: 1 | -1) => { for (let i = r + d; i >= 0 && i < lines.length; i += d) if (lines[i].kind === "row") return i; return r }
|
|
773
|
+
|
|
774
|
+
/* ----- writing cells, with undo */
|
|
775
|
+
const write = (changes: SheetCellChange[]) => {
|
|
776
|
+
if (!changes.length) return
|
|
777
|
+
if (onCellsChange) onCellsChange(changes)
|
|
778
|
+
else for (const ch of changes) onCellChange(ch.rowId, ch.columnId, (ch.value ?? "") as string | number | boolean)
|
|
779
|
+
}
|
|
780
|
+
const apply = (lbl: string, changes: CellChange[]) => {
|
|
781
|
+
const real = changes.filter((c) => c.before !== c.after && !(isBlank(c.before) && isBlank(c.after)))
|
|
782
|
+
if (!real.length) return 0
|
|
783
|
+
write(real.map((c) => ({ rowId: c.rowId, columnId: c.columnId, value: c.after })))
|
|
784
|
+
const h = history.current
|
|
785
|
+
h.undo.push({ label: lbl, changes: real })
|
|
786
|
+
if (h.undo.length > 100) h.undo.shift()
|
|
787
|
+
h.redo = []
|
|
788
|
+
setRejected((prev) => {
|
|
789
|
+
const gone = real.filter((c) => `${c.rowId}:${c.columnId}` in prev)
|
|
790
|
+
if (!gone.length) return prev
|
|
791
|
+
const next = { ...prev }
|
|
792
|
+
for (const c of gone) delete next[`${c.rowId}:${c.columnId}`]
|
|
793
|
+
return next
|
|
794
|
+
})
|
|
795
|
+
return real.length
|
|
796
|
+
}
|
|
797
|
+
const undo = () => {
|
|
798
|
+
const b = history.current.undo.pop()
|
|
799
|
+
if (!b) return say("Nothing to undo.")
|
|
800
|
+
write(b.changes.map((c) => ({ rowId: c.rowId, columnId: c.columnId, value: c.before })))
|
|
801
|
+
history.current.redo.push(b)
|
|
802
|
+
setRejected({})
|
|
803
|
+
say(`Undid ${b.label}${b.changes.length > 1 ? ` of ${plural(b.changes.length, "cell")}` : ""}.`)
|
|
804
|
+
}
|
|
805
|
+
const redo = () => {
|
|
806
|
+
const b = history.current.redo.pop()
|
|
807
|
+
if (!b) return say("Nothing to redo.")
|
|
808
|
+
write(b.changes.map((c) => ({ rowId: c.rowId, columnId: c.columnId, value: c.after })))
|
|
809
|
+
history.current.undo.push(b)
|
|
810
|
+
say(`Redid ${b.label}${b.changes.length > 1 ? ` of ${plural(b.changes.length, "cell")}` : ""}.`)
|
|
811
|
+
}
|
|
812
|
+
const report = (done: string, n: number, rejects: Reject[], extra = "") => {
|
|
813
|
+
setRejected(Object.fromEntries(rejects.map((x) => [x.key, x.reason])))
|
|
814
|
+
let msg = `${done} ${plural(n, "cell")}.`
|
|
815
|
+
if (rejects.length) msg += ` ${plural(rejects.length, "cell")} left as they were. ${rejects[0].where}: ${rejects[0].reason}${rejects.length > 1 ? " Each one is outlined in red; move to it to read why." : ""}`
|
|
816
|
+
msg += extra
|
|
817
|
+
say(msg, rejects.length || extra ? "bad" : "plain")
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const tsvOfRange = () => {
|
|
821
|
+
if (!range) return ""
|
|
822
|
+
return toTsv(dataLines(range).map(({ row }) => visible.slice(range.c1, range.c2 + 1).map((col) => copyText(col, row))))
|
|
823
|
+
}
|
|
824
|
+
const clearRange = (lbl = "clear") => {
|
|
825
|
+
if (!range) return 0
|
|
826
|
+
const changes: CellChange[] = []
|
|
827
|
+
let kept = 0
|
|
828
|
+
for (const { row } of dataLines(range)) for (const col of visible.slice(range.c1, range.c2 + 1)) {
|
|
829
|
+
if (isComputed(col) || col.notApplicable?.(row)) { kept++; continue }
|
|
830
|
+
changes.push({ rowId: row.id, columnId: col.id, before: valueOf(col, row), after: col.type === "boolean" ? false : "" })
|
|
831
|
+
}
|
|
832
|
+
const n = apply(lbl, changes)
|
|
833
|
+
if (lbl === "clear") {
|
|
834
|
+
const row = rowAt(pos.r), col = visible[pos.c]
|
|
835
|
+
if (rangeCells === 1 && row && col) say(n ? `Cleared ${col.label} for ${rowName(row)}.` : kept ? `${col.label} is ${computedHow(col)}, so it can't be cleared.` : "Nothing to clear.")
|
|
836
|
+
else say(`Cleared ${plural(n, "cell")}.${kept ? ` ${plural(kept, "calculated or not applicable cell")} kept.` : ""}`)
|
|
837
|
+
}
|
|
838
|
+
return n
|
|
839
|
+
}
|
|
840
|
+
const afterCopy = (kind: "copy" | "cut") => {
|
|
841
|
+
if (!range) return
|
|
842
|
+
const ls = dataLines(range), nC = range.c2 - range.c1 + 1
|
|
843
|
+
if (kind === "cut") { const n = clearRange("cut"); say(`Cut ${plural(n, "cell")}. Paste them where you want them.`) }
|
|
844
|
+
else say(`Copied ${plural(ls.length * nC, "cell")}. Paste here, or into Excel or Google Sheets.`)
|
|
845
|
+
}
|
|
846
|
+
const pasteText = (text: string) => {
|
|
847
|
+
const R = range
|
|
848
|
+
if (!R) return
|
|
849
|
+
const data = parseTsv(text)
|
|
850
|
+
const h = data.length, w = Math.max(...data.map((r) => r.length))
|
|
851
|
+
const targets: { r: number; row: SheetRow; n: number }[] = []
|
|
852
|
+
for (let r = R.r1; r < lines.length; r++) { const l = lines[r]; if (l.kind === "row") targets.push({ r, row: l.row, n: l.n }) }
|
|
853
|
+
const selRows = dataLines(R).length, selCols = R.c2 - R.c1 + 1
|
|
854
|
+
let outH = h, outW = w
|
|
855
|
+
// One value into a bigger selection fills it; a block that divides the selection evenly repeats, as in Excel.
|
|
856
|
+
if ((h === 1 && w === 1) || (selRows % h === 0 && selCols % w === 0)) { outH = Math.max(h, selRows); outW = Math.max(w, selCols) }
|
|
857
|
+
const changes: CellChange[] = []
|
|
858
|
+
const rejects: Reject[] = []
|
|
859
|
+
let lost = 0
|
|
860
|
+
for (let i = 0; i < outH; i++) {
|
|
861
|
+
for (let j = 0; j < outW; j++) {
|
|
862
|
+
const t = targets[i], c = R.c1 + j
|
|
863
|
+
if (!t || c >= visible.length) { lost++; continue }
|
|
864
|
+
const col = visible[c]
|
|
865
|
+
const res = coerce(col, data[i % h]?.[j % w] ?? "", t.row)
|
|
866
|
+
if ("reason" in res) rejects.push({ key: keyOf(t.row, col), where: `${col.label}, row ${t.n}`, reason: res.reason })
|
|
867
|
+
else changes.push({ rowId: t.row.id, columnId: col.id, before: valueOf(col, t.row), after: res.value })
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
apply("paste", changes)
|
|
871
|
+
if (targets.length) {
|
|
872
|
+
const last = targets[Math.min(outH, targets.length) - 1]
|
|
873
|
+
setPos({ r: targets[0].r, c: R.c1 }); setExt({ r: last.r, c: Math.min(visible.length - 1, R.c1 + outW - 1) }); setSelAll(false)
|
|
874
|
+
}
|
|
875
|
+
report("Pasted", changes.length, rejects, lost ? ` ${plural(lost, "value")} didn't fit: the sheet ends at row ${targets[targets.length - 1]?.n ?? 0}, column ${visible[visible.length - 1]?.label}. Add rows, then paste again.` : "")
|
|
876
|
+
}
|
|
877
|
+
const fill = (dir: "down" | "right") => {
|
|
878
|
+
const R = range
|
|
879
|
+
if (!R) return
|
|
880
|
+
const ls = dataLines(R)
|
|
881
|
+
const changes: CellChange[] = []
|
|
882
|
+
const rejects: Reject[] = []
|
|
883
|
+
let kept = 0
|
|
884
|
+
const put = (col: SheetColumn, row: SheetRow, n: number, text: string) => {
|
|
885
|
+
if (isComputed(col)) { kept++; return }
|
|
886
|
+
const res = coerce(col, text, row)
|
|
887
|
+
if ("reason" in res) rejects.push({ key: keyOf(row, col), where: `${col.label}, row ${n}`, reason: res.reason })
|
|
888
|
+
else changes.push({ rowId: row.id, columnId: col.id, before: valueOf(col, row), after: res.value })
|
|
889
|
+
}
|
|
890
|
+
if (dir === "down") {
|
|
891
|
+
let src: SheetRow | null = null
|
|
892
|
+
let targets = ls
|
|
893
|
+
if (ls.length <= 1) { const above = nextData(R.r1, -1); src = above !== R.r1 ? rowAt(above) : null }
|
|
894
|
+
else { src = ls[0].row; targets = ls.slice(1) }
|
|
895
|
+
if (!src) return say("Nothing above to fill down from.")
|
|
896
|
+
for (let c = R.c1; c <= R.c2; c++) for (const t of targets) put(visible[c], t.row, t.n, copyText(visible[c], src))
|
|
897
|
+
} else {
|
|
898
|
+
const from = R.c1 === R.c2 ? R.c1 - 1 : R.c1
|
|
899
|
+
if (from < 0) return say("Nothing to the left to fill right from.")
|
|
900
|
+
for (const t of ls) for (let c = R.c1 === R.c2 ? R.c1 : R.c1 + 1; c <= R.c2; c++) put(visible[c], t.row, t.n, copyText(visible[from], t.row))
|
|
901
|
+
}
|
|
902
|
+
apply(dir === "down" ? "fill down" : "fill right", changes)
|
|
903
|
+
report(dir === "down" ? "Filled down" : "Filled right", changes.length, rejects, kept ? ` ${plural(kept, "calculated cell")} kept.` : "")
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Copy, cut and paste use the browser's own clipboard events, so they work with the keyboard and the Edit menu.
|
|
907
|
+
const clipHandlers = React.useRef({ copy: (_e: ClipboardEvent, _k: "copy" | "cut") => {}, paste: (_e: ClipboardEvent) => {} })
|
|
908
|
+
clipHandlers.current = {
|
|
909
|
+
copy: (e, kind) => {
|
|
910
|
+
if (!range || editing !== null) return
|
|
911
|
+
e.preventDefault()
|
|
912
|
+
e.clipboardData?.setData("text/plain", tsvOfRange())
|
|
913
|
+
if (clip.current) clip.current.handled = true
|
|
914
|
+
afterCopy(kind)
|
|
915
|
+
},
|
|
916
|
+
paste: (e) => {
|
|
917
|
+
if (!range || editing !== null) return
|
|
918
|
+
e.preventDefault()
|
|
919
|
+
if (clip.current) clip.current.handled = true
|
|
920
|
+
pasteText(e.clipboardData?.getData("text/plain") ?? "")
|
|
921
|
+
},
|
|
922
|
+
}
|
|
923
|
+
React.useEffect(() => {
|
|
924
|
+
const inGrid = () => { const a = document.activeElement; return !!a && !!grid.current?.contains(a) && !a.closest("input, textarea, select") }
|
|
925
|
+
const onCopy = (e: ClipboardEvent) => { if (inGrid()) clipHandlers.current.copy(e, "copy") }
|
|
926
|
+
const onCut = (e: ClipboardEvent) => { if (inGrid()) clipHandlers.current.copy(e, "cut") }
|
|
927
|
+
const onPaste = (e: ClipboardEvent) => { if (inGrid()) clipHandlers.current.paste(e) }
|
|
928
|
+
document.addEventListener("copy", onCopy)
|
|
929
|
+
document.addEventListener("cut", onCut)
|
|
930
|
+
document.addEventListener("paste", onPaste)
|
|
931
|
+
return () => { document.removeEventListener("copy", onCopy); document.removeEventListener("cut", onCut); document.removeEventListener("paste", onPaste) }
|
|
932
|
+
}, [])
|
|
933
|
+
/** If the browser doesn't raise its own clipboard event for the shortcut, use the Clipboard API instead. */
|
|
934
|
+
const clipboardFallback = (kind: "copy" | "cut" | "paste") => {
|
|
935
|
+
clip.current = { kind, handled: false }
|
|
936
|
+
window.setTimeout(() => {
|
|
937
|
+
const c = clip.current
|
|
938
|
+
clip.current = null
|
|
939
|
+
if (!c || c.handled) return
|
|
940
|
+
if (kind === "paste") navigator.clipboard?.readText().then(pasteText).catch(() => say(`Couldn't read the clipboard. Press ${M}V again, or allow clipboard access.`, "bad"))
|
|
941
|
+
else navigator.clipboard?.writeText(tsvOfRange()).then(() => afterCopy(kind)).catch(() => say(`Couldn't ${kind} to the clipboard. Press ${M}${kind === "cut" ? "X" : "C"} again, or allow clipboard access.`, "bad"))
|
|
942
|
+
}, 60)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const patch = (id: string, p: Partial<SheetColumn>) => onColumnsChange(columns.map((c) => (c.id === id ? { ...c, ...p } : c)))
|
|
946
|
+
const insertAt = (index: number) => {
|
|
947
|
+
if (onInsertColumn) return onInsertColumn(index)
|
|
948
|
+
const id = `col-${Date.now().toString(36)}`
|
|
949
|
+
const next = [...columns]
|
|
950
|
+
next.splice(index, 0, { id, label: "New column", type: "text", width: 160 })
|
|
951
|
+
onColumnsChange(next)
|
|
952
|
+
renameNext.current = true
|
|
953
|
+
setRenaming(id)
|
|
954
|
+
}
|
|
955
|
+
const remove = (col: SheetColumn) => {
|
|
956
|
+
const before = columns
|
|
957
|
+
onColumnsChange(columns.filter((c) => c.id !== col.id))
|
|
958
|
+
toast(`Deleted column “${col.label}”`, { description: "Its values are kept until you leave the table.", action: { label: "Undo", onClick: () => onColumnsChange(before) } })
|
|
959
|
+
}
|
|
960
|
+
const togglePin = (col: SheetColumn) => {
|
|
961
|
+
patch(col.id, { pinned: !col.pinned })
|
|
962
|
+
setStatus(col.pinned ? `Unpinned ${col.label}.` : `Pinned ${col.label}. It stays in view when you scroll sideways.`)
|
|
963
|
+
}
|
|
964
|
+
const MIN_W = 60, MAX_W = 640
|
|
965
|
+
const clampW = (w: number) => Math.round(Math.max(MIN_W, Math.min(MAX_W, w)))
|
|
966
|
+
const setWidth = (col: SheetColumn, w: number) => { const next = clampW(w); patch(col.id, { width: next }); setStatus(`${col.label} is ${next}px wide.`) }
|
|
967
|
+
/** Fit to the widest value: measured with the grid's own font, plus room for the header's icons. */
|
|
968
|
+
const fitWidth = (col: SheetColumn) => {
|
|
969
|
+
const ctx = document.createElement("canvas").getContext("2d")
|
|
970
|
+
const el = grid.current
|
|
971
|
+
if (!ctx || !el) return
|
|
972
|
+
const cs = getComputedStyle(el)
|
|
973
|
+
ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
|
|
974
|
+
const pad = (dense ? 16 : 24) + (col.type === "select" ? 18 : col.type === "boolean" ? 40 : 0)
|
|
975
|
+
let w = ctx.measureText(col.label).width + (dense ? 16 : 24) + 44
|
|
976
|
+
for (const row of rows) w = Math.max(w, ctx.measureText(copyText(col, row)).width + pad)
|
|
977
|
+
setWidth(col, Math.min(w, 480))
|
|
978
|
+
}
|
|
979
|
+
const startResize = (e: React.PointerEvent, col: SheetColumn) => {
|
|
980
|
+
if (e.button !== 0) return
|
|
981
|
+
e.preventDefault()
|
|
982
|
+
e.stopPropagation()
|
|
983
|
+
const x0 = e.clientX, w0 = col.width
|
|
984
|
+
const target = e.currentTarget as HTMLElement
|
|
985
|
+
target.setPointerCapture(e.pointerId)
|
|
986
|
+
let w = w0
|
|
987
|
+
const onMove = (ev: PointerEvent) => { w = clampW(w0 + ev.clientX - x0); setDrag({ id: col.id, w }) }
|
|
988
|
+
const onUp = () => {
|
|
989
|
+
target.removeEventListener("pointermove", onMove)
|
|
990
|
+
target.removeEventListener("pointerup", onUp)
|
|
991
|
+
target.removeEventListener("pointercancel", onUp)
|
|
992
|
+
setDrag(null)
|
|
993
|
+
if (w !== w0) setWidth(col, w)
|
|
994
|
+
}
|
|
995
|
+
target.addEventListener("pointermove", onMove)
|
|
996
|
+
target.addEventListener("pointerup", onUp)
|
|
997
|
+
target.addEventListener("pointercancel", onUp)
|
|
998
|
+
}
|
|
999
|
+
const toggleGroup = (key: string) => {
|
|
1000
|
+
const g = lines.find((l) => l.kind === "group" && l.key === key)
|
|
1001
|
+
setCollapsed((s) => { const n = new Set(s); if (n.has(key)) n.delete(key); else n.add(key); return n })
|
|
1002
|
+
if (g?.kind === "group") say(`${g.label} ${g.open ? `collapsed, ${plural(g.rows.length, "row")} hidden` : "expanded"}.`)
|
|
1003
|
+
}
|
|
1004
|
+
const toggleBool = (row: SheetRow, col: SheetColumn) => {
|
|
1005
|
+
if (col.notApplicable?.(row)) return say(`${col.label} doesn't apply to ${rowName(row)}.`)
|
|
1006
|
+
const next = !valueOf(col, row)
|
|
1007
|
+
apply("edit", [{ rowId: row.id, columnId: col.id, before: valueOf(col, row), after: next }])
|
|
1008
|
+
say(`${col.label} for ${rowName(row)}: ${next ? col.onLabel ?? "Yes" : col.offLabel ?? "No"}.`)
|
|
1009
|
+
}
|
|
1010
|
+
const setSelected = (ids: Set<string>) => onSelectedRowIdsChange?.([...ids])
|
|
1011
|
+
const toggleRows = (rs: SheetRow[]) => {
|
|
1012
|
+
if (!selectable || !rs.length) return
|
|
1013
|
+
const all = rs.every((r) => selection.has(r.id))
|
|
1014
|
+
const next = new Set(selection)
|
|
1015
|
+
for (const r of rs) if (all) next.delete(r.id); else next.add(r.id)
|
|
1016
|
+
setSelected(next)
|
|
1017
|
+
say(`${all ? "Deselected" : "Selected"} ${plural(rs.length, "row")}. ${plural(next.size, "row")} selected in all.`)
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const startEdit = (initial?: string) => {
|
|
1021
|
+
const col = visible[pos.c], line = lines[pos.r]
|
|
1022
|
+
if (!col || !line) return
|
|
1023
|
+
if (line.kind === "group") return toggleGroup(line.key)
|
|
1024
|
+
const row = line.row
|
|
1025
|
+
if (col.notApplicable?.(row)) return say(`${col.label} doesn't apply to ${rowName(row)}.`)
|
|
1026
|
+
if (col.type === "boolean") return toggleBool(row, col)
|
|
1027
|
+
if (isComputed(col)) {
|
|
1028
|
+
const cell = row.computed?.[col.id]
|
|
1029
|
+
if (cell?.state === "failed" && onRetryCell) {
|
|
1030
|
+
onRetryCell(row.id, col.id)
|
|
1031
|
+
setStatus(`Running ${col.label} again for ${rowName(row)}${col.costPerRow ? `, about ${formatCredits(col.costPerRow)}` : ""}.`)
|
|
1032
|
+
return
|
|
1033
|
+
}
|
|
1034
|
+
setStatus(`${col.label} is ${computedHow(col)}, so it can't be typed in.${onEditColumn ? " Open the column menu to edit how it's made." : ""}`)
|
|
1035
|
+
return
|
|
1036
|
+
}
|
|
1037
|
+
setError("")
|
|
1038
|
+
setEditing(initial ?? String(row.values[col.id] ?? ""))
|
|
1039
|
+
}
|
|
1040
|
+
const commit = (then: "stay" | "next" | "prev" | "down" | "up", raw?: string) => {
|
|
1041
|
+
const col = visible[pos.c], row = rowAt(pos.r)
|
|
1042
|
+
if (!col || !row) { setEditing(null); return false }
|
|
1043
|
+
const res = coerce(col, raw ?? editing ?? "", row)
|
|
1044
|
+
if ("reason" in res) { setError(res.reason); say(`${col.label}: ${res.reason}`, "bad"); return false }
|
|
1045
|
+
apply("edit", [{ rowId: row.id, columnId: col.id, before: valueOf(col, row), after: res.value }])
|
|
1046
|
+
setError("")
|
|
1047
|
+
setStatus(`Saved ${col.label} for ${rowName(row)}.`)
|
|
1048
|
+
setEditing(null)
|
|
1049
|
+
if (then === "next") go({ r: pos.r, c: pos.c + 1 })
|
|
1050
|
+
else if (then === "prev") go({ r: pos.r, c: pos.c - 1 })
|
|
1051
|
+
else if (then === "down") go({ r: nextData(pos.r, 1), c: pos.c })
|
|
1052
|
+
else if (then === "up") go({ r: nextData(pos.r, -1), c: pos.c })
|
|
1053
|
+
return true
|
|
1054
|
+
}
|
|
1055
|
+
/** ⌘Enter: the typed value goes into every selected cell. */
|
|
1056
|
+
const fillSelection = () => {
|
|
1057
|
+
if (!range || editing === null) return
|
|
1058
|
+
const text = editing
|
|
1059
|
+
const changes: CellChange[] = []
|
|
1060
|
+
const rejects: Reject[] = []
|
|
1061
|
+
for (const t of dataLines(range)) for (const col of visible.slice(range.c1, range.c2 + 1)) {
|
|
1062
|
+
const res = coerce(col, text, t.row)
|
|
1063
|
+
if ("reason" in res) rejects.push({ key: keyOf(t.row, col), where: `${col.label}, row ${t.n}`, reason: res.reason })
|
|
1064
|
+
else changes.push({ rowId: t.row.id, columnId: col.id, before: valueOf(col, t.row), after: res.value })
|
|
1065
|
+
}
|
|
1066
|
+
apply("fill", changes)
|
|
1067
|
+
setEditing(null)
|
|
1068
|
+
setError("")
|
|
1069
|
+
report("Filled", changes.length, rejects)
|
|
1070
|
+
}
|
|
1071
|
+
const cancel = () => { setEditing(null); setError(""); setStatus("Edit cancelled.") }
|
|
1072
|
+
|
|
1073
|
+
const onFilterKey = (e: React.KeyboardEvent, input: HTMLInputElement) => {
|
|
1074
|
+
const c = Number(input.dataset.filter)
|
|
1075
|
+
const col = visible[c]
|
|
1076
|
+
if (e.key === "ArrowDown" || e.key === "Enter") { e.preventDefault(); move(0, c) }
|
|
1077
|
+
else if (e.key === "ArrowUp") { e.preventDefault(); move(top, c) }
|
|
1078
|
+
else if (e.key === "Escape" && input.value) { e.preventDefault(); e.stopPropagation(); setFilters((f) => ({ ...f, [col.id]: "" })); say(`Cleared the ${col.label} filter.`) }
|
|
1079
|
+
else if (e.key === "ArrowLeft" && input.selectionStart === 0 && input.selectionEnd === 0 && c > 0) { e.preventDefault(); move(-1, c - 1) }
|
|
1080
|
+
else if (e.key === "ArrowRight" && input.selectionStart === input.value.length && c < visible.length - 1) { e.preventDefault(); move(-1, c + 1) }
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
const onKey = (e: React.KeyboardEvent) => {
|
|
1084
|
+
const t = e.target as HTMLElement
|
|
1085
|
+
if (renaming !== null) return
|
|
1086
|
+
if (t.closest("[role=menu]")) return
|
|
1087
|
+
if (t.dataset.filter !== undefined) return onFilterKey(e, t as HTMLInputElement)
|
|
1088
|
+
if (editing !== null) {
|
|
1089
|
+
if (visible[pos.c]?.type === "select") return
|
|
1090
|
+
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); cancel() }
|
|
1091
|
+
else if (e.key === "Enter") { e.preventDefault(); if (mod(e)) fillSelection(); else commit(e.shiftKey ? "up" : "down") }
|
|
1092
|
+
else if (e.key === "Tab") { e.preventDefault(); commit(e.shiftKey ? "prev" : "next") }
|
|
1093
|
+
return
|
|
1094
|
+
}
|
|
1095
|
+
const k = e.key, m = mod(e), sh = e.shiftKey
|
|
1096
|
+
const header = pos.r < 0
|
|
1097
|
+
const arrow = ({ ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0] } as Record<string, [number, number]>)[k]
|
|
1098
|
+
if (arrow && !e.altKey) {
|
|
1099
|
+
e.preventDefault()
|
|
1100
|
+
const [dr, dc] = arrow
|
|
1101
|
+
if (header) return move(pos.r + dr, pos.c + dc)
|
|
1102
|
+
if (m) { const to = edge(sh ? ext : pos, dr, dc); return sh ? extendTo(to) : go(to) }
|
|
1103
|
+
if (sh) return extendTo({ r: ext.r + dr, c: ext.c + dc })
|
|
1104
|
+
return move(pos.r + dr, pos.c + dc)
|
|
1105
|
+
}
|
|
1106
|
+
if (k === "Home" || k === "End") {
|
|
1107
|
+
e.preventDefault()
|
|
1108
|
+
const c = k === "Home" ? 0 : visible.length - 1
|
|
1109
|
+
if (header) return move(pos.r, c)
|
|
1110
|
+
const r = m ? (k === "Home" ? 0 : lines.length - 1) : sh ? ext.r : pos.r
|
|
1111
|
+
return sh ? extendTo({ r, c }) : go({ r, c })
|
|
1112
|
+
}
|
|
1113
|
+
if (k === "PageDown" || k === "PageUp") {
|
|
1114
|
+
e.preventDefault()
|
|
1115
|
+
const d = k === "PageDown" ? 10 : -10
|
|
1116
|
+
if (header) return d > 0 ? go({ r: 0, c: pos.c }) : undefined
|
|
1117
|
+
return sh ? extendTo({ r: ext.r + d, c: ext.c }) : go({ r: pos.r + d, c: pos.c })
|
|
1118
|
+
}
|
|
1119
|
+
if (header) {
|
|
1120
|
+
if (e.altKey && sh && (k === "ArrowRight" || k === "ArrowLeft")) { e.preventDefault(); const col = visible[pos.c]; return setWidth(col, col.width + (k === "ArrowRight" ? 16 : -16)) }
|
|
1121
|
+
if ((k === "ArrowDown" && e.altKey) || k === "F2") { e.preventDefault(); setMenuFor(visible[pos.c].id) }
|
|
1122
|
+
// Enter and Space open the menu through the trigger itself.
|
|
1123
|
+
return
|
|
1124
|
+
}
|
|
1125
|
+
if (e.ctrlKey && !e.metaKey && !e.altKey && k.toLowerCase() === "y") { e.preventDefault(); return redo() }
|
|
1126
|
+
if (m && !e.altKey) {
|
|
1127
|
+
const low = k.toLowerCase()
|
|
1128
|
+
if (low === "c" || low === "x" || low === "v") return clipboardFallback(low === "c" ? "copy" : low === "x" ? "cut" : "paste")
|
|
1129
|
+
if (low === "a") { e.preventDefault(); setSelAll(true); return say(`Selected all ${plural(dataLines({ r1: 0, c1: 0, r2: lines.length - 1, c2: 0 }).length * visible.length, "cell")}.`) }
|
|
1130
|
+
if (low === "d") { e.preventDefault(); return fill("down") }
|
|
1131
|
+
if (low === "r") { e.preventDefault(); return fill("right") }
|
|
1132
|
+
if (low === "z") { e.preventDefault(); return sh ? redo() : undo() }
|
|
1133
|
+
// As in Google Sheets: ⌘/ lists the shortcuts, ⌘; puts today's date in a date cell.
|
|
1134
|
+
if (k === "/") { e.preventDefault(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "?" })); return }
|
|
1135
|
+
if (k === ";") {
|
|
1136
|
+
e.preventDefault()
|
|
1137
|
+
const col = visible[pos.c], row = rowAt(pos.r)
|
|
1138
|
+
if (!col || !row || col.type !== "date" || col.notApplicable?.(row)) return say("⌘; puts today's date in a date cell.")
|
|
1139
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
1140
|
+
apply("edit", [{ rowId: row.id, columnId: col.id, before: valueOf(col, row), after: today }])
|
|
1141
|
+
return say(`${col.label} for ${rowName(row)}: today.`)
|
|
1142
|
+
}
|
|
1143
|
+
return
|
|
1144
|
+
}
|
|
1145
|
+
if (k === "Escape" && rangeCells > 1) { e.preventDefault(); e.stopPropagation(); go(pos); return say("Selection cleared.") }
|
|
1146
|
+
if (k === " " && sh) { e.preventDefault(); return toggleRows(range ? dataLines(range).map((l) => l.row) : []) }
|
|
1147
|
+
if (k === " " && e.ctrlKey) { e.preventDefault(); setPos({ r: 0, c: pos.c }); setExt({ r: lines.length - 1, c: pos.c }); setSelAll(false); return say(`Selected the ${visible[pos.c].label} column.`) }
|
|
1148
|
+
if ((k === "F10" && sh) || k === "ContextMenu") { const row = rowAt(pos.r); if (row && hasRowMenu) { e.preventDefault(); setRowMenu(row.id) } return }
|
|
1149
|
+
const line = lines[pos.r]
|
|
1150
|
+
if (line?.kind === "group") {
|
|
1151
|
+
if (k === "Enter" || k === " " || k === "F2") { e.preventDefault(); toggleGroup(line.key) }
|
|
1152
|
+
return
|
|
1153
|
+
}
|
|
1154
|
+
const col = visible[pos.c], row = rowAt(pos.r)
|
|
1155
|
+
if (!col || !row) return
|
|
1156
|
+
if (col.type === "boolean" && (k === "Enter" || k === " ")) { e.preventDefault(); return toggleBool(row, col) }
|
|
1157
|
+
if (k === "Enter" || k === "F2") { e.preventDefault(); startEdit() }
|
|
1158
|
+
else if (k === "Delete" || k === "Backspace") { e.preventDefault(); clearRange() }
|
|
1159
|
+
else if (k.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && !isComputed(col) && !col.notApplicable?.(row) && col.type === "select") { e.preventDefault(); startEdit() }
|
|
1160
|
+
else if (k.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && !isComputed(col) && !col.notApplicable?.(row) && !["date", "boolean"].includes(col.type)) { e.preventDefault(); startEdit(k) }
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const pinStyle = (i: number) => (bodyCols[i]?.pinned ? ({ "--l": `${lefts[i]}px` } as React.CSSProperties) : undefined)
|
|
1164
|
+
|
|
1165
|
+
const headerMenu = (col: SheetColumn, i: number) => {
|
|
1166
|
+
const full = columns.indexOf(col)
|
|
1167
|
+
const [asc, desc] = SORT_WORDS[col.type] ?? ["Sort A to Z", "Sort Z to A"]
|
|
1168
|
+
const sorted = sort?.columnId === col.id ? sort.direction : null
|
|
1169
|
+
return (
|
|
1170
|
+
<DropdownMenuContent
|
|
1171
|
+
align="start"
|
|
1172
|
+
className="w-60"
|
|
1173
|
+
onCloseAutoFocus={(e) => { if (renameNext.current) e.preventDefault() }}
|
|
1174
|
+
>
|
|
1175
|
+
<DropdownMenuLabel className="flex items-center gap-control text-ink-2"><ColumnTypeIcon type={col.type} provider={col.provider} />{COLUMN_TYPES[col.type].label} column{col.costPerRow ? ` · ${formatCredits(col.costPerRow)} / row` : ""}</DropdownMenuLabel>
|
|
1176
|
+
{col.formula && <p className="truncate px-2 pb-2 font-mono text-sm text-ink-2">{col.formula}</p>}
|
|
1177
|
+
<DropdownMenuItem onSelect={() => { renameNext.current = true; setRenaming(col.id) }}><Pencil />Rename</DropdownMenuItem>
|
|
1178
|
+
{isComputed(col) ? (
|
|
1179
|
+
<>
|
|
1180
|
+
{onEditColumn && <DropdownMenuItem onSelect={() => onEditColumn(col.id)}><Settings2 />Edit column</DropdownMenuItem>}
|
|
1181
|
+
{onRetryColumn && !!col.status?.errors && <DropdownMenuItem onSelect={() => onRetryColumn(col.id)}><RefreshCw />Run {col.status.errors} failed {col.status.errors === 1 ? "cell" : "cells"} again{col.costPerRow ? ` · ≈ ${formatCredits(col.costPerRow * col.status.errors)}` : ""}</DropdownMenuItem>}
|
|
1182
|
+
</>
|
|
1183
|
+
) : (
|
|
1184
|
+
<DropdownMenuSub>
|
|
1185
|
+
<DropdownMenuSubTrigger><ColumnTypeIcon type={col.type} />Change type</DropdownMenuSubTrigger>
|
|
1186
|
+
<DropdownMenuSubContent className="w-44">
|
|
1187
|
+
<DropdownMenuRadioGroup value={col.type} onValueChange={(v) => { patch(col.id, { type: v as ColumnType }); setStatus(`${col.label} is now a ${COLUMN_TYPES[v as ColumnType].label.toLowerCase()} column. Values that don't fit are kept as text.`) }}>
|
|
1188
|
+
{(["text", "number", "date", "select", "boolean"] as const).map((t) => <DropdownMenuRadioItem key={t} value={t}>{COLUMN_TYPES[t].label}</DropdownMenuRadioItem>)}
|
|
1189
|
+
</DropdownMenuRadioGroup>
|
|
1190
|
+
</DropdownMenuSubContent>
|
|
1191
|
+
</DropdownMenuSub>
|
|
1192
|
+
)}
|
|
1193
|
+
{onSortChange && (
|
|
1194
|
+
<>
|
|
1195
|
+
<DropdownMenuSeparator />
|
|
1196
|
+
<DropdownMenuItem onSelect={() => onSortChange({ columnId: col.id, direction: "asc" })}><ArrowUp />{asc}{sorted === "asc" && <Check className="ml-auto" />}</DropdownMenuItem>
|
|
1197
|
+
<DropdownMenuItem onSelect={() => onSortChange({ columnId: col.id, direction: "desc" })}><ArrowDown />{desc}{sorted === "desc" && <Check className="ml-auto" />}</DropdownMenuItem>
|
|
1198
|
+
{sorted && <DropdownMenuItem onSelect={() => onSortChange(null)}><ArrowUpDown />Clear sort</DropdownMenuItem>}
|
|
1199
|
+
</>
|
|
1200
|
+
)}
|
|
1201
|
+
{onFilterColumn && <DropdownMenuItem onSelect={() => onFilterColumn(col.id)}><ListFilter />Filter by this column</DropdownMenuItem>}
|
|
1202
|
+
<DropdownMenuSeparator />
|
|
1203
|
+
<DropdownMenuItem role="menuitemcheckbox" aria-checked={!!col.wrap} onSelect={(e) => { e.preventDefault(); patch(col.id, { wrap: !col.wrap }) }}><WrapText />Wrap text{col.wrap && <Check className="ml-auto" />}</DropdownMenuItem>
|
|
1204
|
+
<DropdownMenuItem onSelect={() => togglePin(col)}>{col.pinned ? <><PinOff />Unpin column</> : <><Pin />Pin column</>}</DropdownMenuItem>
|
|
1205
|
+
<DropdownMenuSub>
|
|
1206
|
+
<DropdownMenuSubTrigger><MoveHorizontal />Column width</DropdownMenuSubTrigger>
|
|
1207
|
+
<DropdownMenuSubContent className="w-52">
|
|
1208
|
+
<DropdownMenuItem onSelect={(e) => { e.preventDefault(); setWidth(col, col.width + 40) }}><UnfoldHorizontal />Wider<DropdownMenuShortcut>{isMac ? "⌥⇧→" : "Alt+Shift+→"}</DropdownMenuShortcut></DropdownMenuItem>
|
|
1209
|
+
<DropdownMenuItem onSelect={(e) => { e.preventDefault(); setWidth(col, col.width - 40) }}><FoldHorizontal />Narrower<DropdownMenuShortcut>{isMac ? "⌥⇧←" : "Alt+Shift+←"}</DropdownMenuShortcut></DropdownMenuItem>
|
|
1210
|
+
<DropdownMenuItem onSelect={() => fitWidth(col)}><MoveHorizontal />Fit to content</DropdownMenuItem>
|
|
1211
|
+
</DropdownMenuSubContent>
|
|
1212
|
+
</DropdownMenuSub>
|
|
1213
|
+
{!col.locked && <DropdownMenuItem onSelect={() => { patch(col.id, { hidden: true }); setStatus(`Hid ${col.label}. Show it again from Columns.`); move(pos.r, Math.min(i, visible.length - 2)) }}><EyeOff />Hide column</DropdownMenuItem>}
|
|
1214
|
+
<DropdownMenuSeparator />
|
|
1215
|
+
<DropdownMenuItem onSelect={() => insertAt(full)}><ArrowLeftToLine />Insert column left</DropdownMenuItem>
|
|
1216
|
+
<DropdownMenuItem onSelect={() => insertAt(full + 1)}><ArrowRightToLine />Insert column right</DropdownMenuItem>
|
|
1217
|
+
{!col.locked && (
|
|
1218
|
+
<>
|
|
1219
|
+
<DropdownMenuSeparator />
|
|
1220
|
+
<DropdownMenuItem variant="destructive" onSelect={() => remove(col)}><Trash2 />Delete column</DropdownMenuItem>
|
|
1221
|
+
</>
|
|
1222
|
+
)}
|
|
1223
|
+
</DropdownMenuContent>
|
|
1224
|
+
)
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const renderValue = (col: SheetColumn, row: SheetRow) => {
|
|
1228
|
+
if (col.notApplicable?.(row)) return <span aria-label="not applicable" className="text-sm text-ink-3">n/a</span>
|
|
1229
|
+
if (isComputed(col)) return <ComputedCell col={col} cell={row.computed?.[col.id]} wrap={col.wrap} />
|
|
1230
|
+
const v = row.values[col.id]
|
|
1231
|
+
if (col.type === "boolean")
|
|
1232
|
+
return (
|
|
1233
|
+
<span className="inline-flex items-center gap-2">
|
|
1234
|
+
<Switch size="sm" tabIndex={-1} checked={!!v} aria-label={`${col.label} for ${rowName(row)}`} onCheckedChange={() => toggleBool(row, col)} />
|
|
1235
|
+
<span className={cn("truncate", v ? "text-ink" : "text-ink-3")}>{v ? col.onLabel ?? "Yes" : col.offLabel ?? "No"}</span>
|
|
1236
|
+
</span>
|
|
1237
|
+
)
|
|
1238
|
+
if (col.render) return col.render(v, row)
|
|
1239
|
+
// A select reads as a selector: the value and a chevron that opens the list. Click the active cell, the chevron, or press Enter.
|
|
1240
|
+
if (col.type === "select")
|
|
1241
|
+
return (
|
|
1242
|
+
<span className="flex min-w-0 items-center gap-1">
|
|
1243
|
+
<span className={cn("min-w-0 flex-1 truncate", isBlank(v) && "text-ink-3")}>{isBlank(v) ? <span className="opacity-0 group-hover/cell:opacity-100">Choose</span> : String(v)}</span>
|
|
1244
|
+
<span data-cell-open aria-hidden="true" className="-mr-1 grid size-4 shrink-0 cursor-pointer place-items-center rounded-xs text-ink-3 opacity-0 group-hover/row:opacity-100 group-focus/cell:opacity-100 hover:bg-selected hover:text-ink pointer-coarse:opacity-100">
|
|
1245
|
+
<ChevronDown className="size-3" />
|
|
1246
|
+
</span>
|
|
1247
|
+
</span>
|
|
1248
|
+
)
|
|
1249
|
+
if (isBlank(v)) return null
|
|
1250
|
+
const text = col.type === "number" && typeof v === "number" ? fmtFor(col, v) : col.type === "date" ? fmtDate(String(v)) : String(v)
|
|
1251
|
+
return <span className={cn("block", col.wrap ? "whitespace-normal break-words" : "truncate")}>{text}</span>
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
const editor = (col: SheetColumn, row: SheetRow) => {
|
|
1255
|
+
if (col.type === "select")
|
|
1256
|
+
return (
|
|
1257
|
+
<Select defaultOpen value={editing ?? ""} onValueChange={(v) => commit("stay", v)} onOpenChange={(o) => { if (!o) setEditing((cur) => { if (cur !== null) setStatus("Edit cancelled."); return null }) }}>
|
|
1258
|
+
{/* The cell is the trigger: its 2px outline already marks it, so the trigger draws no border, shadow or ring of its own. */}
|
|
1259
|
+
<SelectTrigger size="sm" aria-label={`${col.label} for ${rowName(row)}`} className="w-full gap-1 rounded-none border-0 bg-transparent p-0 shadow-none hover:border-0 focus-visible:ring-0 data-[size=sm]:h-full"><SelectValue placeholder="Choose" /></SelectTrigger>
|
|
1260
|
+
{/* Opens below the cell, never over it, so the row being changed stays in view. */}
|
|
1261
|
+
<SelectContent position="popper" align="start" sideOffset={4}>{(col.options ?? []).map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}</SelectContent>
|
|
1262
|
+
</Select>
|
|
1263
|
+
)
|
|
1264
|
+
return (
|
|
1265
|
+
<input
|
|
1266
|
+
autoFocus
|
|
1267
|
+
type={col.type === "date" ? "date" : "text"}
|
|
1268
|
+
value={editing ?? ""}
|
|
1269
|
+
aria-label={`${col.label} for ${rowName(row)}`}
|
|
1270
|
+
aria-invalid={!!error || undefined}
|
|
1271
|
+
inputMode={col.type === "number" ? "decimal" : undefined}
|
|
1272
|
+
onChange={(e) => { setEditing(e.target.value); setError("") }}
|
|
1273
|
+
onBlur={() => { if (editing !== null && !commit("stay")) setEditing(null) }}
|
|
1274
|
+
className={cn("h-full w-full bg-transparent outline-none", col.type === "number" && "text-right")}
|
|
1275
|
+
/>
|
|
1276
|
+
)
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// Sum, average and count of the selected numbers, as in the status bar of Excel and Google Sheets.
|
|
1280
|
+
const rangeStats = (() => {
|
|
1281
|
+
if (!range || rangeCells < 2) return null
|
|
1282
|
+
const ls = dataLines(range)
|
|
1283
|
+
const cols = visible.slice(range.c1, range.c2 + 1)
|
|
1284
|
+
let count = 0
|
|
1285
|
+
const nums: number[] = []
|
|
1286
|
+
for (const { row } of ls) for (const col of cols) {
|
|
1287
|
+
const v = valueOf(col, row)
|
|
1288
|
+
if (isBlank(v) || col.notApplicable?.(row)) continue
|
|
1289
|
+
count++
|
|
1290
|
+
if (col.type === "number" && v !== null && v !== undefined && !Number.isNaN(Number(v))) nums.push(Number(v))
|
|
1291
|
+
}
|
|
1292
|
+
const numCols = cols.filter((c) => c.type === "number")
|
|
1293
|
+
const money = numCols.length && numCols.every((c) => c.currency && c.currency === numCols[0].currency) ? numCols[0] : undefined
|
|
1294
|
+
const sum = nums.reduce((a, b) => a + b, 0)
|
|
1295
|
+
return nums.length ? `Sum ${fmtFor(money, sum)} · Average ${fmtFor(money, Math.round((sum / nums.length) * 100) / 100)} · Count ${count}` : `Count ${count}`
|
|
1296
|
+
})()
|
|
1297
|
+
|
|
1298
|
+
const rowMenuFor = (row: SheetRow) => (
|
|
1299
|
+
<DropdownMenu open={rowMenu === row.id} onOpenChange={(o) => setRowMenu(o ? row.id : null)}>
|
|
1300
|
+
<DropdownMenuTrigger asChild>
|
|
1301
|
+
<Button variant="ghost" size="icon-xs" tabIndex={-1} aria-label={`Actions for ${rowName(row)}`} className="relative size-5 text-ink-3 after:absolute after:-inset-0.5 hover:text-ink" onMouseDown={(e) => e.stopPropagation()}>
|
|
1302
|
+
<EllipsisVertical />
|
|
1303
|
+
</Button>
|
|
1304
|
+
</DropdownMenuTrigger>
|
|
1305
|
+
<DropdownMenuContent align="start" className="w-52" onCloseAutoFocus={(e) => e.preventDefault()}>
|
|
1306
|
+
{onOpenRow && <DropdownMenuItem onSelect={() => onOpenRow(row.id)}><PanelRight />Open</DropdownMenuItem>}
|
|
1307
|
+
{onOpenRow && rowActions && <DropdownMenuSeparator />}
|
|
1308
|
+
{rowActions?.(row)}
|
|
1309
|
+
</DropdownMenuContent>
|
|
1310
|
+
</DropdownMenu>
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
// Body rows are memoised: a key press re-renders only the rows whose cells change. They call back through this
|
|
1314
|
+
// stable object, which always holds the latest handlers.
|
|
1315
|
+
const rowApiRef = React.useRef<SheetRowApi>(null as unknown as SheetRowApi)
|
|
1316
|
+
rowApiRef.current = {
|
|
1317
|
+
mouseDown: (e, r, c, isEditing) => {
|
|
1318
|
+
clickOpens.current = false
|
|
1319
|
+
if (isEditing || e.button !== 0) return
|
|
1320
|
+
if (e.shiftKey && pos.r >= 0) { e.preventDefault(); extendTo({ r, c }); return }
|
|
1321
|
+
// A second click on the active select cell, or a click on its chevron, opens the list.
|
|
1322
|
+
clickOpens.current = visible[c]?.type === "select" && ((pos.r === r && pos.c === c && rangeCells <= 1) || !!(e.target as HTMLElement).closest("[data-cell-open]"))
|
|
1323
|
+
setPos({ r, c }); setExt({ r, c }); setSelAll(false); dragging.current = true
|
|
1324
|
+
},
|
|
1325
|
+
click: () => { if (clickOpens.current) { clickOpens.current = false; startEdit() } },
|
|
1326
|
+
mouseEnter: (e, r, c) => { if (dragging.current && e.buttons === 1 && (ext.r !== r || ext.c !== c)) extendTo({ r, c }) },
|
|
1327
|
+
doubleClick: () => startEdit(),
|
|
1328
|
+
contextMenu: (e, r, c, row) => { if (!hasRowMenu) return; e.preventDefault(); go({ r, c }); setRowMenu(row.id) },
|
|
1329
|
+
renderValue,
|
|
1330
|
+
editor,
|
|
1331
|
+
rowMenu: rowMenuFor,
|
|
1332
|
+
toggleRow: (row) => toggleRows([row]),
|
|
1333
|
+
rowName,
|
|
1334
|
+
openRow: (id) => onOpenRow?.(id),
|
|
1335
|
+
}
|
|
1336
|
+
const rowApi = React.useMemo<SheetRowApi>(() => ({
|
|
1337
|
+
mouseDown: (...a) => rowApiRef.current.mouseDown(...a),
|
|
1338
|
+
click: (...a) => rowApiRef.current.click(...a),
|
|
1339
|
+
mouseEnter: (...a) => rowApiRef.current.mouseEnter(...a),
|
|
1340
|
+
doubleClick: () => rowApiRef.current.doubleClick(),
|
|
1341
|
+
contextMenu: (...a) => rowApiRef.current.contextMenu(...a),
|
|
1342
|
+
renderValue: (...a) => rowApiRef.current.renderValue(...a),
|
|
1343
|
+
editor: (...a) => rowApiRef.current.editor(...a),
|
|
1344
|
+
rowMenu: (...a) => rowApiRef.current.rowMenu(...a),
|
|
1345
|
+
toggleRow: (...a) => rowApiRef.current.toggleRow(...a),
|
|
1346
|
+
rowName: (...a) => rowApiRef.current.rowName(...a),
|
|
1347
|
+
openRow: (...a) => rowApiRef.current.openRow(...a),
|
|
1348
|
+
}), [])
|
|
1349
|
+
|
|
1350
|
+
const shownIds = filteredRows.map((r) => r.id)
|
|
1351
|
+
const allChecked = shownIds.length > 0 && shownIds.every((id) => selection.has(id))
|
|
1352
|
+
const someChecked = shownIds.some((id) => selection.has(id))
|
|
1353
|
+
const headerRows = 1 + (filterRow ? 1 : 0) + (hasStatusRow ? 1 : 0)
|
|
1354
|
+
const pinCls = (i: number, head = false) => bodyCols[i]?.pinned && cn("sticky left-(--l)", head ? "z-30" : "z-1", !bodyCols[i + 1]?.pinned && "border-r")
|
|
1355
|
+
|
|
1356
|
+
// In a workspace the sheet fills its region edge to edge; elsewhere it is a framed card.
|
|
1357
|
+
const inWorkspace = useWorkspace()
|
|
1358
|
+
return (
|
|
1359
|
+
<div
|
|
1360
|
+
data-slot="sheet-grid"
|
|
1361
|
+
data-size={size}
|
|
1362
|
+
onFocus={() => { engaged.current = true }}
|
|
1363
|
+
onBlur={(e) => { const to = e.relatedTarget as HTMLElement | null; if (to && !e.currentTarget.contains(to) && !to.closest("[role=menu], [role=listbox]")) engaged.current = false }}
|
|
1364
|
+
className={cn("flex min-h-0 flex-col overflow-hidden bg-surface", !inWorkspace && "rounded-sm border border-line", className)}>
|
|
1365
|
+
<div
|
|
1366
|
+
ref={boxRef}
|
|
1367
|
+
className="min-h-0 flex-1 overflow-auto"
|
|
1368
|
+
style={{ "--pl": `${pinnedWidth}px`, "--tf": `${H}px`, "--ts": `${H + (filterRow ? H : 0)}px`, "--hh": `${headHeight}px`, "--fh": `${hasTotals ? H : 0}px`, "--rh": `${ROWHEAD}px` } as React.CSSProperties}
|
|
1369
|
+
>
|
|
1370
|
+
<table
|
|
1371
|
+
ref={grid}
|
|
1372
|
+
role="grid"
|
|
1373
|
+
aria-label={label}
|
|
1374
|
+
aria-multiselectable="true"
|
|
1375
|
+
aria-rowcount={lines.length + headerRows + (hasTotals ? 1 : 0)}
|
|
1376
|
+
aria-colcount={visible.length + 1}
|
|
1377
|
+
onKeyDown={onKey}
|
|
1378
|
+
style={{ "--tw": `${tableWidth}px` } as React.CSSProperties}
|
|
1379
|
+
className="w-(--tw) min-w-full table-fixed border-separate border-spacing-0 text-base"
|
|
1380
|
+
>
|
|
1381
|
+
<colgroup>
|
|
1382
|
+
<col className="w-(--rh)" />
|
|
1383
|
+
{visible.map((c) => <col key={c.id} className="w-(--w)" style={{ "--w": `${wOf(c)}px` } as React.CSSProperties} />)}
|
|
1384
|
+
</colgroup>
|
|
1385
|
+
<thead>
|
|
1386
|
+
<tr aria-rowindex={1}>
|
|
1387
|
+
<th scope="col" className={cn("sticky top-0 left-0 z-40 border-r border-b border-line bg-surface-2 px-1 text-left", dense ? "h-7" : "h-9")}>
|
|
1388
|
+
{selectable ? (
|
|
1389
|
+
<span style={{ "--s": `${SLOT}px` } as React.CSSProperties} className="grid w-(--s) place-items-center">
|
|
1390
|
+
<Checkbox tabIndex={-1} aria-label="Select all rows shown" checked={allChecked ? true : someChecked ? "indeterminate" : false} onCheckedChange={() => { const next = new Set(selection); for (const id of shownIds) if (allChecked) next.delete(id); else next.add(id); setSelected(next); say(allChecked ? "Deselected all rows shown." : `Selected ${plural(shownIds.length, "row")}.`) }} />
|
|
1391
|
+
</span>
|
|
1392
|
+
) : <span className="sr-only">Row</span>}
|
|
1393
|
+
</th>
|
|
1394
|
+
{visible.map((c, i) => {
|
|
1395
|
+
const sorted = sort?.columnId === c.id ? sort.direction : null
|
|
1396
|
+
const here = pos.r === top && pos.c === i
|
|
1397
|
+
return (
|
|
1398
|
+
<th
|
|
1399
|
+
key={c.id}
|
|
1400
|
+
scope="col"
|
|
1401
|
+
aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}
|
|
1402
|
+
style={pinStyle(i)}
|
|
1403
|
+
className={cn("group/head sticky top-0 z-20 border-b border-line bg-surface-2 p-0 text-left text-sm font-medium text-ink-2", dense ? "h-7" : "h-9", pinCls(i, true))}
|
|
1404
|
+
>
|
|
1405
|
+
{renaming === c.id ? (
|
|
1406
|
+
<div className="px-1">
|
|
1407
|
+
<Input
|
|
1408
|
+
autoFocus
|
|
1409
|
+
defaultValue={c.label}
|
|
1410
|
+
aria-label={`Rename ${c.label}`}
|
|
1411
|
+
className={cn(dense && "h-6")}
|
|
1412
|
+
onFocus={(e) => e.currentTarget.select()}
|
|
1413
|
+
onKeyDown={(e) => {
|
|
1414
|
+
e.stopPropagation()
|
|
1415
|
+
if (e.key === "Enter") e.currentTarget.blur()
|
|
1416
|
+
if (e.key === "Escape") { e.preventDefault(); e.currentTarget.dataset.cancel = "1"; e.currentTarget.blur() }
|
|
1417
|
+
}}
|
|
1418
|
+
onBlur={(e) => {
|
|
1419
|
+
const name = e.currentTarget.value.trim()
|
|
1420
|
+
if (!e.currentTarget.dataset.cancel && name && name !== c.label) { patch(c.id, { label: name }); setStatus(`Renamed to ${name}.`) }
|
|
1421
|
+
renameNext.current = false
|
|
1422
|
+
setRenaming(null)
|
|
1423
|
+
setPos({ r: top, c: Math.max(0, visible.findIndex((x) => x.id === c.id)) })
|
|
1424
|
+
window.setTimeout(() => grid.current?.querySelector<HTMLElement>(`[data-col="${c.id}"]`)?.focus())
|
|
1425
|
+
}}
|
|
1426
|
+
/>
|
|
1427
|
+
</div>
|
|
1428
|
+
) : (
|
|
1429
|
+
<DropdownMenu open={menuFor === c.id} onOpenChange={(o) => setMenuFor(o ? c.id : null)}>
|
|
1430
|
+
<DropdownMenuTrigger asChild>
|
|
1431
|
+
<button
|
|
1432
|
+
type="button"
|
|
1433
|
+
data-cell={`${top}:${i}`}
|
|
1434
|
+
data-col={c.id}
|
|
1435
|
+
tabIndex={here && editing === null ? 0 : -1}
|
|
1436
|
+
aria-haspopup="menu"
|
|
1437
|
+
onMouseDown={() => { setPos({ r: top, c: i }); setExt({ r: top, c: i }) }}
|
|
1438
|
+
onKeyDown={(e) => { if (e.key.startsWith("Arrow") && !e.altKey) e.preventDefault() }}
|
|
1439
|
+
className={cn("flex w-full items-center gap-control text-left hover:bg-hover", dense ? "h-7 px-2" : "h-9 px-3", here ? "outline-2 -outline-offset-2 outline-solid outline-primary" : "outline-none", pos.r >= 0 && pos.c === i && "bg-primary-soft text-primary-ink")}
|
|
1440
|
+
>
|
|
1441
|
+
<ColumnTypeIcon type={c.type} provider={c.provider} />
|
|
1442
|
+
<span className={cn("min-w-0 flex-1 truncate", c.type === "number" && "text-right")}>{c.label}</span>
|
|
1443
|
+
{c.pinned && <Pin className="size-3 shrink-0 text-ink-3" aria-label="Pinned" />}
|
|
1444
|
+
{sorted && (sorted === "asc" ? <ArrowUp className="size-3 shrink-0 text-ink-3" aria-hidden="true" /> : <ArrowDown className="size-3 shrink-0 text-ink-3" aria-hidden="true" />)}
|
|
1445
|
+
<ChevronDown className="size-3 shrink-0 text-ink-3" aria-hidden="true" />
|
|
1446
|
+
</button>
|
|
1447
|
+
</DropdownMenuTrigger>
|
|
1448
|
+
{headerMenu(c, i)}
|
|
1449
|
+
</DropdownMenu>
|
|
1450
|
+
)}
|
|
1451
|
+
{/* Drag to resize, double-click to fit. Keyboard: ⌥⇧ and an arrow on the header, or Column width in its menu. */}
|
|
1452
|
+
<span
|
|
1453
|
+
aria-hidden="true"
|
|
1454
|
+
title="Drag to resize, double-click to fit"
|
|
1455
|
+
onPointerDown={(e) => startResize(e, c)}
|
|
1456
|
+
onDoubleClick={(e) => { e.stopPropagation(); fitWidth(c) }}
|
|
1457
|
+
className={cn(
|
|
1458
|
+
"absolute top-0 right-0 z-10 h-full w-2 cursor-col-resize touch-none",
|
|
1459
|
+
"after:absolute after:inset-y-1 after:right-0 after:w-0.5 after:rounded-full after:bg-primary after:opacity-0 after:transition-opacity after:duration-(--duration-fast) hover:after:opacity-100",
|
|
1460
|
+
drag?.id === c.id && "after:opacity-100"
|
|
1461
|
+
)}
|
|
1462
|
+
/>
|
|
1463
|
+
</th>
|
|
1464
|
+
)
|
|
1465
|
+
})}
|
|
1466
|
+
</tr>
|
|
1467
|
+
{filterRow && (
|
|
1468
|
+
<tr aria-rowindex={2}>
|
|
1469
|
+
<th scope="row" className={cn("sticky top-(--tf) left-0 z-40 border-r border-b border-line bg-surface px-1 text-xs font-normal text-ink-3", dense ? "h-7" : "h-9")}>
|
|
1470
|
+
<span style={{ "--s": `${SLOT}px` } as React.CSSProperties} className="grid w-(--s) place-items-center"><Search className="size-3.5" aria-hidden="true" /></span><span className="sr-only">Filters</span>
|
|
1471
|
+
</th>
|
|
1472
|
+
{visible.map((c, i) => {
|
|
1473
|
+
const here = pos.r === -1 && pos.c === i
|
|
1474
|
+
return (
|
|
1475
|
+
<td key={c.id} style={pinStyle(i)} className={cn("sticky top-(--tf) z-20 border-b border-line bg-surface px-1", dense ? "h-7" : "h-9", pinCls(i, true))}>
|
|
1476
|
+
<input
|
|
1477
|
+
data-cell={`-1:${i}`}
|
|
1478
|
+
data-filter={i}
|
|
1479
|
+
tabIndex={here ? 0 : -1}
|
|
1480
|
+
value={filters[c.id] ?? ""}
|
|
1481
|
+
placeholder="Search"
|
|
1482
|
+
aria-label={`Filter ${c.label}`}
|
|
1483
|
+
onFocus={() => { if (!here) { setPos({ r: -1, c: i }); setExt({ r: -1, c: i }) } }}
|
|
1484
|
+
onChange={(e) => {
|
|
1485
|
+
const q = e.target.value
|
|
1486
|
+
setFilters((f) => ({ ...f, [c.id]: q }))
|
|
1487
|
+
const n = rows.filter((r) => Object.entries({ ...filters, [c.id]: q }).every(([id, v]) => { const col = columns.find((x) => x.id === id); return !v.trim() || !col || copyText(col, r).toLowerCase().includes(v.trim().toLowerCase()) })).length
|
|
1488
|
+
say(`Showing ${plural(n, "row")} of ${rows.length.toLocaleString("en-GB")}.`)
|
|
1489
|
+
}}
|
|
1490
|
+
className={cn(
|
|
1491
|
+
"w-full min-w-0 rounded-xs border border-input bg-background px-control text-sm text-ink outline-none placeholder:text-ink-3 hover:border-input-hover focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary/30",
|
|
1492
|
+
dense ? "h-5.5" : "h-7",
|
|
1493
|
+
c.type === "number" && "text-right",
|
|
1494
|
+
filters[c.id] && "border-primary"
|
|
1495
|
+
)}
|
|
1496
|
+
/>
|
|
1497
|
+
</td>
|
|
1498
|
+
)
|
|
1499
|
+
})}
|
|
1500
|
+
</tr>
|
|
1501
|
+
)}
|
|
1502
|
+
{hasStatusRow && (
|
|
1503
|
+
<tr aria-rowindex={filterRow ? 3 : 2}>
|
|
1504
|
+
<th scope="row" title="Run status" className="sticky top-(--ts) left-0 z-40 h-7 border-r border-b border-line bg-surface px-1 text-xs font-normal text-ink-3">
|
|
1505
|
+
<span style={{ "--s": `${SLOT}px` } as React.CSSProperties} className="grid w-(--s) place-items-center"><Activity className="size-3.5" aria-hidden="true" /></span><span className="sr-only">Status</span>
|
|
1506
|
+
</th>
|
|
1507
|
+
{visible.map((c, i) => (
|
|
1508
|
+
<td key={c.id} style={pinStyle(i)} className={cn("sticky top-(--ts) z-20 h-7 border-b border-line bg-surface", dense ? "px-2" : "px-3", pinCls(i, true))}>
|
|
1509
|
+
{isComputed(c) && c.status ? <ColumnStatus status={c.status} /> : null}
|
|
1510
|
+
</td>
|
|
1511
|
+
))}
|
|
1512
|
+
</tr>
|
|
1513
|
+
)}
|
|
1514
|
+
</thead>
|
|
1515
|
+
<tbody>
|
|
1516
|
+
{lines.map((line, r) => {
|
|
1517
|
+
if (line.kind === "group") {
|
|
1518
|
+
return (
|
|
1519
|
+
<tr key={`g:${line.key}`} aria-rowindex={r + headerRows + 1} data-slot="sheet-group-row">
|
|
1520
|
+
<th scope="row" className={cn("sticky left-0 z-10 border-r border-b border-line bg-surface-2", dense ? "h-7" : "h-9")}><span className="sr-only">Group</span></th>
|
|
1521
|
+
{visible.map((c, ci) => {
|
|
1522
|
+
const here = pos.r === r && pos.c === ci
|
|
1523
|
+
const agg = c.total ? aggregate(c, line.rows) : null
|
|
1524
|
+
return (
|
|
1525
|
+
<td
|
|
1526
|
+
key={c.id}
|
|
1527
|
+
role="gridcell"
|
|
1528
|
+
data-cell={`${r}:${ci}`}
|
|
1529
|
+
tabIndex={here ? 0 : -1}
|
|
1530
|
+
aria-readonly="true"
|
|
1531
|
+
aria-expanded={ci === 0 ? line.open : undefined}
|
|
1532
|
+
onMouseDown={() => go({ r, c: ci })}
|
|
1533
|
+
style={pinStyle(ci)}
|
|
1534
|
+
className={cn(
|
|
1535
|
+
"scroll-mt-(--hh) scroll-mb-(--fh) scroll-ml-(--pl) border-b border-line bg-surface-2 whitespace-nowrap select-none",
|
|
1536
|
+
cellCls,
|
|
1537
|
+
pinCls(ci),
|
|
1538
|
+
c.type === "number" && "tabular text-right text-sm text-ink-2",
|
|
1539
|
+
here ? "outline-2 -outline-offset-2 outline-solid outline-primary" : "outline-none"
|
|
1540
|
+
)}
|
|
1541
|
+
>
|
|
1542
|
+
{ci === 0 ? (
|
|
1543
|
+
<span className="flex min-w-0 items-center gap-control">
|
|
1544
|
+
<button type="button" tabIndex={-1} aria-label={`${line.open ? "Collapse" : "Expand"} ${line.label}`} onMouseDown={(e) => e.stopPropagation()} onClick={() => { go({ r, c: 0 }); toggleGroup(line.key) }} className="-ml-1 grid size-5 shrink-0 place-items-center rounded-xs text-ink-3 hover:bg-hover hover:text-ink">
|
|
1545
|
+
<ChevronRight aria-hidden="true" className={cn("size-3.5 transition-transform duration-(--duration-fast) ease-(--ease-out)", line.open && "rotate-90")} />
|
|
1546
|
+
</button>
|
|
1547
|
+
<span className="truncate font-medium text-ink">{line.label}</span>
|
|
1548
|
+
<span className="tabular shrink-0 text-sm text-ink-3">{line.rows.length}</span>
|
|
1549
|
+
</span>
|
|
1550
|
+
) : agg !== null ? (
|
|
1551
|
+
<span>{c.total === "average" ? "Avg " : ""}{fmtFor(c, agg)}</span>
|
|
1552
|
+
) : null}
|
|
1553
|
+
</td>
|
|
1554
|
+
)
|
|
1555
|
+
})}
|
|
1556
|
+
</tr>
|
|
1557
|
+
)
|
|
1558
|
+
}
|
|
1559
|
+
const row = line.row
|
|
1560
|
+
const multi = !!range && rangeCells > 1 && r >= range.r1 && r <= range.r2
|
|
1561
|
+
const active = pos.r === r
|
|
1562
|
+
return (
|
|
1563
|
+
<SheetBodyRow
|
|
1564
|
+
key={row.id}
|
|
1565
|
+
api={rowApi}
|
|
1566
|
+
row={row}
|
|
1567
|
+
n={line.n}
|
|
1568
|
+
r={r}
|
|
1569
|
+
rowIndex={r + headerRows + 1}
|
|
1570
|
+
visible={bodyCols}
|
|
1571
|
+
lefts={lefts}
|
|
1572
|
+
cellCls={cellCls}
|
|
1573
|
+
dense={dense}
|
|
1574
|
+
activeC={active ? pos.c : -1}
|
|
1575
|
+
editing={active ? editing : null}
|
|
1576
|
+
error={active ? error : ""}
|
|
1577
|
+
selC1={multi ? range.c1 : -1}
|
|
1578
|
+
selC2={multi ? range.c2 : -1}
|
|
1579
|
+
rejected={rejected}
|
|
1580
|
+
checked={selection.has(row.id)}
|
|
1581
|
+
selectable={selectable}
|
|
1582
|
+
selecting={selection.size > 0}
|
|
1583
|
+
slot={SLOT}
|
|
1584
|
+
hasRowMenu={hasRowMenu}
|
|
1585
|
+
menuOpen={rowMenu === row.id}
|
|
1586
|
+
openColId={onOpenRow ? nameCol?.id : undefined}
|
|
1587
|
+
/>
|
|
1588
|
+
)
|
|
1589
|
+
})}
|
|
1590
|
+
</tbody>
|
|
1591
|
+
{hasTotals && (
|
|
1592
|
+
<tfoot>
|
|
1593
|
+
<tr aria-rowindex={lines.length + headerRows + 1}>
|
|
1594
|
+
<th scope="row" title="Total" className={cn("sticky bottom-0 left-0 z-40 border-t border-r border-line bg-surface-2 px-1 text-sm font-medium text-ink-2", dense ? "h-7" : "h-9")}>
|
|
1595
|
+
<span style={{ "--s": `${SLOT}px` } as React.CSSProperties} className="grid w-(--s) place-items-center"><Sigma className="size-3.5" aria-hidden="true" /></span><span className="sr-only">Total</span>
|
|
1596
|
+
</th>
|
|
1597
|
+
{visible.map((c, i) => {
|
|
1598
|
+
const agg = c.total ? aggregate(c, filteredRows) : null
|
|
1599
|
+
return (
|
|
1600
|
+
<td key={c.id} style={pinStyle(i)} className={cn("sticky bottom-0 z-20 border-t border-line bg-surface-2 text-sm font-medium whitespace-nowrap text-ink", cellCls, pinCls(i, true), c.type === "number" && "tabular text-right")}>
|
|
1601
|
+
{agg !== null ? `${c.total === "average" ? "Avg " : ""}${fmtFor(c, agg)}` : i === 0 ? plural(filteredRows.length, "row") : ""}
|
|
1602
|
+
</td>
|
|
1603
|
+
)
|
|
1604
|
+
})}
|
|
1605
|
+
</tr>
|
|
1606
|
+
</tfoot>
|
|
1607
|
+
)}
|
|
1608
|
+
</table>
|
|
1609
|
+
{lines.length === 0 && <p className="px-4 py-6 text-base text-ink-2">{rows.length ? "No rows match these filters." : "No rows yet. Add a row or import a CSV file."}</p>}
|
|
1610
|
+
</div>
|
|
1611
|
+
<div className="flex flex-wrap items-center gap-x-1 gap-y-1 border-t border-line px-2 py-1">
|
|
1612
|
+
{onAddRows && (
|
|
1613
|
+
<>
|
|
1614
|
+
{/* Adding rows is announced, not written into the status line: the new row itself is the confirmation. */}
|
|
1615
|
+
<Button variant="ghost" size="xs" onClick={() => { adding.current = lines.length; onAddRows(1); setAddSaid("Added 1 row at the bottom.") }}><Plus />Add row</Button>
|
|
1616
|
+
<Button variant="ghost" size="xs" onClick={() => { adding.current = lines.length; onAddRows(10); setAddSaid("Added 10 rows at the bottom.") }}>Add 10 rows</Button>
|
|
1617
|
+
<span aria-live="polite" className="sr-only">{addSaid}</span>
|
|
1618
|
+
<span className="mx-1 h-4 w-px bg-line" aria-hidden="true" />
|
|
1619
|
+
</>
|
|
1620
|
+
)}
|
|
1621
|
+
<p aria-live="polite" className={cn("min-w-0 flex-1 truncate text-sm", tone === "bad" || error ? "text-bad" : "text-ink-3")}>
|
|
1622
|
+
{status || `Arrow keys move, Shift+arrows select. Enter edits; Enter again saves and moves down. ${M}C copies, ${M}V pastes, ${M}Z undoes.`}
|
|
1623
|
+
</p>
|
|
1624
|
+
{rangeStats && <span className="tabular shrink-0 text-sm text-ink-2">{rangeStats}</span>}
|
|
1625
|
+
</div>
|
|
1626
|
+
</div>
|
|
1627
|
+
)
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/* ---------------------------------------------------------------- columns menu */
|
|
1631
|
+
|
|
1632
|
+
export type ColumnsMenuItem = { id: string; label: string; type?: ColumnType; provider?: ProviderId; hidden?: boolean; locked?: boolean }
|
|
1633
|
+
|
|
1634
|
+
/** Which columns show and in what order, for sheets and list tables. Applies at once; reorder by drag, by keyboard or with the Move buttons. */
|
|
1635
|
+
export function ColumnsMenu<T extends ColumnsMenuItem>({
|
|
1636
|
+
columns,
|
|
1637
|
+
onChange,
|
|
1638
|
+
align = "end",
|
|
1639
|
+
wrap,
|
|
1640
|
+
onWrapChange,
|
|
1641
|
+
pageSize,
|
|
1642
|
+
pageSizes = [25, 50, 100],
|
|
1643
|
+
onPageSizeChange,
|
|
1644
|
+
note,
|
|
1645
|
+
}: {
|
|
1646
|
+
columns: T[]
|
|
1647
|
+
onChange: (columns: T[]) => void
|
|
1648
|
+
align?: "start" | "end"
|
|
1649
|
+
/** List tables: wrap long text in cells. Shown when onWrapChange is passed. */
|
|
1650
|
+
wrap?: boolean
|
|
1651
|
+
onWrapChange?: (wrap: boolean) => void
|
|
1652
|
+
/** List tables: rows per page. Shown when onPageSizeChange is passed. */
|
|
1653
|
+
pageSize?: number
|
|
1654
|
+
pageSizes?: number[]
|
|
1655
|
+
onPageSizeChange?: (size: number) => void
|
|
1656
|
+
/** Where the choice is kept, such as "Saved for you on this table". */
|
|
1657
|
+
note?: string
|
|
1658
|
+
}) {
|
|
1659
|
+
const [query, setQuery] = React.useState("")
|
|
1660
|
+
const [grabbed, setGrabbed] = React.useState<{ id: string; before: T[] } | null>(null)
|
|
1661
|
+
const [dragId, setDragId] = React.useState<string | null>(null)
|
|
1662
|
+
const [said, setSaid] = React.useState("")
|
|
1663
|
+
const shownCount = columns.filter((c) => !c.hidden).length
|
|
1664
|
+
const list = columns.filter((c) => c.label.toLowerCase().includes(query.trim().toLowerCase()))
|
|
1665
|
+
|
|
1666
|
+
const moveTo = (id: string, to: number) => {
|
|
1667
|
+
const from = columns.findIndex((c) => c.id === id)
|
|
1668
|
+
if (from < 0 || to < 0 || to >= columns.length || to === from) return
|
|
1669
|
+
const next = [...columns]
|
|
1670
|
+
const [item] = next.splice(from, 1)
|
|
1671
|
+
next.splice(to, 0, item)
|
|
1672
|
+
onChange(next)
|
|
1673
|
+
setSaid(`${item.label} moved to position ${to + 1} of ${columns.length}.`)
|
|
1674
|
+
}
|
|
1675
|
+
const setHidden = (id: string, hidden: boolean) => onChange(columns.map((c) => (c.id === id ? { ...c, hidden } : c)))
|
|
1676
|
+
|
|
1677
|
+
return (
|
|
1678
|
+
<Popover onOpenChange={(o) => { if (!o) { setQuery(""); setGrabbed(null) } }}>
|
|
1679
|
+
<PopoverTrigger asChild>
|
|
1680
|
+
<Button variant="outline" size="sm"><Columns3 />Columns</Button>
|
|
1681
|
+
</PopoverTrigger>
|
|
1682
|
+
<PopoverContent align={align} className="w-80 p-0">
|
|
1683
|
+
<div className="grid gap-2 border-b border-line p-2">
|
|
1684
|
+
<div className="relative">
|
|
1685
|
+
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-ink-3" aria-hidden="true" />
|
|
1686
|
+
<Input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Find a column" aria-label="Find a column" className="pl-8" />
|
|
1687
|
+
</div>
|
|
1688
|
+
<div className="flex items-center gap-1">
|
|
1689
|
+
<span className="tabular flex-1 text-sm text-ink-2">{shownCount} of {columns.length} shown</span>
|
|
1690
|
+
<Button variant="ghost" size="xs" onClick={() => onChange(columns.map((c) => ({ ...c, hidden: false })))}>Show all</Button>
|
|
1691
|
+
<Button variant="ghost" size="xs" onClick={() => onChange(columns.map((c) => (c.locked ? c : { ...c, hidden: true })))}>Hide all</Button>
|
|
1692
|
+
</div>
|
|
1693
|
+
</div>
|
|
1694
|
+
<ul aria-label="Columns, in table order" className="grid max-h-80 overflow-y-auto p-1">
|
|
1695
|
+
{list.map((c) => {
|
|
1696
|
+
const i = columns.indexOf(c)
|
|
1697
|
+
const isGrabbed = grabbed?.id === c.id
|
|
1698
|
+
return (
|
|
1699
|
+
<li
|
|
1700
|
+
key={c.id}
|
|
1701
|
+
onDragOver={(e) => { if (dragId) e.preventDefault() }}
|
|
1702
|
+
onDrop={(e) => { e.preventDefault(); if (dragId) moveTo(dragId, i); setDragId(null) }}
|
|
1703
|
+
className={cn("flex h-8 items-center gap-control rounded-xs px-1 hover:bg-hover", (isGrabbed || dragId === c.id) && "bg-selected")}
|
|
1704
|
+
>
|
|
1705
|
+
<button
|
|
1706
|
+
type="button"
|
|
1707
|
+
draggable={!query}
|
|
1708
|
+
onDragStart={(e) => { e.dataTransfer.effectAllowed = "move"; setDragId(c.id) }}
|
|
1709
|
+
onDragEnd={() => setDragId(null)}
|
|
1710
|
+
aria-label={`Reorder ${c.label}`}
|
|
1711
|
+
aria-pressed={isGrabbed}
|
|
1712
|
+
aria-describedby="columns-menu-help"
|
|
1713
|
+
onKeyDown={(e) => {
|
|
1714
|
+
if (e.key === " " || e.key === "Enter") {
|
|
1715
|
+
e.preventDefault()
|
|
1716
|
+
if (isGrabbed) { setGrabbed(null); setSaid(`Dropped ${c.label} at position ${i + 1} of ${columns.length}.`) }
|
|
1717
|
+
else { setGrabbed({ id: c.id, before: columns }); setSaid(`Picked up ${c.label}. Use the arrow keys to move it, Space to drop, Esc to cancel.`) }
|
|
1718
|
+
} else if (isGrabbed && (e.key === "ArrowUp" || e.key === "ArrowDown")) {
|
|
1719
|
+
e.preventDefault()
|
|
1720
|
+
moveTo(c.id, i + (e.key === "ArrowUp" ? -1 : 1))
|
|
1721
|
+
} else if (isGrabbed && e.key === "Escape") {
|
|
1722
|
+
e.preventDefault(); e.stopPropagation()
|
|
1723
|
+
onChange(grabbed.before); setGrabbed(null); setSaid(`Move cancelled. ${c.label} is back where it was.`)
|
|
1724
|
+
}
|
|
1725
|
+
}}
|
|
1726
|
+
className="relative grid size-6 shrink-0 cursor-grab place-items-center rounded-xs text-ink-3 hover:text-ink"
|
|
1727
|
+
>
|
|
1728
|
+
<GripVertical className="size-3.5" aria-hidden="true" />
|
|
1729
|
+
</button>
|
|
1730
|
+
{c.type && <ColumnTypeIcon type={c.type} provider={c.provider} />}
|
|
1731
|
+
<span className={cn("min-w-0 flex-1 truncate text-base", c.hidden ? "text-ink-3" : "text-ink")}>{c.label}</span>
|
|
1732
|
+
<Button variant="ghost" size="icon-xs" aria-label={`Move ${c.label} up`} disabled={i === 0} onClick={() => moveTo(c.id, i - 1)}><ArrowUp /></Button>
|
|
1733
|
+
<Button variant="ghost" size="icon-xs" aria-label={`Move ${c.label} down`} disabled={i === columns.length - 1} onClick={() => moveTo(c.id, i + 1)}><ArrowDown /></Button>
|
|
1734
|
+
{c.locked ? (
|
|
1735
|
+
<span className="grid size-6 place-items-center text-ink-3"><Lock className="size-3.5" aria-hidden="true" /><span className="sr-only">Always shown</span></span>
|
|
1736
|
+
) : (
|
|
1737
|
+
<Button variant="ghost" size="icon-xs" aria-label={c.hidden ? `Show ${c.label}` : `Hide ${c.label}`} aria-pressed={!c.hidden} onClick={() => setHidden(c.id, !c.hidden)}>
|
|
1738
|
+
{c.hidden ? <EyeOff /> : <Eye className="text-ink" />}
|
|
1739
|
+
</Button>
|
|
1740
|
+
)}
|
|
1741
|
+
</li>
|
|
1742
|
+
)
|
|
1743
|
+
})}
|
|
1744
|
+
{!list.length && <li className="px-2 py-3 text-base text-ink-2">No columns match “{query}”.</li>}
|
|
1745
|
+
</ul>
|
|
1746
|
+
{(onWrapChange || onPageSizeChange) && (
|
|
1747
|
+
<div className="grid gap-2 border-t border-line px-3 py-2">
|
|
1748
|
+
{onWrapChange && (
|
|
1749
|
+
<label className="flex h-7 items-center justify-between gap-3 text-base text-ink">
|
|
1750
|
+
Wrap long text
|
|
1751
|
+
<Switch checked={!!wrap} onCheckedChange={onWrapChange} />
|
|
1752
|
+
</label>
|
|
1753
|
+
)}
|
|
1754
|
+
{onPageSizeChange && (
|
|
1755
|
+
<div className="flex h-7 items-center justify-between gap-3 text-base text-ink">
|
|
1756
|
+
<span id="columns-menu-page-size">Rows per page</span>
|
|
1757
|
+
<RadioGroup aria-labelledby="columns-menu-page-size" value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))} className="flex gap-3">
|
|
1758
|
+
{pageSizes.map((n) => (
|
|
1759
|
+
<label key={n} className="tabular flex items-center gap-control text-base text-ink">
|
|
1760
|
+
<RadioGroupItem value={String(n)} />
|
|
1761
|
+
{n}
|
|
1762
|
+
</label>
|
|
1763
|
+
))}
|
|
1764
|
+
</RadioGroup>
|
|
1765
|
+
</div>
|
|
1766
|
+
)}
|
|
1767
|
+
</div>
|
|
1768
|
+
)}
|
|
1769
|
+
<p id="columns-menu-help" className="border-t border-line px-3 py-2 text-xs text-ink-3">{note ? `${note}. ` : ""}Drag a handle, or focus it and press Space, then the arrow keys.</p>
|
|
1770
|
+
<p aria-live="assertive" className="sr-only">{said}</p>
|
|
1771
|
+
</PopoverContent>
|
|
1772
|
+
</Popover>
|
|
1773
|
+
)
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
/* ---------------------------------------------------------------- run status bar */
|
|
1777
|
+
|
|
1778
|
+
export type RunHistoryItem = { id: string; label: string; rows: number; credits: number; outcome: "Done" | "Stopped" | "Failed"; errors?: number; when: React.ReactNode }
|
|
1779
|
+
|
|
1780
|
+
/** The bottom bar of a sheet: what is running, how far it is, what it has cost, and Stop. */
|
|
1781
|
+
export function RunStatusBar({
|
|
1782
|
+
running,
|
|
1783
|
+
done,
|
|
1784
|
+
total,
|
|
1785
|
+
errors = 0,
|
|
1786
|
+
creditsUsed,
|
|
1787
|
+
costPerRow,
|
|
1788
|
+
label,
|
|
1789
|
+
onStop,
|
|
1790
|
+
onRunRemaining,
|
|
1791
|
+
onShowErrors,
|
|
1792
|
+
autoRun,
|
|
1793
|
+
onAutoRunChange,
|
|
1794
|
+
history,
|
|
1795
|
+
className,
|
|
1796
|
+
}: {
|
|
1797
|
+
running: boolean
|
|
1798
|
+
/** Rows finished (done, failed or not found) in the current or last run. */
|
|
1799
|
+
done: number
|
|
1800
|
+
total: number
|
|
1801
|
+
errors?: number
|
|
1802
|
+
/** Credits spent by the current or last run. */
|
|
1803
|
+
creditsUsed: number
|
|
1804
|
+
/** Credits per row for every computed column together; stated next to Auto-run and Run remaining. */
|
|
1805
|
+
costPerRow: number
|
|
1806
|
+
/** What is running: "Summarise role and Work email". */
|
|
1807
|
+
label?: string
|
|
1808
|
+
onStop: () => void
|
|
1809
|
+
onRunRemaining?: () => void
|
|
1810
|
+
onShowErrors?: () => void
|
|
1811
|
+
autoRun: boolean
|
|
1812
|
+
onAutoRunChange: (on: boolean) => void
|
|
1813
|
+
history?: RunHistoryItem[]
|
|
1814
|
+
className?: string
|
|
1815
|
+
}) {
|
|
1816
|
+
const pct = total ? Math.floor((done / total) * 100) : 100
|
|
1817
|
+
const n = (x: number) => x.toLocaleString("en-GB")
|
|
1818
|
+
const remaining = total - done
|
|
1819
|
+
const errorText = errors ? `${n(errors)} ${errors === 1 ? "error" : "errors"}` : ""
|
|
1820
|
+
const historyTitle = React.useId()
|
|
1821
|
+
return (
|
|
1822
|
+
<div role="region" aria-label="Run status" data-slot="run-status-bar" className={cn("flex flex-wrap items-center gap-x-3 gap-y-2 border-t border-line bg-surface px-3 py-2 text-sm", className)}>
|
|
1823
|
+
<div role="status" className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
|
|
1824
|
+
{running ? (
|
|
1825
|
+
<>
|
|
1826
|
+
<Loader2 className="size-3.5 shrink-0 animate-spin text-ink-3" aria-hidden="true" />
|
|
1827
|
+
<ShimmerText className="font-medium">{label ? `Running ${label}` : "Running"}</ShimmerText>
|
|
1828
|
+
<span className="tabular text-ink-2">{n(done)} of {n(total)} rows · {pct}%</span>
|
|
1829
|
+
<span role="progressbar" aria-label="Rows run" aria-valuemin={0} aria-valuemax={total} aria-valuenow={done} className="h-1.5 w-24 overflow-hidden rounded-full bg-hover">
|
|
1830
|
+
<span className="block h-full w-(--p) rounded-full bg-primary transition-all duration-(--duration-slow) ease-(--ease-standard)" style={{ "--p": `${pct}%` } as React.CSSProperties} />
|
|
1831
|
+
</span>
|
|
1832
|
+
<span className="tabular text-ink-2">{formatCredits(creditsUsed)} so far</span>
|
|
1833
|
+
</>
|
|
1834
|
+
) : remaining > 0 ? (
|
|
1835
|
+
<>
|
|
1836
|
+
<Square className="size-3 shrink-0 text-ink-3" aria-hidden="true" />
|
|
1837
|
+
<span className="font-medium text-ink">Stopped at {pct}%</span>
|
|
1838
|
+
<span className="tabular text-ink-2">{n(done)} of {n(total)} rows · {formatCredits(creditsUsed)} used</span>
|
|
1839
|
+
</>
|
|
1840
|
+
) : (
|
|
1841
|
+
<>
|
|
1842
|
+
<Check className="size-3.5 shrink-0 text-ok" aria-hidden="true" />
|
|
1843
|
+
<span className="font-medium text-ink">{errors ? "Every row has run" : "Table up to date"}</span>
|
|
1844
|
+
<span className="tabular text-ink-2">{n(total)} rows · {formatCredits(creditsUsed)} used</span>
|
|
1845
|
+
</>
|
|
1846
|
+
)}
|
|
1847
|
+
{errorText && (onShowErrors
|
|
1848
|
+
? <Button variant="link" size="xs" className="text-bad" onClick={onShowErrors}><CircleAlert />{errorText}</Button>
|
|
1849
|
+
: <span className="inline-flex items-center gap-1 text-bad"><CircleAlert className="size-3.5" aria-hidden="true" />{errorText}</span>)}
|
|
1850
|
+
</div>
|
|
1851
|
+
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
1852
|
+
{running ? (
|
|
1853
|
+
<Button variant="outline" size="sm" onClick={onStop}><Square />Stop</Button>
|
|
1854
|
+
) : remaining > 0 && onRunRemaining ? (
|
|
1855
|
+
<Button variant="outline" size="sm" onClick={onRunRemaining}>Run {n(remaining)} remaining · ≈ {formatCredits(remaining * costPerRow)}</Button>
|
|
1856
|
+
) : null}
|
|
1857
|
+
<label className="flex items-center gap-2 text-ink">
|
|
1858
|
+
<Switch checked={autoRun} onCheckedChange={onAutoRunChange} aria-describedby="auto-run-cost" />
|
|
1859
|
+
Auto-run new rows
|
|
1860
|
+
<span id="auto-run-cost" className="tabular text-ink-3">≈ {formatCredits(costPerRow)} / row</span>
|
|
1861
|
+
</label>
|
|
1862
|
+
{history && (
|
|
1863
|
+
<Popover>
|
|
1864
|
+
<PopoverTrigger asChild><Button variant="ghost" size="sm"><History />History</Button></PopoverTrigger>
|
|
1865
|
+
{/* Focus the panel itself on open, not the first row: a relative Timestamp there would open its tooltip unasked. Tab reaches the rows. */}
|
|
1866
|
+
<PopoverContent align="end" tabIndex={-1} aria-labelledby={historyTitle} onOpenAutoFocus={(e) => { e.preventDefault(); (e.target as HTMLElement).focus() }} className="w-96 max-w-(--radix-popover-content-available-width) p-0">
|
|
1867
|
+
<h3 id={historyTitle} className="border-b border-line px-3 py-2 text-base font-semibold text-ink">Run history</h3>
|
|
1868
|
+
{history.length ? (
|
|
1869
|
+
<ul className="grid max-h-80 divide-y divide-line overflow-y-auto">
|
|
1870
|
+
{history.map((h) => (
|
|
1871
|
+
<li key={h.id} className="grid gap-0.5 px-3 py-2">
|
|
1872
|
+
<span className="flex items-center gap-2"><span className="min-w-0 flex-1 truncate text-base text-ink">{h.label}</span><Badge variant={h.outcome === "Done" ? "success" : h.outcome === "Failed" ? "destructive" : "secondary"} dot>{h.outcome}</Badge></span>
|
|
1873
|
+
<span className="tabular text-sm text-ink-2">{n(h.rows)} rows · {formatCredits(h.credits)}{h.errors ? ` · ${n(h.errors)} ${h.errors === 1 ? "error" : "errors"}` : ""} · {h.when}</span>
|
|
1874
|
+
</li>
|
|
1875
|
+
))}
|
|
1876
|
+
</ul>
|
|
1877
|
+
) : <p className="px-3 py-3 text-base text-ink-2">No runs yet.</p>}
|
|
1878
|
+
</PopoverContent>
|
|
1879
|
+
</Popover>
|
|
1880
|
+
)}
|
|
1881
|
+
</div>
|
|
1882
|
+
</div>
|
|
1883
|
+
)
|
|
1884
|
+
}
|