@twoux/ui 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +61 -0
- package/LICENSES/Apache-2.0.txt +202 -0
- package/NOTICE.md +64 -0
- package/README.md +211 -2
- 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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwoUX composition: SplitButton, built on the generated shadcn ButtonGroup, Button and DropdownMenu.
|
|
3
|
+
* The main part runs the most common action with its label ("Import data"); the chevron part opens the related
|
|
4
|
+
* variants ("Import all", "Import selection") and has its own name ("More import options").
|
|
5
|
+
*/
|
|
6
|
+
import { ChevronDown } from "lucide-react"
|
|
7
|
+
import * as React from "react"
|
|
8
|
+
import { cn } from "../lib/cn"
|
|
9
|
+
import { Button } from "./button"
|
|
10
|
+
import { ButtonGroup, ButtonGroupSeparator } from "./button-group"
|
|
11
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "./dropdown-menu"
|
|
12
|
+
|
|
13
|
+
export function SplitButton({
|
|
14
|
+
children,
|
|
15
|
+
onClick,
|
|
16
|
+
menuLabel,
|
|
17
|
+
menu,
|
|
18
|
+
variant = "outline",
|
|
19
|
+
size = "sm",
|
|
20
|
+
disabled,
|
|
21
|
+
loading,
|
|
22
|
+
shortcut,
|
|
23
|
+
align = "end",
|
|
24
|
+
className,
|
|
25
|
+
}: {
|
|
26
|
+
/** The main action's label, with an optional leading icon. */
|
|
27
|
+
children: React.ReactNode
|
|
28
|
+
onClick: () => void
|
|
29
|
+
/** Names the chevron part, e.g. "More import options". */
|
|
30
|
+
menuLabel: string
|
|
31
|
+
/** DropdownMenuItem children: the variants of the main action. Repeat the main action first so the menu is complete. */
|
|
32
|
+
menu: React.ReactNode
|
|
33
|
+
variant?: "default" | "outline" | "secondary"
|
|
34
|
+
size?: "xs" | "sm" | "default"
|
|
35
|
+
disabled?: boolean
|
|
36
|
+
loading?: boolean
|
|
37
|
+
shortcut?: string
|
|
38
|
+
align?: "start" | "end"
|
|
39
|
+
className?: string
|
|
40
|
+
}) {
|
|
41
|
+
const icon = size === "xs" ? "icon-xs" : size === "sm" ? "icon-sm" : "icon"
|
|
42
|
+
return (
|
|
43
|
+
<ButtonGroup data-slot="split-button" data-variant={variant} className={className}>
|
|
44
|
+
<Button variant={variant} size={size} onClick={onClick} disabled={disabled} loading={loading} shortcut={shortcut}>
|
|
45
|
+
{children}
|
|
46
|
+
</Button>
|
|
47
|
+
{variant !== "outline" && <ButtonGroupSeparator />}
|
|
48
|
+
<DropdownMenu>
|
|
49
|
+
<DropdownMenuTrigger asChild>
|
|
50
|
+
<Button variant={variant} size={icon} aria-label={menuLabel} disabled={disabled} className={cn(size === "xs" ? "w-5" : "w-6")}>
|
|
51
|
+
<ChevronDown />
|
|
52
|
+
</Button>
|
|
53
|
+
</DropdownMenuTrigger>
|
|
54
|
+
<DropdownMenuContent align={align} className="min-w-48">{menu}</DropdownMenuContent>
|
|
55
|
+
</DropdownMenu>
|
|
56
|
+
</ButtonGroup>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TwoUX composition: a vertical list of numbered steps joined by a line (shadcn has no stepper).
|
|
3
|
+
* Done steps show a check, the current step is indigo and marked aria-current="step",
|
|
4
|
+
* later steps are faded. With onStepSelect, done steps become buttons to go back to.
|
|
5
|
+
*/
|
|
6
|
+
import { Check } from "lucide-react"
|
|
7
|
+
import { cn } from "../lib/cn"
|
|
8
|
+
import { Swap } from "./motion"
|
|
9
|
+
|
|
10
|
+
type StepItem = { title: string; description?: string }
|
|
11
|
+
|
|
12
|
+
export function Stepper({
|
|
13
|
+
steps,
|
|
14
|
+
current,
|
|
15
|
+
onStepSelect,
|
|
16
|
+
className,
|
|
17
|
+
"aria-label": label = "Progress",
|
|
18
|
+
}: {
|
|
19
|
+
steps: StepItem[]
|
|
20
|
+
/** Index of the current step. Steps before it are done. */
|
|
21
|
+
current: number
|
|
22
|
+
/** Lets people go back to a done step. Steps ahead stay locked. */
|
|
23
|
+
onStepSelect?: (index: number) => void
|
|
24
|
+
className?: string
|
|
25
|
+
"aria-label"?: string
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<ol aria-label={label} data-slot="stepper" className={cn("grid", className)}>
|
|
29
|
+
{steps.map((step, i) => {
|
|
30
|
+
const state = i < current ? "done" : i === current ? "current" : "upcoming"
|
|
31
|
+
const last = i === steps.length - 1
|
|
32
|
+
const marker = (
|
|
33
|
+
<span
|
|
34
|
+
data-slot="stepper-marker"
|
|
35
|
+
className={cn(
|
|
36
|
+
"relative z-10 grid size-5 shrink-0 place-items-center rounded-full border text-sm font-semibold tabular transition-[color,background-color,border-color] duration-(--duration-slow) ease-(--ease-standard)",
|
|
37
|
+
state === "done" && "border-primary bg-primary text-primary-foreground",
|
|
38
|
+
state === "current" && "border-primary bg-primary-soft text-primary-ink",
|
|
39
|
+
state === "upcoming" && "border-line-strong bg-surface text-ink-3"
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
<Swap kind="icon" swapKey={state === "done" ? "done" : "number"}>{state === "done" ? <Check className="size-3" strokeWidth={3} aria-hidden="true" /> : i + 1}</Swap>
|
|
43
|
+
</span>
|
|
44
|
+
)
|
|
45
|
+
const text = (
|
|
46
|
+
<span className="grid gap-0.5 pb-5 text-left">
|
|
47
|
+
<span className={cn("text-base font-medium transition-colors duration-(--duration-slow) ease-(--ease-standard)", state === "upcoming" ? "text-ink-3" : "text-ink")}>
|
|
48
|
+
{step.title}
|
|
49
|
+
<span className="sr-only">{state === "done" ? " (done)" : state === "current" ? " (current step)" : ""}</span>
|
|
50
|
+
</span>
|
|
51
|
+
{step.description && <span className={cn("text-sm transition-colors duration-(--duration-slow) ease-(--ease-standard)", state === "upcoming" ? "text-ink-3" : "text-ink-2")}>{step.description}</span>}
|
|
52
|
+
</span>
|
|
53
|
+
)
|
|
54
|
+
const canSelect = state === "done" && !!onStepSelect
|
|
55
|
+
return (
|
|
56
|
+
<li key={step.title} data-state={state} aria-current={state === "current" ? "step" : undefined} className="group/step relative grid grid-cols-[20px_1fr] gap-x-3 has-[button:hover]:[&_[data-slot=stepper-marker]]:bg-primary-hover">
|
|
57
|
+
{!last && (
|
|
58
|
+
// The line fills downwards when the step is done, instead of switching colour.
|
|
59
|
+
<span aria-hidden="true" className="absolute top-5 bottom-0 left-[9.5px] w-px overflow-hidden bg-line-strong">
|
|
60
|
+
<span className={cn("block size-full origin-top bg-primary transition-transform duration-(--duration-slow) ease-(--ease-standard)", i < current ? "scale-y-100" : "scale-y-0")} />
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
{/* Same elements in every state, so colour and icon changes can animate; a done step gets a button laid over the row. */}
|
|
64
|
+
{marker}
|
|
65
|
+
{text}
|
|
66
|
+
{canSelect && (
|
|
67
|
+
<button type="button" onClick={() => onStepSelect(i)} className="peer absolute inset-0 rounded-xs">
|
|
68
|
+
<span className="sr-only">Go back to {step.title}</span>
|
|
69
|
+
</button>
|
|
70
|
+
)}
|
|
71
|
+
</li>
|
|
72
|
+
)
|
|
73
|
+
})}
|
|
74
|
+
</ol>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type { StepItem }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated from shadcn/ui new-york-v4 "switch" by scripts/tailor-shadcn.mjs. Do not edit by hand.
|
|
3
|
+
* TwoUX changes:
|
|
4
|
+
* - Thumb glides with TwoUX's ease-out
|
|
5
|
+
* - Off track at 3.59:1 so the switch is visible when off (WCAG 1.4.11)
|
|
6
|
+
* - Invisible hit area: 28px around the 16px track (WCAG 2.5.8)
|
|
7
|
+
* - Sized to its label: a 16×28 track by default (14×24 small), no taller than the 15px text beside it, with 2px of track around the thumb
|
|
8
|
+
* - Thumb travels the full track at each size
|
|
9
|
+
*/
|
|
10
|
+
"use client"
|
|
11
|
+
|
|
12
|
+
import * as React from "react"
|
|
13
|
+
import { cn } from "../lib/cn"
|
|
14
|
+
import { Switch as SwitchPrimitive } from "radix-ui"
|
|
15
|
+
|
|
16
|
+
function Switch({
|
|
17
|
+
className,
|
|
18
|
+
size = "default",
|
|
19
|
+
...props
|
|
20
|
+
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
|
21
|
+
size?: "sm" | "default"
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<SwitchPrimitive.Root
|
|
25
|
+
data-slot="switch"
|
|
26
|
+
data-size={size}
|
|
27
|
+
className={cn(
|
|
28
|
+
"peer group/switch relative inline-flex after:absolute after:-inset-1.5 after:rounded-full shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 px-px data-[size=default]:h-4 data-[size=default]:w-7 data-[size=sm]:h-3.5 data-[size=sm]:w-6 data-[state=checked]:bg-primary data-[state=unchecked]:bg-control",
|
|
29
|
+
className
|
|
30
|
+
)}
|
|
31
|
+
{...props}
|
|
32
|
+
>
|
|
33
|
+
<SwitchPrimitive.Thumb
|
|
34
|
+
data-slot="switch-thumb"
|
|
35
|
+
className={cn(
|
|
36
|
+
"pointer-events-none block rounded-full bg-background ring-0 transition-transform duration-(--duration-base) ease-(--ease-out) group-data-[size=default]/switch:size-3 group-data-[size=sm]/switch:size-2.5 group-data-[size=default]/switch:data-[state=checked]:translate-x-3 group-data-[size=sm]/switch:data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0"
|
|
37
|
+
)}
|
|
38
|
+
/>
|
|
39
|
+
</SwitchPrimitive.Root>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export { Switch }
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated from shadcn/ui new-york-v4 "table" by scripts/tailor-shadcn.mjs. Do not edit by hand.
|
|
3
|
+
* TwoUX changes:
|
|
4
|
+
* - Adds containerClassName, so a table can scroll inside a fixed height and keep its header sticky
|
|
5
|
+
* - Adds size="dense": 28px rows and header with 8px padding, the same 13px text, for spreadsheet-like data (inventories, ledgers, imports, mapping). Not a density setting
|
|
6
|
+
* - Sticky header on the quiet surface, with the light divider
|
|
7
|
+
* - Rows: light divider, hover fill, indigo tint when selected, an indigo edge on the keyboard-focused row
|
|
8
|
+
* - Rows and header use the row tokens (36px, or 28px dense), 12px side padding and 2px top and bottom, so a 28px control in a cell keeps the row at 36px; header labels 12px medium grey, in sentence case
|
|
9
|
+
* - Frozen columns: data-pinned on a head and its cells keeps them in view when the table scrolls sideways, at left var(--pin); data-pinned="last" draws the edge
|
|
10
|
+
*/
|
|
11
|
+
"use client"
|
|
12
|
+
|
|
13
|
+
import * as React from "react"
|
|
14
|
+
import { cn } from "../lib/cn"
|
|
15
|
+
|
|
16
|
+
function Table({ className, containerClassName, size = "default", ...props }: React.ComponentProps<"table"> & { containerClassName?: string; size?: "default" | "dense" }) {
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
data-slot="table-container"
|
|
20
|
+
className={cn("relative w-full overflow-x-auto", containerClassName)}
|
|
21
|
+
>
|
|
22
|
+
<table
|
|
23
|
+
data-slot="table"
|
|
24
|
+
data-size={size}
|
|
25
|
+
className={cn("group/table w-full caption-bottom text-base", className)}
|
|
26
|
+
{...props}
|
|
27
|
+
/>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
|
33
|
+
return (
|
|
34
|
+
<thead
|
|
35
|
+
data-slot="table-header"
|
|
36
|
+
className={cn("sticky top-0 z-10 bg-surface-2 [&_tr]:border-b [&_tr]:border-line", className)}
|
|
37
|
+
{...props}
|
|
38
|
+
/>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
|
43
|
+
return (
|
|
44
|
+
<tbody
|
|
45
|
+
data-slot="table-body"
|
|
46
|
+
className={cn("[&_tr:last-child]:border-0", className)}
|
|
47
|
+
{...props}
|
|
48
|
+
/>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
|
53
|
+
return (
|
|
54
|
+
<tfoot
|
|
55
|
+
data-slot="table-footer"
|
|
56
|
+
className={cn(
|
|
57
|
+
"border-t border-line bg-surface-2 font-medium [&>tr]:last:border-b-0",
|
|
58
|
+
className
|
|
59
|
+
)}
|
|
60
|
+
{...props}
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|
66
|
+
return (
|
|
67
|
+
<tr
|
|
68
|
+
data-slot="table-row"
|
|
69
|
+
className={cn(
|
|
70
|
+
"border-b border-line transition-colors duration-(--duration-fast) ease-(--ease-out) outline-none hover:bg-hover has-aria-expanded:bg-hover data-[state=selected]:bg-primary-soft data-[focused=true]:shadow-[inset_2px_0_0_var(--color-primary)]",
|
|
71
|
+
className
|
|
72
|
+
)}
|
|
73
|
+
{...props}
|
|
74
|
+
/>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|
79
|
+
return (
|
|
80
|
+
<th
|
|
81
|
+
data-slot="table-head"
|
|
82
|
+
className={cn(
|
|
83
|
+
"h-row px-3 text-left align-middle text-sm font-medium whitespace-nowrap text-ink-2 group-data-[size=dense]/table:h-row-dense group-data-[size=dense]/table:px-2 data-pinned:sticky data-pinned:left-[var(--pin,0px)] data-pinned:z-20 data-pinned:bg-surface-2 data-[pinned=last]:border-r data-[pinned=last]:border-line [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
|
84
|
+
className
|
|
85
|
+
)}
|
|
86
|
+
{...props}
|
|
87
|
+
/>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|
92
|
+
return (
|
|
93
|
+
<td
|
|
94
|
+
data-slot="table-cell"
|
|
95
|
+
className={cn(
|
|
96
|
+
"h-row px-3 py-hair align-middle whitespace-nowrap group-data-[size=dense]/table:h-row-dense group-data-[size=dense]/table:px-2 group-data-[size=dense]/table:py-0 data-pinned:sticky data-pinned:left-[var(--pin,0px)] data-pinned:z-1 data-pinned:bg-surface data-[pinned=last]:border-r data-[pinned=last]:border-line in-[tr:hover]:data-pinned:bg-hover in-data-[state=selected]:data-pinned:bg-primary-soft [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
|
97
|
+
className
|
|
98
|
+
)}
|
|
99
|
+
{...props}
|
|
100
|
+
/>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function TableCaption({
|
|
105
|
+
className,
|
|
106
|
+
...props
|
|
107
|
+
}: React.ComponentProps<"caption">) {
|
|
108
|
+
return (
|
|
109
|
+
<caption
|
|
110
|
+
data-slot="table-caption"
|
|
111
|
+
className={cn("mt-4 text-base text-muted-foreground", className)}
|
|
112
|
+
{...props}
|
|
113
|
+
/>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export {
|
|
118
|
+
Table,
|
|
119
|
+
TableHeader,
|
|
120
|
+
TableBody,
|
|
121
|
+
TableFooter,
|
|
122
|
+
TableHead,
|
|
123
|
+
TableRow,
|
|
124
|
+
TableCell,
|
|
125
|
+
TableCaption,
|
|
126
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated from shadcn/ui new-york-v4 "tabs" by scripts/tailor-shadcn.mjs. Do not edit by hand.
|
|
3
|
+
* TwoUX changes:
|
|
4
|
+
* - Inactive tabs use secondary text (9:1), not 60% ink, which is 4.46:1 on the tab rail
|
|
5
|
+
* - TwoUX's default is the underline (line) style for page sections
|
|
6
|
+
* - Line tabs span the width with a divider underneath
|
|
7
|
+
* - Line tabs: no padding, indigo text and underline when active
|
|
8
|
+
* - Adds count after the label; it pops when it changes
|
|
9
|
+
* - One indicator slides to the active tab (underline or pill) instead of jumping; vertical tabs keep per-tab styling
|
|
10
|
+
*/
|
|
11
|
+
"use client"
|
|
12
|
+
|
|
13
|
+
import * as React from "react"
|
|
14
|
+
import { cva, type VariantProps } from "class-variance-authority"
|
|
15
|
+
import { cn } from "../lib/cn"
|
|
16
|
+
import { Tabs as TabsPrimitive } from "radix-ui"
|
|
17
|
+
import { Count } from "./motion"
|
|
18
|
+
import { ActiveIndicator } from "../lib/active-indicator"
|
|
19
|
+
|
|
20
|
+
function Tabs({
|
|
21
|
+
className,
|
|
22
|
+
orientation = "horizontal",
|
|
23
|
+
...props
|
|
24
|
+
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
|
25
|
+
return (
|
|
26
|
+
<TabsPrimitive.Root
|
|
27
|
+
data-slot="tabs"
|
|
28
|
+
data-orientation={orientation}
|
|
29
|
+
orientation={orientation}
|
|
30
|
+
className={cn(
|
|
31
|
+
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
|
32
|
+
className
|
|
33
|
+
)}
|
|
34
|
+
{...props}
|
|
35
|
+
/>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const tabsListVariants = cva(
|
|
40
|
+
"group/tabs-list inline-flex w-fit items-center justify-center rounded-md p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
|
41
|
+
{
|
|
42
|
+
variants: {
|
|
43
|
+
variant: {
|
|
44
|
+
default: "bg-muted",
|
|
45
|
+
line: "h-9 w-full justify-start gap-5 rounded-none border-b border-line bg-transparent p-0",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
defaultVariants: {
|
|
49
|
+
variant: "line",
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
function TabsList({
|
|
55
|
+
className,
|
|
56
|
+
variant = "line",
|
|
57
|
+
...props
|
|
58
|
+
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
|
59
|
+
VariantProps<typeof tabsListVariants>) {
|
|
60
|
+
return (
|
|
61
|
+
<TabsPrimitive.List
|
|
62
|
+
data-slot="tabs-list"
|
|
63
|
+
data-variant={variant}
|
|
64
|
+
className={cn(tabsListVariants({ variant }), "relative", className)}
|
|
65
|
+
{...props}
|
|
66
|
+
>
|
|
67
|
+
<ActiveIndicator
|
|
68
|
+
selector="[data-state=active]"
|
|
69
|
+
fit={variant === "line" ? "x" : "box"}
|
|
70
|
+
className={cn(
|
|
71
|
+
"group-data-[orientation=vertical]/tabs:hidden",
|
|
72
|
+
variant === "line" ? "bottom-[-1px] h-0.5 rounded-full bg-primary" : "rounded-sm bg-background shadow-sm"
|
|
73
|
+
)}
|
|
74
|
+
/>
|
|
75
|
+
{props.children}
|
|
76
|
+
</TabsPrimitive.List>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function TabsTrigger({
|
|
81
|
+
className,
|
|
82
|
+
count,
|
|
83
|
+
children,
|
|
84
|
+
...props
|
|
85
|
+
}: React.ComponentProps<typeof TabsPrimitive.Trigger> & { count?: number }) {
|
|
86
|
+
return (
|
|
87
|
+
<TabsPrimitive.Trigger
|
|
88
|
+
data-slot="tabs-trigger"
|
|
89
|
+
className={cn(
|
|
90
|
+
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-sm border border-transparent px-2 py-1 text-base font-medium whitespace-nowrap text-ink-2 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[orientation=vertical]/tabs:group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
91
|
+
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
|
92
|
+
"group-data-[orientation=vertical]/tabs:data-[state=active]:bg-background data-[state=active]:text-foreground",
|
|
93
|
+
"group-data-[variant=line]/tabs-list:flex-none group-data-[variant=line]/tabs-list:px-0 group-data-[variant=line]/tabs-list:data-[state=active]:text-primary-ink group-data-[variant=line]/tabs-list:after:bottom-[-1px] group-data-[variant=line]/tabs-list:after:bg-primary",
|
|
94
|
+
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[orientation=vertical]/tabs:group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
|
95
|
+
className
|
|
96
|
+
)}
|
|
97
|
+
{...props}
|
|
98
|
+
>
|
|
99
|
+
{children}
|
|
100
|
+
{count !== undefined && <Count value={count} className="text-sm font-normal text-ink-3" />}
|
|
101
|
+
</TabsPrimitive.Trigger>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function TabsContent({
|
|
106
|
+
className,
|
|
107
|
+
...props
|
|
108
|
+
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
|
109
|
+
return (
|
|
110
|
+
<TabsPrimitive.Content
|
|
111
|
+
data-slot="tabs-content"
|
|
112
|
+
className={cn("flex-1 outline-none", className)}
|
|
113
|
+
{...props}
|
|
114
|
+
/>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TagInput: several short values that don't come from a list (email addresses, domains, keywords), typed or pasted.
|
|
3
|
+
* TwoUX composition on the generated shadcn Field. Nothing is dropped silently: duplicates are reported, invalid values
|
|
4
|
+
* stay as marked tags with their reason, and values past the limit stay in the input. Proposal: proposals/tag-input.md.
|
|
5
|
+
*/
|
|
6
|
+
"use client"
|
|
7
|
+
|
|
8
|
+
import { AlertCircle, X } from "lucide-react"
|
|
9
|
+
import * as React from "react"
|
|
10
|
+
import { cn } from "../lib/cn"
|
|
11
|
+
import { Field, FieldDescription, FieldLabel } from "./field"
|
|
12
|
+
|
|
13
|
+
type TagInputProps = {
|
|
14
|
+
label: string
|
|
15
|
+
value: string[]
|
|
16
|
+
onValueChange: (value: string[]) => void
|
|
17
|
+
/** The reason a value is not valid, or undefined when it is: "Not an email address." */
|
|
18
|
+
validateValue?: (value: string) => string | undefined
|
|
19
|
+
/** Most values people can add. Shows "3 of 10" under the field. */
|
|
20
|
+
max?: number
|
|
21
|
+
/** The thing entered, used in messages: { one: "address", many: "addresses" }. Defaults to value and values. */
|
|
22
|
+
noun?: { one: string; many: string }
|
|
23
|
+
/** Defaults to "Separate <many> with commas or new lines". */
|
|
24
|
+
hint?: string
|
|
25
|
+
/** An error from the form's own checks, such as "Add at least one address." */
|
|
26
|
+
error?: string
|
|
27
|
+
placeholder?: string
|
|
28
|
+
required?: boolean
|
|
29
|
+
optional?: boolean
|
|
30
|
+
disabled?: boolean
|
|
31
|
+
/** Values stay focusable and readable, without remove or edit. */
|
|
32
|
+
readOnly?: boolean
|
|
33
|
+
id?: string
|
|
34
|
+
/** Each value is submitted as a separate form value. */
|
|
35
|
+
name?: string
|
|
36
|
+
className?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SPLIT = /[,;\n\t]+/
|
|
40
|
+
const same = (a: string, b: string) => a.localeCompare(b, "en-GB", { sensitivity: "base" }) === 0
|
|
41
|
+
|
|
42
|
+
/** Several free values in one field: Enter or comma adds, pasted lists are split, each value is checked as it is added. */
|
|
43
|
+
export function TagInput({ label, value, onValueChange, validateValue, max, noun = { one: "value", many: "values" }, hint, error, placeholder, required, optional, disabled, readOnly, id, name, className }: TagInputProps) {
|
|
44
|
+
const auto = React.useId()
|
|
45
|
+
const fid = id ?? auto
|
|
46
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
47
|
+
const chipRefs = React.useRef<(HTMLButtonElement | null)[]>([])
|
|
48
|
+
const [text, setText] = React.useState("")
|
|
49
|
+
const [notice, setNotice] = React.useState("")
|
|
50
|
+
const [flash, setFlash] = React.useState<string | null>(null)
|
|
51
|
+
const [focusIndex, setFocusIndex] = React.useState<number | null>(null)
|
|
52
|
+
|
|
53
|
+
React.useEffect(() => {
|
|
54
|
+
if (focusIndex === null) return
|
|
55
|
+
;(chipRefs.current[focusIndex] ?? inputRef.current)?.focus()
|
|
56
|
+
setFocusIndex(null)
|
|
57
|
+
}, [focusIndex, value])
|
|
58
|
+
React.useEffect(() => {
|
|
59
|
+
if (!flash) return
|
|
60
|
+
const t = setTimeout(() => setFlash(null), 600)
|
|
61
|
+
return () => clearTimeout(t)
|
|
62
|
+
}, [flash])
|
|
63
|
+
|
|
64
|
+
const reasons = value.map((v) => validateValue?.(v))
|
|
65
|
+
const invalid = reasons.filter(Boolean).length
|
|
66
|
+
const word = (n: number) => (n === 1 ? noun.one : noun.many)
|
|
67
|
+
|
|
68
|
+
/** Adds values; returns the text that has to stay in the input (values past the limit). */
|
|
69
|
+
const add = (items: string[], pasted: boolean) => {
|
|
70
|
+
const next = [...value]
|
|
71
|
+
let added = 0, bad = 0
|
|
72
|
+
const dups: string[] = []
|
|
73
|
+
const left: string[] = []
|
|
74
|
+
for (const raw of items) {
|
|
75
|
+
const v = raw.trim()
|
|
76
|
+
if (!v) continue
|
|
77
|
+
const dup = next.find((x) => same(x, v))
|
|
78
|
+
if (dup) { dups.push(dup); continue }
|
|
79
|
+
if (max && next.length >= max) { left.push(v); continue }
|
|
80
|
+
next.push(v)
|
|
81
|
+
added++
|
|
82
|
+
if (validateValue?.(v)) bad++
|
|
83
|
+
}
|
|
84
|
+
if (added) onValueChange(next)
|
|
85
|
+
const parts: string[] = []
|
|
86
|
+
if (pasted && added) parts.push(`${added} added${bad ? `, ${bad} not valid` : ""}.`)
|
|
87
|
+
else if (bad) parts.push(`${validateValue?.(next[next.length - 1])}`)
|
|
88
|
+
if (dups.length === 1 && !pasted) parts.push(`${dups[0]} is already added.`)
|
|
89
|
+
else if (dups.length) parts.push(`${dups.length} already added.`)
|
|
90
|
+
if (left.length) parts.push(`You can add up to ${max}. ${left.length} ${word(left.length)} weren't added.`)
|
|
91
|
+
setNotice(parts.join(" "))
|
|
92
|
+
if (dups.length) setFlash(dups[dups.length - 1])
|
|
93
|
+
return left.join(", ")
|
|
94
|
+
}
|
|
95
|
+
const commit = () => {
|
|
96
|
+
if (!text.trim()) return
|
|
97
|
+
setText(add(text.split(SPLIT), false))
|
|
98
|
+
}
|
|
99
|
+
const remove = (i: number, then: "next" | "prev") => {
|
|
100
|
+
const v = value[i]
|
|
101
|
+
onValueChange(value.filter((_, j) => j !== i))
|
|
102
|
+
setNotice(`${v} removed.`)
|
|
103
|
+
// Focus the tag before (Backspace) or the one that moved into this place (Delete); the input when none is left.
|
|
104
|
+
const left = value.length - 1
|
|
105
|
+
setFocusIndex(left === 0 ? -1 : then === "prev" ? Math.max(i - 1, 0) : Math.min(i, left - 1))
|
|
106
|
+
}
|
|
107
|
+
const edit = (i: number) => {
|
|
108
|
+
const v = value[i]
|
|
109
|
+
onValueChange(value.filter((_, j) => j !== i))
|
|
110
|
+
setText(v)
|
|
111
|
+
setNotice("")
|
|
112
|
+
setFocusIndex(-1)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const shownError = error ?? (invalid ? `${invalid} ${word(invalid)} ${invalid === 1 ? "isn't" : "aren't"} valid. Fix or remove ${invalid === 1 ? "it" : "them"}.` : undefined)
|
|
116
|
+
const hintText = hint ?? `Separate ${noun.many} with commas or new lines`
|
|
117
|
+
const msgId = `${fid}-msg`
|
|
118
|
+
const helpId = `${fid}-help`
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<Field data-invalid={!!shownError || undefined} className={cn("gap-2", className)}>
|
|
122
|
+
<FieldLabel htmlFor={fid} id={`${fid}-label`}>
|
|
123
|
+
{label}
|
|
124
|
+
{required && <span aria-hidden="true" className="text-bad">*</span>}
|
|
125
|
+
{optional && <span className="font-normal text-ink-3">(optional)</span>}
|
|
126
|
+
</FieldLabel>
|
|
127
|
+
<div
|
|
128
|
+
data-slot="tag-input"
|
|
129
|
+
onMouseDown={(e) => {
|
|
130
|
+
if (e.target === e.currentTarget) {
|
|
131
|
+
e.preventDefault()
|
|
132
|
+
inputRef.current?.focus()
|
|
133
|
+
}
|
|
134
|
+
}}
|
|
135
|
+
className={cn(
|
|
136
|
+
"flex min-h-8 w-full min-w-0 cursor-text flex-wrap items-center gap-1 rounded-sm border border-input bg-background px-1 py-1 shadow-xs transition-[color,background-color,border-color,box-shadow] duration-(--duration-fast) ease-(--ease-out) hover:border-input-hover",
|
|
137
|
+
"focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50",
|
|
138
|
+
shownError && "not-focus-within:border-destructive-edge",
|
|
139
|
+
disabled && "pointer-events-none opacity-50",
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
{value.length > 0 && (
|
|
143
|
+
<ul role="list" aria-labelledby={`${fid}-label`} className="contents">
|
|
144
|
+
{value.map((v, i) => {
|
|
145
|
+
const reason = reasons[i]
|
|
146
|
+
return (
|
|
147
|
+
<li
|
|
148
|
+
key={v}
|
|
149
|
+
className={cn(
|
|
150
|
+
"inline-flex h-5.5 max-w-60 items-center gap-1 rounded-xs border px-control text-sm transition-colors duration-(--duration-base) ease-(--ease-out)",
|
|
151
|
+
reason ? "border-bad-line bg-bad-bg text-ink" : "border-line-strong bg-surface text-ink",
|
|
152
|
+
flash === v && "bg-selected",
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
{reason && <AlertCircle className="size-3 shrink-0 text-bad" aria-hidden="true" />}
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
tabIndex={-1}
|
|
159
|
+
ref={(el) => {
|
|
160
|
+
chipRefs.current[i] = el
|
|
161
|
+
}}
|
|
162
|
+
aria-label={reason ? `${v}, not valid: ${reason}` : v}
|
|
163
|
+
aria-describedby={readOnly ? undefined : helpId}
|
|
164
|
+
title={reason}
|
|
165
|
+
disabled={disabled}
|
|
166
|
+
onDoubleClick={() => !readOnly && edit(i)}
|
|
167
|
+
onKeyDown={(e) => {
|
|
168
|
+
if (e.key === "ArrowLeft") { e.preventDefault(); chipRefs.current[i - 1]?.focus() }
|
|
169
|
+
if (e.key === "ArrowRight") { e.preventDefault(); (chipRefs.current[i + 1] ?? inputRef.current)?.focus() }
|
|
170
|
+
if (e.key === "Home") { e.preventDefault(); chipRefs.current[0]?.focus() }
|
|
171
|
+
if (e.key === "End" || e.key === "Escape") { e.preventDefault(); inputRef.current?.focus() }
|
|
172
|
+
if (readOnly) return
|
|
173
|
+
if (e.key === "Backspace") { e.preventDefault(); remove(i, "prev") }
|
|
174
|
+
if (e.key === "Delete") { e.preventDefault(); remove(i, "next") }
|
|
175
|
+
if (e.key === "Enter") { e.preventDefault(); edit(i) }
|
|
176
|
+
}}
|
|
177
|
+
className="min-w-0 truncate rounded-xs outline-none focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring"
|
|
178
|
+
>
|
|
179
|
+
{v}
|
|
180
|
+
</button>
|
|
181
|
+
{!readOnly && (
|
|
182
|
+
<button
|
|
183
|
+
type="button"
|
|
184
|
+
tabIndex={-1}
|
|
185
|
+
aria-label={`Remove ${v}`}
|
|
186
|
+
disabled={disabled}
|
|
187
|
+
onClick={() => remove(i, "next")}
|
|
188
|
+
className="relative -mr-0.5 grid size-4 shrink-0 place-items-center rounded-xs text-ink-3 after:absolute after:-inset-1 hover:bg-hover hover:text-ink"
|
|
189
|
+
>
|
|
190
|
+
<X className="size-3" aria-hidden="true" />
|
|
191
|
+
</button>
|
|
192
|
+
)}
|
|
193
|
+
</li>
|
|
194
|
+
)
|
|
195
|
+
})}
|
|
196
|
+
</ul>
|
|
197
|
+
)}
|
|
198
|
+
<input
|
|
199
|
+
ref={inputRef}
|
|
200
|
+
id={fid}
|
|
201
|
+
value={text}
|
|
202
|
+
readOnly={readOnly}
|
|
203
|
+
disabled={disabled}
|
|
204
|
+
placeholder={value.length ? undefined : placeholder}
|
|
205
|
+
autoComplete="off"
|
|
206
|
+
aria-invalid={shownError ? true : undefined}
|
|
207
|
+
aria-describedby={msgId}
|
|
208
|
+
aria-required={required || undefined}
|
|
209
|
+
onChange={(e) => {
|
|
210
|
+
const t = e.target.value
|
|
211
|
+
if (/[,;]/.test(t)) setText(add(t.split(SPLIT), false))
|
|
212
|
+
else setText(t)
|
|
213
|
+
}}
|
|
214
|
+
onPaste={(e) => {
|
|
215
|
+
const pasted = e.clipboardData.getData("text")
|
|
216
|
+
if (!SPLIT.test(pasted)) return
|
|
217
|
+
e.preventDefault()
|
|
218
|
+
setText(add((text + pasted).split(SPLIT), true))
|
|
219
|
+
}}
|
|
220
|
+
onBlur={commit}
|
|
221
|
+
onKeyDown={(e) => {
|
|
222
|
+
const atStart = e.currentTarget.selectionStart === 0 && e.currentTarget.selectionEnd === 0
|
|
223
|
+
if (e.key === "Enter" && text.trim()) { e.preventDefault(); commit() }
|
|
224
|
+
if (e.key === "Tab" && !e.shiftKey && text.trim()) commit()
|
|
225
|
+
if ((e.key === "Backspace" || e.key === "ArrowLeft") && atStart && value.length) {
|
|
226
|
+
e.preventDefault()
|
|
227
|
+
chipRefs.current[value.length - 1]?.focus()
|
|
228
|
+
}
|
|
229
|
+
}}
|
|
230
|
+
className="h-5.5 min-w-24 flex-1 bg-transparent px-control text-base outline-none placeholder:text-muted-foreground"
|
|
231
|
+
/>
|
|
232
|
+
</div>
|
|
233
|
+
<div className="flex items-start gap-2">
|
|
234
|
+
<div className="min-w-0 flex-1">
|
|
235
|
+
{shownError ? (
|
|
236
|
+
// One message that wraps as text. Each invalid tag carries its own reason, and the live notice
|
|
237
|
+
// still tells screen readers what the last paste or entry did.
|
|
238
|
+
<p id={msgId} className="flex items-start gap-control text-sm text-bad">
|
|
239
|
+
<AlertCircle className="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
|
|
240
|
+
<span className="min-w-0">{shownError}</span>
|
|
241
|
+
</p>
|
|
242
|
+
) : (
|
|
243
|
+
<FieldDescription id={msgId}>{notice || hintText}</FieldDescription>
|
|
244
|
+
)}
|
|
245
|
+
</div>
|
|
246
|
+
{max && <span className="tabular shrink-0 text-sm text-ink-3">{value.length} of {max}</span>}
|
|
247
|
+
</div>
|
|
248
|
+
<span id={helpId} hidden>Enter to edit, Delete to remove</span>
|
|
249
|
+
<span className="sr-only" role="status" aria-live="polite">{notice}</span>
|
|
250
|
+
{name && value.map((v) => <input key={v} type="hidden" name={name} value={v} />)}
|
|
251
|
+
</Field>
|
|
252
|
+
)
|
|
253
|
+
}
|