@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
3
|
import { Table, TableBody, TableCell, TableRow } from "./table";
|
|
4
|
+
|
|
4
5
|
describe("Table", () => {
|
|
5
6
|
it("renders cells", () => {
|
|
6
7
|
render(
|
|
@@ -15,3 +16,143 @@ describe("Table", () => {
|
|
|
15
16
|
expect(screen.getByText("Alpha")).toBeInTheDocument();
|
|
16
17
|
});
|
|
17
18
|
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* jsdom reports 0 for every layout metric, so overflow has to be simulated —
|
|
22
|
+
* same idiom as `packages/data/src/data-table/data-table.test.tsx`. Metrics
|
|
23
|
+
* are (re-)applied and the component's own `onScroll` handler is fired so the
|
|
24
|
+
* re-measurement runs through the real code path, not by poking at state.
|
|
25
|
+
*/
|
|
26
|
+
function setScrollMetrics(
|
|
27
|
+
el: HTMLElement,
|
|
28
|
+
{ scrollWidth, clientWidth, scrollHeight, clientHeight }: Record<string, number>,
|
|
29
|
+
) {
|
|
30
|
+
Object.defineProperty(el, "scrollWidth", { configurable: true, value: scrollWidth });
|
|
31
|
+
Object.defineProperty(el, "clientWidth", { configurable: true, value: clientWidth });
|
|
32
|
+
Object.defineProperty(el, "scrollHeight", { configurable: true, value: scrollHeight });
|
|
33
|
+
Object.defineProperty(el, "clientHeight", { configurable: true, value: clientHeight });
|
|
34
|
+
fireEvent.scroll(el);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function scrollRegionOf(container: HTMLElement): HTMLElement {
|
|
38
|
+
const el = container.querySelector<HTMLElement>('[data-slot="table-scroll-region"]');
|
|
39
|
+
if (!el) throw new Error('no [data-slot="table-scroll-region"] in the rendered output');
|
|
40
|
+
return el;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("Table — #366 the scroll wrapper's tab stop exists only while it measurably overflows", () => {
|
|
44
|
+
it("adds NO tab stop and NO accessible name to a table that fits its container", () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<Table>
|
|
47
|
+
<TableBody>
|
|
48
|
+
<TableRow>
|
|
49
|
+
<TableCell>Alpha</TableCell>
|
|
50
|
+
</TableRow>
|
|
51
|
+
</TableBody>
|
|
52
|
+
</Table>,
|
|
53
|
+
);
|
|
54
|
+
const scrollRegion = scrollRegionOf(container);
|
|
55
|
+
setScrollMetrics(scrollRegion, {
|
|
56
|
+
scrollWidth: 300,
|
|
57
|
+
clientWidth: 300,
|
|
58
|
+
scrollHeight: 100,
|
|
59
|
+
clientHeight: 100,
|
|
60
|
+
});
|
|
61
|
+
// A table that doesn't scroll must not gain a focus stop that does
|
|
62
|
+
// nothing, nor announce itself as "scrollable" — axe's
|
|
63
|
+
// `scrollable-region-focusable` only fires the other way round, so this
|
|
64
|
+
// is the locking assertion for it.
|
|
65
|
+
expect(scrollRegion).not.toHaveAttribute("tabindex");
|
|
66
|
+
expect(scrollRegion).not.toHaveAttribute("aria-label");
|
|
67
|
+
expect(screen.queryByLabelText("Table contents, scrollable")).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("gains the tab stop + accessible name once the region measurably overflows vertically", () => {
|
|
71
|
+
const { container } = render(
|
|
72
|
+
<Table>
|
|
73
|
+
<TableBody>
|
|
74
|
+
<TableRow>
|
|
75
|
+
<TableCell>Alpha</TableCell>
|
|
76
|
+
</TableRow>
|
|
77
|
+
</TableBody>
|
|
78
|
+
</Table>,
|
|
79
|
+
);
|
|
80
|
+
const scrollRegion = scrollRegionOf(container);
|
|
81
|
+
setScrollMetrics(scrollRegion, {
|
|
82
|
+
scrollWidth: 300,
|
|
83
|
+
clientWidth: 300,
|
|
84
|
+
scrollHeight: 800,
|
|
85
|
+
clientHeight: 200,
|
|
86
|
+
});
|
|
87
|
+
expect(scrollRegion).toHaveAttribute("tabindex", "0");
|
|
88
|
+
expect(screen.getByLabelText("Table contents, scrollable")).toBe(scrollRegion);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("gains the tab stop + accessible name once the region measurably overflows horizontally", () => {
|
|
92
|
+
const { container } = render(
|
|
93
|
+
<Table>
|
|
94
|
+
<TableBody>
|
|
95
|
+
<TableRow>
|
|
96
|
+
<TableCell>Alpha</TableCell>
|
|
97
|
+
</TableRow>
|
|
98
|
+
</TableBody>
|
|
99
|
+
</Table>,
|
|
100
|
+
);
|
|
101
|
+
const scrollRegion = scrollRegionOf(container);
|
|
102
|
+
setScrollMetrics(scrollRegion, {
|
|
103
|
+
scrollWidth: 900,
|
|
104
|
+
clientWidth: 300,
|
|
105
|
+
scrollHeight: 100,
|
|
106
|
+
clientHeight: 100,
|
|
107
|
+
});
|
|
108
|
+
expect(scrollRegion).toHaveAttribute("tabindex", "0");
|
|
109
|
+
expect(screen.getByLabelText("Table contents, scrollable")).toBe(scrollRegion);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("drops the tab stop again when the overflow goes away (e.g. the container grows)", () => {
|
|
113
|
+
const { container } = render(
|
|
114
|
+
<Table>
|
|
115
|
+
<TableBody>
|
|
116
|
+
<TableRow>
|
|
117
|
+
<TableCell>Alpha</TableCell>
|
|
118
|
+
</TableRow>
|
|
119
|
+
</TableBody>
|
|
120
|
+
</Table>,
|
|
121
|
+
);
|
|
122
|
+
const scrollRegion = scrollRegionOf(container);
|
|
123
|
+
setScrollMetrics(scrollRegion, {
|
|
124
|
+
scrollWidth: 300,
|
|
125
|
+
clientWidth: 300,
|
|
126
|
+
scrollHeight: 800,
|
|
127
|
+
clientHeight: 200,
|
|
128
|
+
});
|
|
129
|
+
expect(scrollRegion).toHaveAttribute("tabindex", "0");
|
|
130
|
+
setScrollMetrics(scrollRegion, {
|
|
131
|
+
scrollWidth: 300,
|
|
132
|
+
clientWidth: 300,
|
|
133
|
+
scrollHeight: 800,
|
|
134
|
+
clientHeight: 900,
|
|
135
|
+
});
|
|
136
|
+
expect(scrollRegion).not.toHaveAttribute("tabindex");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('never adds a redundant role="region" landmark', () => {
|
|
140
|
+
const { container } = render(
|
|
141
|
+
<Table>
|
|
142
|
+
<TableBody>
|
|
143
|
+
<TableRow>
|
|
144
|
+
<TableCell>Alpha</TableCell>
|
|
145
|
+
</TableRow>
|
|
146
|
+
</TableBody>
|
|
147
|
+
</Table>,
|
|
148
|
+
);
|
|
149
|
+
const scrollRegion = scrollRegionOf(container);
|
|
150
|
+
setScrollMetrics(scrollRegion, {
|
|
151
|
+
scrollWidth: 300,
|
|
152
|
+
clientWidth: 300,
|
|
153
|
+
scrollHeight: 800,
|
|
154
|
+
clientHeight: 200,
|
|
155
|
+
});
|
|
156
|
+
expect(scrollRegion).not.toHaveAttribute("role");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -1,17 +1,76 @@
|
|
|
1
1
|
import {
|
|
2
2
|
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
3
7
|
type HTMLAttributes,
|
|
4
8
|
type TdHTMLAttributes,
|
|
5
9
|
type ThHTMLAttributes,
|
|
6
10
|
} from "react";
|
|
7
11
|
import { cn } from "../../lib/cn";
|
|
12
|
+
import { useLocale } from "../locale-provider";
|
|
8
13
|
|
|
14
|
+
/**
|
|
15
|
+
* `Table`'s own scroll wrapper, gated on MEASURED overflow (#366).
|
|
16
|
+
*
|
|
17
|
+
* `className`/`ref`/`...props` all land on the `<table>` — unchanged, no new
|
|
18
|
+
* prop — so this wrapper is internal plumbing, not a new public surface. It
|
|
19
|
+
* follows `DataTable`'s non-virtualized branch verbatim
|
|
20
|
+
* (`packages/data/src/data-table/data-table.tsx:1658-1695`, #330), the
|
|
21
|
+
* closest precedent: a plain `overflow-auto` box with no pinned columns, no
|
|
22
|
+
* loading overlay and no edge fade to carry along, just the keyboard
|
|
23
|
+
* affordance itself.
|
|
24
|
+
*
|
|
25
|
+
* A table that FITS must stay a byte-identical no-op: no `tabIndex`, no
|
|
26
|
+
* `aria-label`, no ring — an unconditional stop would announce "scrollable"
|
|
27
|
+
* on content that never scrolls. Only a table that measurably overflows
|
|
28
|
+
* gets the tab stop, its accessible name (WCAG 4.1.2) and the compound focus
|
|
29
|
+
* ring (`focus-ring-inset` — the wrapper's own edge is the scroll clip, so an
|
|
30
|
+
* outside ring would be cut, `.claude/rules/theming.md`). No `role="region"`:
|
|
31
|
+
* both existing precedents (`DataTable`, `SheetTable`) decided against a
|
|
32
|
+
* redundant landmark over a real `<table>`, and this wrapper adds none either.
|
|
33
|
+
*/
|
|
9
34
|
export const Table = forwardRef<HTMLTableElement, HTMLAttributes<HTMLTableElement>>(function Table(
|
|
10
35
|
{ className, ...props },
|
|
11
36
|
ref,
|
|
12
37
|
) {
|
|
38
|
+
const { t } = useLocale();
|
|
39
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
40
|
+
const [scrollOverflows, setScrollOverflows] = useState(false);
|
|
41
|
+
|
|
42
|
+
const updateScrollAffordance = useCallback(() => {
|
|
43
|
+
const el = scrollRef.current;
|
|
44
|
+
if (!el) return;
|
|
45
|
+
// 1px tolerance absorbs sub-pixel layout rounding, which would otherwise
|
|
46
|
+
// report a permanent 0.5px overflow on a table that visually fits.
|
|
47
|
+
setScrollOverflows(
|
|
48
|
+
el.scrollWidth > el.clientWidth + 1 || el.scrollHeight > el.clientHeight + 1,
|
|
49
|
+
);
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
const el = scrollRef.current;
|
|
54
|
+
if (!el) return;
|
|
55
|
+
updateScrollAffordance();
|
|
56
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
57
|
+
// Observe the CONTAINER (viewport changes) and the <table> inside it
|
|
58
|
+
// (content changes its intrinsic size without resizing the container).
|
|
59
|
+
const observer = new ResizeObserver(updateScrollAffordance);
|
|
60
|
+
observer.observe(el);
|
|
61
|
+
if (el.firstElementChild) observer.observe(el.firstElementChild);
|
|
62
|
+
return () => observer.disconnect();
|
|
63
|
+
}, [updateScrollAffordance]);
|
|
64
|
+
|
|
13
65
|
return (
|
|
14
|
-
<div
|
|
66
|
+
<div
|
|
67
|
+
ref={scrollRef}
|
|
68
|
+
data-slot="table-scroll-region"
|
|
69
|
+
tabIndex={scrollOverflows ? 0 : undefined}
|
|
70
|
+
aria-label={scrollOverflows ? t("ui.table.scrollRegion") : undefined}
|
|
71
|
+
onScroll={updateScrollAffordance}
|
|
72
|
+
className="relative w-full overflow-auto focus-ring-inset"
|
|
73
|
+
>
|
|
15
74
|
<table ref={ref} className={cn("w-full caption-bottom text-body", className)} {...props} />
|
|
16
75
|
</div>
|
|
17
76
|
);
|
|
@@ -142,7 +142,13 @@ export const TabsTrigger = forwardRef<
|
|
|
142
142
|
ref={mergedRef}
|
|
143
143
|
className={cn(
|
|
144
144
|
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-colors duration-fast",
|
|
145
|
-
|
|
145
|
+
// `ring-offset-1`, not `-2`: the compound indicator's reach is
|
|
146
|
+
// `ring-offset` + 2px ring + 1px contour, and `TabsList` is an
|
|
147
|
+
// `overflow-x-auto` strip with `p-1` (4px). At offset 2 the reach is 5px
|
|
148
|
+
// and the scroll box clips the contour on three sides (#67 fix round 2,
|
|
149
|
+
// measured on `patterns-templates-object-detail-hub--default` tab stop
|
|
150
|
+
// 9); at offset 1 it is exactly 4px and the loop closes.
|
|
151
|
+
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
146
152
|
"disabled:pointer-events-none disabled:opacity-50",
|
|
147
153
|
"data-[state=active]:bg-surface-elevated data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
|
148
154
|
className,
|
|
@@ -160,7 +166,7 @@ export const TabsContent = forwardRef<
|
|
|
160
166
|
<TabsPrimitive.Content
|
|
161
167
|
ref={ref}
|
|
162
168
|
className={cn(
|
|
163
|
-
"mt-2 focus-
|
|
169
|
+
"mt-2 focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
164
170
|
"data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:[--tw-ease:var(--ease-entrance)]",
|
|
165
171
|
className,
|
|
166
172
|
)}
|
|
@@ -208,7 +208,7 @@ export const TagInput = forwardRef<HTMLDivElement, TagInputProps>(function TagIn
|
|
|
208
208
|
}}
|
|
209
209
|
className={cn(
|
|
210
210
|
"rounded-full p-0.5 transition-colors",
|
|
211
|
-
"hover:bg-secondary-foreground/20 focus-
|
|
211
|
+
"hover:bg-secondary-foreground/20 focus-ring",
|
|
212
212
|
"disabled:pointer-events-none disabled:opacity-50",
|
|
213
213
|
)}
|
|
214
214
|
>
|
|
@@ -240,7 +240,8 @@ export const TagInput = forwardRef<HTMLDivElement, TagInputProps>(function TagIn
|
|
|
240
240
|
/>
|
|
241
241
|
</InputGroup>
|
|
242
242
|
{error && (
|
|
243
|
-
|
|
243
|
+
// #124: running text (the validation message) — ink rung.
|
|
244
|
+
<p role="alert" className="text-xs text-destructive-text">
|
|
244
245
|
{error}
|
|
245
246
|
</p>
|
|
246
247
|
)}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
DropdownMenuTrigger,
|
|
13
13
|
} from "../dropdown-menu";
|
|
14
14
|
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar } from "../sidebar";
|
|
15
|
+
import { useLocale } from "../locale-provider";
|
|
15
16
|
import { cn } from "../../lib/cn";
|
|
16
17
|
|
|
17
18
|
export type TeamSwitcherTeam = {
|
|
@@ -37,6 +38,7 @@ export interface TeamSwitcherProps {
|
|
|
37
38
|
*/
|
|
38
39
|
export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherProps) {
|
|
39
40
|
const { isMobile } = useSidebar();
|
|
41
|
+
const { t } = useLocale();
|
|
40
42
|
const [activeTeam, setActiveTeam] = React.useState(teams[0]);
|
|
41
43
|
if (!activeTeam) return null;
|
|
42
44
|
const Logo = activeTeam.logo;
|
|
@@ -48,21 +50,44 @@ export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherPr
|
|
|
48
50
|
<DropdownMenuTrigger asChild>
|
|
49
51
|
<SidebarMenuButton
|
|
50
52
|
size="lg"
|
|
53
|
+
// The name is written out rather than left to the button's own
|
|
54
|
+
// contents, because the contents cannot carry it in every state:
|
|
55
|
+
// collapsed to the 48px rail the trigger is one glyph, and the
|
|
56
|
+
// label beside it is either gone or clipped out of view by the
|
|
57
|
+
// button's `overflow-hidden` — either way axe reads a nameless
|
|
58
|
+
// button (`button-name`, critical). The string is exactly the
|
|
59
|
+
// visible label, in reading order, so WCAG 2.5.3 (label in name)
|
|
60
|
+
// holds expanded, where that label IS on screen. Verified by
|
|
61
|
+
// deleting this line and watching three dashboard stories fail
|
|
62
|
+
// `button-name` (critical).
|
|
63
|
+
aria-label={`${activeTeam.name} ${activeTeam.plan}`}
|
|
51
64
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
52
65
|
>
|
|
66
|
+
{/* Collapsed, this row is ONE ICON in a 48px rail and has to sit
|
|
67
|
+
on the same centre line as every nav entry above and below it.
|
|
68
|
+
Measured before this fix: the 32px plate did not fit the
|
|
69
|
+
button's 16px content box, so it overflowed (clipped by the
|
|
70
|
+
button's own `overflow-hidden`) and its glyph landed 8px to the
|
|
71
|
+
trailing side of every other icon in the rail. So the plate
|
|
72
|
+
becomes the glyph's own 16px box — no fill, no radius, nothing
|
|
73
|
+
to overflow — and the label and chevron stand down: at 48px
|
|
74
|
+
there is no room to read them, and they are what crowds the
|
|
75
|
+
plate. Expanded, all three come back unchanged. */}
|
|
53
76
|
<div
|
|
54
77
|
className={cn(
|
|
55
|
-
"flex aspect-square size-8 items-center justify-center rounded-lg",
|
|
78
|
+
"flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg",
|
|
56
79
|
logoClassName ?? "bg-sidebar-accent text-sidebar-accent-foreground",
|
|
80
|
+
"group-data-[collapsible=icon]:size-4 group-data-[collapsible=icon]:rounded-none group-data-[collapsible=icon]:bg-transparent group-data-[collapsible=icon]:text-sidebar-foreground",
|
|
57
81
|
)}
|
|
58
82
|
>
|
|
59
83
|
<Logo className="size-4" />
|
|
60
84
|
</div>
|
|
61
|
-
<div className="grid flex-1 text-start text-sm leading-tight">
|
|
85
|
+
<div className="grid flex-1 text-start text-sm leading-tight group-data-[collapsible=icon]:hidden">
|
|
62
86
|
<span className="truncate font-semibold">{activeTeam.name}</span>
|
|
63
87
|
<span className="truncate text-xs">{activeTeam.plan}</span>
|
|
64
88
|
</div>
|
|
65
|
-
|
|
89
|
+
{/* Decorative, so it simply goes. */}
|
|
90
|
+
<ChevronsUpDown className="ms-auto group-data-[collapsible=icon]:hidden" />
|
|
66
91
|
</SidebarMenuButton>
|
|
67
92
|
</DropdownMenuTrigger>
|
|
68
93
|
<DropdownMenuContent
|
|
@@ -71,7 +96,9 @@ export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherPr
|
|
|
71
96
|
side={isMobile ? "bottom" : "right"}
|
|
72
97
|
sideOffset={4}
|
|
73
98
|
>
|
|
74
|
-
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
99
|
+
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
|
100
|
+
{t("ui.teamSwitcher.label")}
|
|
101
|
+
</DropdownMenuLabel>
|
|
75
102
|
{teams.map((team, index) => (
|
|
76
103
|
<DropdownMenuItem
|
|
77
104
|
key={team.name}
|
|
@@ -90,7 +117,9 @@ export function TeamSwitcher({ teams, logoClassName, className }: TeamSwitcherPr
|
|
|
90
117
|
<div className="flex size-6 items-center justify-center rounded-md border bg-transparent">
|
|
91
118
|
<Plus className="size-4" />
|
|
92
119
|
</div>
|
|
93
|
-
<div className="font-medium text-muted-foreground">
|
|
120
|
+
<div className="font-medium text-muted-foreground">
|
|
121
|
+
{t("ui.teamSwitcher.addTeam")}
|
|
122
|
+
</div>
|
|
94
123
|
</DropdownMenuItem>
|
|
95
124
|
</DropdownMenuContent>
|
|
96
125
|
</DropdownMenu>
|
|
@@ -13,7 +13,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
|
|
|
13
13
|
className={cn(
|
|
14
14
|
"flex min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
|
|
15
15
|
"placeholder:text-muted-foreground",
|
|
16
|
-
"focus-
|
|
16
|
+
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
17
17
|
"disabled:cursor-not-allowed disabled:opacity-50 disabled:border-border",
|
|
18
18
|
"aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive",
|
|
19
19
|
className,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
DropdownMenuTrigger,
|
|
13
13
|
} from "../dropdown-menu";
|
|
14
14
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../tooltip";
|
|
15
|
+
import { useLocale } from "../locale-provider";
|
|
15
16
|
import { useThemeTransition, type ThemeTransitionEffect } from "./use-theme-transition";
|
|
16
17
|
|
|
17
18
|
const SYSTEM_STORAGE_KEY = "brand-ui-theme-system";
|
|
@@ -107,6 +108,7 @@ export const ThemeSwitcher = forwardRef<HTMLButtonElement, ThemeSwitcherProps>(
|
|
|
107
108
|
ref,
|
|
108
109
|
) {
|
|
109
110
|
const { theme, themeDefinitions, setTheme } = useTheme();
|
|
111
|
+
const { t } = useLocale();
|
|
110
112
|
const switchTheme = useThemeTransition(effect);
|
|
111
113
|
const isControlled = preference !== undefined;
|
|
112
114
|
const [uncontrolledIsSystem, setUncontrolledIsSystem] = useState(false);
|
|
@@ -215,7 +217,7 @@ export const ThemeSwitcher = forwardRef<HTMLButtonElement, ThemeSwitcherProps>(
|
|
|
215
217
|
{showSystem ? (
|
|
216
218
|
<DropdownMenuItem onSelect={() => pickSystem()}>
|
|
217
219
|
<Monitor />
|
|
218
|
-
<span>
|
|
220
|
+
<span>{t("ui.themeSwitcher.system")}</span>
|
|
219
221
|
{isSystem ? <Check className="ml-auto" /> : null}
|
|
220
222
|
</DropdownMenuItem>
|
|
221
223
|
) : null}
|
|
@@ -254,7 +256,9 @@ export const ThemeSwitcher = forwardRef<HTMLButtonElement, ThemeSwitcherProps>(
|
|
|
254
256
|
// `role` is optional only because `order[i]` is an indexed read under
|
|
255
257
|
// `noUncheckedIndexedAccess`; the modulo above makes it always present.
|
|
256
258
|
const roleLabel = (role: "light" | "dark" | "system" | undefined) =>
|
|
257
|
-
role === "system"
|
|
259
|
+
role === "system"
|
|
260
|
+
? t("ui.themeSwitcher.system")
|
|
261
|
+
: labelOf(role === "dark" ? darkTheme : lightTheme);
|
|
258
262
|
|
|
259
263
|
return (
|
|
260
264
|
<TooltipProvider>
|
|
@@ -15,8 +15,8 @@ type ViewTransitionDocument = Document & {
|
|
|
15
15
|
* Transitions API (the chanhdai.com "theme toggle effect", adapted to brand-ui).
|
|
16
16
|
*
|
|
17
17
|
* - Direction-aware: sets `data-vt="to-dark|to-light"` on `<html>` from the
|
|
18
|
-
* target theme's `dark` flag, so the clip-path wipes the right way for ANY
|
|
19
|
-
*
|
|
18
|
+
* target theme's `dark` flag, so the clip-path wipes the right way for ANY
|
|
19
|
+
* theme (not just light/dark).
|
|
20
20
|
* - `data-vt-effect` selects the reveal shape (default "polygon").
|
|
21
21
|
* - Motion-aware: under `useReducedMotion()` (OS or in-app preference) — or when
|
|
22
22
|
* the browser lacks `startViewTransition` — it falls back to an instant,
|
|
@@ -11,6 +11,9 @@ const meta = {
|
|
|
11
11
|
docs: {
|
|
12
12
|
description: {
|
|
13
13
|
component:
|
|
14
|
+
"The GENERIC ordered-steps rail; an agent execution trace is `AI/AgentTimeline` " +
|
|
15
|
+
"and git history is `Data/RevisionTimeline` — see " +
|
|
16
|
+
"[Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs). " +
|
|
14
17
|
"Ordered steps with a vertical connector and status-colored nodes — THE shared " +
|
|
15
18
|
"rail spine (#190). Two front doors over one rail: the array `items` API (the " +
|
|
16
19
|
"editor's `:::timeline` target, `done|active|pending`) and the compound " +
|
|
@@ -78,6 +81,30 @@ export const Compound: Story = {
|
|
|
78
81
|
},
|
|
79
82
|
};
|
|
80
83
|
|
|
84
|
+
export const TabularTimestamps: Story = {
|
|
85
|
+
// #386 — a right-aligned timestamp column of differing digit widths
|
|
86
|
+
// ("09:14" vs "11:47" vs "9:02") must not jitter: the span needs
|
|
87
|
+
// `tabular-nums` so every digit occupies equal width.
|
|
88
|
+
args: { items: [] },
|
|
89
|
+
render: () => (
|
|
90
|
+
<TimelineRoot aria-label="Run with numeric timestamps">
|
|
91
|
+
<TimelineItem status="complete" timestamp="09:14">
|
|
92
|
+
Searched financial filings
|
|
93
|
+
</TimelineItem>
|
|
94
|
+
<TimelineItem status="complete" timestamp="11:47">
|
|
95
|
+
Queried finance.revenue
|
|
96
|
+
</TimelineItem>
|
|
97
|
+
<TimelineItem status="pending" timestamp="9:02">
|
|
98
|
+
Draft the board note
|
|
99
|
+
</TimelineItem>
|
|
100
|
+
</TimelineRoot>
|
|
101
|
+
),
|
|
102
|
+
play: async ({ canvas }) => {
|
|
103
|
+
const timestamp = await waitFor(() => canvas.getByText("09:14"));
|
|
104
|
+
await expect(getComputedStyle(timestamp).fontVariantNumeric).toContain("tabular-nums");
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
81
108
|
export const CanonicalStatuses: Story = {
|
|
82
109
|
args: { items: [] },
|
|
83
110
|
render: () => (
|
|
@@ -17,6 +17,16 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Fork-prevention: a Timeline re-implementation outside this folder fails
|
|
19
19
|
* `pnpm timeline-fork:check` (scripts/check-timeline-fork.mjs).
|
|
20
|
+
*
|
|
21
|
+
* Deliberately NOT on this rail (a different grammar, not an oversight —
|
|
22
|
+
* RM-014, #133): `RevisionTimeline` (@elabs-ai/components-ui). Its rail is a
|
|
23
|
+
* multi-lane commit DAG whose node colour is LANE IDENTITY (the chart ramp),
|
|
24
|
+
* not `Status`, and whose fork/merge joins are cross-row beziers living in one
|
|
25
|
+
* SVG coordinate space spanning the whole list — neither of which a
|
|
26
|
+
* status-keyed node or an item-local `w-px` connector can express. Carrying
|
|
27
|
+
* both grammars here would cost ~5 opt-in props, four of them switching this
|
|
28
|
+
* rail's own node/connector/geometry/status announcement off. The four-point
|
|
29
|
+
* reason is in that component's docblock; reopen #133 before converging them.
|
|
20
30
|
*/
|
|
21
31
|
import { forwardRef, type HTMLAttributes, type LiHTMLAttributes, type ReactNode } from "react";
|
|
22
32
|
import { cn } from "../../lib/cn";
|
|
@@ -141,7 +151,7 @@ export const TimelineItem = forwardRef<HTMLLIElement, TimelineItemProps>(functio
|
|
|
141
151
|
{children}
|
|
142
152
|
</span>
|
|
143
153
|
{timestamp ? (
|
|
144
|
-
<span className="shrink-0 text-meta text-muted-foreground">{timestamp}</span>
|
|
154
|
+
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">{timestamp}</span>
|
|
145
155
|
) : null}
|
|
146
156
|
</div>
|
|
147
157
|
{description ? <p className="mt-0.5 text-body text-muted-foreground">{description}</p> : null}
|
|
@@ -21,7 +21,7 @@ export const toggleVariants = cva(
|
|
|
21
21
|
// asChild wrapper overwrites data-state="on" with "closed" — aria-pressed
|
|
22
22
|
// (Toggle) and aria-checked (ToggleGroupItem) are set by the toggle primitive
|
|
23
23
|
// alone and survive the merge. (#214)
|
|
24
|
-
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors duration-fast ease-standard hover:bg-muted hover:text-muted-foreground focus-
|
|
24
|
+
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors duration-fast ease-standard hover:bg-muted hover:text-muted-foreground focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground data-[state=on]:font-semibold aria-pressed:bg-accent aria-pressed:text-accent-foreground aria-pressed:font-semibold aria-checked:bg-accent aria-checked:text-accent-foreground aria-checked:font-semibold [&_svg]:size-4 [&_svg]:shrink-0",
|
|
25
25
|
{
|
|
26
26
|
variants: {
|
|
27
27
|
variant: {
|
|
@@ -28,10 +28,18 @@ import {
|
|
|
28
28
|
* expect there.
|
|
29
29
|
*/
|
|
30
30
|
const meta = {
|
|
31
|
-
title: "
|
|
31
|
+
title: "Layout/Toolbar",
|
|
32
32
|
component: Toolbar,
|
|
33
33
|
tags: ["autodocs"],
|
|
34
|
-
parameters: {
|
|
34
|
+
parameters: {
|
|
35
|
+
layout: "padded",
|
|
36
|
+
docs: {
|
|
37
|
+
description: {
|
|
38
|
+
component:
|
|
39
|
+
"The one row that genuinely claims the `toolbar` ARIA role: Radix delegates a roving tabindex to it, so ten controls cost one tab stop instead of ten. The row above a list, table or board — filters left, actions right — is `Layout/ViewToolbar`, which deliberately does NOT claim that role because its controls are ordinary tab stops. See [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
35
43
|
args: { "aria-label": "Formatting" },
|
|
36
44
|
} satisfies Meta<typeof Toolbar>;
|
|
37
45
|
|
|
@@ -483,7 +483,7 @@ function RevealSelectedNodeDemo() {
|
|
|
483
483
|
key={id}
|
|
484
484
|
type="button"
|
|
485
485
|
onClick={() => setTarget(id)}
|
|
486
|
-
className="rounded-md border border-border-strong bg-background px-3 py-1.5 text-body font-medium text-foreground hover:bg-surface-muted focus-
|
|
486
|
+
className="rounded-md border border-border-strong bg-background px-3 py-1.5 text-body font-medium text-foreground hover:bg-surface-muted focus-ring"
|
|
487
487
|
>
|
|
488
488
|
Navigate to Node {Number(id.split("-")[1]) + 1}
|
|
489
489
|
</button>
|
|
@@ -535,7 +535,7 @@ export const RevealSelectedNode: Story = {
|
|
|
535
535
|
* (like the Sidebar itself) perceivability comes from the foreground/weight shift
|
|
536
536
|
* plus `aria-selected`, not fill contrast alone.
|
|
537
537
|
*
|
|
538
|
-
* NOTE: cross-theme visual correctness (
|
|
538
|
+
* NOTE: cross-theme visual correctness (every shipped theme — light,
|
|
539
539
|
* dark) requires a real render and is owed to a human reviewer;
|
|
540
540
|
* the tokens are semantic so they adapt, but pixel-level confirmation cannot be
|
|
541
541
|
* automated in this environment.
|
|
@@ -15,6 +15,7 @@ import { ChevronRight, AlertCircle, RotateCcw } from "lucide-react";
|
|
|
15
15
|
import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
|
|
16
16
|
import { cn } from "../../lib/cn";
|
|
17
17
|
import { Checkbox } from "../checkbox/checkbox";
|
|
18
|
+
import { useLocale } from "../locale-provider";
|
|
18
19
|
import { Skeleton } from "../skeleton/skeleton";
|
|
19
20
|
import { Spinner } from "../spinner/spinner";
|
|
20
21
|
import { useTreeKeyboard } from "./use-tree-keyboard";
|
|
@@ -222,6 +223,7 @@ function VirtualFlatRow<T>({
|
|
|
222
223
|
registerNodeRef,
|
|
223
224
|
setActiveId,
|
|
224
225
|
}: VirtualFlatRowProps<T>) {
|
|
226
|
+
const { t } = useLocale();
|
|
225
227
|
const { node, level, setSize, posInSet } = row;
|
|
226
228
|
const isLoading = lazyState === "loading";
|
|
227
229
|
const isError = lazyState === "error";
|
|
@@ -262,7 +264,7 @@ function VirtualFlatRow<T>({
|
|
|
262
264
|
"flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-md px-2 py-1.5 text-body outline-none",
|
|
263
265
|
"transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
264
266
|
"hover:bg-accent hover:text-accent-foreground",
|
|
265
|
-
"focus-
|
|
267
|
+
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
266
268
|
isSelected &&
|
|
267
269
|
selectionMode !== "none" &&
|
|
268
270
|
(surface === "sidebar"
|
|
@@ -319,9 +321,11 @@ function VirtualFlatRow<T>({
|
|
|
319
321
|
|
|
320
322
|
{/* label */}
|
|
321
323
|
{isError ? (
|
|
322
|
-
|
|
324
|
+
// #124: running text ("Failed to load…"), not a mark — the ink rung
|
|
325
|
+
// clears >=4.5:1 and the wrapped icon inherits it fine (>=3:1 trivially).
|
|
326
|
+
<span id={labelId} className="min-w-0 flex items-center gap-1 text-destructive-text">
|
|
323
327
|
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
|
|
324
|
-
<span className="truncate">
|
|
328
|
+
<span className="truncate">{t("ui.tree.failedToLoad")}</span>
|
|
325
329
|
<button
|
|
326
330
|
type="button"
|
|
327
331
|
aria-label="Retry loading children"
|
|
@@ -332,7 +336,7 @@ function VirtualFlatRow<T>({
|
|
|
332
336
|
className={cn(
|
|
333
337
|
"ms-1 flex items-center gap-0.5 rounded px-1 py-0.5 text-caption",
|
|
334
338
|
"text-muted-foreground hover:text-foreground",
|
|
335
|
-
"focus-
|
|
339
|
+
"focus-ring",
|
|
336
340
|
)}
|
|
337
341
|
>
|
|
338
342
|
<RotateCcw className="size-3" aria-hidden="true" />
|
|
@@ -392,19 +396,20 @@ function LoadingRow({ level }: { level: number }) {
|
|
|
392
396
|
}
|
|
393
397
|
|
|
394
398
|
function ErrorRow({ level, onRetry }: { level: number; onRetry: () => void }) {
|
|
399
|
+
const { t } = useLocale();
|
|
395
400
|
const indentStyle = { paddingLeft: `${(level - 1) * 1}rem` };
|
|
396
401
|
return (
|
|
397
402
|
<div
|
|
398
403
|
role="treeitem"
|
|
399
|
-
aria-label="
|
|
404
|
+
aria-label={t("ui.tree.failedToLoad")}
|
|
400
405
|
aria-disabled="true"
|
|
401
406
|
tabIndex={-1}
|
|
402
|
-
className="flex min-w-0 items-center gap-1.5 px-2 py-1.5 text-body text-destructive"
|
|
407
|
+
className="flex min-w-0 items-center gap-1.5 px-2 py-1.5 text-body text-destructive-text"
|
|
403
408
|
style={indentStyle}
|
|
404
409
|
>
|
|
405
410
|
<span aria-hidden="true" className="size-4 shrink-0" />
|
|
406
411
|
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
|
|
407
|
-
<span className="truncate">
|
|
412
|
+
<span className="truncate">{t("ui.tree.failedToLoad")}</span>
|
|
408
413
|
<button
|
|
409
414
|
type="button"
|
|
410
415
|
onClick={(e) => {
|
|
@@ -414,7 +419,7 @@ function ErrorRow({ level, onRetry }: { level: number; onRetry: () => void }) {
|
|
|
414
419
|
className={cn(
|
|
415
420
|
"ms-1 flex items-center gap-0.5 rounded px-1 py-0.5 text-xs",
|
|
416
421
|
"text-muted-foreground hover:text-foreground",
|
|
417
|
-
"focus-
|
|
422
|
+
"focus-ring",
|
|
418
423
|
)}
|
|
419
424
|
>
|
|
420
425
|
<RotateCcw className="size-3" aria-hidden="true" />
|
|
@@ -541,7 +546,7 @@ function TreeItem<T>({
|
|
|
541
546
|
"flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-md px-2 py-1.5 text-body outline-none",
|
|
542
547
|
"transition-colors duration-fast ease-standard motion-reduce:transition-none",
|
|
543
548
|
"hover:bg-accent hover:text-accent-foreground",
|
|
544
|
-
"focus-
|
|
549
|
+
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
545
550
|
isSelected &&
|
|
546
551
|
selectionMode !== "none" &&
|
|
547
552
|
(surface === "sidebar"
|
|
@@ -119,7 +119,7 @@ export const ProseLink = forwardRef<HTMLAnchorElement, ProseLinkProps>(function
|
|
|
119
119
|
// #399 — `text-primary-text`, not the `text-primary` FILL rung: a prose
|
|
120
120
|
// link is ordinary body text and owes WCAG 1.4.3 AA (4.5:1), which the
|
|
121
121
|
// fill only guarantees at the 1.4.11 mark bar (3:1).
|
|
122
|
-
"font-medium text-primary-text underline underline-offset-4 hover:no-underline focus-
|
|
122
|
+
"font-medium text-primary-text underline underline-offset-4 hover:no-underline focus-ring",
|
|
123
123
|
className,
|
|
124
124
|
)}
|
|
125
125
|
{...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|