@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
|
@@ -8,16 +8,43 @@ import {
|
|
|
8
8
|
} from "react";
|
|
9
9
|
import { Slot } from "@radix-ui/react-slot";
|
|
10
10
|
import { cn } from "../../lib/cn";
|
|
11
|
+
import { hasRenderableContent } from "../../lib/has-renderable-content";
|
|
11
12
|
import { Label } from "../label";
|
|
12
13
|
|
|
13
14
|
export interface FieldRowProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
14
15
|
/** Field label — rendered as a real `<label>`, click-to-focus associated with the control. */
|
|
15
16
|
label: ReactNode;
|
|
16
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Optional help/description text, always visible (not gated by an error).
|
|
19
|
+
* Renders nothing — and contributes no id to `aria-describedby` — when
|
|
20
|
+
* `description` is falsy (`false`/`0`/`""`/`null`/`undefined`), an empty
|
|
21
|
+
* array, or an array containing only falsy values (`{list.map(...)}` on an
|
|
22
|
+
* empty list; `[a && "x", b && "y"]` with both false).
|
|
23
|
+
*
|
|
24
|
+
* **Known limit:** a value that is itself a COMPONENT that renders nothing
|
|
25
|
+
* (returns `null`/an empty fragment) is not knowable before render, so it
|
|
26
|
+
* still produces an empty paragraph that `aria-describedby` points at.
|
|
27
|
+
* `FieldDescription` and the wider React ecosystem share this limit — pass
|
|
28
|
+
* a falsy value instead of a component that may render nothing, e.g.
|
|
29
|
+
* `description={hasHint ? hintText : undefined}`, never
|
|
30
|
+
* `description={<MaybeRendersNull />}`.
|
|
31
|
+
*/
|
|
17
32
|
description?: ReactNode;
|
|
18
33
|
/**
|
|
19
34
|
* Validation error text. When present: sets `aria-invalid` on the control,
|
|
20
35
|
* is appended to `aria-describedby`, and is announced via `role="alert"`.
|
|
36
|
+
* Renders nothing — and leaves `aria-invalid="false"` — when `error` is
|
|
37
|
+
* falsy (`false`/`0`/`""`/`null`/`undefined`), an empty array, or an array
|
|
38
|
+
* containing only falsy values (`{errors.map(...)}` on an empty list — the
|
|
39
|
+
* idiomatic "no errors" shape).
|
|
40
|
+
*
|
|
41
|
+
* **Known limit:** a value that is itself a COMPONENT that renders nothing
|
|
42
|
+
* (returns `null`/an empty fragment) is not knowable before render, so it
|
|
43
|
+
* still produces an empty `role="alert"` element that `aria-describedby`
|
|
44
|
+
* points at. `FieldError` and the wider React ecosystem share this limit —
|
|
45
|
+
* pass a falsy value instead of a component that may render nothing, e.g.
|
|
46
|
+
* `error={hasError ? message : undefined}`, never
|
|
47
|
+
* `error={<MaybeRendersNull />}`.
|
|
21
48
|
*/
|
|
22
49
|
error?: ReactNode;
|
|
23
50
|
/**
|
|
@@ -62,11 +89,19 @@ export const FieldRow = forwardRef<HTMLDivElement, FieldRowProps>(function Field
|
|
|
62
89
|
const childProps = control.props;
|
|
63
90
|
const controlId = childProps.id ?? `${id}-control`;
|
|
64
91
|
|
|
92
|
+
// Same predicate `FieldDescription`/`FieldError` use (`../field/field.tsx`)
|
|
93
|
+
// — bare truthiness (`description ? … : null`) treats an empty/all-falsy
|
|
94
|
+
// ARRAY as content, since every array is truthy in JS. `errors.map(...)`
|
|
95
|
+
// on an empty list is the idiomatic "no errors" shape, so that gap is a
|
|
96
|
+
// real defect here, not a hypothetical (#93).
|
|
97
|
+
const hasDescription = hasRenderableContent(description);
|
|
98
|
+
const hasError = hasRenderableContent(error);
|
|
99
|
+
|
|
65
100
|
const describedBy =
|
|
66
101
|
[
|
|
67
102
|
childProps["aria-describedby"] ?? null,
|
|
68
|
-
|
|
69
|
-
|
|
103
|
+
hasDescription ? descriptionId : null,
|
|
104
|
+
hasError ? errorId : null,
|
|
70
105
|
]
|
|
71
106
|
.filter(Boolean)
|
|
72
107
|
.join(" ") || undefined;
|
|
@@ -86,12 +121,19 @@ export const FieldRow = forwardRef<HTMLDivElement, FieldRowProps>(function Field
|
|
|
86
121
|
<Slot
|
|
87
122
|
id={controlId}
|
|
88
123
|
aria-describedby={describedBy}
|
|
89
|
-
|
|
124
|
+
// `FieldRoot.invalid` (`../field/field.tsx`) is intentionally
|
|
125
|
+
// INDEPENDENT of whether a `FieldError` with content is mounted — a
|
|
126
|
+
// caller can mark the control invalid before its message settles.
|
|
127
|
+
// `FieldRow` has no such prop: `error` is its only invalidity
|
|
128
|
+
// signal, so "no error content" must mean "not invalid" — deriving
|
|
129
|
+
// this from raw `!!error` marked `error={[]}` invalid with nothing
|
|
130
|
+
// to explain why (#93).
|
|
131
|
+
aria-invalid={hasError}
|
|
90
132
|
data-slot="field-row-control"
|
|
91
133
|
>
|
|
92
134
|
{cloneElement(control, { id: controlId, "aria-describedby": describedBy })}
|
|
93
135
|
</Slot>
|
|
94
|
-
{
|
|
136
|
+
{hasDescription ? (
|
|
95
137
|
<p
|
|
96
138
|
id={descriptionId}
|
|
97
139
|
data-slot="field-row-description"
|
|
@@ -100,7 +142,7 @@ export const FieldRow = forwardRef<HTMLDivElement, FieldRowProps>(function Field
|
|
|
100
142
|
{description}
|
|
101
143
|
</p>
|
|
102
144
|
) : null}
|
|
103
|
-
{
|
|
145
|
+
{hasError ? (
|
|
104
146
|
<p
|
|
105
147
|
id={errorId}
|
|
106
148
|
role="alert"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
2
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
3
4
|
import {
|
|
4
5
|
FileUpload,
|
|
5
6
|
FileUploadDropzone,
|
|
@@ -106,6 +107,21 @@ export const Default: Story = {
|
|
|
106
107
|
</FileUpload>
|
|
107
108
|
</div>
|
|
108
109
|
),
|
|
110
|
+
play: async ({ canvasElement }) => {
|
|
111
|
+
const canvas = within(canvasElement);
|
|
112
|
+
// #322 — the dropzone (`focus-ring-within`) is the sole compound focus
|
|
113
|
+
// indicator for this composite control; the inner "Browse files" button
|
|
114
|
+
// must delegate its own indicator to it, never paint a second one.
|
|
115
|
+
const browseButton = canvas.getByRole("button", { name: "Browse files" });
|
|
116
|
+
await userEvent.tab();
|
|
117
|
+
await expect(browseButton).toHaveFocus();
|
|
118
|
+
const dropzone = browseButton.closest('[data-slot="file-upload-dropzone"]');
|
|
119
|
+
await expect(dropzone).not.toBeNull();
|
|
120
|
+
const dropzoneStyle = getComputedStyle(dropzone as HTMLElement);
|
|
121
|
+
const buttonStyle = getComputedStyle(browseButton);
|
|
122
|
+
await expect(dropzoneStyle.outlineStyle).toBe("solid");
|
|
123
|
+
await expect(buttonStyle.outlineStyle).not.toBe("solid");
|
|
124
|
+
},
|
|
109
125
|
};
|
|
110
126
|
|
|
111
127
|
export const Uncontrolled: Story = {
|
|
@@ -88,7 +88,11 @@ describe("FileUpload", () => {
|
|
|
88
88
|
expect(onFilesChange.mock.calls[0]![0]).toHaveLength(0);
|
|
89
89
|
});
|
|
90
90
|
|
|
91
|
-
it("FileUploadList
|
|
91
|
+
it("FileUploadList announces via aria-live while keeping its native list role", () => {
|
|
92
|
+
// NOT role="status" — that would override the <ul>'s implicit `list`
|
|
93
|
+
// role and strip `listitem` from every <FileUploadItem> inside it
|
|
94
|
+
// (an axe `listitem` violation). `aria-live="polite"` alone still
|
|
95
|
+
// announces additions/removals to assistive tech.
|
|
92
96
|
const f = makeUploadFile();
|
|
93
97
|
render(
|
|
94
98
|
<FileUpload files={[f]} onFilesChange={() => {}}>
|
|
@@ -97,8 +101,9 @@ describe("FileUpload", () => {
|
|
|
97
101
|
</FileUploadList>
|
|
98
102
|
</FileUpload>,
|
|
99
103
|
);
|
|
100
|
-
const list = screen.getByRole("
|
|
104
|
+
const list = screen.getByRole("list");
|
|
101
105
|
expect(list).toHaveAttribute("aria-live", "polite");
|
|
106
|
+
expect(list).not.toHaveAttribute("role");
|
|
102
107
|
});
|
|
103
108
|
|
|
104
109
|
it("FileUploadList returns null when no files and no children", () => {
|
|
@@ -289,7 +289,7 @@ export const FileUploadDropzone = forwardRef<HTMLElement, FileUploadDropzoneProp
|
|
|
289
289
|
"hover:border-ring hover:bg-accent/50",
|
|
290
290
|
"data-[dragging]:border-primary data-[dragging]:bg-primary/5",
|
|
291
291
|
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
|
292
|
-
"focus-
|
|
292
|
+
"focus-ring-within focus-within:ring-offset-2 focus-within:ring-offset-background",
|
|
293
293
|
className,
|
|
294
294
|
)}
|
|
295
295
|
{...props}
|
|
@@ -312,7 +312,11 @@ export const FileUploadDropzone = forwardRef<HTMLElement, FileUploadDropzoneProp
|
|
|
312
312
|
}}
|
|
313
313
|
className={cn(
|
|
314
314
|
// #399 — inline text trigger inside a sentence → `-text` rung.
|
|
315
|
-
"text-primary-text underline-offset-2 hover:underline focus-
|
|
315
|
+
"text-primary-text underline-offset-2 hover:underline focus-ring rounded-sm",
|
|
316
|
+
// #322 — the dropzone's `focus-ring-within` (above) is the
|
|
317
|
+
// one compound indicator for this composite control; this
|
|
318
|
+
// button delegates rather than painting a second one.
|
|
319
|
+
"focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
|
316
320
|
"disabled:pointer-events-none disabled:opacity-50",
|
|
317
321
|
)}
|
|
318
322
|
>
|
|
@@ -343,9 +347,13 @@ export const FileUploadList = forwardRef<HTMLUListElement, FileUploadListProps>(
|
|
|
343
347
|
if (files.length === 0 && !children) return null;
|
|
344
348
|
|
|
345
349
|
return (
|
|
350
|
+
// `aria-live="polite"` alone is enough to announce additions/removals —
|
|
351
|
+
// it does NOT require `role="status"`. Adding that role here used to
|
|
352
|
+
// override the `<ul>`'s implicit `list` role, which strips the
|
|
353
|
+
// `listitem` role from every `<FileUploadItem>` (`<li>`) inside it and
|
|
354
|
+
// fails axe's `listitem` rule the moment the list is non-empty.
|
|
346
355
|
<ul
|
|
347
356
|
ref={ref}
|
|
348
|
-
role="status"
|
|
349
357
|
aria-live="polite"
|
|
350
358
|
aria-label="Selected files"
|
|
351
359
|
data-slot="file-upload-list"
|
|
@@ -434,7 +442,10 @@ export const FileUploadItem = forwardRef<HTMLLIElement, FileUploadItemProps>(
|
|
|
434
442
|
<Progress value={resolvedProgress} className="h-1" />
|
|
435
443
|
)}
|
|
436
444
|
{isError && resolvedError && (
|
|
437
|
-
|
|
445
|
+
// #124: this is running text (the rejection reason), so it takes the
|
|
446
|
+
// >=4.5:1 ink rung `text-destructive-text` — not the 3:1 mark rung the
|
|
447
|
+
// `statusIcon.error` glyph above correctly keeps.
|
|
448
|
+
<p role="alert" className="text-xs text-destructive-text">
|
|
438
449
|
{resolvedError}
|
|
439
450
|
</p>
|
|
440
451
|
)}
|
|
@@ -80,7 +80,8 @@ export const FormLabel = forwardRef<
|
|
|
80
80
|
htmlFor={formItemId}
|
|
81
81
|
className={cn(
|
|
82
82
|
"transition-colors duration-fast ease-standard",
|
|
83
|
-
error
|
|
83
|
+
// #124: the label's own error-state text, not a mark — ink rung.
|
|
84
|
+
error && "text-destructive-text",
|
|
84
85
|
className,
|
|
85
86
|
)}
|
|
86
87
|
{...props}
|
|
@@ -129,7 +130,9 @@ export const FormMessage = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLP
|
|
|
129
130
|
ref={ref}
|
|
130
131
|
id={formMessageId}
|
|
131
132
|
className={cn(
|
|
132
|
-
|
|
133
|
+
// #124: the error message body is running text — ink rung, not the
|
|
134
|
+
// 3:1 mark rung `text-destructive` guarantees.
|
|
135
|
+
"text-sm font-medium text-destructive-text animate-in fade-in slide-in-from-top-1 ease-entrance",
|
|
133
136
|
className,
|
|
134
137
|
)}
|
|
135
138
|
{...props}
|
|
@@ -59,7 +59,7 @@ describe("IconButton", () => {
|
|
|
59
59
|
);
|
|
60
60
|
const wrapper = document.querySelector('[data-slot="icon-button"]')!;
|
|
61
61
|
expect(wrapper).toHaveAttribute("tabindex", "0");
|
|
62
|
-
expect(wrapper.className).toContain("focus-
|
|
62
|
+
expect(wrapper.className).toContain("focus-ring");
|
|
63
63
|
await userEvent.tab();
|
|
64
64
|
expect(wrapper).toHaveFocus();
|
|
65
65
|
});
|
|
@@ -101,8 +101,8 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
|
|
101
101
|
// While disabled this span IS the focus target (the button is out
|
|
102
102
|
// of the tab order), so it must carry the system's focus ring —
|
|
103
103
|
// without this it falls back to the UA default outline, which is
|
|
104
|
-
// a hardcoded browser blue identical in
|
|
105
|
-
"focus-
|
|
104
|
+
// a hardcoded browser blue identical in every theme.
|
|
105
|
+
"focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
106
106
|
disabled && "cursor-not-allowed",
|
|
107
107
|
)}
|
|
108
108
|
>
|
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, waitFor } from "storybook/test";
|
|
2
3
|
import { Input } from "./input";
|
|
3
4
|
|
|
4
5
|
const meta = {
|
|
5
6
|
title: "Core/Input",
|
|
6
7
|
component: Input,
|
|
7
8
|
tags: ["autodocs"],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"The plain text field. A search box with a leading icon and a clear button is `Data/SearchInput`, which is built on this one; see [Choosing between similar components](?path=/docs/docs-choosing-between-similar-components--docs).",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
8
17
|
args: { placeholder: "you@example.com" },
|
|
9
18
|
argTypes: {
|
|
10
19
|
type: {
|
|
@@ -42,3 +51,115 @@ type Story = StoryObj<typeof meta>;
|
|
|
42
51
|
export const Default: Story = {};
|
|
43
52
|
export const Invalid: Story = { args: { "aria-invalid": true, defaultValue: "not-an-email" } };
|
|
44
53
|
export const Disabled: Story = { args: { disabled: true, value: "Disabled" } };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The element whose `data-theme` actually GOVERNS `el` — the nearest ancestor
|
|
57
|
+
* carrying the attribute, falling back to the document element.
|
|
58
|
+
*
|
|
59
|
+
* The earlier version of this helper guessed between `document.body` and
|
|
60
|
+
* `document.documentElement`. When the decorator wrote `data-theme` onto a
|
|
61
|
+
* wrapper closer to the story than either of those, flipping the far ancestor
|
|
62
|
+
* was a silent no-op: the nearer attribute still won the cascade, so BOTH
|
|
63
|
+
* branches of the loop below asserted in whatever theme the run had already
|
|
64
|
+
* chosen, and the docblock's "checked in both themes" claim was false. That
|
|
65
|
+
* stayed invisible until the suite was run under `STORYBOOK_THEME=dark`, where
|
|
66
|
+
* the same two branches both asserted in dark and the guard fired. Resolving
|
|
67
|
+
* the governing element from the input itself cannot drift that way.
|
|
68
|
+
*/
|
|
69
|
+
function themeHost(el: Element): HTMLElement {
|
|
70
|
+
return (el.closest("[data-theme]") as HTMLElement | null) ?? document.documentElement;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Regression lock for #49: `Input` must set its OWN text colour rather than
|
|
75
|
+
* inherit whatever ambient ink its container declares. Nested inside a
|
|
76
|
+
* `text-sidebar-foreground` wrapper (the `sidebar-04` mail search field's real
|
|
77
|
+
* shape), the input's resolved `color` must still match an un-nested `Input`'s
|
|
78
|
+
* — not the wrapper's near-invisible-on-`--background` sidebar ink. Runs in
|
|
79
|
+
* the Storybook browser project so `getComputedStyle` reflects the real
|
|
80
|
+
* Tailwind/token cascade (a jsdom unit test has none — `css: false` in
|
|
81
|
+
* `vitest.config.ts` — so it can only assert the class list, which would stay
|
|
82
|
+
* green even if a later change kept the class string but lost the cascade).
|
|
83
|
+
* Checked in BOTH themes via an in-place `data-theme` flip (no remount), so
|
|
84
|
+
* the fix is verified theme-safe rather than merely assumed to be from the
|
|
85
|
+
* class being additive.
|
|
86
|
+
*/
|
|
87
|
+
export const InAmbientTextContainer: Story = {
|
|
88
|
+
render: () => (
|
|
89
|
+
<div className="flex flex-col gap-4">
|
|
90
|
+
<Input aria-label="reference input" placeholder="reference (no ambient wrapper)" />
|
|
91
|
+
<div className="text-sidebar-foreground">
|
|
92
|
+
<Input aria-label="nested input" placeholder="nested under text-sidebar-foreground" />
|
|
93
|
+
</div>
|
|
94
|
+
<div className="text-muted-foreground">
|
|
95
|
+
<Input aria-label="muted input" placeholder="nested under text-muted-foreground" />
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
),
|
|
99
|
+
play: async ({ canvas, userEvent }) => {
|
|
100
|
+
const reference = canvas.getByLabelText<HTMLInputElement>("reference input");
|
|
101
|
+
const nested = canvas.getByLabelText<HTMLInputElement>("nested input");
|
|
102
|
+
const muted = canvas.getByLabelText<HTMLInputElement>("muted input");
|
|
103
|
+
await userEvent.type(nested, "search query");
|
|
104
|
+
|
|
105
|
+
const assertMatchesReference = async () => {
|
|
106
|
+
await waitFor(() => {
|
|
107
|
+
expect(getComputedStyle(nested).color).toBe(getComputedStyle(reference).color);
|
|
108
|
+
expect(getComputedStyle(muted).color).toBe(getComputedStyle(reference).color);
|
|
109
|
+
});
|
|
110
|
+
/*
|
|
111
|
+
* Guard against a vacuous pass: an ambient wrapper's ink must actually
|
|
112
|
+
* differ from the input's own, or this lock could never fail.
|
|
113
|
+
*
|
|
114
|
+
* The guard rides on the `text-muted-foreground` wrapper, NOT the
|
|
115
|
+
* sidebar one. `--sidebar-foreground` and `--foreground` are separate
|
|
116
|
+
* roles that happen to carry the same literal in the dark reference
|
|
117
|
+
* theme (both `oklch(0.95 0.004 257)`) — a legitimate coincidence, not
|
|
118
|
+
* an undeclared alias: light's sidebar ink is near-white because that
|
|
119
|
+
* theme's sidebar ground is DARK, and dark's is near-white because
|
|
120
|
+
* everything is. So in dark the sidebar wrapper cannot express a
|
|
121
|
+
* difference, and asserting one there is unsatisfiable.
|
|
122
|
+
* `--muted-foreground` differs from `--foreground` in both reference
|
|
123
|
+
* themes (light 0.5 vs 0.3, dark 0.72 vs 0.95), so it can carry the
|
|
124
|
+
* guard in either. The sidebar wrapper stays because it is the real
|
|
125
|
+
* shape this locks (the `sidebar-04` mail search field).
|
|
126
|
+
*/
|
|
127
|
+
await expect(getComputedStyle(muted).color).not.toBe(
|
|
128
|
+
getComputedStyle(muted.parentElement!).color,
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const host = themeHost(nested);
|
|
133
|
+
const original = host.getAttribute("data-theme");
|
|
134
|
+
/*
|
|
135
|
+
* Prove the flip actually took, rather than assuming it. A flip that lands
|
|
136
|
+
* on an element the cascade ignores turns the whole cross-theme claim into
|
|
137
|
+
* a no-op, which is precisely how this story passed for two themes while
|
|
138
|
+
* only ever measuring one.
|
|
139
|
+
*/
|
|
140
|
+
const assertThemeApplied = async (expected: string) => {
|
|
141
|
+
await waitFor(() => {
|
|
142
|
+
expect(getComputedStyle(host).getPropertyValue("--foreground").trim()).not.toBe("");
|
|
143
|
+
});
|
|
144
|
+
expect(host.getAttribute("data-theme")).toBe(expected);
|
|
145
|
+
expect(nested.closest("[data-theme]")).toBe(host);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
host.setAttribute("data-theme", "light");
|
|
150
|
+
await assertThemeApplied("light");
|
|
151
|
+
const lightInk = getComputedStyle(nested).color;
|
|
152
|
+
await assertMatchesReference();
|
|
153
|
+
|
|
154
|
+
host.setAttribute("data-theme", "dark");
|
|
155
|
+
await assertThemeApplied("dark");
|
|
156
|
+
await assertMatchesReference();
|
|
157
|
+
// The two themes must actually resolve to different ink, or "checked in
|
|
158
|
+
// both themes" is a claim about an attribute nobody read.
|
|
159
|
+
expect(getComputedStyle(nested).color).not.toBe(lightInk);
|
|
160
|
+
} finally {
|
|
161
|
+
if (original === null) host.removeAttribute("data-theme");
|
|
162
|
+
else host.setAttribute("data-theme", original);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
};
|
|
@@ -16,4 +16,17 @@ describe("Input", () => {
|
|
|
16
16
|
const el = screen.getByLabelText<HTMLInputElement>("email");
|
|
17
17
|
expect(el).toBeDisabled();
|
|
18
18
|
});
|
|
19
|
+
|
|
20
|
+
it("applies text-foreground class to ensure text color is not inherited from parent", () => {
|
|
21
|
+
// Render Input inside a container with a different text color (simulating sidebar)
|
|
22
|
+
render(
|
|
23
|
+
<div className="text-sidebar-foreground">
|
|
24
|
+
<Input aria-label="search" />
|
|
25
|
+
</div>,
|
|
26
|
+
);
|
|
27
|
+
const el = screen.getByLabelText<HTMLInputElement>("search");
|
|
28
|
+
// Verify that the Input element has the text-foreground class
|
|
29
|
+
// This ensures its text color is explicitly set and not inherited
|
|
30
|
+
expect(el.className).toContain("text-foreground");
|
|
31
|
+
});
|
|
19
32
|
});
|
|
@@ -12,9 +12,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
|
12
12
|
ref={ref}
|
|
13
13
|
type={type}
|
|
14
14
|
className={cn(
|
|
15
|
-
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
|
|
15
|
+
"flex h-9 w-full rounded-md border border-input bg-background text-foreground px-3 py-1 text-sm shadow-sm transition-[color,background-color,border-color,box-shadow] duration-fast ease-standard",
|
|
16
16
|
"placeholder:text-muted-foreground",
|
|
17
|
-
"focus-
|
|
17
|
+
"focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
|
18
18
|
"disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-muted disabled:border-border",
|
|
19
19
|
"aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive",
|
|
20
20
|
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, waitFor } from "storybook/test";
|
|
2
3
|
import { Search } from "lucide-react";
|
|
3
4
|
import {
|
|
4
5
|
InputGroup,
|
|
@@ -85,3 +86,76 @@ export const Card: Story = {
|
|
|
85
86
|
</div>
|
|
86
87
|
),
|
|
87
88
|
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Regression lock for #67 (fix round 2) — the compound focus indicator must be
|
|
92
|
+
* ONE mark on ONE element.
|
|
93
|
+
*
|
|
94
|
+
* `InputGroup` delegates its indicator to the wrapper, because the control is
|
|
95
|
+
* borderless and full-bleed and a ring around IT would be drawn inside the
|
|
96
|
+
* well. Before this lock the delegation was only half done and the indicator
|
|
97
|
+
* split across two elements: the wrapper painted the brand ring and no contour
|
|
98
|
+
* (its own base `outline-none` set `--tw-outline-style: none`, and a following
|
|
99
|
+
* `outline-1` re-declared `outline-style` from that variable), while the inner
|
|
100
|
+
* control painted the contour and no ring, ~10px inside, at a different radius.
|
|
101
|
+
* On light that read as a stray dark rectangle across the composer, and the
|
|
102
|
+
* well kept the 1.23:1 un-contoured lime ring #67 exists to remove.
|
|
103
|
+
*
|
|
104
|
+
* THE ASSERTION IS ON COMPUTED STYLE, NOT ON THE CLASS STRING. The class string
|
|
105
|
+
* was already "correct" while the bug shipped — `has-[…]:focus-ring-static` was
|
|
106
|
+
* present and cancelled by a sibling utility — so a class-name assertion (or a
|
|
107
|
+
* jsdom unit test, which has no CSS at all: `css: false` in `vitest.config.ts`)
|
|
108
|
+
* stays green through the exact regression this locks. It therefore lives in
|
|
109
|
+
* the Storybook browser project, where `getComputedStyle` reflects the real
|
|
110
|
+
* Tailwind cascade.
|
|
111
|
+
*/
|
|
112
|
+
export const CompoundFocusIndicator: Story = {
|
|
113
|
+
tags: ["!autodocs"],
|
|
114
|
+
render: () => (
|
|
115
|
+
<InputGroup className="max-w-sm">
|
|
116
|
+
<InputGroupAddon>
|
|
117
|
+
<Search />
|
|
118
|
+
</InputGroupAddon>
|
|
119
|
+
<InputGroupInput aria-label="locked control" placeholder="Search…" />
|
|
120
|
+
</InputGroup>
|
|
121
|
+
),
|
|
122
|
+
play: async ({ canvas, userEvent }) => {
|
|
123
|
+
const control = canvas.getByLabelText<HTMLInputElement>("locked control");
|
|
124
|
+
const wrapper = control.closest<HTMLElement>("[data-slot=input-group]")!;
|
|
125
|
+
await userEvent.tab();
|
|
126
|
+
await expect(control).toHaveFocus();
|
|
127
|
+
|
|
128
|
+
/*
|
|
129
|
+
* Settle past `transition-colors`: reading immediately after focus catches
|
|
130
|
+
* `outline-color` mid-transition and reports the element's own ink instead
|
|
131
|
+
* of the contour token. That artefact is why the utility now declares
|
|
132
|
+
* `outline-color` at rest (themes.css); the wait keeps this lock honest
|
|
133
|
+
* about which property it is measuring.
|
|
134
|
+
*/
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
const w = getComputedStyle(wrapper);
|
|
137
|
+
// The wrapper owns BOTH layers.
|
|
138
|
+
expect(w.outlineStyle).not.toBe("none");
|
|
139
|
+
expect(w.outlineWidth).toBe("1px");
|
|
140
|
+
expect(ringSpread(w)).toBeGreaterThan(0);
|
|
141
|
+
// …and they are flush: the contour sits exactly on the ring's outer edge.
|
|
142
|
+
expect(parseFloat(w.outlineOffset)).toBe(ringSpread(w));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// The control paints NEITHER layer — no second mark inside the well.
|
|
146
|
+
const c = getComputedStyle(control);
|
|
147
|
+
await expect(c.outlineStyle).toBe("none");
|
|
148
|
+
await expect(ringSpread(c)).toBe(0);
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* `--tw-ring-shadow` is a `0 0 0 calc(<offset> + <width>) <colour>` string; the
|
|
154
|
+
* painted reach is that `calc`. Reading it (rather than `boxShadow`) keeps the
|
|
155
|
+
* measurement independent of whatever elevation rung the element also carries.
|
|
156
|
+
*/
|
|
157
|
+
function ringSpread(style: CSSStyleDeclaration): number {
|
|
158
|
+
const shadow = style.getPropertyValue("--tw-ring-shadow");
|
|
159
|
+
const match = /calc\(\s*([\d.]+)px\s*\+\s*([\d.]+)px\s*\)/.exec(shadow);
|
|
160
|
+
return match ? parseFloat(match[1]) + parseFloat(match[2]) : 0;
|
|
161
|
+
}
|
|
@@ -7,12 +7,21 @@ import { Textarea } from "../textarea";
|
|
|
7
7
|
|
|
8
8
|
export const inputGroupVariants = cva(
|
|
9
9
|
cn(
|
|
10
|
-
"group/input-group relative flex w-full flex-wrap items-center rounded-md
|
|
10
|
+
"group/input-group relative flex w-full flex-wrap items-center rounded-md transition-[color,box-shadow] duration-fast ease-standard",
|
|
11
11
|
// Grow to fit a textarea even when it's nested (e.g. wrapped in a
|
|
12
12
|
// `display:contents` body like PromptInputBody). `:has(>textarea)`
|
|
13
13
|
// misses through that wrapper, so match any descendant textarea.
|
|
14
14
|
"h-9 has-[textarea]:h-auto",
|
|
15
|
-
|
|
15
|
+
// The compound indicator is the wrapper's, not the control's — the control
|
|
16
|
+
// is borderless and full-bleed, so a ring around IT would draw inside the
|
|
17
|
+
// well. `focus-ring-static` paints BOTH layers; the only geometry knob is
|
|
18
|
+
// `ring-offset-1`, which `outline-offset: calc(--tw-ring-offset-width + 2px)`
|
|
19
|
+
// tracks so ring and contour stay flush at the same radius. Do NOT add an
|
|
20
|
+
// `outline-*` utility here: `outline-1` re-declares
|
|
21
|
+
// `outline-style: var(--tw-outline-style)`, which `outline-none` on this
|
|
22
|
+
// same element used to pin to `none` — that pairing shipped, and it cancelled
|
|
23
|
+
// the contour on every composer, NumberInput and TagInput (#67 fix round 2).
|
|
24
|
+
"has-[[data-slot=input-group-control]:focus-visible]:focus-ring-static has-[[data-slot=input-group-control]:focus-visible]:ring-offset-1 has-[[data-slot=input-group-control]:focus-visible]:ring-offset-background",
|
|
16
25
|
"has-[[data-slot=input-group-control][aria-invalid=true]]:ring-2 has-[[data-slot=input-group-control][aria-invalid=true]]:ring-destructive",
|
|
17
26
|
),
|
|
18
27
|
{
|
|
@@ -144,7 +153,7 @@ export const InputGroupInput = forwardRef<HTMLInputElement, ComponentProps<typeo
|
|
|
144
153
|
ref={ref}
|
|
145
154
|
data-slot="input-group-control"
|
|
146
155
|
className={cn(
|
|
147
|
-
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
|
156
|
+
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
|
148
157
|
className,
|
|
149
158
|
)}
|
|
150
159
|
{...props}
|
|
@@ -160,7 +169,7 @@ export const InputGroupTextarea = forwardRef<HTMLTextAreaElement, ComponentProps
|
|
|
160
169
|
ref={ref}
|
|
161
170
|
data-slot="input-group-control"
|
|
162
171
|
className={cn(
|
|
163
|
-
"flex-1 resize-none rounded-none border-0 bg-transparent py-2.5 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
|
172
|
+
"flex-1 resize-none rounded-none border-0 bg-transparent py-2.5 shadow-none focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
|
164
173
|
className,
|
|
165
174
|
)}
|
|
166
175
|
{...props}
|
|
@@ -42,7 +42,11 @@ export const InputOTPSlot = forwardRef<
|
|
|
42
42
|
ref={ref}
|
|
43
43
|
className={cn(
|
|
44
44
|
"relative flex size-9 items-center justify-center border-y border-e border-input text-sm shadow-sm transition-[color,border-color,box-shadow] duration-fast ease-standard first:rounded-s-md first:border-s last:rounded-e-md",
|
|
45
|
-
|
|
45
|
+
// The OTP slot IS the field's focus indicator: the real focused element is
|
|
46
|
+
// InputOTP's visually-hidden <input>, and `isActive` is the caret's slot. It
|
|
47
|
+
// therefore carries the compound indicator (#67) — the state is JS-driven, so
|
|
48
|
+
// it is the `-static` flavour rather than a `:focus-visible` one.
|
|
49
|
+
isActive && "z-10 focus-ring-static",
|
|
46
50
|
className,
|
|
47
51
|
)}
|
|
48
52
|
{...props}
|