@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,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwoUX composition: an editable grid for enriching many columns (WAI-ARIA grid pattern).
|
|
3
|
+
* One tab stop; arrows move between cells, Home/End to the row's ends; Enter or F2 edits, Esc cancels,
|
|
4
|
+
* Enter saves and stays, Tab saves and moves on. Validation per column; the message shows in the status line.
|
|
5
|
+
* Pinned columns and the header stay in view while scrolling; the totals row counts only the rows shown.
|
|
6
|
+
* Optional row selection: a checkbox column (Shift+Space toggles the active row) and the bulk action bar above the header.
|
|
7
|
+
*/
|
|
8
|
+
import { Pencil } from "lucide-react"
|
|
9
|
+
import * as React from "react"
|
|
10
|
+
import { cn } from "../lib/cn"
|
|
11
|
+
import { Checkbox } from "./checkbox"
|
|
12
|
+
import { type BulkAction, BulkActionBar } from "./data-table"
|
|
13
|
+
|
|
14
|
+
export type GridColumn = {
|
|
15
|
+
id: string
|
|
16
|
+
label: string
|
|
17
|
+
/** px; pinned columns need one */
|
|
18
|
+
width: number
|
|
19
|
+
pin?: boolean
|
|
20
|
+
editable?: boolean
|
|
21
|
+
type?: "text" | "number"
|
|
22
|
+
/** Return a message to block the save. */
|
|
23
|
+
validate?: (value: string) => string | undefined
|
|
24
|
+
total?: "sum" | "average"
|
|
25
|
+
}
|
|
26
|
+
export type GridRow = { id: string } & Record<string, string | number>
|
|
27
|
+
|
|
28
|
+
export function DataGrid({
|
|
29
|
+
columns,
|
|
30
|
+
rows,
|
|
31
|
+
onCellChange,
|
|
32
|
+
"aria-label": label,
|
|
33
|
+
selectedRowIds,
|
|
34
|
+
onSelectedRowIdsChange,
|
|
35
|
+
bulkActions,
|
|
36
|
+
noun,
|
|
37
|
+
className,
|
|
38
|
+
}: {
|
|
39
|
+
columns: GridColumn[]
|
|
40
|
+
rows: GridRow[]
|
|
41
|
+
onCellChange: (rowId: string, columnId: string, value: string | number) => void
|
|
42
|
+
"aria-label": string
|
|
43
|
+
/** Selected rows. Pass with onSelectedRowIdsChange to add a checkbox column. */
|
|
44
|
+
selectedRowIds?: string[]
|
|
45
|
+
onSelectedRowIdsChange?: (ids: string[]) => void
|
|
46
|
+
/** Actions for the bulk action bar above the header while rows are selected; extras move into More. */
|
|
47
|
+
bulkActions?: BulkAction[]
|
|
48
|
+
/** Plural noun for the selection count: "2 listings selected". */
|
|
49
|
+
noun?: string
|
|
50
|
+
className?: string
|
|
51
|
+
}) {
|
|
52
|
+
const selectable = !!onSelectedRowIdsChange
|
|
53
|
+
const selected = selectedRowIds ?? []
|
|
54
|
+
const setSelected = (ids: string[]) => onSelectedRowIdsChange?.(ids)
|
|
55
|
+
const toggleRow = (id: string) => setSelected(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id])
|
|
56
|
+
const allChecked = rows.length > 0 && rows.every((r) => selected.includes(r.id)) ? true : rows.some((r) => selected.includes(r.id)) ? "indeterminate" : false
|
|
57
|
+
// The checkbox column is pinned at the left edge, so pinned data columns start after it (w-9, 36px).
|
|
58
|
+
const offset = selectable ? 36 : 0
|
|
59
|
+
const [pos, setPos] = React.useState({ r: 0, c: 0 })
|
|
60
|
+
const [editing, setEditing] = React.useState<string | null>(null)
|
|
61
|
+
const [error, setError] = React.useState<string>("")
|
|
62
|
+
const [status, setStatus] = React.useState("")
|
|
63
|
+
const grid = React.useRef<HTMLTableElement>(null)
|
|
64
|
+
const lefts = columns.reduce<number[]>((acc, c, i) => [...acc, i === 0 ? offset : acc[i - 1] + (columns[i - 1].pin ? columns[i - 1].width : 0)], [])
|
|
65
|
+
const totalsRow = columns.some((c) => c.total)
|
|
66
|
+
|
|
67
|
+
const cellEl = (r: number, c: number) => grid.current?.querySelector<HTMLElement>(`[data-cell="${r}-${c}"]`)
|
|
68
|
+
React.useEffect(() => { if (editing === null) cellEl(pos.r, pos.c)?.focus({ preventScroll: false }) }, [pos, editing])
|
|
69
|
+
|
|
70
|
+
const move = (r: number, c: number) => setPos({ r: Math.max(0, Math.min(rows.length - 1, r)), c: Math.max(0, Math.min(columns.length - 1, c)) })
|
|
71
|
+
const startEdit = (initial?: string) => {
|
|
72
|
+
const col = columns[pos.c]
|
|
73
|
+
if (!col.editable) { setStatus(`${col.label} can't be edited here.`); return }
|
|
74
|
+
setError("")
|
|
75
|
+
setEditing(initial ?? String(rows[pos.r][col.id] ?? ""))
|
|
76
|
+
}
|
|
77
|
+
const commit = (then: "stay" | "next") => {
|
|
78
|
+
const col = columns[pos.c], row = rows[pos.r]
|
|
79
|
+
const v = editing ?? ""
|
|
80
|
+
const msg = col.validate?.(v)
|
|
81
|
+
if (msg) { setError(msg); setStatus(`${col.label}: ${msg}`); return false }
|
|
82
|
+
onCellChange(row.id, col.id, col.type === "number" ? Number(v) : v)
|
|
83
|
+
setStatus(`Saved ${col.label} for ${row[columns[0].id]}.`)
|
|
84
|
+
setEditing(null)
|
|
85
|
+
if (then === "next") move(pos.r, pos.c + 1)
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const onKey = (e: React.KeyboardEvent) => {
|
|
90
|
+
if (editing !== null) {
|
|
91
|
+
if (e.key === "Escape") { e.preventDefault(); setEditing(null); setError(""); setStatus("Edit cancelled.") }
|
|
92
|
+
else if (e.key === "Enter") { e.preventDefault(); commit("stay") }
|
|
93
|
+
else if (e.key === "Tab") { e.preventDefault(); commit("next") }
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
const k = e.key
|
|
97
|
+
if (k === "ArrowRight") { e.preventDefault(); move(pos.r, pos.c + 1) }
|
|
98
|
+
else if (k === "ArrowLeft") { e.preventDefault(); move(pos.r, pos.c - 1) }
|
|
99
|
+
else if (k === "ArrowDown") { e.preventDefault(); move(pos.r + 1, pos.c) }
|
|
100
|
+
else if (k === "ArrowUp") { e.preventDefault(); move(pos.r - 1, pos.c) }
|
|
101
|
+
else if (k === "Home") { e.preventDefault(); move(e.ctrlKey ? 0 : pos.r, 0) }
|
|
102
|
+
else if (k === "End") { e.preventDefault(); move(e.ctrlKey ? rows.length - 1 : pos.r, columns.length - 1) }
|
|
103
|
+
else if (k === " " && e.shiftKey && selectable) { e.preventDefault(); toggleRow(rows[pos.r].id); setStatus(`${selected.includes(rows[pos.r].id) ? "Deselected" : "Selected"} ${rows[pos.r][columns[0].id]}.`) }
|
|
104
|
+
else if (k === "Enter" || k === "F2") { e.preventDefault(); startEdit() }
|
|
105
|
+
else if (k.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && columns[pos.c].editable) { e.preventDefault(); startEdit(k) }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const fmt = (v: unknown) => (typeof v === "number" ? v.toLocaleString("en-GB") : String(v ?? ""))
|
|
109
|
+
const total = (c: GridColumn) => {
|
|
110
|
+
const nums = rows.map((r) => Number(r[c.id])).filter((n) => !Number.isNaN(n))
|
|
111
|
+
if (!nums.length) return ""
|
|
112
|
+
const sum = nums.reduce((a, b) => a + b, 0)
|
|
113
|
+
return c.total === "average" ? (sum / nums.length).toLocaleString("en-GB", { maximumFractionDigits: 1 }) : sum.toLocaleString("en-GB")
|
|
114
|
+
}
|
|
115
|
+
const stickyStyle = (i: number) => (columns[i].pin ? ({ "--l": `${lefts[i]}px` } as React.CSSProperties) : undefined)
|
|
116
|
+
const widthStyle = (i: number) => ({ ...stickyStyle(i), "--w": `${columns[i].width}px` }) as React.CSSProperties
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className={cn("grid overflow-hidden rounded-sm border border-line bg-surface", className)}>
|
|
120
|
+
{selectable && bulkActions?.length ? (
|
|
121
|
+
<BulkActionBar count={selected.length} noun={noun} onClear={() => setSelected([])} actions={bulkActions} />
|
|
122
|
+
) : null}
|
|
123
|
+
<div className="max-h-96 overflow-auto">
|
|
124
|
+
<table ref={grid} role="grid" aria-label={label} aria-rowcount={rows.length + 1} aria-multiselectable={selectable || undefined} onKeyDown={onKey} className="w-max min-w-full border-separate border-spacing-0 text-base">
|
|
125
|
+
<thead>
|
|
126
|
+
<tr>
|
|
127
|
+
{selectable && (
|
|
128
|
+
<th scope="col" className="sticky top-0 left-0 z-30 h-9 w-9 min-w-9 border-b border-line bg-surface-2 pl-3">
|
|
129
|
+
<Checkbox aria-label="Select all rows" tabIndex={-1} className="translate-y-0.5" checked={allChecked} onCheckedChange={(v) => setSelected(v ? rows.map((r) => r.id) : [])} />
|
|
130
|
+
</th>
|
|
131
|
+
)}
|
|
132
|
+
{columns.map((c, i) => (
|
|
133
|
+
<th
|
|
134
|
+
key={c.id}
|
|
135
|
+
scope="col"
|
|
136
|
+
style={widthStyle(i)}
|
|
137
|
+
className={cn("sticky top-0 z-10 h-9 w-(--w) min-w-(--w) border-b border-line bg-surface-2 px-3 text-left text-sm font-medium whitespace-nowrap text-ink-2", c.pin && "left-(--l) z-20", c.type === "number" && "text-right")}
|
|
138
|
+
>
|
|
139
|
+
<span className="inline-flex items-center gap-1">{c.label}{c.editable && <Pencil className="size-3 text-ink-3" aria-label="(editable)" />}</span>
|
|
140
|
+
</th>
|
|
141
|
+
))}
|
|
142
|
+
</tr>
|
|
143
|
+
</thead>
|
|
144
|
+
<tbody>
|
|
145
|
+
{rows.map((row, r) => (
|
|
146
|
+
<tr key={row.id} aria-selected={selectable ? selected.includes(row.id) : undefined} className="group/row">
|
|
147
|
+
{selectable && (
|
|
148
|
+
<td className={cn("sticky left-0 z-[2] h-9 w-9 min-w-9 border-b border-line pl-3", selected.includes(row.id) ? "bg-primary-soft" : "bg-surface")}>
|
|
149
|
+
<Checkbox aria-label={`Select ${row[columns[0].id]}`} tabIndex={-1} className="translate-y-0.5" checked={selected.includes(row.id)} onCheckedChange={() => toggleRow(row.id)} />
|
|
150
|
+
</td>
|
|
151
|
+
)}
|
|
152
|
+
{columns.map((c, ci) => {
|
|
153
|
+
const here = pos.r === r && pos.c === ci
|
|
154
|
+
const isEditing = here && editing !== null
|
|
155
|
+
return (
|
|
156
|
+
<td
|
|
157
|
+
key={c.id}
|
|
158
|
+
role="gridcell"
|
|
159
|
+
data-cell={`${r}-${ci}`}
|
|
160
|
+
tabIndex={here && !isEditing ? 0 : -1}
|
|
161
|
+
aria-readonly={!c.editable || undefined}
|
|
162
|
+
onMouseDown={() => { if (!isEditing) setPos({ r, c: ci }) }}
|
|
163
|
+
onDoubleClick={() => startEdit()}
|
|
164
|
+
style={widthStyle(ci)}
|
|
165
|
+
className={cn(
|
|
166
|
+
"h-9 w-(--w) min-w-(--w) border-b border-line px-3 whitespace-nowrap outline-none",
|
|
167
|
+
c.pin ? "sticky left-(--l) z-[1]" : "",
|
|
168
|
+
selected.includes(row.id) ? "bg-primary-soft" : "bg-surface",
|
|
169
|
+
c.type === "number" && "tabular text-right",
|
|
170
|
+
c.editable && !isEditing && "hover:bg-hover",
|
|
171
|
+
here && (isEditing && error ? "shadow-[inset_0_0_0_2px_var(--color-bad)]" : "shadow-[inset_0_0_0_2px_var(--color-primary)]"), isEditing && "bg-surface"
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
{isEditing ? (
|
|
175
|
+
<input
|
|
176
|
+
autoFocus
|
|
177
|
+
value={editing}
|
|
178
|
+
aria-label={`${c.label} for ${row[columns[0].id]}`}
|
|
179
|
+
aria-invalid={!!error || undefined}
|
|
180
|
+
inputMode={c.type === "number" ? "decimal" : undefined}
|
|
181
|
+
onChange={(e) => { setEditing(e.target.value); setError("") }}
|
|
182
|
+
onBlur={() => { if (editing !== null && !commit("stay")) setEditing(null) }}
|
|
183
|
+
className={cn("h-full w-full bg-transparent outline-none", c.type === "number" && "text-right")}
|
|
184
|
+
/>
|
|
185
|
+
) : (
|
|
186
|
+
<span className="block truncate">{fmt(row[c.id])}</span>
|
|
187
|
+
)}
|
|
188
|
+
</td>
|
|
189
|
+
)
|
|
190
|
+
})}
|
|
191
|
+
</tr>
|
|
192
|
+
))}
|
|
193
|
+
</tbody>
|
|
194
|
+
{totalsRow && (
|
|
195
|
+
<tfoot>
|
|
196
|
+
<tr>
|
|
197
|
+
{selectable && <td className="sticky bottom-0 left-0 z-[3] h-9 border-t border-line bg-surface-2" />}
|
|
198
|
+
{columns.map((c, i) => (
|
|
199
|
+
<td key={c.id} style={widthStyle(i)} className={cn("sticky bottom-0 h-9 border-t border-line bg-surface-2 px-3 text-sm font-medium whitespace-nowrap text-ink", c.pin && "left-(--l) z-[2]", c.type === "number" && "tabular text-right")}>
|
|
200
|
+
{i === 0 ? `Totals (${rows.length} rows shown)` : c.total ? `${c.total === "average" ? "Avg " : ""}${total(c)}` : ""}
|
|
201
|
+
</td>
|
|
202
|
+
))}
|
|
203
|
+
</tr>
|
|
204
|
+
</tfoot>
|
|
205
|
+
)}
|
|
206
|
+
</table>
|
|
207
|
+
</div>
|
|
208
|
+
<p aria-live="polite" className={cn("min-h-8 border-t border-line px-3 py-2 text-sm", error ? "text-bad" : "text-ink-3")}>
|
|
209
|
+
{status || `Arrow keys move between cells. Enter or F2 edits, Esc cancels, Tab saves and moves on.${selectable ? " Shift+Space selects the row." : ""}`}
|
|
210
|
+
</p>
|
|
211
|
+
</div>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwoUX compositions for the List table, built on the generated shadcn Table, Button and Badge.
|
|
3
|
+
* TableSortHead: only the sorted column shows its arrow; aria-sort sits on that header only.
|
|
4
|
+
* TablePagination: range and total, selection count first, Previous / "1 of 3" / Next.
|
|
5
|
+
* BulkActionBar: grey band directly above the table while rows are selected (actions, count, Clear selection); up to four actions,
|
|
6
|
+
* the rest and any that don't fit in More; Esc clears; filters and header stay in view.
|
|
7
|
+
* useRowKeyboard: J/K or arrows move row focus, X selects, ⇧J/⇧K extend, Enter opens, Esc clears.
|
|
8
|
+
*/
|
|
9
|
+
import { ArrowUp, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react"
|
|
10
|
+
import * as React from "react"
|
|
11
|
+
import { cn } from "../lib/cn"
|
|
12
|
+
import { Button } from "./button"
|
|
13
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuShortcut, DropdownMenuTrigger } from "./dropdown-menu"
|
|
14
|
+
import { TableHead } from "./table"
|
|
15
|
+
|
|
16
|
+
type Sort = false | "asc" | "desc"
|
|
17
|
+
|
|
18
|
+
export function TableSortHead({
|
|
19
|
+
sorted,
|
|
20
|
+
onSort,
|
|
21
|
+
align = "left",
|
|
22
|
+
className,
|
|
23
|
+
children,
|
|
24
|
+
...props
|
|
25
|
+
}: React.ComponentProps<typeof TableHead> & { sorted: Sort; onSort: () => void; align?: "left" | "right" }) {
|
|
26
|
+
return (
|
|
27
|
+
<TableHead
|
|
28
|
+
aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}
|
|
29
|
+
className={cn("p-0", className)}
|
|
30
|
+
{...props}
|
|
31
|
+
>
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
onClick={onSort}
|
|
35
|
+
className={cn(
|
|
36
|
+
"group/sort flex h-9 w-full items-center gap-1 rounded-xs px-3 font-medium group-data-[size=dense]/table:h-7 group-data-[size=dense]/table:px-2 text-ink-2 transition-colors duration-(--duration-fast) ease-(--ease-out) hover:text-ink",
|
|
37
|
+
align === "right" ? "flex-row-reverse text-right" : "text-left",
|
|
38
|
+
sorted && "text-ink"
|
|
39
|
+
)}
|
|
40
|
+
>
|
|
41
|
+
{children}
|
|
42
|
+
<ArrowUp
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
className={cn(
|
|
45
|
+
"size-3 shrink-0 transition-[opacity,rotate] duration-(--duration-fast) ease-(--ease-out)",
|
|
46
|
+
sorted ? "opacity-100" : "opacity-0 group-hover/sort:opacity-60 group-focus-visible/sort:opacity-60",
|
|
47
|
+
sorted === "desc" && "rotate-180"
|
|
48
|
+
)}
|
|
49
|
+
/>
|
|
50
|
+
</button>
|
|
51
|
+
</TableHead>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function TablePagination({
|
|
56
|
+
page,
|
|
57
|
+
pageCount,
|
|
58
|
+
pageSize,
|
|
59
|
+
total,
|
|
60
|
+
selected = 0,
|
|
61
|
+
noun = "rows",
|
|
62
|
+
onPageChange,
|
|
63
|
+
className,
|
|
64
|
+
}: {
|
|
65
|
+
/** Zero-based page index */
|
|
66
|
+
page: number
|
|
67
|
+
pageCount: number
|
|
68
|
+
pageSize: number
|
|
69
|
+
total: number
|
|
70
|
+
selected?: number
|
|
71
|
+
noun?: string
|
|
72
|
+
onPageChange: (page: number) => void
|
|
73
|
+
className?: string
|
|
74
|
+
}) {
|
|
75
|
+
const from = total === 0 ? 0 : page * pageSize + 1
|
|
76
|
+
const to = Math.min(total, (page + 1) * pageSize)
|
|
77
|
+
const fmt = (n: number) => n.toLocaleString("en-GB")
|
|
78
|
+
return (
|
|
79
|
+
<nav aria-label="Pagination" data-slot="table-pagination" className={cn("flex h-10 items-center gap-3 border-t border-line px-3 text-sm text-ink-2", className)}>
|
|
80
|
+
<p className="tabular min-w-0 truncate" aria-live="polite">
|
|
81
|
+
{selected > 0 && <span className="font-medium text-ink">{fmt(selected)} of {fmt(total)} selected · </span>}
|
|
82
|
+
Showing {fmt(from)}–{fmt(to)} of {fmt(total)} {noun}
|
|
83
|
+
</p>
|
|
84
|
+
<div className="ml-auto flex items-center gap-1">
|
|
85
|
+
<Button variant="ghost" size="icon-sm" aria-label="Previous page" disabled={page <= 0} onClick={() => onPageChange(page - 1)}>
|
|
86
|
+
<ChevronLeft />
|
|
87
|
+
</Button>
|
|
88
|
+
<span className="tabular px-1">{fmt(page + 1)} of {fmt(Math.max(1, pageCount))}</span>
|
|
89
|
+
<Button variant="ghost" size="icon-sm" aria-label="Next page" disabled={page >= pageCount - 1} onClick={() => onPageChange(page + 1)}>
|
|
90
|
+
<ChevronRight />
|
|
91
|
+
</Button>
|
|
92
|
+
</div>
|
|
93
|
+
</nav>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export type BulkAction = {
|
|
98
|
+
label: string
|
|
99
|
+
icon?: React.ReactNode
|
|
100
|
+
onSelect: () => void
|
|
101
|
+
/** Shown on the button, or beside the item once it moves into More. Keeps working in both places. */
|
|
102
|
+
shortcut?: string
|
|
103
|
+
/** Red text; put destructive actions last. */
|
|
104
|
+
destructive?: boolean
|
|
105
|
+
disabled?: boolean
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Most actions shown as buttons before the rest move into More (selection-bulk-actions). */
|
|
109
|
+
const MAX_VISIBLE = 4
|
|
110
|
+
const GAP = 4
|
|
111
|
+
|
|
112
|
+
export function BulkActionBar({
|
|
113
|
+
count,
|
|
114
|
+
onClear,
|
|
115
|
+
noun,
|
|
116
|
+
actions = [],
|
|
117
|
+
size = "default",
|
|
118
|
+
className,
|
|
119
|
+
children,
|
|
120
|
+
}: {
|
|
121
|
+
count: number
|
|
122
|
+
onClear: () => void
|
|
123
|
+
/** Plural noun for the count: "2 listings selected". Without it: "2 selected". */
|
|
124
|
+
noun?: string
|
|
125
|
+
/** The bar's actions, in order. Up to four show as buttons; the rest, and any that don't fit, move into More. */
|
|
126
|
+
actions?: BulkAction[]
|
|
127
|
+
/** Match the table: "dense" for Table or SheetGrid size="dense". */
|
|
128
|
+
size?: "default" | "dense"
|
|
129
|
+
className?: string
|
|
130
|
+
/** Custom controls that always show before the actions, such as a menu button. */
|
|
131
|
+
children?: React.ReactNode
|
|
132
|
+
}) {
|
|
133
|
+
const ref = React.useRef<HTMLDivElement>(null)
|
|
134
|
+
const area = React.useRef<HTMLDivElement>(null)
|
|
135
|
+
const more = React.useRef<HTMLButtonElement>(null)
|
|
136
|
+
const [fit, setFit] = React.useState(Math.min(actions.length, MAX_VISIBLE))
|
|
137
|
+
const btn = size === "dense" ? "xs" : "sm"
|
|
138
|
+
|
|
139
|
+
React.useEffect(() => {
|
|
140
|
+
if (!count) return
|
|
141
|
+
const onKey = (e: KeyboardEvent) => {
|
|
142
|
+
if (e.key !== "Escape" || e.defaultPrevented) return
|
|
143
|
+
if ((e.target as HTMLElement | null)?.closest("[role=dialog], [role=alertdialog], [role=menu]")) return
|
|
144
|
+
onClear()
|
|
145
|
+
}
|
|
146
|
+
document.addEventListener("keydown", onKey)
|
|
147
|
+
return () => document.removeEventListener("keydown", onKey)
|
|
148
|
+
}, [count, onClear])
|
|
149
|
+
|
|
150
|
+
// Measure every action button (overflowed ones stay laid out, invisible, so they keep their width and shortcut)
|
|
151
|
+
// and show as many as fit beside More.
|
|
152
|
+
React.useLayoutEffect(() => {
|
|
153
|
+
const el = area.current
|
|
154
|
+
if (!el || !count) return
|
|
155
|
+
const measure = () => {
|
|
156
|
+
const widths = Array.from(el.querySelectorAll<HTMLElement>("[data-bulk-action]")).map((b) => b.offsetWidth)
|
|
157
|
+
const room = el.clientWidth
|
|
158
|
+
const moreW = (more.current?.offsetWidth ?? 0) + GAP
|
|
159
|
+
const total = widths.reduce((a, w) => a + w + GAP, 0)
|
|
160
|
+
let n = widths.length
|
|
161
|
+
if (n > MAX_VISIBLE || total > room) {
|
|
162
|
+
n = 0
|
|
163
|
+
let used = 0
|
|
164
|
+
while (n < widths.length && n < MAX_VISIBLE && used + widths[n] + GAP + moreW <= room) used += widths[n++] + GAP
|
|
165
|
+
}
|
|
166
|
+
setFit(n)
|
|
167
|
+
}
|
|
168
|
+
measure()
|
|
169
|
+
const ro = new ResizeObserver(measure)
|
|
170
|
+
ro.observe(el)
|
|
171
|
+
return () => ro.disconnect()
|
|
172
|
+
}, [count, actions.length, actions.map((a) => a.label).join("|"), btn])
|
|
173
|
+
|
|
174
|
+
if (!count) return null
|
|
175
|
+
|
|
176
|
+
// Toolbar pattern: one Tab stop, arrows move between buttons
|
|
177
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
178
|
+
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return
|
|
179
|
+
const items = Array.from(ref.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled):not([data-overflow])") ?? [])
|
|
180
|
+
const i = items.indexOf(document.activeElement as HTMLButtonElement)
|
|
181
|
+
if (i < 0) return
|
|
182
|
+
e.preventDefault()
|
|
183
|
+
items[(i + (e.key === "ArrowRight" ? 1 : -1) + items.length) % items.length]?.focus()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const n = count.toLocaleString("en-GB")
|
|
187
|
+
const label = `${n} ${noun ? `${noun} ` : ""}selected`
|
|
188
|
+
const overflow = actions.slice(fit)
|
|
189
|
+
const hide = "invisible absolute top-0 left-0"
|
|
190
|
+
|
|
191
|
+
// A band directly above the table (below any filters), in the page flow: the header row stays visible.
|
|
192
|
+
// The count and Clear selection always stay in view; actions that don't fit move into More.
|
|
193
|
+
return (
|
|
194
|
+
<div
|
|
195
|
+
ref={ref}
|
|
196
|
+
role="toolbar"
|
|
197
|
+
aria-label={`Actions for ${label}`}
|
|
198
|
+
data-slot="bulk-action-bar"
|
|
199
|
+
data-size={size}
|
|
200
|
+
onKeyDown={onKeyDown}
|
|
201
|
+
className={cn(
|
|
202
|
+
"@container/bulk flex h-10 animate-fade-in items-center gap-3 border-b border-line bg-surface-2 px-3 data-[size=dense]:h-8 data-[size=dense]:px-2",
|
|
203
|
+
className
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
{children && <div className="flex shrink-0 items-center gap-1">{children}</div>}
|
|
207
|
+
<div ref={area} className="relative flex h-full min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
|
208
|
+
{actions.map((a, i) => (
|
|
209
|
+
<Button
|
|
210
|
+
key={a.label}
|
|
211
|
+
data-bulk-action=""
|
|
212
|
+
data-overflow={i >= fit || undefined}
|
|
213
|
+
aria-hidden={i >= fit || undefined}
|
|
214
|
+
tabIndex={i >= fit ? -1 : undefined}
|
|
215
|
+
variant="ghost"
|
|
216
|
+
size={btn}
|
|
217
|
+
shortcut={a.shortcut}
|
|
218
|
+
disabled={a.disabled}
|
|
219
|
+
onClick={a.onSelect}
|
|
220
|
+
className={cn("shrink-0", a.destructive && "text-bad hover:text-bad", i >= fit && hide)}
|
|
221
|
+
>
|
|
222
|
+
{a.icon}
|
|
223
|
+
{a.label}
|
|
224
|
+
</Button>
|
|
225
|
+
))}
|
|
226
|
+
<DropdownMenu>
|
|
227
|
+
<DropdownMenuTrigger asChild>
|
|
228
|
+
<Button
|
|
229
|
+
ref={more}
|
|
230
|
+
variant="ghost"
|
|
231
|
+
size={btn}
|
|
232
|
+
aria-label={`More actions, ${overflow.length}`}
|
|
233
|
+
data-overflow={!overflow.length || undefined}
|
|
234
|
+
aria-hidden={!overflow.length || undefined}
|
|
235
|
+
tabIndex={overflow.length ? undefined : -1}
|
|
236
|
+
className={cn("shrink-0", !overflow.length && hide)}
|
|
237
|
+
>
|
|
238
|
+
More
|
|
239
|
+
<ChevronDown className="text-ink-3" />
|
|
240
|
+
</Button>
|
|
241
|
+
</DropdownMenuTrigger>
|
|
242
|
+
<DropdownMenuContent align="start">
|
|
243
|
+
{overflow.map((a) => (
|
|
244
|
+
<DropdownMenuItem key={a.label} variant={a.destructive ? "destructive" : "default"} disabled={a.disabled} onSelect={a.onSelect}>
|
|
245
|
+
{a.icon}
|
|
246
|
+
{a.label}
|
|
247
|
+
{a.shortcut && <DropdownMenuShortcut>{a.shortcut}</DropdownMenuShortcut>}
|
|
248
|
+
</DropdownMenuItem>
|
|
249
|
+
))}
|
|
250
|
+
</DropdownMenuContent>
|
|
251
|
+
</DropdownMenu>
|
|
252
|
+
</div>
|
|
253
|
+
<div className="flex shrink-0 items-center gap-2">
|
|
254
|
+
<span className="tabular text-sm font-medium whitespace-nowrap text-ink" aria-live="polite">
|
|
255
|
+
{n} {noun && <span className="@max-lg/bulk:hidden">{noun} </span>}selected
|
|
256
|
+
</span>
|
|
257
|
+
<span aria-hidden="true" className="text-ink-3">·</span>
|
|
258
|
+
<Button
|
|
259
|
+
variant="ghost"
|
|
260
|
+
size={btn}
|
|
261
|
+
shortcut="Esc"
|
|
262
|
+
onClick={onClear}
|
|
263
|
+
aria-label="Clear selection"
|
|
264
|
+
className="@max-lg/bulk:[&_[data-slot=kbd]]:hidden"
|
|
265
|
+
>
|
|
266
|
+
<span>Clear<span className="@max-lg/bulk:hidden"> selection</span></span>
|
|
267
|
+
</Button>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function useRowKeyboard({
|
|
274
|
+
count,
|
|
275
|
+
onToggle,
|
|
276
|
+
onOpen,
|
|
277
|
+
onClear,
|
|
278
|
+
selected = 0,
|
|
279
|
+
enabled = true,
|
|
280
|
+
}: {
|
|
281
|
+
count: number
|
|
282
|
+
/** value is true when extending a selection with ⇧J / ⇧K */
|
|
283
|
+
onToggle: (index: number, value?: boolean) => void
|
|
284
|
+
onOpen?: (index: number) => void
|
|
285
|
+
onClear?: () => void
|
|
286
|
+
/** How many rows are selected. Esc clears them only when there are some, so otherwise it reaches the side panel or dialog. */
|
|
287
|
+
selected?: number
|
|
288
|
+
/** Single-key shortcuts can be turned off in settings. */
|
|
289
|
+
enabled?: boolean
|
|
290
|
+
}) {
|
|
291
|
+
const [focused, setFocused] = React.useState(-1)
|
|
292
|
+
const ref = React.useRef<HTMLTableSectionElement>(null)
|
|
293
|
+
|
|
294
|
+
React.useEffect(() => {
|
|
295
|
+
if (focused >= count) setFocused(count - 1)
|
|
296
|
+
}, [count, focused])
|
|
297
|
+
|
|
298
|
+
React.useEffect(() => {
|
|
299
|
+
ref.current?.querySelector<HTMLElement>(`[data-row-index="${focused}"]`)?.scrollIntoView({ block: "nearest" })
|
|
300
|
+
}, [focused])
|
|
301
|
+
|
|
302
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
303
|
+
if (!enabled || e.metaKey || e.ctrlKey || e.altKey) return
|
|
304
|
+
if ((e.target as HTMLElement).closest("input:not([type=checkbox]), textarea, [contenteditable=true]")) return
|
|
305
|
+
const key = e.key.toLowerCase()
|
|
306
|
+
const move = (d: number) => setFocused((f) => Math.max(0, Math.min(count - 1, (f < 0 ? (d > 0 ? -1 : count) : f) + d)))
|
|
307
|
+
if ((key === "j" && !e.shiftKey) || e.key === "ArrowDown") move(1)
|
|
308
|
+
else if ((key === "k" && !e.shiftKey) || e.key === "ArrowUp") move(-1)
|
|
309
|
+
else if ((key === "j" || key === "k") && e.shiftKey) {
|
|
310
|
+
const next = Math.max(0, Math.min(count - 1, focused + (key === "j" ? 1 : -1)))
|
|
311
|
+
if (focused >= 0) onToggle(focused, true)
|
|
312
|
+
onToggle(next, true)
|
|
313
|
+
setFocused(next)
|
|
314
|
+
}
|
|
315
|
+
else if (key === "x" && focused >= 0) onToggle(focused)
|
|
316
|
+
else if (e.key === "Enter" && focused >= 0 && onOpen) onOpen(focused)
|
|
317
|
+
else if (e.key === "Escape" && onClear && selected > 0) onClear()
|
|
318
|
+
else return
|
|
319
|
+
e.preventDefault()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return {
|
|
323
|
+
focused,
|
|
324
|
+
setFocused,
|
|
325
|
+
/** Spread on TableBody */
|
|
326
|
+
bodyProps: { ref, tabIndex: 0, onKeyDown },
|
|
327
|
+
/** Spread on each TableRow */
|
|
328
|
+
rowProps: (index: number) => ({
|
|
329
|
+
"data-row-index": index,
|
|
330
|
+
"data-focused": focused === index || undefined,
|
|
331
|
+
onMouseDown: () => setFocused(index),
|
|
332
|
+
}),
|
|
333
|
+
}
|
|
334
|
+
}
|