@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,426 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwoUX compositions for the frame of every screen, built from generated shadcn parts (Sheet, Button, Tabs).
|
|
3
|
+
* AppShell + Sidebar*: tinted sidebar with text labels on every item, an inset white canvas, a top bar.
|
|
4
|
+
* Below 860px the sidebar moves into a sheet behind a menu button.
|
|
5
|
+
* sidebarRail collapses the sidebar to a 48px rail of icons (names in tooltips), toggled by a button or ⌘B / Ctrl+B.
|
|
6
|
+
* frame="flush" drops the canvas margin and corners, for workspaces that need every pixel (trading, editors).
|
|
7
|
+
* PageHeader: title, at most three actions with the one primary rightmost, optional view tabs.
|
|
8
|
+
* SidePanel: the single right-hand slot for record detail, help or the assistant; push or overlay. In push mode it is
|
|
9
|
+
* 440px by default and resizable from 360px to half the canvas: splitter (drag, arrows, Home, End, double-click to reset)
|
|
10
|
+
* or the Panel width menu (Narrow, Default, Wide) for a single click.
|
|
11
|
+
* HelpPanel + InfoLink: written help for the topic in view, in the same slot.
|
|
12
|
+
*/
|
|
13
|
+
import { ChevronDown, ChevronUp, ExternalLink, Info, Maximize2, Menu, MoveHorizontal, PanelLeftClose, PanelLeftOpen, X } from "lucide-react"
|
|
14
|
+
import * as React from "react"
|
|
15
|
+
import { FeatureLabel } from "./labels"
|
|
16
|
+
import { Count } from "./motion"
|
|
17
|
+
import { cn } from "../lib/cn"
|
|
18
|
+
import { Button } from "./button"
|
|
19
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger } from "./dropdown-menu"
|
|
20
|
+
import { Kbd } from "./kbd"
|
|
21
|
+
import { Sheet, SheetContent, SheetDescription, SheetTitle } from "./sheet"
|
|
22
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./tooltip"
|
|
23
|
+
import { WorkspaceContext } from "../lib/workspace"
|
|
24
|
+
|
|
25
|
+
/* ---------------------------------------------------------------- app shell */
|
|
26
|
+
|
|
27
|
+
const RailContext = React.createContext(false)
|
|
28
|
+
/** True while the sidebar shows as a rail of icons, so app content in the sidebar (a logo, a switcher) can shrink with it. */
|
|
29
|
+
export const useSidebarRail = () => React.useContext(RailContext)
|
|
30
|
+
const MOD = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl+"
|
|
31
|
+
|
|
32
|
+
export function AppShell({
|
|
33
|
+
sidebar,
|
|
34
|
+
topBar,
|
|
35
|
+
panel,
|
|
36
|
+
children,
|
|
37
|
+
sidebarCollapsed = false,
|
|
38
|
+
sidebarRail = false,
|
|
39
|
+
onSidebarRailChange,
|
|
40
|
+
frame = "inset",
|
|
41
|
+
className,
|
|
42
|
+
}: {
|
|
43
|
+
sidebar: React.ReactNode
|
|
44
|
+
topBar?: React.ReactNode
|
|
45
|
+
panel?: React.ReactNode
|
|
46
|
+
children: React.ReactNode
|
|
47
|
+
/** Focus mode: hides the sidebar on wide screens; the menu button still opens it */
|
|
48
|
+
sidebarCollapsed?: boolean
|
|
49
|
+
/** Shows the sidebar as a rail of icons, each named in a tooltip. Save the choice per person, as with any layout preference. */
|
|
50
|
+
sidebarRail?: boolean
|
|
51
|
+
/** Adds the Collapse sidebar button and the ⌘B / Ctrl+B shortcut. */
|
|
52
|
+
onSidebarRailChange?: (rail: boolean) => void
|
|
53
|
+
/** "flush" puts the canvas edge to edge with no margin or corners and turns on workspace density for everything inside
|
|
54
|
+
* (useWorkspace): only for tool pages that pass the three tests on the Content density page. */
|
|
55
|
+
frame?: "inset" | "flush"
|
|
56
|
+
className?: string
|
|
57
|
+
}) {
|
|
58
|
+
const [navOpen, setNavOpen] = React.useState(false)
|
|
59
|
+
React.useEffect(() => {
|
|
60
|
+
if (!onSidebarRailChange) return
|
|
61
|
+
const onKey = (e: KeyboardEvent) => {
|
|
62
|
+
if (e.key.toLowerCase() === "b" && (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) {
|
|
63
|
+
e.preventDefault()
|
|
64
|
+
onSidebarRailChange(!sidebarRail)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
window.addEventListener("keydown", onKey)
|
|
68
|
+
return () => window.removeEventListener("keydown", onKey)
|
|
69
|
+
}, [sidebarRail, onSidebarRailChange])
|
|
70
|
+
const flush = frame === "flush"
|
|
71
|
+
const toggleLabel = sidebarRail ? "Expand sidebar" : "Collapse sidebar"
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
data-slot="app-shell"
|
|
75
|
+
data-collapsed={sidebarCollapsed || undefined}
|
|
76
|
+
data-rail={(sidebarRail && !sidebarCollapsed) || undefined}
|
|
77
|
+
className={cn("grid h-full min-h-0 grid-cols-[224px_minmax(0,1fr)] bg-bg transition-[grid-template-columns] duration-(--duration-move) ease-(--ease-move) min-[861px]:data-collapsed:grid-cols-[0px_minmax(0,1fr)] min-[861px]:data-rail:grid-cols-[48px_minmax(0,1fr)] max-[860px]:grid-cols-1", className)}
|
|
78
|
+
>
|
|
79
|
+
<RailContext.Provider value={sidebarRail && !sidebarCollapsed}>
|
|
80
|
+
<TooltipProvider delayDuration={200}>
|
|
81
|
+
<nav aria-label="Main" inert={sidebarCollapsed || undefined} className={cn("flex min-h-0 flex-col gap-4 overflow-x-hidden overflow-y-auto px-2 py-3 in-data-collapsed:px-0 in-data-rail:px-2 max-[860px]:hidden", flush && "border-r border-line")}>
|
|
82
|
+
{sidebar}
|
|
83
|
+
{onSidebarRailChange && (
|
|
84
|
+
<div className="mt-auto flex in-data-rail:justify-center">
|
|
85
|
+
<Tooltip>
|
|
86
|
+
<TooltipTrigger asChild>
|
|
87
|
+
<Button variant="ghost" size="icon-sm" aria-label={toggleLabel} aria-keyshortcuts="Meta+B Control+B" aria-expanded={!sidebarRail} onClick={() => onSidebarRailChange(!sidebarRail)}>
|
|
88
|
+
{sidebarRail ? <PanelLeftOpen /> : <PanelLeftClose />}
|
|
89
|
+
</Button>
|
|
90
|
+
</TooltipTrigger>
|
|
91
|
+
<TooltipContent side="right">{toggleLabel} <Kbd>{MOD}B</Kbd></TooltipContent>
|
|
92
|
+
</Tooltip>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
</nav>
|
|
96
|
+
</TooltipProvider>
|
|
97
|
+
</RailContext.Provider>
|
|
98
|
+
<Sheet open={navOpen} onOpenChange={setNavOpen}>
|
|
99
|
+
<SheetContent side="left" className="bg-bg px-2 py-3">
|
|
100
|
+
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
|
101
|
+
<SheetDescription className="sr-only">Main navigation</SheetDescription>
|
|
102
|
+
<nav aria-label="Main" className="flex flex-col gap-4" onClick={(e) => { if ((e.target as HTMLElement).closest("a, [data-slot=sidebar-item]")) setNavOpen(false) }}>{sidebar}</nav>
|
|
103
|
+
</SheetContent>
|
|
104
|
+
</Sheet>
|
|
105
|
+
<div data-density={flush ? "workspace" : undefined} className={cn("flex min-h-0 min-w-0 overflow-hidden bg-surface", flush ? "" : "m-2 rounded-md border border-line max-[860px]:m-0 max-[860px]:rounded-none max-[860px]:border-0")}>
|
|
106
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
107
|
+
{topBar !== undefined && (
|
|
108
|
+
<div className={cn("flex shrink-0 items-center gap-2 border-b border-line max-[860px]:px-2", flush ? "h-10 px-3" : "h-11 px-4")}>
|
|
109
|
+
<Button variant="ghost" size="icon-sm" className="min-[861px]:hidden" aria-label="Open navigation" onClick={() => setNavOpen(true)}><Menu /></Button>
|
|
110
|
+
{topBar}
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
<main className="min-h-0 flex-1 overflow-y-auto"><WorkspaceContext.Provider value={flush}>{children}</WorkspaceContext.Provider></main>
|
|
114
|
+
</div>
|
|
115
|
+
<WorkspaceContext.Provider value={flush}>{panel}</WorkspaceContext.Provider>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function SidebarSection({ title, children }: { title?: string; children: React.ReactNode }) {
|
|
122
|
+
const id = React.useId()
|
|
123
|
+
const rail = useSidebarRail()
|
|
124
|
+
return (
|
|
125
|
+
<div role="group" aria-labelledby={title ? id : undefined} className={cn("grid gap-0.5", rail && title && "border-t border-line pt-3")}>
|
|
126
|
+
{title && <span id={id} className={cn("px-2 pb-1 text-xs font-medium text-ink-3", rail && "sr-only")}>{title}</span>}
|
|
127
|
+
<ul className="grid gap-0.5">{children}</ul>
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function SidebarItem({ icon, label, count, tag, current = false, href, onClick }: { icon?: React.ReactNode; label: string; count?: number; /** A New or Beta label after the name (plain text here; the page title carries the popover) */ tag?: "New" | "Beta"; current?: boolean; href?: string; onClick?: () => void }) {
|
|
133
|
+
const rail = useSidebarRail() && !!icon
|
|
134
|
+
const cls = cn(
|
|
135
|
+
"flex h-7 w-full items-center gap-2 rounded-xs px-2 text-base transition-colors duration-(--duration-fast) [&_svg]:size-3.5 [&_svg]:shrink-0",
|
|
136
|
+
rail && "relative h-9 justify-center px-0 [&_svg]:size-4",
|
|
137
|
+
current ? "bg-surface font-medium text-ink shadow-xs ring-1 ring-line [&_svg]:text-ink-2" : "text-ink-2 hover:bg-selected hover:text-ink [&_svg]:text-ink-3"
|
|
138
|
+
)
|
|
139
|
+
const name = count !== undefined && count > 0 ? `${label}, ${count}` : label
|
|
140
|
+
const body = rail ? (
|
|
141
|
+
<>
|
|
142
|
+
{icon}
|
|
143
|
+
<span className="sr-only">{name}</span>
|
|
144
|
+
{count !== undefined && count > 0 && <span aria-hidden="true" className="absolute top-1.5 right-1.5 size-1.5 rounded-full bg-primary" />}
|
|
145
|
+
</>
|
|
146
|
+
) : (
|
|
147
|
+
<>
|
|
148
|
+
{icon}
|
|
149
|
+
<span className="min-w-0 flex-1 truncate text-left">{label}{tag && <FeatureLabel kind={tag} className="ml-2" />}</span>
|
|
150
|
+
{count !== undefined && count > 0 && <Count value={count} className="text-sm text-ink-3" />}
|
|
151
|
+
</>
|
|
152
|
+
)
|
|
153
|
+
const el = href ? (
|
|
154
|
+
<a data-slot="sidebar-item" href={href} aria-current={current ? "page" : undefined} className={cls} onClick={onClick}>{body}</a>
|
|
155
|
+
) : (
|
|
156
|
+
<button data-slot="sidebar-item" type="button" aria-current={current ? "page" : undefined} className={cls} onClick={onClick}>{body}</button>
|
|
157
|
+
)
|
|
158
|
+
return (
|
|
159
|
+
<li>
|
|
160
|
+
{rail ? (
|
|
161
|
+
<Tooltip>
|
|
162
|
+
<TooltipTrigger asChild>{el}</TooltipTrigger>
|
|
163
|
+
<TooltipContent side="right">{label}{count !== undefined && count > 0 ? ` · ${count}` : ""}{tag ? ` · ${tag}` : ""}</TooltipContent>
|
|
164
|
+
</Tooltip>
|
|
165
|
+
) : el}
|
|
166
|
+
</li>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ---------------------------------------------------------------- page header */
|
|
171
|
+
|
|
172
|
+
export function PageHeader({ title, titleAddon, description, actions, tabs, className }: { title: string; /** After the title: an InfoLink or a FeatureLabel */ titleAddon?: React.ReactNode; description?: string; actions?: React.ReactNode; tabs?: React.ReactNode; className?: string }) {
|
|
173
|
+
// Region spacing tokens: 24px sides and 20px top on an inset canvas, 12px and 8px in a workspace.
|
|
174
|
+
return (
|
|
175
|
+
<header data-slot="page-header" className={cn("grid gap-3 px-page pt-page-top", !tabs && "pb-group", className)}>
|
|
176
|
+
<div className="flex flex-wrap items-start justify-between gap-x-4 gap-y-2">
|
|
177
|
+
<div className="grid min-w-0 gap-0.5">
|
|
178
|
+
<div className="flex flex-wrap items-center gap-x-2"><h1 className="text-xl font-semibold text-ink">{title}</h1>{titleAddon}</div>
|
|
179
|
+
{description && <p className="text-base text-ink-2">{description}</p>}
|
|
180
|
+
</div>
|
|
181
|
+
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
|
182
|
+
</div>
|
|
183
|
+
{tabs}
|
|
184
|
+
</header>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* ---------------------------------------------------------------- side panel */
|
|
189
|
+
|
|
190
|
+
export type PanelMode = "push" | "overlay"
|
|
191
|
+
|
|
192
|
+
export function SidePanel({
|
|
193
|
+
open,
|
|
194
|
+
onOpenChange,
|
|
195
|
+
mode = "push",
|
|
196
|
+
label,
|
|
197
|
+
title,
|
|
198
|
+
meta,
|
|
199
|
+
position,
|
|
200
|
+
onPrevious,
|
|
201
|
+
onNext,
|
|
202
|
+
onOpenFull,
|
|
203
|
+
footer,
|
|
204
|
+
resizable = true,
|
|
205
|
+
width: widthProp,
|
|
206
|
+
onWidthChange,
|
|
207
|
+
children,
|
|
208
|
+
}: {
|
|
209
|
+
open: boolean
|
|
210
|
+
onOpenChange: (open: boolean) => void
|
|
211
|
+
/** Push mode only. On by default; turn it off for panels whose content has one right width. */
|
|
212
|
+
resizable?: boolean
|
|
213
|
+
/** Width in px, to save per person with onWidthChange. Leave out to let the panel keep its own. */
|
|
214
|
+
width?: number
|
|
215
|
+
onWidthChange?: (px: number) => void
|
|
216
|
+
mode?: PanelMode
|
|
217
|
+
/** What the panel is for, read by screen readers: “Listing details”. */
|
|
218
|
+
label: string
|
|
219
|
+
title: React.ReactNode
|
|
220
|
+
/** Record ID and status line under the title. */
|
|
221
|
+
meta?: React.ReactNode
|
|
222
|
+
/** “3 of 9” with previous and next. */
|
|
223
|
+
position?: { index: number; total: number }
|
|
224
|
+
onPrevious?: () => void
|
|
225
|
+
onNext?: () => void
|
|
226
|
+
onOpenFull?: () => void
|
|
227
|
+
/** Pinned footer: the record's one main action. */
|
|
228
|
+
footer?: React.ReactNode
|
|
229
|
+
children: React.ReactNode
|
|
230
|
+
}) {
|
|
231
|
+
// Panels for one thing (settings, a form) put the title in the header bar. Record panels that step through a list keep the
|
|
232
|
+
// bar for previous, next and position, and show the record's name and meta under it.
|
|
233
|
+
const titleInBar = !position
|
|
234
|
+
const [ownWidth, setOwnWidth] = React.useState(PANEL_DEFAULT)
|
|
235
|
+
const width = widthProp ?? ownWidth
|
|
236
|
+
const setWidth = (px: number) => { setOwnWidth(px); onWidthChange?.(px) }
|
|
237
|
+
const canResize = resizable && mode === "push"
|
|
238
|
+
const bar = (
|
|
239
|
+
<div className={cn("flex h-11 shrink-0 items-center gap-1 border-b border-line", titleInBar ? "pr-2 pl-4" : "px-2")}>
|
|
240
|
+
{position && (
|
|
241
|
+
<>
|
|
242
|
+
<Button variant="ghost" size="icon-sm" aria-label="Previous record" disabled={position.index <= 0} onClick={onPrevious} shortcut="K"><ChevronUp /></Button>
|
|
243
|
+
<Button variant="ghost" size="icon-sm" aria-label="Next record" disabled={position.index >= position.total - 1} onClick={onNext} shortcut="J"><ChevronDown /></Button>
|
|
244
|
+
<span className="tabular px-1 text-sm text-ink-3">{position.index + 1} of {position.total}</span>
|
|
245
|
+
</>
|
|
246
|
+
)}
|
|
247
|
+
{titleInBar ? (
|
|
248
|
+
<div className="flex min-w-0 flex-1 items-baseline gap-2">
|
|
249
|
+
<h2 className="truncate text-md font-semibold text-ink">{title}</h2>
|
|
250
|
+
{meta && <span className="truncate text-sm text-ink-3">{meta}</span>}
|
|
251
|
+
</div>
|
|
252
|
+
) : <span className="flex-1" />}
|
|
253
|
+
{canResize && <PanelWidthMenu width={width} onChange={setWidth} />}
|
|
254
|
+
{onOpenFull && <Button variant="ghost" size="icon-sm" aria-label="Open as full page" onClick={onOpenFull}><Maximize2 /></Button>}
|
|
255
|
+
<Button variant="ghost" size="icon-sm" aria-label={`Close ${label.toLowerCase()}`} onClick={() => onOpenChange(false)}><X /></Button>
|
|
256
|
+
</div>
|
|
257
|
+
)
|
|
258
|
+
const body = (
|
|
259
|
+
<>
|
|
260
|
+
{!titleInBar && (
|
|
261
|
+
<div className="grid gap-1 px-4 pt-4 pb-3">
|
|
262
|
+
<h2 className="text-lg font-semibold text-ink">{title}</h2>
|
|
263
|
+
{meta && <div className="text-sm text-ink-2">{meta}</div>}
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
266
|
+
<div className={cn("min-h-0 flex-1 overflow-y-auto px-4 pb-4", titleInBar && "pt-4")}>{children}</div>
|
|
267
|
+
{footer && <div className="flex shrink-0 justify-end gap-2 border-t border-line px-4 py-3">{footer}</div>}
|
|
268
|
+
</>
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
// Esc closes and focus returns to whatever opened the panel (the row), for push mode too.
|
|
272
|
+
const opener = React.useRef<HTMLElement | null>(null)
|
|
273
|
+
React.useEffect(() => {
|
|
274
|
+
if (!open) { opener.current?.focus?.(); return }
|
|
275
|
+
opener.current = document.activeElement as HTMLElement | null
|
|
276
|
+
if (mode !== "push") return
|
|
277
|
+
const onKey = (e: KeyboardEvent) => {
|
|
278
|
+
if (e.key !== "Escape" || e.defaultPrevented) return
|
|
279
|
+
if ((e.target as HTMLElement | null)?.closest("[role=dialog], [role=alertdialog], [role=menu], [role=listbox]")) return
|
|
280
|
+
onOpenChange(false)
|
|
281
|
+
}
|
|
282
|
+
document.addEventListener("keydown", onKey)
|
|
283
|
+
return () => document.removeEventListener("keydown", onKey)
|
|
284
|
+
}, [open, mode, onOpenChange])
|
|
285
|
+
|
|
286
|
+
if (mode === "overlay")
|
|
287
|
+
return (
|
|
288
|
+
<Sheet open={open} onOpenChange={onOpenChange} modal={false}>
|
|
289
|
+
<SheetContent side="right" showCloseButton={false} aria-label={label} onInteractOutside={(e) => e.preventDefault()}>
|
|
290
|
+
<SheetTitle className="sr-only">{label}</SheetTitle>
|
|
291
|
+
<SheetDescription className="sr-only">{label}</SheetDescription>
|
|
292
|
+
{bar}
|
|
293
|
+
{body}
|
|
294
|
+
</SheetContent>
|
|
295
|
+
</Sheet>
|
|
296
|
+
)
|
|
297
|
+
return <PushPanel open={open} label={label} width={width} onWidthChange={canResize ? setWidth : undefined}>{bar}{body}</PushPanel>
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const PANEL_MIN = 360
|
|
301
|
+
const PANEL_DEFAULT = 440
|
|
302
|
+
/** Half the canvas the panel sits in, never less than the minimum. */
|
|
303
|
+
const panelMax = (el: HTMLElement | null) => Math.max(PANEL_MIN, Math.floor((el?.parentElement?.getBoundingClientRect().width ?? 2 * PANEL_DEFAULT) / 2))
|
|
304
|
+
|
|
305
|
+
/* Single-click widths (WCAG 2.5.7): the same result as dragging the splitter. */
|
|
306
|
+
function PanelWidthMenu({ width, onChange }: { width: number; onChange: (px: number) => void }) {
|
|
307
|
+
const ref = React.useRef<HTMLButtonElement>(null)
|
|
308
|
+
const wide = () => panelMax(ref.current?.closest("aside") ?? null)
|
|
309
|
+
const value = width <= PANEL_MIN ? "narrow" : width === PANEL_DEFAULT ? "default" : width >= wide() - 1 ? "wide" : "custom"
|
|
310
|
+
return (
|
|
311
|
+
<DropdownMenu>
|
|
312
|
+
<DropdownMenuTrigger asChild>
|
|
313
|
+
<Button ref={ref} variant="ghost" size="icon-sm" aria-label="Panel width"><MoveHorizontal /></Button>
|
|
314
|
+
</DropdownMenuTrigger>
|
|
315
|
+
<DropdownMenuContent align="end">
|
|
316
|
+
<DropdownMenuLabel>Panel width</DropdownMenuLabel>
|
|
317
|
+
<DropdownMenuRadioGroup value={value} onValueChange={(v) => onChange(v === "narrow" ? PANEL_MIN : v === "wide" ? wide() : PANEL_DEFAULT)}>
|
|
318
|
+
<DropdownMenuRadioItem value="narrow">Narrow</DropdownMenuRadioItem>
|
|
319
|
+
<DropdownMenuRadioItem value="default">Default</DropdownMenuRadioItem>
|
|
320
|
+
<DropdownMenuRadioItem value="wide">Wide, half the page</DropdownMenuRadioItem>
|
|
321
|
+
</DropdownMenuRadioGroup>
|
|
322
|
+
</DropdownMenuContent>
|
|
323
|
+
</DropdownMenu>
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/* Push mode: the panel's width grows from 0 so the list beside it eases aside instead of jumping, and the content
|
|
328
|
+
fades in with the sheet's motion (40px, 200ms ease-in-out, from shadcn's Base UI sheet). It stays mounted while it closes. */
|
|
329
|
+
function PushPanel({ open, label, width = PANEL_DEFAULT, onWidthChange, children }: { open: boolean; label: string; width?: number; onWidthChange?: (px: number) => void; children: React.ReactNode }) {
|
|
330
|
+
const [mounted, setMounted] = React.useState(open)
|
|
331
|
+
const [resizing, setResizing] = React.useState(false)
|
|
332
|
+
const aside = React.useRef<HTMLElement>(null)
|
|
333
|
+
const clamp = (px: number) => Math.round(Math.max(PANEL_MIN, Math.min(panelMax(aside.current), px)))
|
|
334
|
+
const onPointerDown = (e: React.PointerEvent) => {
|
|
335
|
+
if (e.button !== 0 || !onWidthChange) return
|
|
336
|
+
e.preventDefault()
|
|
337
|
+
const start = e.clientX, from = width
|
|
338
|
+
setResizing(true)
|
|
339
|
+
const onMove = (ev: PointerEvent) => onWidthChange(clamp(from + start - ev.clientX))
|
|
340
|
+
const onUp = () => { setResizing(false); window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp) }
|
|
341
|
+
window.addEventListener("pointermove", onMove)
|
|
342
|
+
window.addEventListener("pointerup", onUp)
|
|
343
|
+
}
|
|
344
|
+
const onKeyDown = (e: React.KeyboardEvent) => {
|
|
345
|
+
if (!onWidthChange) return
|
|
346
|
+
const by = e.shiftKey ? 64 : 16
|
|
347
|
+
// The panel sits on the right, so the left arrow makes it wider.
|
|
348
|
+
const next = { ArrowLeft: width + by, ArrowRight: width - by, Home: PANEL_MIN, End: panelMax(aside.current) }[e.key]
|
|
349
|
+
if (next === undefined) return
|
|
350
|
+
e.preventDefault()
|
|
351
|
+
onWidthChange(clamp(next))
|
|
352
|
+
}
|
|
353
|
+
React.useEffect(() => {
|
|
354
|
+
if (open) { setMounted(true); return }
|
|
355
|
+
const t = window.setTimeout(() => setMounted(false), 200)
|
|
356
|
+
return () => window.clearTimeout(t)
|
|
357
|
+
}, [open])
|
|
358
|
+
if (!mounted) return null
|
|
359
|
+
return (
|
|
360
|
+
<aside
|
|
361
|
+
ref={aside}
|
|
362
|
+
aria-label={label}
|
|
363
|
+
data-slot="side-panel"
|
|
364
|
+
data-state={open ? "open" : "closed"}
|
|
365
|
+
data-resizing={resizing || undefined}
|
|
366
|
+
inert={!open || undefined}
|
|
367
|
+
style={{ "--pw": `${width}px` } as React.CSSProperties}
|
|
368
|
+
className="relative flex w-(--pw) shrink-0 justify-end overflow-hidden bg-surface transition-[width] duration-(--duration-move) ease-(--ease-move) starting:w-0 data-[state=closed]:w-0 data-resizing:transition-none max-[860px]:fixed max-[860px]:inset-0 max-[860px]:z-40 max-[860px]:w-full"
|
|
369
|
+
>
|
|
370
|
+
{onWidthChange && (
|
|
371
|
+
<div
|
|
372
|
+
role="separator"
|
|
373
|
+
tabIndex={0}
|
|
374
|
+
aria-orientation="vertical"
|
|
375
|
+
aria-label={`Resize ${label.toLowerCase()}`}
|
|
376
|
+
aria-valuemin={PANEL_MIN}
|
|
377
|
+
aria-valuemax={panelMax(aside.current)}
|
|
378
|
+
aria-valuenow={width}
|
|
379
|
+
onPointerDown={onPointerDown}
|
|
380
|
+
onKeyDown={onKeyDown}
|
|
381
|
+
onDoubleClick={() => onWidthChange(PANEL_DEFAULT)}
|
|
382
|
+
className="absolute inset-y-0 left-0 z-10 w-2 cursor-col-resize touch-none outline-none after:absolute after:inset-y-0 after:left-0 after:w-0.5 after:bg-primary after:opacity-0 after:transition-opacity after:duration-(--duration-fast) after:content-[''] hover:after:opacity-100 focus-visible:after:opacity-100 max-[860px]:hidden"
|
|
383
|
+
/>
|
|
384
|
+
)}
|
|
385
|
+
<div className="flex w-(--pw) min-w-(--pw) shrink-0 flex-col border-l border-line transition-[opacity,translate] duration-(--duration-move) ease-(--ease-move) starting:translate-x-10 starting:opacity-0 in-data-[state=closed]:translate-x-10 in-data-[state=closed]:opacity-0 max-[860px]:w-full max-[860px]:min-w-0">
|
|
386
|
+
{children}
|
|
387
|
+
</div>
|
|
388
|
+
</aside>
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/* ---------------------------------------------------------------- help */
|
|
393
|
+
|
|
394
|
+
export type HelpTopic = { id: string; title: string; body: React.ReactNode; links?: { label: string; href: string }[] }
|
|
395
|
+
|
|
396
|
+
export function HelpPanel({ open, onOpenChange, topic, mode = "push", footer }: { open: boolean; onOpenChange: (o: boolean) => void; topic: HelpTopic | null; mode?: PanelMode; footer?: React.ReactNode }) {
|
|
397
|
+
if (!topic) return null
|
|
398
|
+
return (
|
|
399
|
+
<SidePanel open={open} onOpenChange={onOpenChange} mode={mode} label="Help" title={topic.title}>
|
|
400
|
+
<div className="grid gap-3 text-base text-ink-2 [&_p]:max-w-prose [&_strong]:font-medium [&_strong]:text-ink">{topic.body}</div>
|
|
401
|
+
{topic.links && topic.links.length > 0 && (
|
|
402
|
+
<div className="mt-5 grid gap-2 border-t border-line pt-4">
|
|
403
|
+
<span className="text-sm font-medium text-ink">Learn more</span>
|
|
404
|
+
{topic.links.slice(0, 3).map((l) => (
|
|
405
|
+
<a key={l.href} href={l.href} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-base font-medium text-primary-ink hover:underline">
|
|
406
|
+
{l.label}
|
|
407
|
+
<ExternalLink className="size-3" aria-hidden="true" />
|
|
408
|
+
<span className="sr-only">(opens in a new tab)</span>
|
|
409
|
+
</a>
|
|
410
|
+
))}
|
|
411
|
+
</div>
|
|
412
|
+
)}
|
|
413
|
+
{footer && <div className="mt-5 border-t border-line pt-4">{footer}</div>}
|
|
414
|
+
</SidePanel>
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Small “Info” link next to a heading or label that opens the help panel at that topic. */
|
|
419
|
+
export function InfoLink({ topic, onOpen, label = "Info" }: { topic: string; onOpen: (topicId: string) => void; label?: string }) {
|
|
420
|
+
return (
|
|
421
|
+
<button type="button" onClick={() => onOpen(topic)} className="relative inline-flex items-center gap-0.5 rounded-xs text-sm font-medium text-primary-ink after:absolute after:-inset-1 hover:underline">
|
|
422
|
+
<Info className="size-3" aria-hidden="true" />
|
|
423
|
+
{label}
|
|
424
|
+
</button>
|
|
425
|
+
)
|
|
426
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwoUX composition: every shortcut by task, opened with ? from anywhere outside a text field,
|
|
3
|
+
* with the switch that turns single-key shortcuts off (WCAG 2.1.4). Built on Dialog, Kbd and Switch.
|
|
4
|
+
*/
|
|
5
|
+
import * as React from "react"
|
|
6
|
+
import { cn } from "../lib/cn"
|
|
7
|
+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./dialog"
|
|
8
|
+
import { Kbd } from "./kbd"
|
|
9
|
+
import { Switch } from "./switch"
|
|
10
|
+
|
|
11
|
+
export type ShortcutGroup = { title: string; items: { keys: string[]; action: string }[] }
|
|
12
|
+
|
|
13
|
+
export function ShortcutSheet({ groups, singleKeys, onSingleKeysChange }: { groups: ShortcutGroup[]; singleKeys: boolean; onSingleKeysChange: (on: boolean) => void }) {
|
|
14
|
+
const [open, setOpen] = React.useState(false)
|
|
15
|
+
React.useEffect(() => {
|
|
16
|
+
const onKey = (e: KeyboardEvent) => {
|
|
17
|
+
if (e.key !== "?" || e.metaKey || e.ctrlKey) return
|
|
18
|
+
// A shortcut can dispatch "?" on document (⌘/ in a sheet), whose target isn't an element.
|
|
19
|
+
if (e.target instanceof Element && e.target.closest("input, textarea, select, [contenteditable=true]")) return
|
|
20
|
+
e.preventDefault()
|
|
21
|
+
setOpen(true)
|
|
22
|
+
}
|
|
23
|
+
document.addEventListener("keydown", onKey)
|
|
24
|
+
return () => document.removeEventListener("keydown", onKey)
|
|
25
|
+
}, [])
|
|
26
|
+
return (
|
|
27
|
+
<Dialog open={open} onOpenChange={setOpen}>
|
|
28
|
+
<DialogContent className="max-h-[calc(100dvh-4rem)] overflow-y-auto sm:max-w-lg">
|
|
29
|
+
<DialogHeader>
|
|
30
|
+
<DialogTitle>Keyboard shortcuts</DialogTitle>
|
|
31
|
+
<DialogDescription>Press ? anywhere to open this list.</DialogDescription>
|
|
32
|
+
</DialogHeader>
|
|
33
|
+
<label className="flex items-start justify-between gap-4 rounded-sm bg-surface-2 p-3">
|
|
34
|
+
<span className="grid gap-0.5">
|
|
35
|
+
<span className="text-base font-medium text-ink">Single-key shortcuts</span>
|
|
36
|
+
<span className="text-sm text-ink-2">Turn off if you use a screen reader or voice control. ⌘ shortcuts stay on.</span>
|
|
37
|
+
</span>
|
|
38
|
+
<Switch checked={singleKeys} onCheckedChange={onSingleKeysChange} />
|
|
39
|
+
</label>
|
|
40
|
+
<div className="grid gap-4">
|
|
41
|
+
{groups.map((g) => (
|
|
42
|
+
<section key={g.title} className="grid gap-1">
|
|
43
|
+
<h3 className="text-sm font-medium text-ink-3">{g.title}</h3>
|
|
44
|
+
<dl className="grid divide-y divide-line">
|
|
45
|
+
{g.items.map((it) => (
|
|
46
|
+
<div key={it.action} className="flex items-center justify-between gap-3 py-2">
|
|
47
|
+
<dt className="text-base text-ink">{it.action}</dt>
|
|
48
|
+
<dd className="flex shrink-0 items-center gap-1">{it.keys.map((k) => <Kbd key={k}>{k}</Kbd>)}</dd>
|
|
49
|
+
</div>
|
|
50
|
+
))}
|
|
51
|
+
</dl>
|
|
52
|
+
</section>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
</DialogContent>
|
|
56
|
+
</Dialog>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The same groups as ShortcutSheet, laid out on the page: under a grid or an editor, so the keys are visible without pressing ?. */
|
|
61
|
+
export function ShortcutList({ groups, title = "Keyboard shortcuts", className }: { groups: ShortcutGroup[]; title?: string; className?: string }) {
|
|
62
|
+
const id = React.useId()
|
|
63
|
+
return (
|
|
64
|
+
<section aria-labelledby={id} className={cn("@container grid gap-3", className)}>
|
|
65
|
+
<h3 id={id} className="text-md font-semibold text-ink">{title}</h3>
|
|
66
|
+
<div className="grid gap-x-8 gap-y-4 @xl:grid-cols-2 @5xl:grid-cols-3">
|
|
67
|
+
{groups.map((g) => (
|
|
68
|
+
<section key={g.title} className="grid content-start gap-1">
|
|
69
|
+
<h4 className="text-sm font-medium text-ink-3">{g.title}</h4>
|
|
70
|
+
<dl className="grid divide-y divide-line">
|
|
71
|
+
{g.items.map((it) => (
|
|
72
|
+
<div key={it.action} className="flex items-center justify-between gap-3 py-2">
|
|
73
|
+
<dt className="text-base text-ink">{it.action}</dt>
|
|
74
|
+
<dd className="flex shrink-0 items-center gap-1">{it.keys.map((k) => <Kbd key={k}>{k}</Kbd>)}</dd>
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</dl>
|
|
78
|
+
</section>
|
|
79
|
+
))}
|
|
80
|
+
</div>
|
|
81
|
+
</section>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated from shadcn/ui new-york-v4 "skeleton" by scripts/tailor-shadcn.mjs. Do not edit by hand.
|
|
3
|
+
* TwoUX changes:
|
|
4
|
+
* - Shimmer at 1.4s instead of a pulse, on TwoUX's hover grey; stops with reduced motion
|
|
5
|
+
*/
|
|
6
|
+
import { cn } from "../lib/cn"
|
|
7
|
+
|
|
8
|
+
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
|
9
|
+
return (
|
|
10
|
+
<div
|
|
11
|
+
data-slot="skeleton"
|
|
12
|
+
className={cn("animate-shimmer rounded-xs bg-linear-to-r from-hover via-surface-2 to-hover bg-size-[200%_100%]", className)}
|
|
13
|
+
{...props}
|
|
14
|
+
/>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export { Skeleton }
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated from shadcn/ui new-york-v4 "sonner" by scripts/tailor-shadcn.mjs. Do not edit by hand.
|
|
3
|
+
* TwoUX changes:
|
|
4
|
+
* - Theme from TwoUX's <html data-theme> (light by default), not next-themes
|
|
5
|
+
* - Status icons 14px in their status colour
|
|
6
|
+
* - TwoUX defaults: bottom right, 16px from the edges, one at a time, 6 seconds, 340px wide; Undo is a quiet text button
|
|
7
|
+
* - Solid surface: shadcn points at --popover and --border, which TwoUX names --color-popover and --color-line, so the toast was see-through
|
|
8
|
+
* - Anti-collision: the toast rises above the focused element and anything marked data-toast-avoid (such as a sticky footer), so it never hides them (WCAG 2.4.11)
|
|
9
|
+
*/
|
|
10
|
+
"use client"
|
|
11
|
+
|
|
12
|
+
import * as React from "react"
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
CircleCheckIcon,
|
|
16
|
+
InfoIcon,
|
|
17
|
+
Loader2Icon,
|
|
18
|
+
OctagonXIcon,
|
|
19
|
+
TriangleAlertIcon,
|
|
20
|
+
} from "lucide-react"
|
|
21
|
+
import { useTheme } from "./theme"
|
|
22
|
+
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
|
23
|
+
|
|
24
|
+
/** TwoUX: bottom offset that keeps toasts clear of the focused element and of [data-toast-avoid] elements. */
|
|
25
|
+
function useToastClearance(base = 16, width = 340, height = 96) {
|
|
26
|
+
const [clearance, setClearance] = React.useState(base)
|
|
27
|
+
React.useEffect(() => {
|
|
28
|
+
let frame = 0
|
|
29
|
+
const measure = () => {
|
|
30
|
+
const vw = window.innerWidth
|
|
31
|
+
const vh = window.innerHeight
|
|
32
|
+
const zoneTop = vh - base - height
|
|
33
|
+
const zoneLeft = vw - base - width
|
|
34
|
+
const targets = Array.from(document.querySelectorAll<HTMLElement>("[data-toast-avoid]"))
|
|
35
|
+
const active = document.activeElement as HTMLElement | null
|
|
36
|
+
if (active && active !== document.body && !active.closest("[data-sonner-toaster]")) targets.push(active)
|
|
37
|
+
let next = base
|
|
38
|
+
for (const el of targets) {
|
|
39
|
+
const r = el.getBoundingClientRect()
|
|
40
|
+
if (!r.width || !r.height || r.top < vh * 0.4) continue
|
|
41
|
+
if (r.bottom > zoneTop && r.top < vh && r.right > zoneLeft) next = Math.max(next, Math.round(vh - r.top + 8))
|
|
42
|
+
}
|
|
43
|
+
setClearance(next)
|
|
44
|
+
}
|
|
45
|
+
const update = () => {
|
|
46
|
+
cancelAnimationFrame(frame)
|
|
47
|
+
frame = requestAnimationFrame(measure)
|
|
48
|
+
}
|
|
49
|
+
measure()
|
|
50
|
+
document.addEventListener("focusin", update)
|
|
51
|
+
document.addEventListener("scroll", update, true)
|
|
52
|
+
window.addEventListener("resize", update)
|
|
53
|
+
const mo = new MutationObserver(update)
|
|
54
|
+
mo.observe(document.body, { subtree: true, childList: true, attributes: true, attributeFilter: ["data-toast-avoid"] })
|
|
55
|
+
return () => {
|
|
56
|
+
cancelAnimationFrame(frame)
|
|
57
|
+
document.removeEventListener("focusin", update)
|
|
58
|
+
document.removeEventListener("scroll", update, true)
|
|
59
|
+
window.removeEventListener("resize", update)
|
|
60
|
+
mo.disconnect()
|
|
61
|
+
}
|
|
62
|
+
}, [base, width, height])
|
|
63
|
+
return clearance
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const Toaster = ({ ...props }: ToasterProps) => {
|
|
67
|
+
const clearance = useToastClearance()
|
|
68
|
+
const { resolved } = useTheme()
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Sonner
|
|
72
|
+
theme={resolved}
|
|
73
|
+
className="toaster group"
|
|
74
|
+
icons={{
|
|
75
|
+
success: <CircleCheckIcon className="size-3.5 text-ok" />,
|
|
76
|
+
info: <InfoIcon className="size-3.5 text-ink-2" />,
|
|
77
|
+
warning: <TriangleAlertIcon className="size-3.5 text-warn" />,
|
|
78
|
+
error: <OctagonXIcon className="size-3.5 text-bad" />,
|
|
79
|
+
loading: <Loader2Icon className="size-3.5 animate-spin text-ink-3" />,
|
|
80
|
+
}}
|
|
81
|
+
style={
|
|
82
|
+
{
|
|
83
|
+
"--normal-bg": "var(--color-surface)",
|
|
84
|
+
"--normal-text": "var(--color-ink)",
|
|
85
|
+
"--normal-border": "var(--color-line)",
|
|
86
|
+
"--border-radius": "var(--radius-md)",
|
|
87
|
+
"--width": "340px",
|
|
88
|
+
} as React.CSSProperties
|
|
89
|
+
}
|
|
90
|
+
position="bottom-right"
|
|
91
|
+
offset={{ bottom: clearance, right: 16 }}
|
|
92
|
+
visibleToasts={1}
|
|
93
|
+
duration={6000}
|
|
94
|
+
toastOptions={{
|
|
95
|
+
classNames: {
|
|
96
|
+
toast: "gap-2! px-3! py-2.5! text-base! shadow-pop! border-line!",
|
|
97
|
+
title: "font-medium!",
|
|
98
|
+
description: "text-ink-2!",
|
|
99
|
+
actionButton: "h-6! rounded-xs! bg-transparent! px-2! text-base! font-medium! text-primary-ink! hover:bg-hover!",
|
|
100
|
+
},
|
|
101
|
+
}}
|
|
102
|
+
{...props}
|
|
103
|
+
/>
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { Toaster }
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/** Indeterminate progress. Decorative: the control or region that is busy carries aria-busy and the label. */
|
|
5
|
+
export function Spinner({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
|
|
6
|
+
return (
|
|
7
|
+
<span
|
|
8
|
+
aria-hidden="true"
|
|
9
|
+
className={cn("inline-block size-3.5 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent opacity-80", className)}
|
|
10
|
+
{...props}
|
|
11
|
+
/>
|
|
12
|
+
);
|
|
13
|
+
}
|