@charcuterie/ui 2.16.0 → 2.17.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/MediaTile/MediaTile.d.ts +9 -1
- package/dist/MediaTile/MediaTile.d.ts.map +1 -1
- package/dist/MediaTile/MediaTile.js +16 -4
- package/dist/MediaTile/MediaTile.js.map +1 -1
- package/package.json +1 -1
- package/src/Field/Field.mdx +6 -0
- package/src/MediaTile/MediaTile.mdx +9 -4
- package/src/MediaTile/MediaTile.stories.tsx +14 -0
- package/src/MediaTile/MediaTile.test.tsx +17 -1
- package/src/MediaTile/MediaTile.tsx +56 -15
- package/src/Picker/Picker.mdx +31 -0
- package/src/Picker/Picker.stories.tsx +74 -0
- package/src/Picker/Picker.test.tsx +13 -1
|
@@ -13,6 +13,14 @@ export type MediaTileProps = Omit<ComponentPropsWithRef<"figure">, "children"> &
|
|
|
13
13
|
/** Shown in place of the image when it fails. */
|
|
14
14
|
fallback?: ReactNode;
|
|
15
15
|
href?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Makes the tile a button. Hover, focus-visible and
|
|
18
|
+
* `cursor-pointer` come with it — wrapping `MediaTile` in a
|
|
19
|
+
* bare `<button>` is how a poster ends up with a text cursor
|
|
20
|
+
* and no hover at all. `href` wins if both are set.
|
|
21
|
+
*/
|
|
22
|
+
isDisabled?: boolean;
|
|
23
|
+
onClick?: ComponentPropsWithRef<"button">["onClick"];
|
|
16
24
|
src?: string;
|
|
17
25
|
subtitle?: ReactNode;
|
|
18
26
|
title: string;
|
|
@@ -42,5 +50,5 @@ export type MediaTileProps = Omit<ComponentPropsWithRef<"figure">, "children"> &
|
|
|
42
50
|
* `useState<string>`, which is what makes `loaded → error`
|
|
43
51
|
* impossible instead of merely unlikely — see `mediaStatus.ts`.
|
|
44
52
|
*/
|
|
45
|
-
export declare const MediaTile: ({ alt, aspectRatio, badge, className, fallback, href, src, subtitle, title, ...figureProps }: MediaTileProps) => ReactNode;
|
|
53
|
+
export declare const MediaTile: ({ alt, aspectRatio, badge, className, fallback, href, isDisabled, onClick, src, subtitle, title, ...figureProps }: MediaTileProps) => ReactNode;
|
|
46
54
|
//# sourceMappingURL=MediaTile.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MediaTile.d.ts","sourceRoot":"","sources":["../../src/MediaTile/MediaTile.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAOd,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAA;AAE1D,MAAM,MAAM,cAAc,GAAG,IAAI,CAC/B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,CACX,GAAG;IACF;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAA;IACX,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,uDAAuD;IACvD,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;
|
|
1
|
+
{"version":3,"file":"MediaTile.d.ts","sourceRoot":"","sources":["../../src/MediaTile/MediaTile.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAOd,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAA;AAE1D,MAAM,MAAM,cAAc,GAAG,IAAI,CAC/B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,CACX,GAAG;IACF;;;;OAIG;IACH,GAAG,EAAE,MAAM,CAAA;IACX,WAAW,CAAC,EAAE,cAAc,CAAA;IAC5B,uDAAuD;IACvD,KAAK,CAAC,EAAE,SAAS,CAAA;IACjB,iDAAiD;IACjD,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,CAAC,EAAE,qBAAqB,CAAC,QAAQ,CAAC,CAAC,SAAS,CAAC,CAAA;IACpD,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,QAAQ,CAAC,EAAE,SAAS,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAkBD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,SAAS,GAAI,mHAavB,cAAc,KAAG,SA2KnB,CAAA"}
|
|
@@ -4,6 +4,12 @@ import { useEffect, useRef } from "react";
|
|
|
4
4
|
import { Skeleton } from "../Skeleton/Skeleton.js";
|
|
5
5
|
import { toClassName } from "../toClassName.js";
|
|
6
6
|
import { mediaTransitions } from "./mediaStatus.js";
|
|
7
|
+
/**
|
|
8
|
+
* Shared by the link and the button. A wrapped `<button
|
|
9
|
+
* className="block">` does not get these, which is why a
|
|
10
|
+
* Collection thumbnail looked inert under the pointer.
|
|
11
|
+
*/
|
|
12
|
+
const INTERACTIVE_CLASS = "group flex cursor-pointer flex-col rounded-md outline-offset-2 hover:opacity-90 focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-focus-ring";
|
|
7
13
|
const RATIO_CLASS = {
|
|
8
14
|
// 2:3 is the standard poster trim, which is what Plex, Kavita, and
|
|
9
15
|
// every physical sleeve in the collection actually are.
|
|
@@ -36,7 +42,7 @@ const RATIO_CLASS = {
|
|
|
36
42
|
* `useState<string>`, which is what makes `loaded → error`
|
|
37
43
|
* impossible instead of merely unlikely — see `mediaStatus.ts`.
|
|
38
44
|
*/
|
|
39
|
-
export const MediaTile = ({ alt, aspectRatio = "poster", badge, className, fallback, href, src, subtitle, title, ...figureProps }) => {
|
|
45
|
+
export const MediaTile = ({ alt, aspectRatio = "poster", badge, className, fallback, href, isDisabled = false, onClick, src, subtitle, title, ...figureProps }) => {
|
|
40
46
|
const imageRef = useRef(null);
|
|
41
47
|
const { can, reset, status, transitionTo } = useStatus({
|
|
42
48
|
initialState: "loading",
|
|
@@ -62,7 +68,7 @@ export const MediaTile = ({ alt, aspectRatio = "poster", badge, className, fallb
|
|
|
62
68
|
transitionTo("error");
|
|
63
69
|
}
|
|
64
70
|
}, [can, reset, transitionTo]);
|
|
65
|
-
const media = (_jsxs("div", { className: toClassName("relative overflow-hidden rounded-md bg-surface-sunken", RATIO_CLASS[aspectRatio]), children: [status === "error" ? (_jsx("div", { "aria-label": alt, className: "flex size-full flex-col items-center justify-center gap-1 p-2 text-center text-content-muted text-xs", role: "img", children: fallback ?? _jsx("span", { children: "Image unavailable" }) })) : (_jsxs(_Fragment, { children: [status === "loading" ? (_jsx(Skeleton, { className: "absolute inset-0 size-full" })) : null, _jsx("img", { alt: href ? "" : alt, className: toClassName("size-full object-cover transition-opacity duration-(--duration-normal) ease-standard", status === "loaded"
|
|
71
|
+
const media = (_jsxs("div", { className: toClassName("relative overflow-hidden rounded-md bg-surface-sunken", RATIO_CLASS[aspectRatio]), children: [status === "error" ? (_jsx("div", { "aria-label": alt, className: "flex size-full flex-col items-center justify-center gap-1 p-2 text-center text-content-muted text-xs", role: "img", children: fallback ?? _jsx("span", { children: "Image unavailable" }) })) : (_jsxs(_Fragment, { children: [status === "loading" ? (_jsx(Skeleton, { className: "absolute inset-0 size-full" })) : null, _jsx("img", { alt: href || onClick ? "" : alt, className: toClassName("size-full object-cover transition-opacity duration-(--duration-normal) ease-standard", status === "loaded"
|
|
66
72
|
? "opacity-100"
|
|
67
73
|
: "opacity-0"), decoding: "async", loading: "lazy", onError: () => {
|
|
68
74
|
if (can("error")) {
|
|
@@ -73,7 +79,13 @@ export const MediaTile = ({ alt, aspectRatio = "poster", badge, className, fallb
|
|
|
73
79
|
transitionTo("loaded");
|
|
74
80
|
}
|
|
75
81
|
}, ref: imageRef, src: src })] })), badge ? (_jsx("div", { className: "absolute top-2 start-2", children: badge })) : null] }));
|
|
76
|
-
const caption = (_jsxs("figcaption", { className: "flex flex-col gap-0.5 pt-2", children: [_jsx("span", { className: "truncate font-medium text-content-primary text-sm cq-sm:text-md", children: title }), subtitle ? (_jsx("span", { className: "truncate text-content-muted text-xs", children: subtitle })) : null] }));
|
|
77
|
-
|
|
82
|
+
const caption = title === "" ? null : (_jsxs("figcaption", { className: "flex flex-col gap-0.5 pt-2", children: [_jsx("span", { className: "truncate font-medium text-content-primary text-sm cq-sm:text-md", children: title }), subtitle ? (_jsx("span", { className: "truncate text-content-muted text-xs", children: subtitle })) : null] }));
|
|
83
|
+
// An empty `title` is how a tile sits next to a name the
|
|
84
|
+
// parent already printed. The control still needs a name, so
|
|
85
|
+
// the alt is the fallback — wrapping it in a nameless
|
|
86
|
+
// `<button>` was how Collection's cover thumbnail became
|
|
87
|
+
// unfindable as well as un-hoverable.
|
|
88
|
+
const accessibleName = title === "" ? alt : title;
|
|
89
|
+
return (_jsx("figure", { ...figureProps, className: toClassName("@container flex flex-col", className), children: href ? (_jsxs("a", { "aria-label": accessibleName, className: INTERACTIVE_CLASS, href: href, children: [media, caption] })) : onClick ? (_jsxs("button", { "aria-label": accessibleName, className: toClassName(INTERACTIVE_CLASS, "w-full border-0 bg-transparent p-0 text-start disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:opacity-50"), disabled: isDisabled, onClick: onClick, type: "button", children: [media, caption] })) : (_jsxs(_Fragment, { children: [media, caption] })) }));
|
|
78
90
|
};
|
|
79
91
|
//# sourceMappingURL=MediaTile.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"MediaTile.js","sourceRoot":"","sources":["../../src/MediaTile/MediaTile.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAK9C,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,OAAO,CAAA;AAEzC,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAA0B,CAAA;AACnD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAA;
|
|
1
|
+
{"version":3,"file":"MediaTile.js","sourceRoot":"","sources":["../../src/MediaTile/MediaTile.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/Field/Field.mdx
CHANGED
|
@@ -27,6 +27,12 @@ in one place and cannot be spelled twice.
|
|
|
27
27
|
|
|
28
28
|
<Canvas of={FieldStories.Default} />
|
|
29
29
|
|
|
30
|
+
`Field` is also how a `Picker` (or `Button`, or `Select`) sits next to
|
|
31
|
+
its label **without butting against the last word**. Those controls are
|
|
32
|
+
flush — no outer margin — on purpose. The stacked `gap-1.5` here is the
|
|
33
|
+
gutter. An inline label + trigger is a row with `gap-*`, not a margin
|
|
34
|
+
on the control. See [Picker](../Picker/Picker.mdx#the-trigger-is-flush--the-parent-owns-the-gap).
|
|
35
|
+
|
|
30
36
|
## Props
|
|
31
37
|
|
|
32
38
|
<Controls />
|
|
@@ -40,10 +40,15 @@ carries, and for the same reason.
|
|
|
40
40
|
|
|
41
41
|
## Names
|
|
42
42
|
|
|
43
|
-
`alt` is required. When `href` is set, the
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
`alt` is required. When `href` or `onClick` is set, the control carries
|
|
44
|
+
`aria-label={title}` (or `alt` if `title` is empty) and the image goes `alt=""` — an agent
|
|
45
|
+
writing `getByRole("link", { name: "Blade Runner" })` or `getByRole("button", { name: "Change cover for Blade Runner" })`
|
|
46
|
+
should not have to know whether a subtitle happened to be rendered, and two names for one
|
|
47
|
+
control is a screen reader reading it twice.
|
|
48
|
+
|
|
49
|
+
`onClick` is the button form of that same chrome: pointer cursor, hover fade, focus-visible
|
|
50
|
+
ring. Do not wrap `MediaTile` in a bare `<button>` — Tailwind's preflight leaves a text
|
|
51
|
+
cursor and no hover, which is how a Collection thumbnail looked inert.
|
|
47
52
|
|
|
48
53
|
The caption's type steps up past `--cq-sm`, so a tile in a dense 8-across grid and the same
|
|
49
54
|
tile in a 3-across one are both legible.
|
|
@@ -209,3 +209,17 @@ export const Interactive: Story = {
|
|
|
209
209
|
title: "Blade Runner",
|
|
210
210
|
},
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Same chrome as the link — pointer, hover fade, focus ring —
|
|
215
|
+
* as a button. Wrapping the tile in a bare `<button>` is how a
|
|
216
|
+
* Collection thumbnail ended up with a text cursor.
|
|
217
|
+
*/
|
|
218
|
+
export const InteractiveButton: Story = {
|
|
219
|
+
args: {
|
|
220
|
+
alt: "Change cover for Blade Runner",
|
|
221
|
+
onClick: () => undefined,
|
|
222
|
+
src: toPosterSrc("Blade Runner"),
|
|
223
|
+
title: "",
|
|
224
|
+
},
|
|
225
|
+
}
|
|
@@ -6,7 +6,8 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
6
6
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
7
7
|
import * as stories from "./MediaTile.stories.tsx"
|
|
8
8
|
|
|
9
|
-
const { Default, Interactive } =
|
|
9
|
+
const { Default, Interactive, InteractiveButton } =
|
|
10
|
+
composeStories(stories)
|
|
10
11
|
|
|
11
12
|
test("an unlinked tile's poster carries the name", async () => {
|
|
12
13
|
const { canvas } = await mountStory(Default)
|
|
@@ -39,3 +40,18 @@ test("a linked tile is one link named for its title", async () => {
|
|
|
39
40
|
// for one link is a screen reader reading it twice.
|
|
40
41
|
await expect(canvas.queryAllByRole("img")).toHaveLength(0)
|
|
41
42
|
})
|
|
43
|
+
|
|
44
|
+
test("a clickable tile is one button with a pointer cursor", async () => {
|
|
45
|
+
const { canvas } = await mountStory(InteractiveButton)
|
|
46
|
+
|
|
47
|
+
const button = expectAgentDrivable(canvas, {
|
|
48
|
+
name: "Change cover for Blade Runner",
|
|
49
|
+
role: "button",
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
await expect(button).toHaveStyle({ cursor: "pointer" })
|
|
53
|
+
|
|
54
|
+
await userEvent.tab()
|
|
55
|
+
|
|
56
|
+
await expect(button).toHaveFocus()
|
|
57
|
+
})
|
|
@@ -27,11 +27,27 @@ export type MediaTileProps = Omit<
|
|
|
27
27
|
/** Shown in place of the image when it fails. */
|
|
28
28
|
fallback?: ReactNode
|
|
29
29
|
href?: string
|
|
30
|
+
/**
|
|
31
|
+
* Makes the tile a button. Hover, focus-visible and
|
|
32
|
+
* `cursor-pointer` come with it — wrapping `MediaTile` in a
|
|
33
|
+
* bare `<button>` is how a poster ends up with a text cursor
|
|
34
|
+
* and no hover at all. `href` wins if both are set.
|
|
35
|
+
*/
|
|
36
|
+
isDisabled?: boolean
|
|
37
|
+
onClick?: ComponentPropsWithRef<"button">["onClick"]
|
|
30
38
|
src?: string
|
|
31
39
|
subtitle?: ReactNode
|
|
32
40
|
title: string
|
|
33
41
|
}
|
|
34
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Shared by the link and the button. A wrapped `<button
|
|
45
|
+
* className="block">` does not get these, which is why a
|
|
46
|
+
* Collection thumbnail looked inert under the pointer.
|
|
47
|
+
*/
|
|
48
|
+
const INTERACTIVE_CLASS =
|
|
49
|
+
"group flex cursor-pointer flex-col rounded-md outline-offset-2 hover:opacity-90 focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-focus-ring"
|
|
50
|
+
|
|
35
51
|
const RATIO_CLASS: Record<MediaTileRatio, string> = {
|
|
36
52
|
// 2:3 is the standard poster trim, which is what Plex, Kavita, and
|
|
37
53
|
// every physical sleeve in the collection actually are.
|
|
@@ -72,6 +88,8 @@ export const MediaTile = ({
|
|
|
72
88
|
className,
|
|
73
89
|
fallback,
|
|
74
90
|
href,
|
|
91
|
+
isDisabled = false,
|
|
92
|
+
onClick,
|
|
75
93
|
src,
|
|
76
94
|
subtitle,
|
|
77
95
|
title,
|
|
@@ -144,7 +162,7 @@ export const MediaTile = ({
|
|
|
144
162
|
) : null}
|
|
145
163
|
|
|
146
164
|
<img
|
|
147
|
-
alt={href ? "" : alt}
|
|
165
|
+
alt={href || onClick ? "" : alt}
|
|
148
166
|
className={toClassName(
|
|
149
167
|
"size-full object-cover transition-opacity duration-(--duration-normal) ease-standard",
|
|
150
168
|
status === "loaded"
|
|
@@ -177,19 +195,27 @@ export const MediaTile = ({
|
|
|
177
195
|
</div>
|
|
178
196
|
)
|
|
179
197
|
|
|
180
|
-
const caption =
|
|
181
|
-
|
|
182
|
-
<
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
{subtitle ? (
|
|
187
|
-
<span className="truncate text-content-muted text-xs">
|
|
188
|
-
{subtitle}
|
|
198
|
+
const caption =
|
|
199
|
+
title === "" ? null : (
|
|
200
|
+
<figcaption className="flex flex-col gap-0.5 pt-2">
|
|
201
|
+
<span className="truncate font-medium text-content-primary text-sm cq-sm:text-md">
|
|
202
|
+
{title}
|
|
189
203
|
</span>
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
204
|
+
|
|
205
|
+
{subtitle ? (
|
|
206
|
+
<span className="truncate text-content-muted text-xs">
|
|
207
|
+
{subtitle}
|
|
208
|
+
</span>
|
|
209
|
+
) : null}
|
|
210
|
+
</figcaption>
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
// An empty `title` is how a tile sits next to a name the
|
|
214
|
+
// parent already printed. The control still needs a name, so
|
|
215
|
+
// the alt is the fallback — wrapping it in a nameless
|
|
216
|
+
// `<button>` was how Collection's cover thumbnail became
|
|
217
|
+
// unfindable as well as un-hoverable.
|
|
218
|
+
const accessibleName = title === "" ? alt : title
|
|
193
219
|
|
|
194
220
|
return (
|
|
195
221
|
<figure
|
|
@@ -207,14 +233,29 @@ export const MediaTile = ({
|
|
|
207
233
|
// The `<img>` inside goes `alt=""` for the same reason: two
|
|
208
234
|
// names for one link is a screen reader reading it twice.
|
|
209
235
|
<a
|
|
210
|
-
aria-label={
|
|
211
|
-
className=
|
|
236
|
+
aria-label={accessibleName}
|
|
237
|
+
className={INTERACTIVE_CLASS}
|
|
212
238
|
href={href}
|
|
213
239
|
>
|
|
214
240
|
{media}
|
|
215
241
|
|
|
216
242
|
{caption}
|
|
217
243
|
</a>
|
|
244
|
+
) : onClick ? (
|
|
245
|
+
<button
|
|
246
|
+
aria-label={accessibleName}
|
|
247
|
+
className={toClassName(
|
|
248
|
+
INTERACTIVE_CLASS,
|
|
249
|
+
"w-full border-0 bg-transparent p-0 text-start disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:opacity-50",
|
|
250
|
+
)}
|
|
251
|
+
disabled={isDisabled}
|
|
252
|
+
onClick={onClick}
|
|
253
|
+
type="button"
|
|
254
|
+
>
|
|
255
|
+
{media}
|
|
256
|
+
|
|
257
|
+
{caption}
|
|
258
|
+
</button>
|
|
218
259
|
) : (
|
|
219
260
|
<>
|
|
220
261
|
{media}
|
package/src/Picker/Picker.mdx
CHANGED
|
@@ -46,6 +46,37 @@ text of the whole node.
|
|
|
46
46
|
|
|
47
47
|
<Canvas of={PickerStories.AllStates} />
|
|
48
48
|
|
|
49
|
+
## The trigger is flush — the parent owns the gap
|
|
50
|
+
|
|
51
|
+
`Picker` is a `Button`. It has **no outer margin**. Next to a text
|
|
52
|
+
label it sits hard against the last word, which is how queuepilot's
|
|
53
|
+
"Chapters queued per turn" landed flush on the `[2 Default]` trigger,
|
|
54
|
+
and why that same app already had a one-off `#setmodal .fieldselect {
|
|
55
|
+
margin-left: 10px }` for the *other* inline pickers.
|
|
56
|
+
|
|
57
|
+
Do not put the gutter on `Picker`. A control that ships its own
|
|
58
|
+
margin is a control that fights `Field` (`flex flex-col gap-1.5`) and
|
|
59
|
+
every toolbar row. The parent that decided to sit text next to a
|
|
60
|
+
trigger also owns the space:
|
|
61
|
+
|
|
62
|
+
```tsx
|
|
63
|
+
{/* stacked — this is Field's job */}
|
|
64
|
+
<Field label="Chapters queued per turn" description="…">
|
|
65
|
+
<Picker label="Chapters queued per turn" … />
|
|
66
|
+
</Field>
|
|
67
|
+
|
|
68
|
+
{/* inline — the row owns the gap */}
|
|
69
|
+
<span className="inline-flex items-center gap-2.5">
|
|
70
|
+
Chapters queued per turn
|
|
71
|
+
<Picker label="Chapters queued per turn" … />
|
|
72
|
+
</span>
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The same rule is `Button`, `Select`, and a `Listbox` trigger. They are
|
|
76
|
+
all flush; they are all the same trap.
|
|
77
|
+
|
|
78
|
+
<Canvas of={PickerStories.NextToText} />
|
|
79
|
+
|
|
49
80
|
## `id` does not survive; `data-testid` does
|
|
50
81
|
|
|
51
82
|
`useAnchoredOverlay` overwrites the trigger's `id` with a generated one so the portalled
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
StoryRow,
|
|
13
13
|
StorySection,
|
|
14
14
|
} from "../board.storyHelpers.tsx"
|
|
15
|
+
import { Field } from "../Field/Field.tsx"
|
|
15
16
|
import type { PickerOption } from "./Picker.tsx"
|
|
16
17
|
import { Picker } from "./Picker.tsx"
|
|
17
18
|
|
|
@@ -182,3 +183,76 @@ export const Interactive: Story = {
|
|
|
182
183
|
/>
|
|
183
184
|
),
|
|
184
185
|
}
|
|
186
|
+
|
|
187
|
+
const COUNTS: PickerOption[] = [
|
|
188
|
+
{ label: "1", value: "1" },
|
|
189
|
+
{
|
|
190
|
+
label: (
|
|
191
|
+
<span className="flex items-center gap-2">
|
|
192
|
+
<span>2</span>
|
|
193
|
+
<Badge intent="neutral" size="sm">
|
|
194
|
+
Default
|
|
195
|
+
</Badge>
|
|
196
|
+
</span>
|
|
197
|
+
),
|
|
198
|
+
textValue: "2 Default",
|
|
199
|
+
value: "2",
|
|
200
|
+
},
|
|
201
|
+
{ label: "Custom…", value: "custom" },
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The trigger has no outer margin. Sitting it next to a label without
|
|
206
|
+
* a parent gap is the flush-against-the-word bug; `Field` or an
|
|
207
|
+
* `inline-flex gap-*` row is the parent owning the gutter.
|
|
208
|
+
*/
|
|
209
|
+
export const NextToText: Story = {
|
|
210
|
+
render: () => (
|
|
211
|
+
<div className="flex flex-col gap-8">
|
|
212
|
+
<StorySection title="Flush — the parent gave it no gap">
|
|
213
|
+
<StoryRow>
|
|
214
|
+
<span>
|
|
215
|
+
Chapters queued per turn{" "}
|
|
216
|
+
<Picker
|
|
217
|
+
label="Flush count"
|
|
218
|
+
onChange={() => undefined}
|
|
219
|
+
options={COUNTS}
|
|
220
|
+
size="sm"
|
|
221
|
+
value="2"
|
|
222
|
+
/>
|
|
223
|
+
</span>
|
|
224
|
+
</StoryRow>
|
|
225
|
+
</StorySection>
|
|
226
|
+
<StorySection title="Inline — the row owns the gap">
|
|
227
|
+
<StoryRow>
|
|
228
|
+
<span className="inline-flex items-center gap-2.5">
|
|
229
|
+
Chapters queued per turn
|
|
230
|
+
<Picker
|
|
231
|
+
label="Inline count"
|
|
232
|
+
onChange={() => undefined}
|
|
233
|
+
options={COUNTS}
|
|
234
|
+
size="sm"
|
|
235
|
+
value="2"
|
|
236
|
+
/>
|
|
237
|
+
</span>
|
|
238
|
+
</StoryRow>
|
|
239
|
+
</StorySection>
|
|
240
|
+
<StorySection title="Stacked — Field owns the gap">
|
|
241
|
+
<StoryRow>
|
|
242
|
+
<Field
|
|
243
|
+
description="How long this entry’s turn is when the queue reaches it."
|
|
244
|
+
label="Chapters queued per turn"
|
|
245
|
+
>
|
|
246
|
+
<Picker
|
|
247
|
+
label="Stacked count"
|
|
248
|
+
onChange={() => undefined}
|
|
249
|
+
options={COUNTS}
|
|
250
|
+
size="sm"
|
|
251
|
+
value="2"
|
|
252
|
+
/>
|
|
253
|
+
</Field>
|
|
254
|
+
</StoryRow>
|
|
255
|
+
</StorySection>
|
|
256
|
+
</div>
|
|
257
|
+
),
|
|
258
|
+
}
|
|
@@ -7,7 +7,7 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./Picker.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const { AllStates, Default, Interactive } =
|
|
10
|
+
const { AllStates, Default, Interactive, NextToText } =
|
|
11
11
|
composeStories(stories)
|
|
12
12
|
|
|
13
13
|
test("the trigger's accessible name carries the current value", async () => {
|
|
@@ -83,6 +83,18 @@ test("a disabled picker does not open", async () => {
|
|
|
83
83
|
)
|
|
84
84
|
})
|
|
85
85
|
|
|
86
|
+
test("an inline picker next to a label is still named and has no axe issues", async () => {
|
|
87
|
+
const { canvas, canvasElement } =
|
|
88
|
+
await mountStory(NextToText)
|
|
89
|
+
|
|
90
|
+
expectAgentDrivable(canvas, {
|
|
91
|
+
name: "Inline count: 2 Default",
|
|
92
|
+
role: "button",
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
await expectNoAxeViolations(canvasElement)
|
|
96
|
+
})
|
|
97
|
+
|
|
86
98
|
test("a rich option label keeps a plain-string trigger via textValue", async () => {
|
|
87
99
|
const { canvas, canvasElement } =
|
|
88
100
|
await mountStory(AllStates)
|