@elabs-ai/components-ui 4.0.0 → 4.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/README.md +10 -8
- package/dist/chunk-JKPPTL63.js +48 -0
- package/dist/chunk-JKPPTL63.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +123 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +2485 -167
- package/dist/index.js +10716 -2706
- package/dist/index.js.map +1 -1
- package/package.json +21 -7
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +1 -1
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +4 -1
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.tsx +35 -2
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +1 -1
- package/src/components/attribution-panel/attributions.generated.ts +81 -77
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.tsx +1 -1
- package/src/components/bento-grid/bento-grid.test.tsx +1 -1
- package/src/components/bento-grid/bento-grid.tsx +1 -1
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +7 -2
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +1 -1
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.tsx +1 -1
- package/src/components/change-review/change-review.stories.tsx +145 -1
- package/src/components/change-review/change-review.test.tsx +143 -0
- package/src/components/change-review/change-review.tsx +185 -4
- package/src/components/checkbox/checkbox.tsx +1 -1
- package/src/components/color-picker/color-picker.tsx +6 -5
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +73 -0
- package/src/components/command/command.tsx +30 -7
- package/src/components/command/index.ts +1 -0
- package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
- package/src/components/command-trigger/command-trigger.test.tsx +18 -0
- package/src/components/command-trigger/command-trigger.tsx +70 -0
- package/src/components/command-trigger/index.ts +1 -0
- package/src/components/context-rail/context-rail.stories.tsx +285 -0
- package/src/components/context-rail/context-rail.test.tsx +263 -0
- package/src/components/context-rail/context-rail.tsx +678 -0
- package/src/components/context-rail/index.ts +1 -0
- package/src/components/copyable-value/copyable-value.tsx +1 -1
- package/src/components/dialog/dialog.tsx +2 -2
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/empty-state/empty-state.test.tsx +26 -0
- package/src/components/empty-state/empty-state.tsx +6 -1
- package/src/components/expand-dialog/expand-dialog.tsx +1 -2
- package/src/components/field/field-context.ts +59 -0
- package/src/components/field/field.stories.tsx +142 -0
- package/src/components/field/field.test.tsx +415 -0
- package/src/components/field/field.tsx +340 -0
- package/src/components/field/index.ts +9 -0
- package/src/components/field-row/field-row.stories.tsx +85 -1
- package/src/components/field-row/field-row.test.tsx +145 -0
- package/src/components/field-row/field-row.tsx +48 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +7 -2
- package/src/components/file-upload/file-upload.tsx +15 -4
- package/src/components/form/form.tsx +5 -2
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +2 -2
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +2 -2
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +13 -4
- package/src/components/input-otp/input-otp.tsx +5 -1
- package/src/components/keyboard-shortcuts/index.ts +6 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
- package/src/components/locale-provider/locale-provider.test.tsx +257 -1
- package/src/components/locale-provider/locale-provider.tsx +63 -8
- package/src/components/locale-provider/messages.ts +501 -2
- package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
- package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
- package/src/components/mention-input/mention-input.tsx +8 -38
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/metric-card/metric-card.stories.tsx +5 -3
- package/src/components/metric-card/metric-card.tsx +21 -2
- package/src/components/model-picker/model-picker.stories.tsx +63 -3
- package/src/components/model-picker/model-picker.test.tsx +63 -0
- package/src/components/model-picker/model-picker.tsx +62 -36
- package/src/components/nav-main/nav-main.tsx +1 -1
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.tsx +57 -5
- package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
- package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
- package/src/components/navigation-menu/navigation-menu.tsx +189 -12
- package/src/components/page-shell/page-shell.stories.tsx +163 -0
- package/src/components/page-shell/page-shell.test.tsx +229 -0
- package/src/components/page-shell/page-shell.tsx +108 -25
- package/src/components/pagination/pagination.test.tsx +90 -0
- package/src/components/pagination/pagination.tsx +9 -5
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +1 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.tsx +1 -1
- package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
- package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
- package/src/components/revision-timeline/revision-timeline.tsx +46 -3
- package/src/components/schema-form/from-json-schema.test.ts +390 -0
- package/src/components/schema-form/from-json-schema.ts +375 -0
- package/src/components/schema-form/index.ts +81 -0
- package/src/components/schema-form/schema-form-spec.ts +698 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +900 -0
- package/src/components/schema-form/schema-form.tsx +1545 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +12 -1
- package/src/components/select/select.tsx +6 -2
- package/src/components/sheet/sheet.tsx +1 -1
- package/src/components/side-dock/index.ts +1 -0
- package/src/components/side-dock/side-dock.stories.tsx +137 -0
- package/src/components/side-dock/side-dock.test.tsx +273 -0
- package/src/components/side-dock/side-dock.tsx +502 -0
- package/src/components/sidebar/index.ts +1 -0
- package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
- package/src/components/sidebar/sidebar.stories.tsx +421 -1
- package/src/components/sidebar/sidebar.test.tsx +36 -1
- package/src/components/sidebar/sidebar.tsx +245 -33
- package/src/components/skip-link/index.ts +1 -0
- package/src/components/skip-link/skip-link.stories.tsx +106 -0
- package/src/components/skip-link/skip-link.test.tsx +20 -0
- package/src/components/skip-link/skip-link.tsx +34 -0
- package/src/components/slider/slider.tsx +1 -1
- package/src/components/split-panel/index.ts +6 -1
- package/src/components/split-panel/split-panel.stories.tsx +18 -2
- package/src/components/split-panel/split-panel.test.tsx +20 -0
- package/src/components/split-panel/split-panel.tsx +28 -8
- package/src/components/state-panel/state-panel.stories.tsx +52 -0
- package/src/components/state-panel/state-panel.test.tsx +125 -0
- package/src/components/state-panel/state-panel.tsx +96 -17
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +1 -1
- package/src/components/table/table.stories.tsx +62 -0
- package/src/components/table/table.test.tsx +142 -1
- package/src/components/table/table.tsx +60 -1
- package/src/components/tabs/tabs.tsx +8 -2
- package/src/components/tag-input/tag-input.tsx +3 -2
- package/src/components/team-switcher/team-switcher.tsx +34 -5
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.tsx +6 -2
- package/src/components/theme-switcher/use-theme-transition.ts +2 -2
- package/src/components/timeline/timeline.stories.tsx +27 -0
- package/src/components/timeline/timeline.tsx +11 -1
- package/src/components/toggle/toggle.tsx +1 -1
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.tsx +14 -9
- package/src/components/typography/prose.tsx +1 -1
- package/src/components/typography/typography.stories.tsx +228 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
- package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
- package/src/components/view-toolbar/view-toolbar.tsx +37 -4
- package/src/components/virtual-select/virtual-select.test.tsx +1 -1
- package/src/components/wizard/wizard.tsx +1 -1
- package/src/components/workspace-picker/index.ts +3 -0
- package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
- package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
- package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
- package/src/components/workspace-picker/workspace-picker.tsx +172 -0
- package/src/illustrations/empty-list-illustration.tsx +26 -0
- package/src/illustrations/error-illustration.tsx +60 -0
- package/src/illustrations/first-run-illustration.tsx +27 -0
- package/src/illustrations/illustration-base.tsx +108 -0
- package/src/illustrations/illustrations.stories.tsx +94 -0
- package/src/illustrations/illustrations.test.tsx +98 -0
- package/src/illustrations/index.ts +16 -0
- package/src/illustrations/no-access-illustration.tsx +45 -0
- package/src/illustrations/no-results-illustration.tsx +46 -0
- package/src/illustrations/offline-illustration.tsx +37 -0
- package/src/illustrations/success-illustration.tsx +40 -0
- package/src/index.ts +75 -2
- package/src/lib/agent-event-model.test.ts +12 -0
- package/src/lib/agent-event-model.ts +42 -0
- package/src/lib/approval-option.test.ts +17 -0
- package/src/lib/approval-option.ts +53 -0
- package/src/lib/check-result.ts +33 -0
- package/src/lib/diff-line.test.ts +36 -0
- package/src/lib/diff-line.ts +63 -0
- package/src/lib/diff-rows.test.ts +76 -0
- package/src/lib/diff-rows.ts +92 -0
- package/src/lib/format-duration.test.ts +22 -0
- package/src/lib/format-duration.ts +30 -0
- package/src/lib/has-renderable-content.test.ts +49 -0
- package/src/lib/has-renderable-content.ts +32 -0
- package/src/lib/operating-mode.test.ts +36 -0
- package/src/lib/operating-mode.ts +81 -0
- package/src/lib/optional-peer.ts +59 -0
- package/src/lib/session-launch.ts +37 -0
- package/src/lib/slash-command.test.ts +44 -0
- package/src/lib/slash-command.ts +45 -0
- package/src/lib/trigger-query.test.ts +94 -0
- package/src/lib/trigger-query.ts +88 -0
- package/src/lib/use-mobile.ts +12 -6
- package/src/motion.stories.tsx +6 -0
- package/src/templates-object-detail-hub.stories.tsx +2 -2
- package/src/templates-screen-states.stories.tsx +5 -5
- package/src/templates-settings.stories.tsx +1 -1
- package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
- package/src/blocks/sidebar-02/logo.tsx +0 -18
- package/src/blocks/sidebar-02/nav-main.tsx +0 -13
- package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
- package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
- package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
- package/src/blocks/sidebar-04/mail-context.tsx +0 -29
- package/src/blocks/sidebar-04/nav-user.tsx +0 -6
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
- package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
- package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
|
@@ -11,6 +11,27 @@
|
|
|
11
11
|
* Design references: GitHub PR file-review (per-hunk viewed/comment),
|
|
12
12
|
* Cursor/Copilot "review changes" panel, Graphite/Linear review UIs.
|
|
13
13
|
*
|
|
14
|
+
* NOT a diff-line renderer, and deliberately never becoming one. A hunk's
|
|
15
|
+
* `before`/`after` are ordinary `ReactNode` prose, drawn as a quiet no-fill
|
|
16
|
+
* comparison; the `−`/`+` markers below mean "previous content"/"proposed
|
|
17
|
+
* content", which is not the same idea as `DiffLine`'s "removed line"/"added
|
|
18
|
+
* line" even though the glyphs coincide. Do NOT reach for `diffLineMarker` here:
|
|
19
|
+
* a coincidence stays a literal (theming.md, intentional-mirror rule).
|
|
20
|
+
*
|
|
21
|
+
* Structured `DiffLine[]` hunks reach this component by INJECTION, which is a
|
|
22
|
+
* binding decision, not an unfinished merge — see
|
|
23
|
+
* `docs/decisions/2026-09-01-brainless-adoption-architecture.md` § 3:
|
|
24
|
+
* - `ChangeHunk` gains NO `lines` field and `DiffLine` never moves here.
|
|
25
|
+
* - The app that depends on both packages passes a `<DiffView>`
|
|
26
|
+
* (`@elabs-ai/components-ai`, Shiki-backed) through `renderHunk` or a hunk's
|
|
27
|
+
* `after` slot. Neither package imports the other, in either direction.
|
|
28
|
+
* - Re-litigated and re-ratified 2026-09-03: moving this component into
|
|
29
|
+
* `@elabs-ai/components-ai` was rejected because it would push a
|
|
30
|
+
* dependency-free component into a layer-2 leaf that `terminal`, `editor`
|
|
31
|
+
* and `viewer` could then never reach.
|
|
32
|
+
* See the `InjectedHunkRenderer` story here, and `ChangeReviewComposition` in
|
|
33
|
+
* `packages/ai/src/diff-view.stories.tsx` for the real two-package wiring.
|
|
34
|
+
*
|
|
14
35
|
* State grid: empty (no hunks → StatePanel), single hunk, mixed, all-approved,
|
|
15
36
|
* all-rejected, very long hunk content. Approval state is signalled with color
|
|
16
37
|
* AND icon/label (colorblind + high-decoration/high-contrast safe).
|
|
@@ -36,12 +57,16 @@ import {
|
|
|
36
57
|
type HTMLAttributes,
|
|
37
58
|
type ReactNode,
|
|
38
59
|
} from "react";
|
|
39
|
-
import { Check, X, CheckCheck, User, Bot, Clock } from "lucide-react";
|
|
60
|
+
import { Check, X, CheckCheck, User, Bot, Clock, ChevronDown } from "lucide-react";
|
|
40
61
|
import { cva, type VariantProps } from "class-variance-authority";
|
|
41
62
|
import { cn } from "../../lib/cn";
|
|
63
|
+
import { type CheckResult } from "../../lib/check-result";
|
|
64
|
+
import { formatElapsed } from "../../lib/format-duration";
|
|
42
65
|
import { Button } from "../button/button";
|
|
43
66
|
import { Badge } from "../badge/badge";
|
|
44
67
|
import { StatePanel } from "../state-panel/state-panel";
|
|
68
|
+
import { StatusIcon } from "../status-badge/status-badge";
|
|
69
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../collapsible/collapsible";
|
|
45
70
|
|
|
46
71
|
// ─── Public types ─────────────────────────────────────────────────────────────
|
|
47
72
|
|
|
@@ -74,6 +99,13 @@ export interface ChangeHunk {
|
|
|
74
99
|
status?: ChangeHunkStatus;
|
|
75
100
|
/** Per-hunk provenance override (overrides the review-level provenance). */
|
|
76
101
|
provenance?: ChangeProvenance;
|
|
102
|
+
/**
|
|
103
|
+
* Verification results for this hunk (lint / types / tests / policy hooks).
|
|
104
|
+
* Purely informational — a failing check never blocks approval, it only
|
|
105
|
+
* informs the reviewer's decision. Grouped into "Before" / "After" sections
|
|
106
|
+
* when both phases are present.
|
|
107
|
+
*/
|
|
108
|
+
checks?: CheckResult[];
|
|
77
109
|
}
|
|
78
110
|
|
|
79
111
|
/** State surface exposed by the ChangeReview context. */
|
|
@@ -334,6 +366,7 @@ export const ChangeReviewProvenance = forwardRef<HTMLDivElement, ChangeReviewPro
|
|
|
334
366
|
"flex flex-wrap items-center gap-x-3 gap-y-1 text-caption text-muted-foreground pb-3",
|
|
335
367
|
className,
|
|
336
368
|
)}
|
|
369
|
+
role="group"
|
|
337
370
|
aria-label="Change provenance"
|
|
338
371
|
{...props}
|
|
339
372
|
>
|
|
@@ -365,6 +398,7 @@ export const ChangeReviewProvenance = forwardRef<HTMLDivElement, ChangeReviewPro
|
|
|
365
398
|
<div
|
|
366
399
|
ref={ref}
|
|
367
400
|
className={cn("pb-3 text-caption text-muted-foreground", className)}
|
|
401
|
+
role="group"
|
|
368
402
|
aria-label="Change provenance"
|
|
369
403
|
{...props}
|
|
370
404
|
>
|
|
@@ -419,7 +453,11 @@ const HUNK_STATUS_ICONS: Record<ChangeHunkStatus, ReactNode> = {
|
|
|
419
453
|
</span>
|
|
420
454
|
),
|
|
421
455
|
removed: (
|
|
422
|
-
|
|
456
|
+
// #124: sibling of `added`/`modified` above — this glyph is a decorative
|
|
457
|
+
// aria-hidden marker beside a labelled span (see HUNK_STATUS_LABELS), and
|
|
458
|
+
// reads as running text at the type scale, so it takes the ink rung the
|
|
459
|
+
// other two already use.
|
|
460
|
+
<span className="text-destructive-text font-bold text-meta" aria-hidden="true">
|
|
423
461
|
−
|
|
424
462
|
</span>
|
|
425
463
|
),
|
|
@@ -436,6 +474,145 @@ const HUNK_STATUS_LABELS: Record<ChangeHunkStatus, string> = {
|
|
|
436
474
|
modified: "modified",
|
|
437
475
|
};
|
|
438
476
|
|
|
477
|
+
// ─── Checks (verification evidence) ────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* A check's `detail` collapses behind a disclosure once it's long enough that
|
|
481
|
+
* showing it inline would dominate the hunk row. Short details always render
|
|
482
|
+
* in place.
|
|
483
|
+
*/
|
|
484
|
+
const CHECK_DETAIL_COLLAPSE_THRESHOLD = 80;
|
|
485
|
+
|
|
486
|
+
function isLongCheckDetail(detail: string): boolean {
|
|
487
|
+
return detail.length > CHECK_DETAIL_COLLAPSE_THRESHOLD || detail.includes("\n");
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const CHECK_PHASE_LABELS: Record<"before" | "after", string> = {
|
|
491
|
+
before: "Before",
|
|
492
|
+
after: "After",
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Group checks for display. Phase headers ("Before" / "After") appear ONLY
|
|
497
|
+
* when both phases are present in the same hunk — a single-phase or
|
|
498
|
+
* unphased list of checks renders flat, with no header noise.
|
|
499
|
+
*/
|
|
500
|
+
function groupChecksByPhase(
|
|
501
|
+
checks: CheckResult[],
|
|
502
|
+
): { phase?: "before" | "after"; items: CheckResult[] }[] {
|
|
503
|
+
const before = checks.filter((c) => c.phase === "before");
|
|
504
|
+
const after = checks.filter((c) => c.phase === "after");
|
|
505
|
+
const showPhaseLabels = before.length > 0 && after.length > 0;
|
|
506
|
+
|
|
507
|
+
if (!showPhaseLabels) {
|
|
508
|
+
return [{ items: checks }];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const rest = checks.filter((c) => c.phase !== "before" && c.phase !== "after");
|
|
512
|
+
const groups: { phase?: "before" | "after"; items: CheckResult[] }[] = [];
|
|
513
|
+
if (rest.length > 0) groups.push({ items: rest });
|
|
514
|
+
groups.push({ phase: "before", items: before });
|
|
515
|
+
groups.push({ phase: "after", items: after });
|
|
516
|
+
return groups;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* A single verification result row: icon + label + accessible status word +
|
|
521
|
+
* optional duration, with an optional collapsed detail line below.
|
|
522
|
+
*
|
|
523
|
+
* The pass/fail signal is carried by TWO non-color cues — the icon (distinct
|
|
524
|
+
* glyph per `StatusIcon`'s canonical vocabulary) and the visible "Passed"/
|
|
525
|
+
* "Failed" text — so it survives in greyscale (WCAG 1.4.1). `StatusIcon` is
|
|
526
|
+
* decorative (`aria-hidden`); the status word is what reaches assistive tech.
|
|
527
|
+
*/
|
|
528
|
+
function ChangeCheckRow({ check }: { check: CheckResult }) {
|
|
529
|
+
const [detailOpen, setDetailOpen] = useState(false);
|
|
530
|
+
const { label, ok, detail, durationMs } = check;
|
|
531
|
+
const statusWord = ok ? "Passed" : "Failed";
|
|
532
|
+
const hasDetail = detail !== undefined && detail !== "";
|
|
533
|
+
const longDetail = hasDetail && isLongCheckDetail(detail);
|
|
534
|
+
|
|
535
|
+
return (
|
|
536
|
+
<div data-slot="change-review-hunk-check" data-ok={ok} className="flex flex-col gap-1">
|
|
537
|
+
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 min-w-0">
|
|
538
|
+
<StatusIcon status={ok ? "complete" : "failed"} className="size-3.5 shrink-0" />
|
|
539
|
+
<span className="text-caption font-medium text-foreground truncate">{label}</span>
|
|
540
|
+
<span className={cn("text-caption", ok ? "text-success-text" : "text-destructive-text")}>
|
|
541
|
+
{statusWord}
|
|
542
|
+
</span>
|
|
543
|
+
{durationMs !== undefined && (
|
|
544
|
+
<span className="text-caption text-muted-foreground tabular-nums shrink-0">
|
|
545
|
+
{formatElapsed(durationMs)}
|
|
546
|
+
</span>
|
|
547
|
+
)}
|
|
548
|
+
</div>
|
|
549
|
+
|
|
550
|
+
{hasDetail &&
|
|
551
|
+
(longDetail ? (
|
|
552
|
+
<Collapsible open={detailOpen} onOpenChange={setDetailOpen}>
|
|
553
|
+
<CollapsibleTrigger
|
|
554
|
+
className={cn(
|
|
555
|
+
"flex items-center gap-1 self-start rounded-sm pl-5 text-caption text-muted-foreground",
|
|
556
|
+
"hover:text-foreground focus-ring",
|
|
557
|
+
)}
|
|
558
|
+
>
|
|
559
|
+
<ChevronDown
|
|
560
|
+
aria-hidden="true"
|
|
561
|
+
className={cn(
|
|
562
|
+
"size-3 transition-transform duration-fast motion-reduce:transition-none",
|
|
563
|
+
detailOpen ? "rotate-180" : "rotate-0",
|
|
564
|
+
)}
|
|
565
|
+
/>
|
|
566
|
+
{detailOpen ? "Hide detail" : "Show detail"}
|
|
567
|
+
</CollapsibleTrigger>
|
|
568
|
+
<CollapsibleContent data-slot="change-review-hunk-check-detail">
|
|
569
|
+
<p className="pl-5 pt-1 text-caption text-muted-foreground break-words">{detail}</p>
|
|
570
|
+
</CollapsibleContent>
|
|
571
|
+
</Collapsible>
|
|
572
|
+
) : (
|
|
573
|
+
<p
|
|
574
|
+
data-slot="change-review-hunk-check-detail"
|
|
575
|
+
className="pl-5 text-caption text-muted-foreground break-words"
|
|
576
|
+
>
|
|
577
|
+
{detail}
|
|
578
|
+
</p>
|
|
579
|
+
))}
|
|
580
|
+
</div>
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/** The full checks section for one hunk — grouped by phase when applicable. */
|
|
585
|
+
function ChangeReviewChecks({ checks }: { checks: CheckResult[] }) {
|
|
586
|
+
const groups = groupChecksByPhase(checks);
|
|
587
|
+
const showPhaseLabels = groups.some((g) => g.phase !== undefined);
|
|
588
|
+
|
|
589
|
+
return (
|
|
590
|
+
<div
|
|
591
|
+
data-slot="change-review-hunk-checks"
|
|
592
|
+
className="flex flex-col gap-2 border-t border-border pt-3"
|
|
593
|
+
>
|
|
594
|
+
{groups.map((group, index) => (
|
|
595
|
+
<div
|
|
596
|
+
key={group.phase ?? `_${index}`}
|
|
597
|
+
data-slot="change-review-hunk-checks-phase"
|
|
598
|
+
className="flex flex-col gap-1.5"
|
|
599
|
+
>
|
|
600
|
+
{showPhaseLabels && group.phase && (
|
|
601
|
+
<span className="text-meta font-medium text-muted-foreground">
|
|
602
|
+
{CHECK_PHASE_LABELS[group.phase]}
|
|
603
|
+
</span>
|
|
604
|
+
)}
|
|
605
|
+
<div className="flex flex-col gap-1.5">
|
|
606
|
+
{group.items.map((check, itemIndex) => (
|
|
607
|
+
<ChangeCheckRow key={`${check.label}-${itemIndex}`} check={check} />
|
|
608
|
+
))}
|
|
609
|
+
</div>
|
|
610
|
+
</div>
|
|
611
|
+
))}
|
|
612
|
+
</div>
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
439
616
|
export interface ChangeReviewHunkProps
|
|
440
617
|
extends
|
|
441
618
|
Omit<HTMLAttributes<HTMLLIElement>, "children">,
|
|
@@ -454,13 +631,13 @@ export interface ChangeReviewHunkProps
|
|
|
454
631
|
* Approval state is signalled with TWO non-color cues:
|
|
455
632
|
* - icon: Check (approved) vs plain toggle (pending)
|
|
456
633
|
* - label: "Approved" badge vs "Approve" button text
|
|
457
|
-
* This ensures the state is clear in
|
|
634
|
+
* This ensures the state is clear in every theme including the low-chroma
|
|
458
635
|
* monochrome surfaces (colorblind-safe).
|
|
459
636
|
*/
|
|
460
637
|
export const ChangeReviewHunk = forwardRef<HTMLLIElement, ChangeReviewHunkProps>(
|
|
461
638
|
function ChangeReviewHunk({ hunk, renderHunk, className, ...props }, ref) {
|
|
462
639
|
const { state, actions } = useChangeReview();
|
|
463
|
-
const { id, title, before, after, status = "modified", provenance } = hunk;
|
|
640
|
+
const { id, title, before, after, status = "modified", provenance, checks } = hunk;
|
|
464
641
|
const approved = state.approved.has(id);
|
|
465
642
|
|
|
466
643
|
const statusLabel = HUNK_STATUS_LABELS[status];
|
|
@@ -513,6 +690,7 @@ export const ChangeReviewHunk = forwardRef<HTMLLIElement, ChangeReviewHunkProps>
|
|
|
513
690
|
<div className="flex items-center gap-2 min-w-0">
|
|
514
691
|
{/* Status indicator: NON-color cue via symbol + color together */}
|
|
515
692
|
<span
|
|
693
|
+
role="img"
|
|
516
694
|
aria-label={`Change type: ${statusLabel}`}
|
|
517
695
|
className="shrink-0 inline-flex items-center justify-center size-5"
|
|
518
696
|
>
|
|
@@ -561,6 +739,9 @@ export const ChangeReviewHunk = forwardRef<HTMLLIElement, ChangeReviewHunkProps>
|
|
|
561
739
|
|
|
562
740
|
{/* Content area: before / after or custom renderHunk */}
|
|
563
741
|
{defaultContent && <div>{defaultContent}</div>}
|
|
742
|
+
|
|
743
|
+
{/* Verification evidence — informational only, never blocks approval */}
|
|
744
|
+
{checks && checks.length > 0 && <ChangeReviewChecks checks={checks} />}
|
|
564
745
|
</li>
|
|
565
746
|
);
|
|
566
747
|
},
|
|
@@ -12,7 +12,7 @@ export const Checkbox = forwardRef<
|
|
|
12
12
|
ref={ref}
|
|
13
13
|
className={cn(
|
|
14
14
|
"peer size-4 shrink-0 rounded-sm border border-input shadow-sm transition-colors duration-fast ease-standard",
|
|
15
|
-
"focus-
|
|
15
|
+
"focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
16
16
|
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
17
17
|
"data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
|
18
18
|
className,
|
|
@@ -120,7 +120,7 @@ const Swatch = forwardRef<HTMLButtonElement, SwatchProps>(function Swatch(
|
|
|
120
120
|
selected
|
|
121
121
|
? "border-foreground ring-2 ring-ring ring-offset-1 ring-offset-background"
|
|
122
122
|
: "border-transparent hover:border-foreground/40",
|
|
123
|
-
"focus-
|
|
123
|
+
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
124
124
|
className,
|
|
125
125
|
)}
|
|
126
126
|
{...props}
|
|
@@ -266,7 +266,7 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
|
|
|
266
266
|
"inline-flex items-center gap-2 rounded-md border border-input bg-background px-3 py-1.5 text-sm shadow-sm",
|
|
267
267
|
"transition-[color,box-shadow] duration-fast",
|
|
268
268
|
"hover:bg-accent hover:text-accent-foreground",
|
|
269
|
-
"focus-
|
|
269
|
+
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
270
270
|
"disabled:pointer-events-none disabled:opacity-50",
|
|
271
271
|
)}
|
|
272
272
|
>
|
|
@@ -297,7 +297,8 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
|
|
|
297
297
|
<div className="mt-3 border-t border-border pt-3">
|
|
298
298
|
{/* Custom hex is NOT theme-aware — it will not adapt to theme changes. */}
|
|
299
299
|
<p className="mb-1.5 text-xs text-muted-foreground">
|
|
300
|
-
|
|
300
|
+
{/* #124: inline running text — ink rung, not the 3:1 mark rung. */}
|
|
301
|
+
Custom hex <span className="text-warning-text">(not theme-aware)</span>
|
|
301
302
|
</p>
|
|
302
303
|
<div className="flex gap-2">
|
|
303
304
|
<input
|
|
@@ -316,7 +317,7 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
|
|
|
316
317
|
}}
|
|
317
318
|
className={cn(
|
|
318
319
|
"h-8 w-28 rounded-md border border-input bg-background px-2 text-sm",
|
|
319
|
-
"focus-
|
|
320
|
+
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
320
321
|
"placeholder:text-muted-foreground",
|
|
321
322
|
)}
|
|
322
323
|
/>
|
|
@@ -326,7 +327,7 @@ export const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(function
|
|
|
326
327
|
className={cn(
|
|
327
328
|
"h-8 rounded-md border border-input bg-background px-2 text-sm",
|
|
328
329
|
"hover:bg-accent hover:text-accent-foreground",
|
|
329
|
-
"focus-
|
|
330
|
+
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
330
331
|
)}
|
|
331
332
|
>
|
|
332
333
|
Apply
|
|
@@ -3,7 +3,6 @@ import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent }
|
|
|
3
3
|
import { expect, waitFor, within } from "storybook/test";
|
|
4
4
|
import { Calculator, Calendar, CreditCard, Settings, Smile, User } from "lucide-react";
|
|
5
5
|
import { Button } from "../button";
|
|
6
|
-
import { DialogTitle } from "../dialog";
|
|
7
6
|
import {
|
|
8
7
|
Command,
|
|
9
8
|
CommandDialog,
|
|
@@ -19,6 +18,20 @@ const meta = {
|
|
|
19
18
|
title: "Overlays/Command",
|
|
20
19
|
component: Command,
|
|
21
20
|
tags: ["autodocs"],
|
|
21
|
+
parameters: {
|
|
22
|
+
docs: {
|
|
23
|
+
description: {
|
|
24
|
+
component:
|
|
25
|
+
"A grouped, searchable, keyboard-driven list (cmdk). Render it inline, or wrap it " +
|
|
26
|
+
"in `CommandDialog` for a full ⌘K palette — pass `CommandDialog`'s optional `title` " +
|
|
27
|
+
"for the dialog's `sr-only` accessible name.\n\n" +
|
|
28
|
+
"**Choosing between the two model/target pickers.** A full ⌘K palette → compose " +
|
|
29
|
+
"`CommandDialog` + `Command*` from `@elabs-ai/components-ui` directly; there is no " +
|
|
30
|
+
"separate selector component. An inline pill in a composer footer that opens the " +
|
|
31
|
+
"same grouped list anchored under itself → `ModelPicker`.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
},
|
|
22
35
|
argTypes: {
|
|
23
36
|
label: {
|
|
24
37
|
description: "Accessible label for the command palette (cmdk `label` prop).",
|
|
@@ -113,10 +126,7 @@ function CommandPaletteDemo() {
|
|
|
113
126
|
Open command palette
|
|
114
127
|
</Button>
|
|
115
128
|
|
|
116
|
-
<CommandDialog open={open} onOpenChange={setOpen}>
|
|
117
|
-
{/* CommandDialog doesn't ship a built-in title, so supply an sr-only one
|
|
118
|
-
for the dialog's accessible name. */}
|
|
119
|
-
<DialogTitle className="sr-only">Command palette</DialogTitle>
|
|
129
|
+
<CommandDialog open={open} onOpenChange={setOpen} title="Command palette">
|
|
120
130
|
<CommandInput placeholder="Type a command or search…" />
|
|
121
131
|
<CommandList>
|
|
122
132
|
<CommandEmpty>No results found.</CommandEmpty>
|
|
@@ -291,7 +301,7 @@ function ExternalInputComboboxDemo() {
|
|
|
291
301
|
role="combobox"
|
|
292
302
|
onKeyDown={forwardKey}
|
|
293
303
|
placeholder="Type @ to mention…"
|
|
294
|
-
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-body
|
|
304
|
+
className="flex h-10 w-full rounded-md border border-input bg-background px-3 text-body focus-ring"
|
|
295
305
|
/>
|
|
296
306
|
<Command
|
|
297
307
|
id="mention-list"
|
|
@@ -4,12 +4,15 @@ import { render, screen, waitFor } from "@testing-library/react";
|
|
|
4
4
|
import userEvent from "@testing-library/user-event";
|
|
5
5
|
import {
|
|
6
6
|
Command,
|
|
7
|
+
CommandDialog,
|
|
7
8
|
CommandGroup,
|
|
8
9
|
CommandInput,
|
|
9
10
|
CommandItem,
|
|
10
11
|
CommandList,
|
|
12
|
+
CommandSeparator,
|
|
11
13
|
useCommandActiveItemId,
|
|
12
14
|
} from "./command";
|
|
15
|
+
import { DialogTitle } from "../dialog";
|
|
13
16
|
|
|
14
17
|
// jsdom does not implement `Element.prototype.scrollIntoView`, and `cmdk`
|
|
15
18
|
// calls it unconditionally (not feature-detected — it's a third-party
|
|
@@ -278,3 +281,73 @@ describe("Command — external combobox input wiring (integration)", () => {
|
|
|
278
281
|
});
|
|
279
282
|
});
|
|
280
283
|
});
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* RM-010 — `CommandDialog` grew a `title` prop when the `ModelSelector` alias
|
|
287
|
+
* shell in `@elabs-ai/components-ai` was deleted; the `sr-only` `DialogTitle`
|
|
288
|
+
* was the one capability that shell had and `ui` lacked.
|
|
289
|
+
*
|
|
290
|
+
* It MUST stay optional. `@elabs-ai/components-ai`'s `VoiceSelectorDialog`
|
|
291
|
+
* spreads `ComponentProps<typeof CommandDialog>` straight through, and callers
|
|
292
|
+
* that already render their own `DialogTitle` in `children` must not be forced
|
|
293
|
+
* to move it. Both branches are asserted so a later "make it required" change
|
|
294
|
+
* fails here rather than in a downstream package.
|
|
295
|
+
*/
|
|
296
|
+
describe("CommandDialog — optional title (RM-010)", () => {
|
|
297
|
+
it("names the dialog from `title`, rendered sr-only", () => {
|
|
298
|
+
render(
|
|
299
|
+
<CommandDialog open title="Command palette">
|
|
300
|
+
<CommandList>
|
|
301
|
+
<CommandItem>Calendar</CommandItem>
|
|
302
|
+
</CommandList>
|
|
303
|
+
</CommandDialog>,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
const dialog = screen.getByRole("dialog");
|
|
307
|
+
expect(dialog).toHaveAccessibleName("Command palette");
|
|
308
|
+
expect(screen.getByText("Command palette")).toHaveClass("sr-only");
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("renders no title of its own when `title` is omitted — a caller's own DialogTitle still wins", () => {
|
|
312
|
+
render(
|
|
313
|
+
<CommandDialog open>
|
|
314
|
+
<DialogTitle>Caller-owned title</DialogTitle>
|
|
315
|
+
<CommandList>
|
|
316
|
+
<CommandItem>Calendar</CommandItem>
|
|
317
|
+
</CommandList>
|
|
318
|
+
</CommandDialog>,
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
expect(screen.getByRole("dialog")).toHaveAccessibleName("Caller-owned title");
|
|
322
|
+
// Exactly one heading — CommandDialog did not add a second, competing one.
|
|
323
|
+
expect(screen.getAllByText(/title/i)).toHaveLength(1);
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
/** #157 — `CommandSeparator` is `cmdk`'s divider between groups, rendered as a
|
|
328
|
+
* direct child of `CommandList`'s `role="listbox"`. A listbox may only own
|
|
329
|
+
* `option`/`group` children (WCAG 1.3.1); a bare `role="separator"` there is
|
|
330
|
+
* neither. */
|
|
331
|
+
describe("Command — CommandSeparator a11y (#157)", () => {
|
|
332
|
+
it("stays in the DOM for sighted users but is removed from the accessibility tree", () => {
|
|
333
|
+
render(
|
|
334
|
+
<Command>
|
|
335
|
+
<CommandList>
|
|
336
|
+
<CommandGroup heading="Fruit">
|
|
337
|
+
<CommandItem value="apple">Apple</CommandItem>
|
|
338
|
+
</CommandGroup>
|
|
339
|
+
<CommandSeparator />
|
|
340
|
+
<CommandGroup heading="Veg">
|
|
341
|
+
<CommandItem value="carrot">Carrot</CommandItem>
|
|
342
|
+
</CommandGroup>
|
|
343
|
+
</CommandList>
|
|
344
|
+
</Command>,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// Still rendered — cmdk owns the visual divider.
|
|
348
|
+
expect(document.querySelector("[cmdk-separator]")).not.toBeNull();
|
|
349
|
+
// Never exposed with its `role="separator"` — a listbox can't own one.
|
|
350
|
+
expect(screen.queryByRole("separator")).toBeNull();
|
|
351
|
+
expect(screen.getByRole("listbox")).toBeInTheDocument();
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -16,7 +16,7 @@ import { Command as CommandPrimitive, useCommandState } from "cmdk";
|
|
|
16
16
|
import { Search } from "lucide-react";
|
|
17
17
|
import { cn } from "../../lib/cn";
|
|
18
18
|
import { mergeRefs } from "../../lib/merge-refs";
|
|
19
|
-
import { Dialog, DialogContent } from "../dialog";
|
|
19
|
+
import { Dialog, DialogContent, DialogTitle } from "../dialog";
|
|
20
20
|
|
|
21
21
|
/** cmdk's own marker attribute on an item row — the scope for the id lookup. */
|
|
22
22
|
const CMDK_ITEM_SELECTOR = '[cmdk-item=""][aria-selected="true"]';
|
|
@@ -149,17 +149,34 @@ export function useCommandActiveItemId(): string | undefined {
|
|
|
149
149
|
return id === NO_COMMAND_PROVIDER ? undefined : id;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
export
|
|
153
|
-
children,
|
|
154
|
-
filter,
|
|
155
|
-
...props
|
|
156
|
-
}: ComponentPropsWithoutRef<typeof Dialog> & {
|
|
152
|
+
export type CommandDialogProps = ComponentPropsWithoutRef<typeof Dialog> & {
|
|
157
153
|
/** Custom cmdk ranking (e.g. strict substring instead of fuzzy). */
|
|
158
154
|
filter?: ComponentPropsWithoutRef<typeof CommandPrimitive>["filter"];
|
|
159
|
-
|
|
155
|
+
/**
|
|
156
|
+
* The dialog's accessible name, rendered `sr-only`.
|
|
157
|
+
*
|
|
158
|
+
* OPTIONAL, and deliberately so: callers that already render their own
|
|
159
|
+
* `DialogTitle` in `children` must not be forced to move it, and
|
|
160
|
+
* `@elabs-ai/components-ai`'s `VoiceSelectorDialog` passes this whole prop
|
|
161
|
+
* type straight through. Supply it when you have no visible title — a
|
|
162
|
+
* `Dialog` with no accessible name is a Radix warning and an axe violation.
|
|
163
|
+
*/
|
|
164
|
+
title?: ReactNode;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A ⌘K command palette in a modal `Dialog`.
|
|
169
|
+
*
|
|
170
|
+
* This IS the palette presentation of `Command` — there is no separate
|
|
171
|
+
* "selector" component. For an inline pill that opens the same grouped,
|
|
172
|
+
* searchable list anchored under itself (a composer footer), reach for
|
|
173
|
+
* `ModelPicker` instead.
|
|
174
|
+
*/
|
|
175
|
+
export function CommandDialog({ children, filter, title, ...props }: CommandDialogProps) {
|
|
160
176
|
return (
|
|
161
177
|
<Dialog {...props}>
|
|
162
178
|
<DialogContent className="overflow-hidden p-0">
|
|
179
|
+
{title === undefined ? null : <DialogTitle className="sr-only">{title}</DialogTitle>}
|
|
163
180
|
<Command
|
|
164
181
|
filter={filter}
|
|
165
182
|
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3"
|
|
@@ -244,6 +261,12 @@ export const CommandSeparator = forwardRef<
|
|
|
244
261
|
<CommandPrimitive.Separator
|
|
245
262
|
ref={ref}
|
|
246
263
|
data-slot="command-separator"
|
|
264
|
+
// `cmdk` renders this as a direct child of its `role="listbox"`
|
|
265
|
+
// (`CommandList`), which may only own `option`/`group` (WCAG 1.3.1,
|
|
266
|
+
// #157) — a purely visual divider between groups, so `aria-hidden`
|
|
267
|
+
// removes it from the accessibility tree instead of asking the listbox
|
|
268
|
+
// to own a role it can't. `{...props}` still lets a caller override it.
|
|
269
|
+
aria-hidden="true"
|
|
247
270
|
className={cn("-mx-1 h-px bg-border", className)}
|
|
248
271
|
{...props}
|
|
249
272
|
/>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, within } from "storybook/test";
|
|
3
|
+
import { CommandTrigger } from "./command-trigger";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "Navigation/CommandTrigger",
|
|
7
|
+
component: CommandTrigger,
|
|
8
|
+
parameters: {
|
|
9
|
+
docs: {
|
|
10
|
+
description: {
|
|
11
|
+
component:
|
|
12
|
+
"The command-palette opener a top bar reaches for: shaped like a search field, " +
|
|
13
|
+
"collapsing to just the icon under `sm`. The visible label and the shortcut `Kbd` are " +
|
|
14
|
+
"both decorative — the accessible name is authored with `aria-label` so the shortcut " +
|
|
15
|
+
"glyph never concatenates into it (#117).",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
tags: ["autodocs"],
|
|
20
|
+
} satisfies Meta<typeof CommandTrigger>;
|
|
21
|
+
export default meta;
|
|
22
|
+
type Story = StoryObj<typeof meta>;
|
|
23
|
+
|
|
24
|
+
/** Default label and platform-detected shortcut. */
|
|
25
|
+
export const Default: Story = {
|
|
26
|
+
play: async ({ canvasElement }) => {
|
|
27
|
+
const button = within(canvasElement).getByRole("button");
|
|
28
|
+
// #117: the Kbd's shortcut glyph must not pollute the computed name.
|
|
29
|
+
await expect(button).toHaveAccessibleName("Search");
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** A caller-supplied label and shortcut — useful when the platform guess is wrong. */
|
|
34
|
+
export const WithCustomShortcut: Story = {
|
|
35
|
+
args: {
|
|
36
|
+
label: "Find anything",
|
|
37
|
+
shortcut: "Ctrl K",
|
|
38
|
+
},
|
|
39
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { CommandTrigger } from "./command-trigger";
|
|
4
|
+
|
|
5
|
+
describe("CommandTrigger", () => {
|
|
6
|
+
it("announces only its label — the shortcut glyph must not pollute the accessible name", () => {
|
|
7
|
+
render(<CommandTrigger />);
|
|
8
|
+
// #117: a Kbd inside a control concatenates into the computed name. Assert the
|
|
9
|
+
// EXACT name; a regex match would happily pass on the polluted string.
|
|
10
|
+
expect(screen.getByRole("button")).toHaveAccessibleName("Search");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("takes a custom label and shortcut", () => {
|
|
14
|
+
render(<CommandTrigger label="Find anything" shortcut="Ctrl K" />);
|
|
15
|
+
expect(screen.getByRole("button")).toHaveAccessibleName("Find anything");
|
|
16
|
+
expect(screen.getByText("Ctrl K")).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { forwardRef, type ComponentProps } from "react";
|
|
2
|
+
import { Search } from "lucide-react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { Kbd } from "../kbd";
|
|
5
|
+
|
|
6
|
+
export interface CommandTriggerProps extends ComponentProps<"button"> {
|
|
7
|
+
/** The visible and announced label. Defaults to "Search". */
|
|
8
|
+
label?: string;
|
|
9
|
+
/** The shortcut hint. Defaults to the platform-correct ⌘ K / Ctrl K. */
|
|
10
|
+
shortcut?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function platformShortcut(): string {
|
|
14
|
+
if (typeof navigator === "undefined") return "Ctrl K";
|
|
15
|
+
// `userAgentData.platform` where available, else the legacy string. Both are
|
|
16
|
+
// hints, not guarantees — the trigger is a hint too, so a wrong guess is cosmetic.
|
|
17
|
+
const platform =
|
|
18
|
+
(navigator as { userAgentData?: { platform?: string } }).userAgentData?.platform ??
|
|
19
|
+
navigator.platform ??
|
|
20
|
+
"";
|
|
21
|
+
return /mac|iphone|ipad/i.test(platform) ? "⌘ K" : "Ctrl K";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The command-palette opener, shaped like a search field: label left, shortcut
|
|
26
|
+
* pinned right, collapsing to the icon alone under `sm`.
|
|
27
|
+
*
|
|
28
|
+
* The button’s accessible name comes entirely from `aria-label` — that is what
|
|
29
|
+
* stops the `Kbd`’s shortcut glyph from concatenating into it (#117); a
|
|
30
|
+
* non-empty `aria-label` short-circuits accessible-name computation before the
|
|
31
|
+
* DOM is ever consulted. The visible label and the `Kbd` are ALSO marked
|
|
32
|
+
* `aria-hidden`, per `accessibility.md`, as defence in depth: if `aria-label`
|
|
33
|
+
* is ever dropped, that turns a silent regression into a loud one — an empty
|
|
34
|
+
* accessible name rather than a plausible wrong one. They do not do the work
|
|
35
|
+
* themselves.
|
|
36
|
+
*
|
|
37
|
+
* `shortcut` reads `navigator` at render time when omitted, which can differ
|
|
38
|
+
* between server and client — a consumer that server-renders this component
|
|
39
|
+
* should pass `shortcut` explicitly to avoid a hydration mismatch.
|
|
40
|
+
*/
|
|
41
|
+
export const CommandTrigger = forwardRef<HTMLButtonElement, CommandTriggerProps>(
|
|
42
|
+
function CommandTrigger({ label = "Search", shortcut, className, ...props }, ref) {
|
|
43
|
+
const hint = shortcut ?? platformShortcut();
|
|
44
|
+
return (
|
|
45
|
+
<button
|
|
46
|
+
ref={ref}
|
|
47
|
+
type="button"
|
|
48
|
+
data-slot="command-trigger"
|
|
49
|
+
aria-label={label}
|
|
50
|
+
className={cn(
|
|
51
|
+
"inline-flex h-8 items-center gap-2 rounded-md border border-input bg-background px-2 text-body text-muted-foreground",
|
|
52
|
+
"hover:bg-accent hover:text-accent-foreground focus-ring",
|
|
53
|
+
"sm:w-56 sm:justify-between sm:ps-2 sm:pe-1.5",
|
|
54
|
+
className,
|
|
55
|
+
)}
|
|
56
|
+
{...props}
|
|
57
|
+
>
|
|
58
|
+
<span className="flex min-w-0 items-center gap-2">
|
|
59
|
+
<Search aria-hidden="true" className="size-4 shrink-0" />
|
|
60
|
+
<span aria-hidden="true" className="hidden truncate sm:inline">
|
|
61
|
+
{label}
|
|
62
|
+
</span>
|
|
63
|
+
</span>
|
|
64
|
+
<Kbd aria-hidden="true" className="hidden sm:inline-flex">
|
|
65
|
+
{hint}
|
|
66
|
+
</Kbd>
|
|
67
|
+
</button>
|
|
68
|
+
);
|
|
69
|
+
},
|
|
70
|
+
);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./command-trigger";
|