@charcuterie/ui 2.4.1 → 2.6.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/dist/Checkbox/Checkbox.d.ts +68 -0
- package/dist/Checkbox/Checkbox.d.ts.map +1 -0
- package/dist/Checkbox/Checkbox.js +108 -0
- package/dist/Checkbox/Checkbox.js.map +1 -0
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +12 -1
- package/dist/Field/Field.js.map +1 -1
- package/dist/Menu/Menu.d.ts +60 -2
- package/dist/Menu/Menu.d.ts.map +1 -1
- package/dist/Menu/Menu.js +58 -11
- package/dist/Menu/Menu.js.map +1 -1
- package/dist/RadioGroup/RadioGroup.d.ts +54 -0
- package/dist/RadioGroup/RadioGroup.d.ts.map +1 -0
- package/dist/RadioGroup/RadioGroup.js +121 -0
- package/dist/RadioGroup/RadioGroup.js.map +1 -0
- package/dist/RadioGroup/RadioGroupOption.d.ts +27 -0
- package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -0
- package/dist/RadioGroup/RadioGroupOption.js +75 -0
- package/dist/RadioGroup/RadioGroupOption.js.map +1 -0
- package/dist/Switch/Switch.d.ts +60 -0
- package/dist/Switch/Switch.d.ts.map +1 -0
- package/dist/Switch/Switch.js +106 -0
- package/dist/Switch/Switch.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/slotWiring.d.ts +9 -7
- package/dist/slotWiring.d.ts.map +1 -1
- package/dist/slotWiring.js +10 -70
- package/dist/slotWiring.js.map +1 -1
- package/package.json +2 -2
- package/src/Checkbox/Checkbox.mdx +71 -0
- package/src/Checkbox/Checkbox.stories.tsx +122 -0
- package/src/Checkbox/Checkbox.test.tsx +109 -0
- package/src/Checkbox/Checkbox.tsx +205 -0
- package/src/Combobox/Combobox.stories.tsx +19 -4
- package/src/Field/Field.stories.tsx +19 -0
- package/src/Field/Field.test.tsx +30 -2
- package/src/Field/Field.tsx +13 -1
- package/src/Menu/Menu.stories.tsx +80 -2
- package/src/Menu/Menu.test.tsx +102 -0
- package/src/Menu/Menu.tsx +156 -17
- package/src/RadioGroup/RadioGroup.mdx +67 -0
- package/src/RadioGroup/RadioGroup.stories.tsx +140 -0
- package/src/RadioGroup/RadioGroup.test.tsx +232 -0
- package/src/RadioGroup/RadioGroup.tsx +223 -0
- package/src/RadioGroup/RadioGroupOption.tsx +148 -0
- package/src/Switch/Switch.mdx +55 -0
- package/src/Switch/Switch.stories.tsx +116 -0
- package/src/Switch/Switch.test.tsx +115 -0
- package/src/Switch/Switch.tsx +183 -0
- package/src/index.ts +9 -0
- package/src/slotWiring.ts +15 -107
- package/src/sourceRules.test.ts +11 -1
- package/src/tailwindCandidates.test.ts +9 -7
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
|
|
4
|
+
import {
|
|
5
|
+
StoryCell,
|
|
6
|
+
StoryGrid,
|
|
7
|
+
StorySection,
|
|
8
|
+
} from "../board.storyHelpers.tsx"
|
|
9
|
+
import { Checkbox } from "./Checkbox.tsx"
|
|
10
|
+
|
|
11
|
+
const meta = {
|
|
12
|
+
title: "Components/Checkbox",
|
|
13
|
+
component: Checkbox,
|
|
14
|
+
parameters: { layout: "padded" },
|
|
15
|
+
argTypes: { size: controlSizeArgType },
|
|
16
|
+
// The component's own defaults, restated — Storybook does not seed
|
|
17
|
+
// `args` from docgen, so an unstated default shows in the props
|
|
18
|
+
// table with nothing selected in its control.
|
|
19
|
+
args: {
|
|
20
|
+
isChecked: false,
|
|
21
|
+
isDisabled: false,
|
|
22
|
+
isReadOnly: false,
|
|
23
|
+
size: "md",
|
|
24
|
+
},
|
|
25
|
+
} satisfies Meta<typeof Checkbox>
|
|
26
|
+
|
|
27
|
+
export default meta
|
|
28
|
+
|
|
29
|
+
type Story = StoryObj<typeof meta>
|
|
30
|
+
|
|
31
|
+
export const Default: Story = {
|
|
32
|
+
args: { label: "Delete originals after import" },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const AllVariants: Story = {
|
|
36
|
+
args: { label: "Delete originals after import" },
|
|
37
|
+
render: (controlProps) => (
|
|
38
|
+
<StorySection title="Three control heights, all reading the density axis rather than hardcoding a length.">
|
|
39
|
+
<StoryGrid columns={3}>
|
|
40
|
+
{(["sm", "md", "lg"] as const).map((size) => (
|
|
41
|
+
<StoryCell key={size} label={size}>
|
|
42
|
+
<Checkbox
|
|
43
|
+
{...controlProps}
|
|
44
|
+
isChecked
|
|
45
|
+
label={`Enabled at ${size}`}
|
|
46
|
+
size={size}
|
|
47
|
+
/>
|
|
48
|
+
</StoryCell>
|
|
49
|
+
))}
|
|
50
|
+
</StoryGrid>
|
|
51
|
+
</StorySection>
|
|
52
|
+
),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `isChecked` seeds the first paint and nothing after — the `<input>`
|
|
57
|
+
* owns its state from then on, so a disabled-and-checked box is a
|
|
58
|
+
* saved preference the user cannot currently change, not a bug.
|
|
59
|
+
*/
|
|
60
|
+
export const AllStates: Story = {
|
|
61
|
+
args: { label: "Delete originals after import" },
|
|
62
|
+
render: (controlProps) => (
|
|
63
|
+
<StorySection title="What varies is whether the box is checked and whether it can be changed.">
|
|
64
|
+
<StoryGrid columns={2}>
|
|
65
|
+
<StoryCell label="unchecked">
|
|
66
|
+
<Checkbox {...controlProps} label="Unchecked" />
|
|
67
|
+
</StoryCell>
|
|
68
|
+
|
|
69
|
+
<StoryCell label="checked">
|
|
70
|
+
<Checkbox
|
|
71
|
+
{...controlProps}
|
|
72
|
+
isChecked
|
|
73
|
+
label="Checked"
|
|
74
|
+
/>
|
|
75
|
+
</StoryCell>
|
|
76
|
+
|
|
77
|
+
<StoryCell label="disabled, unchecked">
|
|
78
|
+
<Checkbox
|
|
79
|
+
{...controlProps}
|
|
80
|
+
isDisabled
|
|
81
|
+
label="Disabled"
|
|
82
|
+
/>
|
|
83
|
+
</StoryCell>
|
|
84
|
+
|
|
85
|
+
<StoryCell label="disabled, checked">
|
|
86
|
+
<Checkbox
|
|
87
|
+
{...controlProps}
|
|
88
|
+
isChecked
|
|
89
|
+
isDisabled
|
|
90
|
+
label="Disabled and checked"
|
|
91
|
+
/>
|
|
92
|
+
</StoryCell>
|
|
93
|
+
|
|
94
|
+
<StoryCell label="read-only, unchecked">
|
|
95
|
+
<Checkbox
|
|
96
|
+
{...controlProps}
|
|
97
|
+
isReadOnly
|
|
98
|
+
label="Read-only"
|
|
99
|
+
/>
|
|
100
|
+
</StoryCell>
|
|
101
|
+
|
|
102
|
+
<StoryCell label="read-only, checked">
|
|
103
|
+
<Checkbox
|
|
104
|
+
{...controlProps}
|
|
105
|
+
isChecked
|
|
106
|
+
isReadOnly
|
|
107
|
+
label="Read-only and checked"
|
|
108
|
+
/>
|
|
109
|
+
</StoryCell>
|
|
110
|
+
</StoryGrid>
|
|
111
|
+
</StorySection>
|
|
112
|
+
),
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The whole keyboard path is the browser's: Tab reaches the box,
|
|
117
|
+
* Space toggles it. A `<label>` wrapping the control is what makes a
|
|
118
|
+
* pointer press on the *text* toggle it too.
|
|
119
|
+
*/
|
|
120
|
+
export const Interactive: Story = {
|
|
121
|
+
args: { label: "Delete originals after import" },
|
|
122
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent, waitFor } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
6
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
7
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
|
+
import * as stories from "./Checkbox.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllStates, Default, Interactive } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
test("it is a checkbox a screen reader can name", async () => {
|
|
14
|
+
const { canvas, canvasElement } =
|
|
15
|
+
await mountStory(Default)
|
|
16
|
+
|
|
17
|
+
const checkbox = expectAgentDrivable(canvas, {
|
|
18
|
+
name: "Delete originals after import",
|
|
19
|
+
role: "checkbox",
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
await expect(checkbox).not.toBeChecked()
|
|
23
|
+
|
|
24
|
+
await expectNoAxeViolations(canvasElement)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("a press toggles it, and the label is part of the target", async () => {
|
|
28
|
+
const { canvas } = await mountStory(Interactive)
|
|
29
|
+
|
|
30
|
+
const checkbox = expectAgentDrivable(canvas, {
|
|
31
|
+
name: "Delete originals after import",
|
|
32
|
+
role: "checkbox",
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
// The label wraps the control, so a click on the text is a click
|
|
36
|
+
// on the box — which is the association `<label htmlFor>` gets
|
|
37
|
+
// wrong half the time in the fleet and this cannot.
|
|
38
|
+
await userEvent.click(checkbox)
|
|
39
|
+
|
|
40
|
+
await waitFor(() => {
|
|
41
|
+
expect(checkbox).toBeChecked()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
await userEvent.click(checkbox)
|
|
45
|
+
|
|
46
|
+
await waitFor(() => {
|
|
47
|
+
expect(checkbox).not.toBeChecked()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("a disabled box cannot be toggled", async () => {
|
|
52
|
+
const { canvas, canvasElement } =
|
|
53
|
+
await mountStory(AllStates)
|
|
54
|
+
|
|
55
|
+
const disabled = expectAgentDrivable(canvas, {
|
|
56
|
+
name: "Disabled",
|
|
57
|
+
role: "checkbox",
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
await expect(disabled).toBeDisabled()
|
|
61
|
+
|
|
62
|
+
await expect(disabled).not.toBeChecked()
|
|
63
|
+
|
|
64
|
+
await userEvent.click(disabled)
|
|
65
|
+
|
|
66
|
+
await expect(disabled).not.toBeChecked()
|
|
67
|
+
|
|
68
|
+
await expectNoAxeViolations(canvasElement)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("isChecked decides the first render", async () => {
|
|
72
|
+
const { canvas } = await mountStory(AllStates)
|
|
73
|
+
|
|
74
|
+
await expect(
|
|
75
|
+
expectAgentDrivable(canvas, {
|
|
76
|
+
name: "Checked",
|
|
77
|
+
role: "checkbox",
|
|
78
|
+
}),
|
|
79
|
+
).toBeChecked()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test("a read-only box is announced and cannot be toggled", async () => {
|
|
83
|
+
const { canvas, canvasElement } =
|
|
84
|
+
await mountStory(AllStates)
|
|
85
|
+
|
|
86
|
+
const readOnly = expectAgentDrivable(canvas, {
|
|
87
|
+
name: "Read-only",
|
|
88
|
+
role: "checkbox",
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Full contrast, not `disabled` — a read-only value stays readable,
|
|
92
|
+
// and it is focusable so a screen reader reaches it. The state is
|
|
93
|
+
// carried by `aria-readonly`, not by removing it from the tree.
|
|
94
|
+
await expect(readOnly).toHaveAttribute(
|
|
95
|
+
"aria-readonly",
|
|
96
|
+
"true",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
await expect(readOnly).not.toBeDisabled()
|
|
100
|
+
|
|
101
|
+
await expect(readOnly).not.toBeChecked()
|
|
102
|
+
|
|
103
|
+
await userEvent.click(readOnly)
|
|
104
|
+
|
|
105
|
+
// The pointer block held — the box did not flip.
|
|
106
|
+
await expect(readOnly).not.toBeChecked()
|
|
107
|
+
|
|
108
|
+
await expectNoAxeViolations(canvasElement)
|
|
109
|
+
})
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { ControlSize } from "@charcuterie/tokens"
|
|
2
|
+
import type { ChangeEvent, ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
5
|
+
import { toClassName } from "../toClassName.ts"
|
|
6
|
+
|
|
7
|
+
export type CheckboxProps = {
|
|
8
|
+
className?: string
|
|
9
|
+
/**
|
|
10
|
+
* The caller's own id, when it has one — a stable target for an
|
|
11
|
+
* autofill hint or a deep link. Left to React's `useId` on the
|
|
12
|
+
* `<input>` otherwise, which is enough for the wrapping `<label>`.
|
|
13
|
+
*/
|
|
14
|
+
id?: string
|
|
15
|
+
/**
|
|
16
|
+
* **Initial** only. The `<input>` is the store from then on — the
|
|
17
|
+
* DOM owns a checkbox's checkedness, and a controlled `checked`
|
|
18
|
+
* prop is the thing this library refuses to have
|
|
19
|
+
* (`SegmentedControl` says the same about `selectedValue`).
|
|
20
|
+
*/
|
|
21
|
+
isChecked?: boolean
|
|
22
|
+
isDisabled?: boolean
|
|
23
|
+
/**
|
|
24
|
+
* Shows the value at full contrast but refuses to change it — a
|
|
25
|
+
* setting you may read here and edit elsewhere. Distinct from
|
|
26
|
+
* `isDisabled`, which dims: a read-only box is not turned down,
|
|
27
|
+
* because the whole point is that you can still read it. Announced
|
|
28
|
+
* as `aria-readonly`, and the toggle is blocked on both pointer and
|
|
29
|
+
* `Space`.
|
|
30
|
+
*/
|
|
31
|
+
isReadOnly?: boolean
|
|
32
|
+
/**
|
|
33
|
+
* The visible text beside the box, and the checkbox's accessible
|
|
34
|
+
* name — the `<label>` wraps the control, so there is no `for` to
|
|
35
|
+
* get wrong. A box with no label is a control a screen reader
|
|
36
|
+
* cannot announce, which is why this is required.
|
|
37
|
+
*/
|
|
38
|
+
label: ReactNode
|
|
39
|
+
onChange?: (isChecked: boolean) => void
|
|
40
|
+
size?: ControlSize
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const BOX_SIZE_CLASS: Record<ControlSize, string> = {
|
|
44
|
+
sm: "size-3.5",
|
|
45
|
+
md: "size-4",
|
|
46
|
+
lg: "size-5",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const CHECK_SIZE_CLASS: Record<ControlSize, string> = {
|
|
50
|
+
sm: "size-2.5",
|
|
51
|
+
md: "size-3",
|
|
52
|
+
lg: "size-3.5",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
|
|
56
|
+
sm: "text-sm",
|
|
57
|
+
md: "text-md",
|
|
58
|
+
lg: "text-lg",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A single boolean, on or off, that owns nothing but itself.
|
|
63
|
+
*
|
|
64
|
+
* This is the primitive mux-magic's `BooleanField` hand-rolled in
|
|
65
|
+
* `bg-slate-700 accent-blue-500` because the library had no checkbox
|
|
66
|
+
* to reach for — palette colours with no light mode, the exact M6
|
|
67
|
+
* defect the token layer exists to delete. Here the box is
|
|
68
|
+
* `bg-surface-sunken` and the fill is `bg-intent-accent-solid`, so
|
|
69
|
+
* one control reads correctly in `daylight`, `midnight`, and on the
|
|
70
|
+
* kiosk with no per-app override.
|
|
71
|
+
*
|
|
72
|
+
* ### The `<label>` wraps the control
|
|
73
|
+
*
|
|
74
|
+
* A checkbox reads to the *left* of its text, which is why it is not
|
|
75
|
+
* a `Field` — `Field` stacks label above control in a `flex-col`,
|
|
76
|
+
* right for a text input and wrong for a boolean, and stacking every
|
|
77
|
+
* checkbox in a step card would double the card's height. Wrapping is
|
|
78
|
+
* also the one label association that needs no `for` at all, so there
|
|
79
|
+
* is no minted-then-dropped id to get wrong.
|
|
80
|
+
*
|
|
81
|
+
* ### The `<input>` is the store
|
|
82
|
+
*
|
|
83
|
+
* `defaultChecked`, not `checked`. The DOM owns a checkbox's state
|
|
84
|
+
* natively, so there is no second copy to keep in step and no
|
|
85
|
+
* controlled-prop round-trip — `onChange` reports the new state to a
|
|
86
|
+
* consumer that wants to persist it, and reading it back is the
|
|
87
|
+
* `<input>`'s job. `isChecked` seeds the first paint and nothing
|
|
88
|
+
* after.
|
|
89
|
+
*/
|
|
90
|
+
export const Checkbox = ({
|
|
91
|
+
className,
|
|
92
|
+
id,
|
|
93
|
+
isChecked = false,
|
|
94
|
+
isDisabled = false,
|
|
95
|
+
isReadOnly = false,
|
|
96
|
+
label,
|
|
97
|
+
onChange,
|
|
98
|
+
size = "md",
|
|
99
|
+
}: CheckboxProps): ReactNode => (
|
|
100
|
+
<label
|
|
101
|
+
className={toClassName(
|
|
102
|
+
"inline-flex items-center gap-2 text-content-secondary select-none",
|
|
103
|
+
TEXT_SIZE_CLASS[size],
|
|
104
|
+
// One cursor, chosen by state — two cursor utilities of the same
|
|
105
|
+
// specificity do not resolve by class-list order.
|
|
106
|
+
isDisabled
|
|
107
|
+
? "cursor-not-allowed"
|
|
108
|
+
: isReadOnly
|
|
109
|
+
? "cursor-default"
|
|
110
|
+
: "cursor-pointer",
|
|
111
|
+
// Disabled dims the whole control with opacity rather than fading
|
|
112
|
+
// the box to a quieter token. The token scale has no step between
|
|
113
|
+
// `border-default` and `border-strong`, so a muted outline was
|
|
114
|
+
// either invisible or indistinguishable from enabled; `opacity-60`
|
|
115
|
+
// keeps the full shape and colour and just turns them down, which
|
|
116
|
+
// is the "present but inactive" the box actually is. Read-only is
|
|
117
|
+
// *not* dimmed — its whole point is that the value stays readable.
|
|
118
|
+
// The same treatment is on `Switch` and `RadioGroup`.
|
|
119
|
+
isDisabled && "opacity-60",
|
|
120
|
+
className,
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
<span className="relative grid shrink-0 place-items-center">
|
|
124
|
+
<input
|
|
125
|
+
aria-readonly={isReadOnly || undefined}
|
|
126
|
+
className={toClassName(
|
|
127
|
+
// `border-2` is the family's shared weight — the `Switch`
|
|
128
|
+
// track's `ring-2` and the `RadioGroup` ring match it, so the
|
|
129
|
+
// three read as one set — and a 2px edge is also what keeps a
|
|
130
|
+
// disabled box visible where a 1px one washed out.
|
|
131
|
+
"peer appearance-none rounded-sm border-2 bg-surface-sunken transition-colors duration-(--duration-fast) ease-standard",
|
|
132
|
+
// Read-only wears the **neutral** intent, not the accent: full
|
|
133
|
+
// contrast (so the value reads) but plainly not an actionable
|
|
134
|
+
// accent control, with a softer `border-default` resting edge.
|
|
135
|
+
// Three distinct states result — enabled (accent, crisp),
|
|
136
|
+
// disabled (accent, dimmed by the wrapper), read-only (neutral).
|
|
137
|
+
// No `disabled:` colour override; the wrapper's `opacity-60`
|
|
138
|
+
// does the dimming, and doubling it on the box is what made a
|
|
139
|
+
// disabled checkbox vanish.
|
|
140
|
+
isReadOnly
|
|
141
|
+
? "border-border-default checked:border-intent-neutral-solid checked:bg-intent-neutral-solid"
|
|
142
|
+
: "border-border-strong checked:border-intent-accent-solid checked:bg-intent-accent-solid",
|
|
143
|
+
isDisabled
|
|
144
|
+
? "cursor-not-allowed"
|
|
145
|
+
: isReadOnly
|
|
146
|
+
? "cursor-default"
|
|
147
|
+
: "cursor-pointer",
|
|
148
|
+
BOX_SIZE_CLASS[size],
|
|
149
|
+
FOCUS_RING_CLASS,
|
|
150
|
+
)}
|
|
151
|
+
defaultChecked={isChecked}
|
|
152
|
+
disabled={isDisabled}
|
|
153
|
+
id={id}
|
|
154
|
+
onChange={(
|
|
155
|
+
event: ChangeEvent<HTMLInputElement>,
|
|
156
|
+
) => {
|
|
157
|
+
if (isReadOnly) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
onChange?.(event.target.checked)
|
|
162
|
+
}}
|
|
163
|
+
onClick={(clickEvent) => {
|
|
164
|
+
// Native checkboxes have no `readonly`, so the toggle is
|
|
165
|
+
// blocked here — `preventDefault` on the click stops the
|
|
166
|
+
// checked flip a pointer would make.
|
|
167
|
+
if (isReadOnly) {
|
|
168
|
+
clickEvent.preventDefault()
|
|
169
|
+
}
|
|
170
|
+
}}
|
|
171
|
+
onKeyDown={(keyEvent) => {
|
|
172
|
+
// …and Space, the keyboard toggle a click block does not
|
|
173
|
+
// cover.
|
|
174
|
+
if (isReadOnly && keyEvent.key === " ") {
|
|
175
|
+
keyEvent.preventDefault()
|
|
176
|
+
}
|
|
177
|
+
}}
|
|
178
|
+
type="checkbox"
|
|
179
|
+
/>
|
|
180
|
+
|
|
181
|
+
<svg
|
|
182
|
+
aria-hidden="true"
|
|
183
|
+
className={toClassName(
|
|
184
|
+
"pointer-events-none invisible absolute peer-checked:visible",
|
|
185
|
+
// Matches whichever fill the box wears, so the tick keeps its
|
|
186
|
+
// guaranteed on-solid contrast in either intent.
|
|
187
|
+
isReadOnly
|
|
188
|
+
? "text-intent-neutral-on-solid"
|
|
189
|
+
: "text-intent-accent-on-solid",
|
|
190
|
+
CHECK_SIZE_CLASS[size],
|
|
191
|
+
)}
|
|
192
|
+
fill="none"
|
|
193
|
+
stroke="currentColor"
|
|
194
|
+
strokeLinecap="round"
|
|
195
|
+
strokeLinejoin="round"
|
|
196
|
+
strokeWidth={3}
|
|
197
|
+
viewBox="0 0 24 24"
|
|
198
|
+
>
|
|
199
|
+
<path d="M20 6 9 17l-5-5" />
|
|
200
|
+
</svg>
|
|
201
|
+
</span>
|
|
202
|
+
|
|
203
|
+
<span>{label}</span>
|
|
204
|
+
</label>
|
|
205
|
+
)
|
|
@@ -88,11 +88,20 @@ const ComboboxHarness = ({
|
|
|
88
88
|
isVisible={isVisible}
|
|
89
89
|
onDismiss={hide}
|
|
90
90
|
onSelect={(value) => {
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
// Mirror the control's own selection model so the "Chosen"
|
|
92
|
+
// line never disagrees with the ticks in the list: multi-select
|
|
93
|
+
// accumulates/toggles, single-select replaces (one value, one
|
|
94
|
+
// ✓). Toggling into an array in single-select made the line
|
|
95
|
+
// claim several were chosen while only the last carried a tick.
|
|
96
|
+
setChosen((previous) => {
|
|
97
|
+
if (!isMultiple) {
|
|
98
|
+
return [value]
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return previous.includes(value)
|
|
93
102
|
? previous.filter((one) => one !== value)
|
|
94
|
-
: [...previous, value]
|
|
95
|
-
)
|
|
103
|
+
: [...previous, value]
|
|
104
|
+
})
|
|
96
105
|
}}
|
|
97
106
|
options={options}
|
|
98
107
|
trigger={
|
|
@@ -415,6 +424,11 @@ const MANY_LONG: ListboxItem[] = Array.from(
|
|
|
415
424
|
* so the list both virtualizes (over ~100 options) and wraps each row to
|
|
416
425
|
* two lines. Rows are measured, not pinned to the 36px estimate, so they
|
|
417
426
|
* lay out below one another instead of overlapping.
|
|
427
|
+
*
|
|
428
|
+
* Multi-select as well, so it exercises the case together: every picked
|
|
429
|
+
* row keeps its own ✓ (a virtualized row that scrolls out and back is
|
|
430
|
+
* re-rendered from `selected`, not remembered by the DOM), and the chips
|
|
431
|
+
* above the trigger stay in sync with the ticks.
|
|
418
432
|
*/
|
|
419
433
|
export const VirtualizedLongOptions: Story = {
|
|
420
434
|
args: {
|
|
@@ -428,6 +442,7 @@ export const VirtualizedLongOptions: Story = {
|
|
|
428
442
|
<ComboboxHarness
|
|
429
443
|
className="w-72"
|
|
430
444
|
isInitiallyVisible
|
|
445
|
+
isMultiple
|
|
431
446
|
options={MANY_LONG}
|
|
432
447
|
triggerLabel="Search folders"
|
|
433
448
|
/>
|
|
@@ -36,6 +36,25 @@ export const Default: Story = {
|
|
|
36
36
|
},
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* The control carries its own `id` — `rename-pattern`, the kind of
|
|
41
|
+
* stable id a deep link or a server-rendered error summary points at.
|
|
42
|
+
* The `Field` adopts it rather than minting a `-control` id over the
|
|
43
|
+
* top, so the `<label htmlFor>` and every outside-in reference agree.
|
|
44
|
+
*/
|
|
45
|
+
export const AdoptsChildId: Story = {
|
|
46
|
+
args: {
|
|
47
|
+
children: (
|
|
48
|
+
<input
|
|
49
|
+
className={TEXT_INPUT_CLASS}
|
|
50
|
+
id="rename-pattern"
|
|
51
|
+
type="text"
|
|
52
|
+
/>
|
|
53
|
+
),
|
|
54
|
+
label: "Rename pattern",
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
39
58
|
/**
|
|
40
59
|
* The control is a slot, not a hardcoded `<input>` — `Select` goes
|
|
41
60
|
* in the same hole, and so does an app's own control. The `id`,
|
package/src/Field/Field.test.tsx
CHANGED
|
@@ -7,8 +7,36 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./Field.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const {
|
|
11
|
-
|
|
10
|
+
const {
|
|
11
|
+
AdoptsChildId,
|
|
12
|
+
AllStates,
|
|
13
|
+
AllVariants,
|
|
14
|
+
Default,
|
|
15
|
+
Group,
|
|
16
|
+
Nested,
|
|
17
|
+
} = composeStories(stories)
|
|
18
|
+
|
|
19
|
+
test("a control's own id is adopted, not overwritten", async () => {
|
|
20
|
+
const { canvas, canvasElement } =
|
|
21
|
+
await mountStory(AdoptsChildId)
|
|
22
|
+
|
|
23
|
+
// The child was written `<input id="rename-pattern" />`. The Field
|
|
24
|
+
// must keep that id — overwriting it with a minted `-control` id is
|
|
25
|
+
// what broke the outside-in references the id exists for — and the
|
|
26
|
+
// `<label htmlFor>` must agree, so the control is still findable by
|
|
27
|
+
// its label's text.
|
|
28
|
+
const control = expectAgentDrivable(canvas, {
|
|
29
|
+
name: "Rename pattern",
|
|
30
|
+
role: "textbox",
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
await expect(control).toHaveAttribute(
|
|
34
|
+
"id",
|
|
35
|
+
"rename-pattern",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
await expectNoAxeViolations(canvasElement)
|
|
39
|
+
})
|
|
12
40
|
|
|
13
41
|
test("the label names the control it points at", async () => {
|
|
14
42
|
const { canvas, canvasElement } =
|
package/src/Field/Field.tsx
CHANGED
|
@@ -126,9 +126,21 @@ export const Field = ({
|
|
|
126
126
|
* link, a server-rendered error summary linking to the control.
|
|
127
127
|
* `useUniqueId` is still called: hooks are unconditional, and it
|
|
128
128
|
* costs one counter read.
|
|
129
|
+
*
|
|
130
|
+
* Precedence is `Field` prop → **the child's own `id`** →
|
|
131
|
+
* generated. The child's is read because a control written as
|
|
132
|
+
* `<input id="rename-pattern" />` puts the id where the author can
|
|
133
|
+
* see it, not on the `Field`; overwriting it with a minted
|
|
134
|
+
* `-control` id used to break exactly the outside-in references the
|
|
135
|
+
* `Field` prop exists to keep — a deep link, an autofill hint, a
|
|
136
|
+
* consumer's own selector. The `Field` prop still wins when both
|
|
137
|
+
* are set: it is the outer, later declaration, and last-writer-wins
|
|
138
|
+
* is the slot's rule for a value.
|
|
129
139
|
*/
|
|
140
|
+
const childId = (children.props as { id?: string }).id
|
|
141
|
+
|
|
130
142
|
const controlId =
|
|
131
|
-
receivedSlotProps.id ?? `${baseId}-control`
|
|
143
|
+
receivedSlotProps.id ?? childId ?? `${baseId}-control`
|
|
132
144
|
|
|
133
145
|
const descriptionId = `${baseId}-description`
|
|
134
146
|
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
UndoIcon,
|
|
15
15
|
} from "../icons.storyHelpers.tsx"
|
|
16
16
|
import { Tooltip } from "../Tooltip/Tooltip.tsx"
|
|
17
|
-
import type { MenuItem } from "./Menu.tsx"
|
|
17
|
+
import type { MenuEntry, MenuItem } from "./Menu.tsx"
|
|
18
18
|
import { Menu } from "./Menu.tsx"
|
|
19
19
|
|
|
20
20
|
const noop = () => undefined
|
|
@@ -41,18 +41,50 @@ const BAY_ACTIONS: MenuItem[] = [
|
|
|
41
41
|
},
|
|
42
42
|
]
|
|
43
43
|
|
|
44
|
+
const GROUPED_ACTIONS: MenuEntry[] = [
|
|
45
|
+
{
|
|
46
|
+
items: [
|
|
47
|
+
{
|
|
48
|
+
key: "retry",
|
|
49
|
+
label: "Retry title",
|
|
50
|
+
onSelect: noop,
|
|
51
|
+
},
|
|
52
|
+
{ key: "skip", label: "Skip title", onSelect: noop },
|
|
53
|
+
],
|
|
54
|
+
key: "disc",
|
|
55
|
+
label: "Disc",
|
|
56
|
+
type: "group",
|
|
57
|
+
},
|
|
58
|
+
{ key: "sep", type: "separator" },
|
|
59
|
+
{
|
|
60
|
+
items: [
|
|
61
|
+
{
|
|
62
|
+
icon: <SettingsIcon />,
|
|
63
|
+
key: "eject",
|
|
64
|
+
label: "Eject disc",
|
|
65
|
+
onSelect: noop,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
key: "danger",
|
|
69
|
+
label: "Danger",
|
|
70
|
+
type: "group",
|
|
71
|
+
},
|
|
72
|
+
]
|
|
73
|
+
|
|
44
74
|
/**
|
|
45
75
|
* A menu's visibility belongs to the app, exactly like `Popover`'s —
|
|
46
76
|
* so the stories hold it in a `useVisibility`, which is what a
|
|
47
77
|
* consumer writes too.
|
|
48
78
|
*/
|
|
49
79
|
const MenuHarness = ({
|
|
80
|
+
emptyState,
|
|
50
81
|
isInitiallyVisible = false,
|
|
51
82
|
items = BAY_ACTIONS,
|
|
52
83
|
triggerLabel,
|
|
53
84
|
}: {
|
|
85
|
+
emptyState?: ReactNode
|
|
54
86
|
isInitiallyVisible?: boolean
|
|
55
|
-
items?:
|
|
87
|
+
items?: MenuEntry[]
|
|
56
88
|
triggerLabel: string
|
|
57
89
|
}): ReactNode => {
|
|
58
90
|
const { hide, isVisible, toggle } = useVisibility({
|
|
@@ -61,6 +93,7 @@ const MenuHarness = ({
|
|
|
61
93
|
|
|
62
94
|
return (
|
|
63
95
|
<Menu
|
|
96
|
+
emptyState={emptyState}
|
|
64
97
|
isVisible={isVisible}
|
|
65
98
|
items={items}
|
|
66
99
|
onDismiss={hide}
|
|
@@ -226,3 +259,48 @@ export const Interactive: Story = {
|
|
|
226
259
|
<MenuHarness triggerLabel="Open bay 4 menu" />
|
|
227
260
|
),
|
|
228
261
|
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* `items` is a union — a `group` (`role="group"`, named by its
|
|
265
|
+
* label) and a `separator` (`role="separator"`) opt in by their
|
|
266
|
+
* `type`, and a bare item still works unchanged. Neither the
|
|
267
|
+
* separator nor the group headings register with the roving group,
|
|
268
|
+
* so arrow-down goes Retry → Skip → Eject, straight past both
|
|
269
|
+
* headings and the rule between them.
|
|
270
|
+
*/
|
|
271
|
+
export const Grouped: Story = {
|
|
272
|
+
args: {
|
|
273
|
+
isVisible: false,
|
|
274
|
+
onDismiss: noop,
|
|
275
|
+
trigger: <Button appearance="outline">Actions</Button>,
|
|
276
|
+
},
|
|
277
|
+
render: () => (
|
|
278
|
+
<MenuHarness
|
|
279
|
+
isInitiallyVisible
|
|
280
|
+
items={GROUPED_ACTIONS}
|
|
281
|
+
triggerLabel="Bay 6"
|
|
282
|
+
/>
|
|
283
|
+
),
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* No actions to show. The `emptyState` renders as a **disabled**
|
|
288
|
+
* `menuitem` — a `role="menu"` must own one — so it is announced but
|
|
289
|
+
* takes no tab stop and no roving-focus membership, and a keyboard
|
|
290
|
+
* user can still Escape out of it.
|
|
291
|
+
*/
|
|
292
|
+
export const Empty: Story = {
|
|
293
|
+
args: {
|
|
294
|
+
isVisible: false,
|
|
295
|
+
onDismiss: noop,
|
|
296
|
+
trigger: <Button appearance="outline">Actions</Button>,
|
|
297
|
+
},
|
|
298
|
+
render: () => (
|
|
299
|
+
<MenuHarness
|
|
300
|
+
emptyState="No actions available"
|
|
301
|
+
isInitiallyVisible
|
|
302
|
+
items={[]}
|
|
303
|
+
triggerLabel="Bay 7"
|
|
304
|
+
/>
|
|
305
|
+
),
|
|
306
|
+
}
|