@elabs-ai/components-ui 4.0.0 → 4.2.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-FCH4ZN6G.js +57 -0
- package/dist/chunk-FCH4ZN6G.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 +2597 -188
- package/dist/index.js +12855 -3578
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +21 -7
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +15 -4
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +56 -4
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +3 -1
- package/src/components/attribution-panel/attributions.generated.ts +83 -79
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +15 -5
- package/src/components/bento-grid/bento-grid.test.tsx +24 -3
- package/src/components/bento-grid/bento-grid.tsx +11 -1
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +10 -4
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +48 -7
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- 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 +214 -24
- package/src/components/checkbox/checkbox.tsx +2 -1
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +22 -15
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +88 -0
- package/src/components/command/command.tsx +38 -13
- 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-menu/context-menu.tsx +16 -7
- 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.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +13 -2
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +14 -6
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- 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 +342 -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 +50 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +90 -12
- package/src/components/file-upload/file-upload.tsx +164 -31
- package/src/components/form/form.tsx +8 -3
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +3 -3
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +8 -4
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +18 -9
- package/src/components/input-otp/input-otp.tsx +8 -2
- 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/label/label.tsx +1 -1
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- 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 +822 -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.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +66 -46
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/menubar/menubar.tsx +16 -6
- 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 +8 -4
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +67 -9
- package/src/components/nav-user/nav-user.tsx +5 -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 +204 -15
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- 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 +109 -0
- package/src/components/pagination/pagination.tsx +27 -19
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +3 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +32 -6
- 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 +53 -5
- 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-store.ts +506 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +1021 -0
- package/src/components/schema-form/schema-form.tsx +1381 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +14 -3
- package/src/components/select/select.tsx +13 -7
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- 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 +299 -0
- package/src/components/side-dock/side-dock.tsx +515 -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 +71 -2
- package/src/components/sidebar/sidebar.tsx +264 -38
- package/src/components/skeleton/skeleton.tsx +1 -0
- 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/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- 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 +110 -24
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +3 -1
- package/src/components/table/table.stories.tsx +64 -0
- package/src/components/table/table.test.tsx +149 -1
- package/src/components/table/table.tsx +71 -2
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +176 -72
- package/src/components/tag-input/tag-input.tsx +6 -3
- package/src/components/team-switcher/team-switcher.tsx +35 -6
- package/src/components/textarea/textarea.tsx +2 -2
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +140 -12
- 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 +10 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +91 -31
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- 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.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +29 -3
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +13 -5
- 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 +80 -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/cn.ts +9 -0
- package/src/lib/csv.ts +48 -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 +94 -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/merge-refs.ts +2 -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-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +34 -12
- 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
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useState, type FormEvent, type HTMLAttributes } from "react";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
import { Button } from "../button";
|
|
6
|
+
import { Input } from "../input";
|
|
7
|
+
import { useLocale } from "../locale-provider";
|
|
8
|
+
import { ModelPicker, type ModelPickerGroup, type ModelPickerStatus } from "../model-picker";
|
|
9
|
+
import { formatLastOpened, type Workspace } from "./workspace-picker-state";
|
|
10
|
+
|
|
11
|
+
export type { Workspace };
|
|
12
|
+
|
|
13
|
+
export interface WorkspacePickerProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
|
14
|
+
/** Recent workspaces, most-relevant first — the caller owns ordering. */
|
|
15
|
+
workspaces: Workspace[];
|
|
16
|
+
/** The currently open workspace's id. */
|
|
17
|
+
currentId?: string;
|
|
18
|
+
/** Fires with the chosen workspace; the popover closes first. */
|
|
19
|
+
onSelect?: (workspace: Workspace) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Free-text path entry, rendered in `ModelPicker`'s `footer` slot below the
|
|
22
|
+
* list. Fires with the raw string the user typed once it validates as
|
|
23
|
+
* non-empty — this component performs no filesystem access of any kind
|
|
24
|
+
* (presentation layer, D5); resolving, normalizing or checking the path is
|
|
25
|
+
* the caller's job.
|
|
26
|
+
*/
|
|
27
|
+
onSubmitPath?: (path: string) => void;
|
|
28
|
+
/**
|
|
29
|
+
* Drives the same four-body state grid as `ModelPicker` — see
|
|
30
|
+
* `ModelPickerStatus`. Default `"ready"`, inherited from `ModelPicker`.
|
|
31
|
+
*/
|
|
32
|
+
status?: ModelPickerStatus;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
/** Accessible name for the trigger and the popover — what the control is FOR. */
|
|
35
|
+
"aria-label"?: string;
|
|
36
|
+
/** Controlled popover open state — see `ModelPicker`'s controlled contract. */
|
|
37
|
+
open?: boolean;
|
|
38
|
+
onOpenChange?: (open: boolean) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Recent-workspace switcher — issue #111, decided in
|
|
43
|
+
* `docs/decisions/2026-09-01-brainless-adoption-architecture.md` § 7.
|
|
44
|
+
*
|
|
45
|
+
* A `ModelPicker` PRESET, not a new picker: `ModelPickerItem`'s
|
|
46
|
+
* `id`/`label`/`description`/`meta` shape is already a superset of what a
|
|
47
|
+
* workspace needs, so this component only supplies workspace-shaped
|
|
48
|
+
* defaults — it maps `path` into the truncating `description` slot, formats
|
|
49
|
+
* `lastOpenedAt` with `Intl.RelativeTimeFormat`, marks the current workspace
|
|
50
|
+
* in the row's TEXT (never colour alone — `ModelPicker`'s check glyph is
|
|
51
|
+
* `aria-hidden` and cannot carry that on its own), and renders the free-text
|
|
52
|
+
* path entry into `ModelPicker`'s `footer` slot so it sits below the list,
|
|
53
|
+
* un-filtered by the search query.
|
|
54
|
+
*
|
|
55
|
+
* Unlike `TeamSwitcher`, this has no `Sidebar` dependency — it works anywhere
|
|
56
|
+
* a picker is needed (a session launch card, a command palette), not only
|
|
57
|
+
* inside a sidebar.
|
|
58
|
+
*/
|
|
59
|
+
export const WorkspacePicker = forwardRef<HTMLDivElement, WorkspacePickerProps>(
|
|
60
|
+
function WorkspacePicker(
|
|
61
|
+
{
|
|
62
|
+
workspaces,
|
|
63
|
+
currentId,
|
|
64
|
+
onSelect,
|
|
65
|
+
onSubmitPath,
|
|
66
|
+
status,
|
|
67
|
+
disabled,
|
|
68
|
+
className,
|
|
69
|
+
open,
|
|
70
|
+
onOpenChange,
|
|
71
|
+
"aria-label": ariaLabelProp,
|
|
72
|
+
...props
|
|
73
|
+
},
|
|
74
|
+
ref,
|
|
75
|
+
) {
|
|
76
|
+
const { t, locale } = useLocale();
|
|
77
|
+
const [path, setPath] = useState("");
|
|
78
|
+
|
|
79
|
+
const current = workspaces.find((workspace) => workspace.id === currentId);
|
|
80
|
+
const triggerLabel = current?.name ?? t("ui.workspacePicker.placeholder");
|
|
81
|
+
const ariaLabel = ariaLabelProp ?? t("ui.workspacePicker.label");
|
|
82
|
+
|
|
83
|
+
const group: ModelPickerGroup = {
|
|
84
|
+
key: "workspaces",
|
|
85
|
+
label: t("ui.workspacePicker.recent"),
|
|
86
|
+
items: workspaces.map((workspace) => {
|
|
87
|
+
const isCurrent = workspace.id === currentId;
|
|
88
|
+
const meta: string[] = [];
|
|
89
|
+
if (workspace.lastOpenedAt) {
|
|
90
|
+
meta.push(formatLastOpened(workspace.lastOpenedAt, locale));
|
|
91
|
+
}
|
|
92
|
+
// Marked in TEXT so the marker reaches the row's accessible name and
|
|
93
|
+
// survives greyscale (accessibility.md's colour-is-never-the-only-
|
|
94
|
+
// channel test) — ModelPicker's own check glyph is `aria-hidden` and
|
|
95
|
+
// cannot carry this alone.
|
|
96
|
+
if (isCurrent) meta.push(t("ui.workspacePicker.current"));
|
|
97
|
+
return {
|
|
98
|
+
id: workspace.id,
|
|
99
|
+
label: workspace.name,
|
|
100
|
+
description: workspace.path,
|
|
101
|
+
meta,
|
|
102
|
+
};
|
|
103
|
+
}),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const handleSelect = (item: { id: string }) => {
|
|
107
|
+
const workspace = workspaces.find((w) => w.id === item.id);
|
|
108
|
+
if (workspace) onSelect?.(workspace);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
112
|
+
event.preventDefault();
|
|
113
|
+
if (path.trim() === "") return;
|
|
114
|
+
onSubmitPath?.(path);
|
|
115
|
+
setPath("");
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<ModelPicker
|
|
120
|
+
ref={ref}
|
|
121
|
+
data-slot="workspace-picker"
|
|
122
|
+
className={cn(className)}
|
|
123
|
+
groups={[group]}
|
|
124
|
+
value={currentId}
|
|
125
|
+
onSelect={handleSelect}
|
|
126
|
+
status={status}
|
|
127
|
+
triggerLabel={triggerLabel}
|
|
128
|
+
aria-label={ariaLabel}
|
|
129
|
+
searchPlaceholder={t("ui.workspacePicker.searchPlaceholder")}
|
|
130
|
+
disabled={disabled}
|
|
131
|
+
open={open}
|
|
132
|
+
onOpenChange={onOpenChange}
|
|
133
|
+
footer={
|
|
134
|
+
// Outside `CommandList` on purpose (ModelPicker's own contract): a
|
|
135
|
+
// persistent action row, never filtered by the search query and
|
|
136
|
+
// never part of cmdk's roving selection.
|
|
137
|
+
//
|
|
138
|
+
// `onKeyDown` stops propagation because cmdk binds ITS OWN keydown
|
|
139
|
+
// handler on the `Command` root and reacts to Enter/Home/End/
|
|
140
|
+
// ArrowUp/ArrowDown regardless of which descendant is focused —
|
|
141
|
+
// without this, pressing Enter here would move the list's
|
|
142
|
+
// highlight instead of submitting the path.
|
|
143
|
+
<form
|
|
144
|
+
data-slot="workspace-picker-path-form"
|
|
145
|
+
onSubmit={handleSubmit}
|
|
146
|
+
onKeyDown={(event) => event.stopPropagation()}
|
|
147
|
+
className="flex items-center gap-2 border-t p-2"
|
|
148
|
+
>
|
|
149
|
+
<Input
|
|
150
|
+
data-slot="workspace-picker-path-input"
|
|
151
|
+
value={path}
|
|
152
|
+
onChange={(event) => setPath(event.target.value)}
|
|
153
|
+
placeholder={t("ui.workspacePicker.pathPlaceholder")}
|
|
154
|
+
aria-label={t("ui.workspacePicker.pathLabel")}
|
|
155
|
+
className="h-8 min-w-0 flex-1"
|
|
156
|
+
/>
|
|
157
|
+
<Button
|
|
158
|
+
type="submit"
|
|
159
|
+
size="sm"
|
|
160
|
+
variant="outline"
|
|
161
|
+
aria-disabled={path.trim() === ""}
|
|
162
|
+
data-slot="workspace-picker-path-submit"
|
|
163
|
+
>
|
|
164
|
+
{t("ui.workspacePicker.openPath")}
|
|
165
|
+
</Button>
|
|
166
|
+
</form>
|
|
167
|
+
}
|
|
168
|
+
{...props}
|
|
169
|
+
/>
|
|
170
|
+
);
|
|
171
|
+
},
|
|
172
|
+
);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { illustrationSvgProps, type IllustrationProps } from "./illustration-base";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Empty list / zero-data-yet state. Three line-art rows read as "empty list"
|
|
6
|
+
* on their own silhouette — no accent mark, per the accent-semantics
|
|
7
|
+
* convention in `illustration-base.tsx` (#48 finding 1): a floating "add"
|
|
8
|
+
* badge here would repeat information the rows already supply, not add any.
|
|
9
|
+
*/
|
|
10
|
+
export const EmptyListIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
|
|
11
|
+
function EmptyListIllustration({ size, className, ...props }, ref) {
|
|
12
|
+
return (
|
|
13
|
+
<svg
|
|
14
|
+
ref={ref}
|
|
15
|
+
{...illustrationSvgProps(size, className)}
|
|
16
|
+
data-slot="empty-list-illustration"
|
|
17
|
+
{...props}
|
|
18
|
+
>
|
|
19
|
+
<rect x="36" y="68" width="64" height="10" rx="5" />
|
|
20
|
+
<rect x="36" y="90" width="64" height="10" rx="5" />
|
|
21
|
+
<rect x="36" y="112" width="44" height="10" rx="5" />
|
|
22
|
+
</svg>
|
|
23
|
+
);
|
|
24
|
+
},
|
|
25
|
+
);
|
|
26
|
+
EmptyListIllustration.displayName = "EmptyListIllustration";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
illustrationSvgProps,
|
|
4
|
+
illustrationAccent,
|
|
5
|
+
type IllustrationProps,
|
|
6
|
+
} from "./illustration-base";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Recoverable-failure state. A line-art document split into two halves with a
|
|
10
|
+
* visible gap between them — distinguishable from `NoAccessIllustration`/
|
|
11
|
+
* `OfflineIllustration` by silhouette alone (a split document reads as
|
|
12
|
+
* "broken" even in greyscale), per the WCAG 1.4.1 "never color-only" rule.
|
|
13
|
+
* The exclamation mark IS meaning-bearing (per the accent-semantics
|
|
14
|
+
* convention in `illustration-base.tsx`, #48 finding 1) — it turns "torn
|
|
15
|
+
* document" into "warning", information the silhouette alone doesn't carry —
|
|
16
|
+
* so it stays, in the upper-right quadrant of the right-hand half, clear of
|
|
17
|
+
* both documents' outlines.
|
|
18
|
+
*
|
|
19
|
+
* Redrawn for #24 fix round 1 (P1-3) — the previous cracked-card + tiny alert
|
|
20
|
+
* triangle read as a lightning bolt or a broken-image placeholder and the
|
|
21
|
+
* triangle's exclamation collapsed to an illegible blob at 4rem.
|
|
22
|
+
*
|
|
23
|
+
* Redrawn again for #48 finding 3 — the mark previously sat at (76-84,
|
|
24
|
+
* 54-95), exactly where the two document halves' torn edges converge, so it
|
|
25
|
+
* read as a knot of overlapping lines rather than a "!". Moved to
|
|
26
|
+
* (98-113, 44-85), 8-15 units clear of the nearest tear line at every
|
|
27
|
+
* y-coordinate it spans.
|
|
28
|
+
*/
|
|
29
|
+
export const ErrorIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
|
|
30
|
+
function ErrorIllustration({ size, className, ...props }, ref) {
|
|
31
|
+
return (
|
|
32
|
+
<svg
|
|
33
|
+
ref={ref}
|
|
34
|
+
{...illustrationSvgProps(size, className)}
|
|
35
|
+
data-slot="error-illustration"
|
|
36
|
+
{...props}
|
|
37
|
+
>
|
|
38
|
+
<path d="M36,32 L83.06,32 L71.29,55.16 L92.47,69.06 L66.59,92.24 L76,120 L36,120 Z" />
|
|
39
|
+
<path d="M124,36 L124,124 L84,124 L74.59,96.24 L100.47,73.06 L79.29,59.16 L91.06,36 Z" />
|
|
40
|
+
<rect
|
|
41
|
+
x="104"
|
|
42
|
+
y="46"
|
|
43
|
+
width="8"
|
|
44
|
+
height="24"
|
|
45
|
+
rx="4"
|
|
46
|
+
style={{ fill: illustrationAccent("var(--destructive-text)") }}
|
|
47
|
+
stroke="none"
|
|
48
|
+
/>
|
|
49
|
+
<circle
|
|
50
|
+
cx="108"
|
|
51
|
+
cy="80"
|
|
52
|
+
r="5"
|
|
53
|
+
style={{ fill: illustrationAccent("var(--destructive-text)") }}
|
|
54
|
+
stroke="none"
|
|
55
|
+
/>
|
|
56
|
+
</svg>
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
ErrorIllustration.displayName = "ErrorIllustration";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { illustrationSvgProps, type IllustrationProps } from "./illustration-base";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* First-run / onboarding state. A line-art planted flag — reads as "start
|
|
6
|
+
* here" on its own silhouette, distinct from `SuccessIllustration`. No
|
|
7
|
+
* accent mark, per the accent-semantics convention in `illustration-base.tsx`
|
|
8
|
+
* (#48 finding 1): a decorative sparkle here would repeat information the
|
|
9
|
+
* flag already supplies, not add any.
|
|
10
|
+
*/
|
|
11
|
+
export const FirstRunIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
|
|
12
|
+
function FirstRunIllustration({ size, className, ...props }, ref) {
|
|
13
|
+
return (
|
|
14
|
+
<svg
|
|
15
|
+
ref={ref}
|
|
16
|
+
{...illustrationSvgProps(size, className)}
|
|
17
|
+
data-slot="first-run-illustration"
|
|
18
|
+
{...props}
|
|
19
|
+
>
|
|
20
|
+
<line x1="64" y1="122" x2="64" y2="46" />
|
|
21
|
+
<path d="M64,46 L100,54 L86,64 L100,74 L64,82 Z" strokeLinejoin="round" />
|
|
22
|
+
<line x1="36" y1="122" x2="90" y2="122" />
|
|
23
|
+
</svg>
|
|
24
|
+
);
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
FirstRunIllustration.displayName = "FirstRunIllustration";
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { SVGProps } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared 160×160 canvas every illustration draws on. Keeping one constant
|
|
6
|
+
* means every illustration's hand-authored coordinates line up on the same
|
|
7
|
+
* grid — every subject's ink is drawn to fill an ~88×88 box centered at
|
|
8
|
+
* (80, 78) (#24 fix round 1, P1-1), so the seven read as one optically-sized
|
|
9
|
+
* family instead of varying 2.1× against each other.
|
|
10
|
+
*/
|
|
11
|
+
export const ILLUSTRATION_VIEW_BOX = "0 0 160 160";
|
|
12
|
+
|
|
13
|
+
export interface IllustrationProps extends SVGProps<SVGSVGElement> {
|
|
14
|
+
/**
|
|
15
|
+
* Rendered width & height (any CSS length — pass a `rem` value to stay
|
|
16
|
+
* density/zoom-aware). Illustrations are drawn to read clearly from about
|
|
17
|
+
* `4rem` (64px) up to `10rem` (160px).
|
|
18
|
+
* @default "7rem"
|
|
19
|
+
*/
|
|
20
|
+
size?: string | number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The `<svg>`-level attributes every illustration shares: canvas, stroke
|
|
25
|
+
* defaults (`currentColor`, so it themes with whatever text color the caller
|
|
26
|
+
* or ambient context applies — e.g. `StatePanel` tints the error kind's slot
|
|
27
|
+
* `text-destructive`), rem-based sizing, and decorative a11y (`aria-hidden`,
|
|
28
|
+
* `role="presentation"`) — every illustration is decorative; the state's
|
|
29
|
+
* title/description text is the accessible content, never the artwork.
|
|
30
|
+
*
|
|
31
|
+
* Deliberately does NOT include `data-slot` — each illustration file declares
|
|
32
|
+
* its own `data-slot="<name>-illustration"` literal alongside this spread, so
|
|
33
|
+
* the stable-selector convention (`.claude/rules/component-api.md`) is
|
|
34
|
+
* visible per-component, not hidden behind a shared factory.
|
|
35
|
+
*/
|
|
36
|
+
export function illustrationSvgProps(size: string | number = "7rem", className?: string) {
|
|
37
|
+
return {
|
|
38
|
+
viewBox: ILLUSTRATION_VIEW_BOX,
|
|
39
|
+
width: size,
|
|
40
|
+
height: size,
|
|
41
|
+
fill: "none",
|
|
42
|
+
stroke: "currentColor",
|
|
43
|
+
strokeWidth: 4,
|
|
44
|
+
strokeLinecap: "round" as const,
|
|
45
|
+
strokeLinejoin: "round" as const,
|
|
46
|
+
"aria-hidden": true as const,
|
|
47
|
+
role: "presentation" as const,
|
|
48
|
+
focusable: "false" as const,
|
|
49
|
+
className: cn("shrink-0", className),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Accent-semantics convention (#48 finding 1): an illustration draws at most
|
|
55
|
+
* ONE accent mark (via `illustrationAccent()` below), and it exists ONLY
|
|
56
|
+
* when it carries information the silhouette (the `currentColor`-stroked
|
|
57
|
+
* subject) does not already supply on its own — never as ornamental
|
|
58
|
+
* flourish wearing a meaning-bearing token. Applied across the seven
|
|
59
|
+
* shipped illustrations:
|
|
60
|
+
* - **Meaning-bearing (kept):** the "×" that turns a magnifying glass into
|
|
61
|
+
* "no results" (`NoResultsIllustration`), the keyhole that turns a padlock
|
|
62
|
+
* body into "restricted" (`NoAccessIllustration`), the checkmark that
|
|
63
|
+
* turns a bare ring into "success" (`SuccessIllustration`), the
|
|
64
|
+
* exclamation that turns a torn document into "warning" rather than
|
|
65
|
+
* merely "broken" (`ErrorIllustration`).
|
|
66
|
+
* - **Was decorative-only, removed (#48):** the "add" badge on
|
|
67
|
+
* `EmptyListIllustration` (three rows already read as "empty list" on
|
|
68
|
+
* their own), the sparkle on `FirstRunIllustration` (the planted flag
|
|
69
|
+
* already reads as "start here"), and the broken-link fragments on
|
|
70
|
+
* `OfflineIllustration` (a slashed cloud already reads as "disconnected" —
|
|
71
|
+
* the universal "no signal" idiom). Their accent-free silhouettes carry
|
|
72
|
+
* the full state alone, matching how `ErrorIllustration`'s silhouette
|
|
73
|
+
* already did before this convention was written down.
|
|
74
|
+
* Every illustration is `aria-hidden` regardless (the state's title/
|
|
75
|
+
* description text is the ACCESSIBLE channel — see `illustrationSvgProps`
|
|
76
|
+
* below), so this is a visual-consistency rule, not an a11y requirement on
|
|
77
|
+
* its own; don't regress the silhouette-carries-meaning property while
|
|
78
|
+
* redrawing any of the four kept accents above.
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The custom property every illustration's single meaning-bearing accent
|
|
83
|
+
* reads through. `StatePanel` sets it on the illustration slot's wrapper for
|
|
84
|
+
* `kind="error"` (`var(--destructive-text)`) so the accent follows the
|
|
85
|
+
* panel's tint instead of staying pinned to one hue inside a re-colored slot
|
|
86
|
+
* (#24 fix round 1, P0-2) — without this, an illustration whose accent
|
|
87
|
+
* defaults to the brand hue renders a lime badge on a red error panel. Falls
|
|
88
|
+
* back to each illustration's own default hue when no ambient override is
|
|
89
|
+
* set.
|
|
90
|
+
*/
|
|
91
|
+
export const ILLUSTRATION_ACCENT_VAR = "--illustration-accent";
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Build the `style` value for an illustration's accent ink: honors the
|
|
95
|
+
* ambient `--illustration-accent` override when set, otherwise `fallback`.
|
|
96
|
+
* `fallback` must be the correct **mark rung** for the accent's hue — e.g.
|
|
97
|
+
* `var(--primary-text)` (NOT `var(--primary)`, a plate color that measures
|
|
98
|
+
* 1.42:1 on `--card` in the `light` theme — #24 fix round 1, P0-1; see
|
|
99
|
+
* `.claude/rules/styling-and-tokens.md` "Which status rung a graphical MARK
|
|
100
|
+
* reaches for"), `var(--success-text)` for a positive-outcome accent, or
|
|
101
|
+
* `var(--destructive-text)` for an error accent — the TEXT rung, deliberately
|
|
102
|
+
* distinct from the FILL rung (`--destructive`) `StatePanel` puts the
|
|
103
|
+
* SUBJECT on for `kind="error"`, so the two don't collapse onto one colour
|
|
104
|
+
* (#48 finding 2).
|
|
105
|
+
*/
|
|
106
|
+
export function illustrationAccent(fallback: string): string {
|
|
107
|
+
return `var(${ILLUSTRATION_ACCENT_VAR}, ${fallback})`;
|
|
108
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import type { ComponentType } from "react";
|
|
3
|
+
import {
|
|
4
|
+
EmptyListIllustration,
|
|
5
|
+
NoResultsIllustration,
|
|
6
|
+
NoAccessIllustration,
|
|
7
|
+
ErrorIllustration,
|
|
8
|
+
OfflineIllustration,
|
|
9
|
+
SuccessIllustration,
|
|
10
|
+
FirstRunIllustration,
|
|
11
|
+
type IllustrationProps,
|
|
12
|
+
} from "./index";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Demo wrapper: illustrations are pure `currentColor` line art plus one
|
|
16
|
+
* meaning-bearing accent (`--primary-text` by default; `--success-text` on
|
|
17
|
+
* `SuccessIllustration`, `--destructive` on `ErrorIllustration`), so a story
|
|
18
|
+
* renders them inside a `text-muted-foreground` container — the same ambient
|
|
19
|
+
* color `StatePanel` applies via its icon/illustration slot. `StatePanel`
|
|
20
|
+
* retints the accent to `--destructive` for any illustration placed in a
|
|
21
|
+
* `kind="error"` slot via the `--illustration-accent` custom property (see
|
|
22
|
+
* `illustration-base.tsx`) — see `States/StatePanel`'s "Error (with
|
|
23
|
+
* illustration, #24 P0-2)" story for that pairing.
|
|
24
|
+
*/
|
|
25
|
+
function Demo(props: IllustrationProps & { as: ComponentType<IllustrationProps> }) {
|
|
26
|
+
const { as: Illustration, ...rest } = props;
|
|
27
|
+
return (
|
|
28
|
+
<div className="text-muted-foreground">
|
|
29
|
+
<Illustration {...rest} />
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const meta = {
|
|
35
|
+
title: "States/Illustrations",
|
|
36
|
+
component: Demo,
|
|
37
|
+
tags: ["autodocs"],
|
|
38
|
+
argTypes: {
|
|
39
|
+
size: {
|
|
40
|
+
control: "text",
|
|
41
|
+
description: "Rendered width/height (any CSS length). Legible ~4rem–10rem.",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
} satisfies Meta<typeof Demo>;
|
|
45
|
+
export default meta;
|
|
46
|
+
type Story = StoryObj<typeof meta>;
|
|
47
|
+
|
|
48
|
+
export const EmptyList: Story = {
|
|
49
|
+
args: { as: EmptyListIllustration },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const NoResults: Story = {
|
|
53
|
+
args: { as: NoResultsIllustration },
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const NoAccess: Story = {
|
|
57
|
+
args: { as: NoAccessIllustration },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const Error: Story = {
|
|
61
|
+
args: { as: ErrorIllustration },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const Offline: Story = {
|
|
65
|
+
args: { as: OfflineIllustration },
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export const Success: Story = {
|
|
69
|
+
args: { as: SuccessIllustration },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const FirstRun: Story = {
|
|
73
|
+
args: { as: FirstRunIllustration },
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const CustomSize: Story = {
|
|
77
|
+
name: "Custom size (10rem)",
|
|
78
|
+
args: { as: EmptyListIllustration, size: "10rem" },
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const AllStates: Story = {
|
|
82
|
+
name: "All seven, side by side",
|
|
83
|
+
render: () => (
|
|
84
|
+
<div className="flex flex-wrap gap-8 text-muted-foreground">
|
|
85
|
+
<EmptyListIllustration />
|
|
86
|
+
<NoResultsIllustration />
|
|
87
|
+
<NoAccessIllustration />
|
|
88
|
+
<ErrorIllustration />
|
|
89
|
+
<OfflineIllustration />
|
|
90
|
+
<SuccessIllustration />
|
|
91
|
+
<FirstRunIllustration />
|
|
92
|
+
</div>
|
|
93
|
+
),
|
|
94
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { render } from "@testing-library/react";
|
|
4
|
+
import {
|
|
5
|
+
EmptyListIllustration,
|
|
6
|
+
NoResultsIllustration,
|
|
7
|
+
NoAccessIllustration,
|
|
8
|
+
ErrorIllustration,
|
|
9
|
+
OfflineIllustration,
|
|
10
|
+
SuccessIllustration,
|
|
11
|
+
FirstRunIllustration,
|
|
12
|
+
ILLUSTRATION_ACCENT_VAR,
|
|
13
|
+
illustrationAccent,
|
|
14
|
+
} from "./index";
|
|
15
|
+
|
|
16
|
+
// No literal hex anywhere in a rendered attribute — only `currentColor` or a
|
|
17
|
+
// `var(--…)` token reference (inline style or attribute) may carry color.
|
|
18
|
+
const RAW_HEX_RE = /#[0-9a-fA-F]{3,6}\b/;
|
|
19
|
+
|
|
20
|
+
const ILLUSTRATIONS = [
|
|
21
|
+
["EmptyListIllustration", EmptyListIllustration, "empty-list-illustration"],
|
|
22
|
+
["NoResultsIllustration", NoResultsIllustration, "no-results-illustration"],
|
|
23
|
+
["NoAccessIllustration", NoAccessIllustration, "no-access-illustration"],
|
|
24
|
+
["ErrorIllustration", ErrorIllustration, "error-illustration"],
|
|
25
|
+
["OfflineIllustration", OfflineIllustration, "offline-illustration"],
|
|
26
|
+
["SuccessIllustration", SuccessIllustration, "success-illustration"],
|
|
27
|
+
["FirstRunIllustration", FirstRunIllustration, "first-run-illustration"],
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
describe("state illustrations", () => {
|
|
31
|
+
it.each(ILLUSTRATIONS)(
|
|
32
|
+
"%s renders a decorative, token-driven <svg>",
|
|
33
|
+
(_name, Component, slot) => {
|
|
34
|
+
const { container } = render(<Component />);
|
|
35
|
+
const svg = container.querySelector("svg");
|
|
36
|
+
expect(svg).not.toBeNull();
|
|
37
|
+
expect(svg?.tagName.toLowerCase()).toBe("svg");
|
|
38
|
+
expect(svg).toHaveAttribute("aria-hidden", "true");
|
|
39
|
+
expect(svg).toHaveAttribute("role", "presentation");
|
|
40
|
+
expect(svg).toHaveAttribute("data-slot", slot);
|
|
41
|
+
|
|
42
|
+
// No raw hex anywhere in the tree's attributes (element attrs + style attr).
|
|
43
|
+
const offenders: string[] = [];
|
|
44
|
+
for (const el of Array.from(container.querySelectorAll("*"))) {
|
|
45
|
+
for (const attr of Array.from(el.attributes)) {
|
|
46
|
+
if (RAW_HEX_RE.test(attr.value)) {
|
|
47
|
+
offenders.push(`${el.tagName}[${attr.name}]="${attr.value}"`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
expect(offenders).toEqual([]);
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
it("accepts a custom `size` and applies it as width/height", () => {
|
|
56
|
+
const { container } = render(<EmptyListIllustration size="10rem" />);
|
|
57
|
+
const svg = container.querySelector("svg");
|
|
58
|
+
expect(svg).toHaveAttribute("width", "10rem");
|
|
59
|
+
expect(svg).toHaveAttribute("height", "10rem");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("defaults to a rem-based size within the legible 64px–160px range", () => {
|
|
63
|
+
const { container } = render(<NoResultsIllustration />);
|
|
64
|
+
const svg = container.querySelector("svg");
|
|
65
|
+
const width = svg?.getAttribute("width") ?? "";
|
|
66
|
+
expect(width).toMatch(/rem$/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("merges a caller className without dropping the base classes", () => {
|
|
70
|
+
const { container } = render(<SuccessIllustration className="text-primary" />);
|
|
71
|
+
const svg = container.querySelector("svg");
|
|
72
|
+
expect(svg?.getAttribute("class")).toContain("text-primary");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// #12/#53 review (P2): `ILLUSTRATION_ACCENT_VAR` is the mechanism `StatePanel`
|
|
76
|
+
// uses internally to safely retint an illustration's meaning-bearing accent
|
|
77
|
+
// (see illustration-base.tsx). A consumer rendering an illustration OUTSIDE
|
|
78
|
+
// `StatePanel` needs the same seam without duplicating the private
|
|
79
|
+
// "--illustration-accent" string, so the barrel must export it.
|
|
80
|
+
it("exports ILLUSTRATION_ACCENT_VAR — the public retint seam for consumers outside StatePanel", () => {
|
|
81
|
+
expect(ILLUSTRATION_ACCENT_VAR).toBe("--illustration-accent");
|
|
82
|
+
// A consumer can set it inline exactly the way StatePanel does internally.
|
|
83
|
+
const { container } = render(
|
|
84
|
+
<div style={{ [ILLUSTRATION_ACCENT_VAR]: "var(--success-text)" } as CSSProperties}>
|
|
85
|
+
<SuccessIllustration />
|
|
86
|
+
</div>,
|
|
87
|
+
);
|
|
88
|
+
const wrapper = container.firstChild as HTMLElement;
|
|
89
|
+
expect(wrapper.style.getPropertyValue(ILLUSTRATION_ACCENT_VAR)).toBe("var(--success-text)");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("exports illustrationAccent — the public helper for building an illustration's fallback stroke/fill color", () => {
|
|
93
|
+
const fallback = "var(--success-text)";
|
|
94
|
+
const result = illustrationAccent(fallback);
|
|
95
|
+
// illustrationAccent(fallback) returns var(--illustration-accent, fallback)
|
|
96
|
+
expect(result).toBe(`var(${ILLUSTRATION_ACCENT_VAR}, ${fallback})`);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type { IllustrationProps } from "./illustration-base";
|
|
2
|
+
// The custom-property name a consumer sets to safely retint an illustration's
|
|
3
|
+
// meaning-bearing accent (see `StatePanel`'s own usage). Previously only the
|
|
4
|
+
// illustration components + `IllustrationProps` were exported here, so a
|
|
5
|
+
// consumer rendering an illustration OUTSIDE `StatePanel` had no way to reach
|
|
6
|
+
// this seam without duplicating the private string (#12/#53 review, P2).
|
|
7
|
+
// Relates to the wider "illustration customization" tracking issue #46 — this
|
|
8
|
+
// change exports the existing constant, it does not itself resolve #46.
|
|
9
|
+
export { ILLUSTRATION_ACCENT_VAR, illustrationAccent } from "./illustration-base";
|
|
10
|
+
export { EmptyListIllustration } from "./empty-list-illustration";
|
|
11
|
+
export { NoResultsIllustration } from "./no-results-illustration";
|
|
12
|
+
export { NoAccessIllustration } from "./no-access-illustration";
|
|
13
|
+
export { ErrorIllustration } from "./error-illustration";
|
|
14
|
+
export { OfflineIllustration } from "./offline-illustration";
|
|
15
|
+
export { SuccessIllustration } from "./success-illustration";
|
|
16
|
+
export { FirstRunIllustration } from "./first-run-illustration";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import {
|
|
3
|
+
illustrationSvgProps,
|
|
4
|
+
illustrationAccent,
|
|
5
|
+
type IllustrationProps,
|
|
6
|
+
} from "./illustration-base";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Permission / access-denied state. A line-art padlock with a `--primary-text`
|
|
10
|
+
* keyhole — reads as "restricted", not "broken". The keyhole is meaning-bearing
|
|
11
|
+
* (per the accent-semantics convention in `illustration-base.tsx`, #48
|
|
12
|
+
* finding 1) — it's what distinguishes a padlock from a generic rounded box.
|
|
13
|
+
*/
|
|
14
|
+
export const NoAccessIllustration = forwardRef<SVGSVGElement, IllustrationProps>(
|
|
15
|
+
function NoAccessIllustration({ size, className, ...props }, ref) {
|
|
16
|
+
return (
|
|
17
|
+
<svg
|
|
18
|
+
ref={ref}
|
|
19
|
+
{...illustrationSvgProps(size, className)}
|
|
20
|
+
data-slot="no-access-illustration"
|
|
21
|
+
{...props}
|
|
22
|
+
>
|
|
23
|
+
<path d="M50,72 V52 A30,20 0 0 1 110,52 V72" />
|
|
24
|
+
<rect x="36" y="72" width="88" height="48" rx="10" />
|
|
25
|
+
<circle
|
|
26
|
+
cx="80"
|
|
27
|
+
cy="92"
|
|
28
|
+
r="6"
|
|
29
|
+
style={{ stroke: illustrationAccent("var(--primary-text)") }}
|
|
30
|
+
strokeWidth={3}
|
|
31
|
+
/>
|
|
32
|
+
<rect
|
|
33
|
+
x="77"
|
|
34
|
+
y="98"
|
|
35
|
+
width="6"
|
|
36
|
+
height="12"
|
|
37
|
+
rx="2"
|
|
38
|
+
style={{ stroke: illustrationAccent("var(--primary-text)") }}
|
|
39
|
+
strokeWidth={3}
|
|
40
|
+
/>
|
|
41
|
+
</svg>
|
|
42
|
+
);
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
NoAccessIllustration.displayName = "NoAccessIllustration";
|