@charcuterie/ui 2.16.0 → 2.18.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 +20 -1
- package/dist/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/Checkbox/Checkbox.js +2 -2
- package/dist/Checkbox/Checkbox.js.map +1 -1
- 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/Checkbox/Checkbox.mdx +24 -0
- package/src/Checkbox/Checkbox.stories.tsx +42 -0
- package/src/Checkbox/Checkbox.test.tsx +52 -1
- package/src/Checkbox/Checkbox.tsx +21 -0
- 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
|
@@ -34,6 +34,25 @@ export type CheckboxProps = {
|
|
|
34
34
|
label: ReactNode;
|
|
35
35
|
onChange?: (isChecked: boolean) => void;
|
|
36
36
|
size?: ControlSize;
|
|
37
|
+
/**
|
|
38
|
+
* The `<input>`'s `value` attribute — which member of a group this
|
|
39
|
+
* box IS, as opposed to whether it is ticked.
|
|
40
|
+
*
|
|
41
|
+
* A lone boolean does not need this. A GROUP does: the browser
|
|
42
|
+
* submits `name=value` for each ticked box in a form, and, more
|
|
43
|
+
* relevantly here, a group is normally read back with one query —
|
|
44
|
+
* `[...root.querySelectorAll("input")].filter(i => i.checked)
|
|
45
|
+
* .map(i => i.value)`. Without a `value` every box in the group
|
|
46
|
+
* reports the string `"on"` and the read is useless, which is why
|
|
47
|
+
* queuepilot's library and ratings pickers were still hand-rolling
|
|
48
|
+
* a raw `<input type="checkbox">` rather than adopting this
|
|
49
|
+
* component.
|
|
50
|
+
*
|
|
51
|
+
* Passed straight through and otherwise inert. It is NOT the
|
|
52
|
+
* checked state and does not become one: `isChecked` still seeds
|
|
53
|
+
* the box, and `onChange` still reports a boolean.
|
|
54
|
+
*/
|
|
55
|
+
value?: string;
|
|
37
56
|
};
|
|
38
57
|
/**
|
|
39
58
|
* A single boolean, on or off, that owns nothing but itself.
|
|
@@ -64,5 +83,5 @@ export type CheckboxProps = {
|
|
|
64
83
|
* `<input>`'s job. `isChecked` seeds the first paint and nothing
|
|
65
84
|
* after.
|
|
66
85
|
*/
|
|
67
|
-
export declare const Checkbox: ({ className, id, isChecked, isDisabled, isReadOnly, label, onChange, size, }: CheckboxProps) => ReactNode;
|
|
86
|
+
export declare const Checkbox: ({ className, id, isChecked, isDisabled, isReadOnly, label, onChange, size, value, }: CheckboxProps) => ReactNode;
|
|
68
87
|
//# sourceMappingURL=Checkbox.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Checkbox.d.ts","sourceRoot":"","sources":["../../src/Checkbox/Checkbox.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAe,SAAS,EAAE,MAAM,OAAO,CAAA;AAKnD,MAAM,MAAM,aAAa,GAAG;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,EAAE,CAAC,EAAE,MAAM,CAAA;IACX;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,KAAK,EAAE,SAAS,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAA;IACvC,IAAI,CAAC,EAAE,WAAW,CAAA;
|
|
1
|
+
{"version":3,"file":"Checkbox.d.ts","sourceRoot":"","sources":["../../src/Checkbox/Checkbox.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAe,SAAS,EAAE,MAAM,OAAO,CAAA;AAKnD,MAAM,MAAM,aAAa,GAAG;IAC1B,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;OAIG;IACH,EAAE,CAAC,EAAE,MAAM,CAAA;IACX;;;;;OAKG;IACH,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;OAKG;IACH,KAAK,EAAE,SAAS,CAAA;IAChB,QAAQ,CAAC,EAAE,CAAC,SAAS,EAAE,OAAO,KAAK,IAAI,CAAA;IACvC,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB;;;;;;;;;;;;;;;;;OAiBG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAoBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,QAAQ,GAAI,qFAUtB,aAAa,KAAG,SA2GlB,CAAA"}
|
|
@@ -45,7 +45,7 @@ const TEXT_SIZE_CLASS = {
|
|
|
45
45
|
* `<input>`'s job. `isChecked` seeds the first paint and nothing
|
|
46
46
|
* after.
|
|
47
47
|
*/
|
|
48
|
-
export const Checkbox = ({ className, id, isChecked = false, isDisabled = false, isReadOnly = false, label, onChange, size = "md", }) => (_jsxs("label", { className: toClassName("inline-flex items-center gap-2 text-content-secondary select-none", TEXT_SIZE_CLASS[size],
|
|
48
|
+
export const Checkbox = ({ className, id, isChecked = false, isDisabled = false, isReadOnly = false, label, onChange, size = "md", value, }) => (_jsxs("label", { className: toClassName("inline-flex items-center gap-2 text-content-secondary select-none", TEXT_SIZE_CLASS[size],
|
|
49
49
|
// One cursor, chosen by state — two cursor utilities of the same
|
|
50
50
|
// specificity do not resolve by class-list order.
|
|
51
51
|
isDisabled
|
|
@@ -99,7 +99,7 @@ export const Checkbox = ({ className, id, isChecked = false, isDisabled = false,
|
|
|
99
99
|
if (isReadOnly && keyEvent.key === " ") {
|
|
100
100
|
keyEvent.preventDefault();
|
|
101
101
|
}
|
|
102
|
-
}, type: "checkbox" }), _jsx("svg", { "aria-hidden": "true", className: toClassName("pointer-events-none invisible absolute peer-checked:visible",
|
|
102
|
+
}, type: "checkbox", value: value }), _jsx("svg", { "aria-hidden": "true", className: toClassName("pointer-events-none invisible absolute peer-checked:visible",
|
|
103
103
|
// Matches whichever fill the box wears, so the tick keeps its
|
|
104
104
|
// guaranteed on-solid contrast in either intent.
|
|
105
105
|
isReadOnly
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Checkbox.js","sourceRoot":"","sources":["../../src/Checkbox/Checkbox.tsx"],"names":[],"mappings":";AAGA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"Checkbox.js","sourceRoot":"","sources":["../../src/Checkbox/Checkbox.tsx"],"names":[],"mappings":";AAGA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AACrD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAyD/C,MAAM,cAAc,GAAgC;IAClD,EAAE,EAAE,UAAU;IACd,EAAE,EAAE,QAAQ;IACZ,EAAE,EAAE,QAAQ;CACb,CAAA;AAED,MAAM,gBAAgB,GAAgC;IACpD,EAAE,EAAE,UAAU;IACd,EAAE,EAAE,QAAQ;IACZ,EAAE,EAAE,UAAU;CACf,CAAA;AAED,MAAM,eAAe,GAAgC;IACnD,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;CACd,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,MAAM,CAAC,MAAM,QAAQ,GAAG,CAAC,EACvB,SAAS,EACT,EAAE,EACF,SAAS,GAAG,KAAK,EACjB,UAAU,GAAG,KAAK,EAClB,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,QAAQ,EACR,IAAI,GAAG,IAAI,EACX,KAAK,GACS,EAAa,EAAE,CAAC,CAC9B,iBACE,SAAS,EAAE,WAAW,CACpB,mEAAmE,EACnE,eAAe,CAAC,IAAI,CAAC;IACrB,iEAAiE;IACjE,kDAAkD;IAClD,UAAU;QACR,CAAC,CAAC,oBAAoB;QACtB,CAAC,CAAC,UAAU;YACV,CAAC,CAAC,gBAAgB;YAClB,CAAC,CAAC,gBAAgB;IACtB,kEAAkE;IAClE,kEAAkE;IAClE,+DAA+D;IAC/D,mEAAmE;IACnE,kEAAkE;IAClE,kEAAkE;IAClE,mEAAmE;IACnE,sDAAsD;IACtD,UAAU,IAAI,YAAY,EAC1B,SAAS,CACV,aAED,gBAAM,SAAS,EAAC,2CAA2C,aACzD,iCACiB,UAAU,IAAI,SAAS,EACtC,SAAS,EAAE,WAAW;oBACpB,0DAA0D;oBAC1D,8DAA8D;oBAC9D,8DAA8D;oBAC9D,mDAAmD;oBACnD,uHAAuH;oBACvH,+DAA+D;oBAC/D,8DAA8D;oBAC9D,+DAA+D;oBAC/D,0DAA0D;oBAC1D,iEAAiE;oBACjE,6DAA6D;oBAC7D,8DAA8D;oBAC9D,4BAA4B;oBAC5B,UAAU;wBACR,CAAC,CAAC,2FAA2F;wBAC7F,CAAC,CAAC,wFAAwF,EAC5F,UAAU;wBACR,CAAC,CAAC,oBAAoB;wBACtB,CAAC,CAAC,UAAU;4BACV,CAAC,CAAC,gBAAgB;4BAClB,CAAC,CAAC,gBAAgB,EACtB,cAAc,CAAC,IAAI,CAAC,EACpB,gBAAgB,CACjB,EACD,cAAc,EAAE,SAAS,EACzB,QAAQ,EAAE,UAAU,EACpB,EAAE,EAAE,EAAE,EACN,QAAQ,EAAE,CACR,KAAoC,EACpC,EAAE;wBACF,IAAI,UAAU,EAAE,CAAC;4BACf,OAAM;wBACR,CAAC;wBAED,QAAQ,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;oBAClC,CAAC,EACD,OAAO,EAAE,CAAC,UAAU,EAAE,EAAE;wBACtB,yDAAyD;wBACzD,yDAAyD;wBACzD,qCAAqC;wBACrC,IAAI,UAAU,EAAE,CAAC;4BACf,UAAU,CAAC,cAAc,EAAE,CAAA;wBAC7B,CAAC;oBACH,CAAC,EACD,SAAS,EAAE,CAAC,QAAQ,EAAE,EAAE;wBACtB,yDAAyD;wBACzD,SAAS;wBACT,IAAI,UAAU,IAAI,QAAQ,CAAC,GAAG,KAAK,GAAG,EAAE,CAAC;4BACvC,QAAQ,CAAC,cAAc,EAAE,CAAA;wBAC3B,CAAC;oBACH,CAAC,EACD,IAAI,EAAC,UAAU,EACf,KAAK,EAAE,KAAK,GACZ,EAEF,6BACc,MAAM,EAClB,SAAS,EAAE,WAAW,CACpB,6DAA6D;oBAC7D,8DAA8D;oBAC9D,iDAAiD;oBACjD,UAAU;wBACR,CAAC,CAAC,8BAA8B;wBAChC,CAAC,CAAC,6BAA6B,EACjC,gBAAgB,CAAC,IAAI,CAAC,CACvB,EACD,IAAI,EAAC,MAAM,EACX,MAAM,EAAC,cAAc,EACrB,aAAa,EAAC,OAAO,EACrB,cAAc,EAAC,OAAO,EACtB,WAAW,EAAE,CAAC,EACd,OAAO,EAAC,WAAW,YAEnB,eAAM,CAAC,EAAC,iBAAiB,GAAG,GACxB,IACD,EAEP,yBAAO,KAAK,GAAQ,IACd,CACT,CAAA"}
|
|
@@ -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
|
@@ -62,6 +62,30 @@ is a control every consumer has to hold it for.
|
|
|
62
62
|
here and change elsewhere, not an actionable accent. It announces `aria-readonly`, and
|
|
63
63
|
the toggle is blocked on both pointer and `Space`.
|
|
64
64
|
|
|
65
|
+
## A group of boxes needs `value`
|
|
66
|
+
|
|
67
|
+
`value` is the `<input>`'s `value` attribute: **which member of a group** this box is, as
|
|
68
|
+
opposed to whether it is ticked. A lone boolean should omit it.
|
|
69
|
+
|
|
70
|
+
A group needs it, because a group is read back with one query over its container rather
|
|
71
|
+
than by keeping a callback's worth of state per box:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
const chosen = [...group.querySelectorAll<HTMLInputElement>("input[type=checkbox]")]
|
|
75
|
+
.filter((input) => input.checked)
|
|
76
|
+
.map((input) => input.value)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Without a `value` every box in the group answers the UA default `"on"`, so that read
|
|
80
|
+
returns N copies of one meaningless string. This is why queuepilot's library and ratings
|
|
81
|
+
pickers were still hand-rolling a raw `<input type="checkbox">` instead of using this
|
|
82
|
+
component — the component was correct and simply could not express the case.
|
|
83
|
+
|
|
84
|
+
It is passed straight through and is otherwise inert: it is **not** the checked state and
|
|
85
|
+
does not become one. `isChecked` still seeds the box, `onChange` still reports a boolean.
|
|
86
|
+
|
|
87
|
+
<Canvas of={CheckboxStories.WithValues} />
|
|
88
|
+
|
|
65
89
|
## Checkbox or `Switch`
|
|
66
90
|
|
|
67
91
|
Same state kind, different meaning. A checkbox is a value you **submit** — "delete
|
|
@@ -120,3 +120,45 @@ export const AllStates: Story = {
|
|
|
120
120
|
export const Interactive: Story = {
|
|
121
121
|
args: { label: "Delete originals after import" },
|
|
122
122
|
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `value` names which member of a group a box IS — not whether it is
|
|
126
|
+
* ticked. It matters as soon as there is more than one box: a group is
|
|
127
|
+
* read back with a single query over its container, and without a
|
|
128
|
+
* `value` every box in it answers `"on"`.
|
|
129
|
+
*
|
|
130
|
+
* ```ts
|
|
131
|
+
* const chosen = [...group.querySelectorAll("input")]
|
|
132
|
+
* .filter((input) => input.checked)
|
|
133
|
+
* .map((input) => input.value)
|
|
134
|
+
* ```
|
|
135
|
+
*
|
|
136
|
+
* A lone boolean needs none of this and should omit the prop.
|
|
137
|
+
*/
|
|
138
|
+
export const WithValues: Story = {
|
|
139
|
+
args: { label: "Anime" },
|
|
140
|
+
render: (controlProps) => (
|
|
141
|
+
<StorySection title="A library picker: each box carries the id the caller will read back, and the checked ones are the answer.">
|
|
142
|
+
<StoryGrid columns={2}>
|
|
143
|
+
{[
|
|
144
|
+
{ id: "11", isChosen: true, title: "Anime" },
|
|
145
|
+
{ id: "1", isChosen: false, title: "Movies" },
|
|
146
|
+
{ id: "15", isChosen: true, title: "Shorts" },
|
|
147
|
+
{ id: "2", isChosen: false, title: "Shows" },
|
|
148
|
+
].map((library) => (
|
|
149
|
+
<StoryCell
|
|
150
|
+
key={library.id}
|
|
151
|
+
label={`value="${library.id}"`}
|
|
152
|
+
>
|
|
153
|
+
<Checkbox
|
|
154
|
+
{...controlProps}
|
|
155
|
+
isChecked={library.isChosen}
|
|
156
|
+
label={library.title}
|
|
157
|
+
value={library.id}
|
|
158
|
+
/>
|
|
159
|
+
</StoryCell>
|
|
160
|
+
))}
|
|
161
|
+
</StoryGrid>
|
|
162
|
+
</StorySection>
|
|
163
|
+
),
|
|
164
|
+
}
|
|
@@ -7,7 +7,7 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./Checkbox.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const { AllStates, Default, Interactive } =
|
|
10
|
+
const { AllStates, Default, Interactive, WithValues } =
|
|
11
11
|
composeStories(stories)
|
|
12
12
|
|
|
13
13
|
test("it is a checkbox a screen reader can name", async () => {
|
|
@@ -107,3 +107,54 @@ test("a read-only box is announced and cannot be toggled", async () => {
|
|
|
107
107
|
|
|
108
108
|
await expectNoAxeViolations(canvasElement)
|
|
109
109
|
})
|
|
110
|
+
|
|
111
|
+
test("value names the member, and a group reads back the ticked ones", async () => {
|
|
112
|
+
const { canvas, canvasElement } =
|
|
113
|
+
await mountStory(WithValues)
|
|
114
|
+
|
|
115
|
+
// The prop is the `<input>`'s `value` and nothing else — it must not
|
|
116
|
+
// touch the checked state, which `isChecked` still owns alone.
|
|
117
|
+
await expect(
|
|
118
|
+
expectAgentDrivable(canvas, {
|
|
119
|
+
name: "Anime",
|
|
120
|
+
role: "checkbox",
|
|
121
|
+
}),
|
|
122
|
+
).toHaveAttribute("value", "11")
|
|
123
|
+
|
|
124
|
+
await expect(
|
|
125
|
+
expectAgentDrivable(canvas, {
|
|
126
|
+
name: "Movies",
|
|
127
|
+
role: "checkbox",
|
|
128
|
+
}),
|
|
129
|
+
).not.toBeChecked()
|
|
130
|
+
|
|
131
|
+
// The read this prop exists for. Without a `value` every box here
|
|
132
|
+
// answers the UA default `"on"` and the result is four copies of one
|
|
133
|
+
// meaningless string, which is why a group could not use this
|
|
134
|
+
// component at all before.
|
|
135
|
+
const chosen = [
|
|
136
|
+
...canvasElement.querySelectorAll<HTMLInputElement>(
|
|
137
|
+
"input[type=checkbox]",
|
|
138
|
+
),
|
|
139
|
+
]
|
|
140
|
+
.filter((input) => input.checked)
|
|
141
|
+
.map((input) => input.value)
|
|
142
|
+
|
|
143
|
+
await expect(chosen).toEqual(["11", "15"])
|
|
144
|
+
|
|
145
|
+
await expectNoAxeViolations(canvasElement)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test("a box with no value stays out of the way", async () => {
|
|
149
|
+
const { canvas } = await mountStory(Default)
|
|
150
|
+
|
|
151
|
+
// `value` is optional and a lone boolean should omit it. React drops
|
|
152
|
+
// an `undefined` attribute entirely rather than writing an empty
|
|
153
|
+
// string, so the DOM looks exactly as it did before the prop existed.
|
|
154
|
+
await expect(
|
|
155
|
+
expectAgentDrivable(canvas, {
|
|
156
|
+
name: "Delete originals after import",
|
|
157
|
+
role: "checkbox",
|
|
158
|
+
}),
|
|
159
|
+
).not.toHaveAttribute("value")
|
|
160
|
+
})
|
|
@@ -38,6 +38,25 @@ export type CheckboxProps = {
|
|
|
38
38
|
label: ReactNode
|
|
39
39
|
onChange?: (isChecked: boolean) => void
|
|
40
40
|
size?: ControlSize
|
|
41
|
+
/**
|
|
42
|
+
* The `<input>`'s `value` attribute — which member of a group this
|
|
43
|
+
* box IS, as opposed to whether it is ticked.
|
|
44
|
+
*
|
|
45
|
+
* A lone boolean does not need this. A GROUP does: the browser
|
|
46
|
+
* submits `name=value` for each ticked box in a form, and, more
|
|
47
|
+
* relevantly here, a group is normally read back with one query —
|
|
48
|
+
* `[...root.querySelectorAll("input")].filter(i => i.checked)
|
|
49
|
+
* .map(i => i.value)`. Without a `value` every box in the group
|
|
50
|
+
* reports the string `"on"` and the read is useless, which is why
|
|
51
|
+
* queuepilot's library and ratings pickers were still hand-rolling
|
|
52
|
+
* a raw `<input type="checkbox">` rather than adopting this
|
|
53
|
+
* component.
|
|
54
|
+
*
|
|
55
|
+
* Passed straight through and otherwise inert. It is NOT the
|
|
56
|
+
* checked state and does not become one: `isChecked` still seeds
|
|
57
|
+
* the box, and `onChange` still reports a boolean.
|
|
58
|
+
*/
|
|
59
|
+
value?: string
|
|
41
60
|
}
|
|
42
61
|
|
|
43
62
|
const BOX_SIZE_CLASS: Record<ControlSize, string> = {
|
|
@@ -96,6 +115,7 @@ export const Checkbox = ({
|
|
|
96
115
|
label,
|
|
97
116
|
onChange,
|
|
98
117
|
size = "md",
|
|
118
|
+
value,
|
|
99
119
|
}: CheckboxProps): ReactNode => (
|
|
100
120
|
<label
|
|
101
121
|
className={toClassName(
|
|
@@ -176,6 +196,7 @@ export const Checkbox = ({
|
|
|
176
196
|
}
|
|
177
197
|
}}
|
|
178
198
|
type="checkbox"
|
|
199
|
+
value={value}
|
|
179
200
|
/>
|
|
180
201
|
|
|
181
202
|
<svg
|
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)
|