@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.
@@ -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;CACnB,CAAA;AAoBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,eAAO,MAAM,QAAQ,GAAI,8EAStB,aAAa,KAAG,SA0GlB,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":"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"}
1
+ {"version":3,"file":"Checkbox.js","sourceRoot":"","sources":["../../src/Checkbox/Checkbox.tsx"],"names":[],"mappings":"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"}
@@ -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;AAUD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,SAAS,GAAI,8FAWvB,cAAc,KAAG,SAoJnB,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
- return (_jsx("figure", { ...figureProps, className: toClassName("@container flex flex-col", className), children: href ? (_jsxs("a", { "aria-label": title, className: "group flex 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", href: href, children: [media, caption] })) : (_jsxs(_Fragment, { children: [media, caption] })) }));
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;AAyBnD,MAAM,WAAW,GAAmC;IAClD,mEAAmE;IACnE,wDAAwD;IACxD,MAAM,EAAE,cAAc;IACtB,MAAM,EAAE,eAAe;IACvB,KAAK,EAAE,cAAc;CACtB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,EACxB,GAAG,EACH,WAAW,GAAG,QAAQ,EACtB,KAAK,EACL,SAAS,EACT,QAAQ,EACR,IAAI,EACJ,GAAG,EACH,QAAQ,EACR,KAAK,EACL,GAAG,WAAW,EACC,EAAa,EAAE;IAC9B,MAAM,QAAQ,GAAG,MAAM,CAAmB,IAAI,CAAC,CAAA;IAE/C,MAAM,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,GAAG,SAAS,CAAC;QACrD,YAAY,EAAE,SAAS;QACvB,WAAW,EAAE,gBAAgB;KAC9B,CAAC,CAAA;IAEF,SAAS,CAAC,GAAG,EAAE;QACb,wDAAwD;QACxD,KAAK,EAAE,CAAA;QAEP,MAAM,KAAK,GAAG,QAAQ,CAAC,OAAO,CAAA;QAE9B,IAAI,CAAC,KAAK,EAAE,QAAQ,EAAE,CAAC;YACrB,OAAM;QACR,CAAC;QAED,0DAA0D;QAC1D,6DAA6D;QAC7D,mBAAmB;QACnB,IAAI,KAAK,CAAC,YAAY,GAAG,CAAC,EAAE,CAAC;YAC3B,IAAI,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAClB,YAAY,CAAC,QAAQ,CAAC,CAAA;YACxB,CAAC;YAED,OAAM;QACR,CAAC;QAED,IAAI,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;YACjB,YAAY,CAAC,OAAO,CAAC,CAAA;QACvB,CAAC;IACH,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,YAAY,CAAC,CAAC,CAAA;IAE9B,MAAM,KAAK,GAAG,CACZ,eACE,SAAS,EAAE,WAAW,CACpB,uDAAuD,EACvD,WAAW,CAAC,WAAW,CAAC,CACzB,aAEA,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,CACpB,4BAOc,GAAG,EACf,SAAS,EAAC,sGAAsG,EAChH,IAAI,EAAC,KAAK,YAQT,QAAQ,IAAI,+CAA8B,GACvC,CACP,CAAC,CAAC,CAAC,CACF,8BACG,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,CACtB,KAAC,QAAQ,IAAC,SAAS,EAAC,4BAA4B,GAAG,CACpD,CAAC,CAAC,CAAC,IAAI,EAER,cACE,GAAG,EAAE,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,EACpB,SAAS,EAAE,WAAW,CACpB,sFAAsF,EACtF,MAAM,KAAK,QAAQ;4BACjB,CAAC,CAAC,aAAa;4BACf,CAAC,CAAC,WAAW,CAChB,EACD,QAAQ,EAAC,OAAO,EAChB,OAAO,EAAC,MAAM,EACd,OAAO,EAAE,GAAG,EAAE;4BACZ,IAAI,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;gCACjB,YAAY,CAAC,OAAO,CAAC,CAAA;4BACvB,CAAC;wBACH,CAAC,EACD,MAAM,EAAE,GAAG,EAAE;4BACX,IAAI,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;gCAClB,YAAY,CAAC,QAAQ,CAAC,CAAA;4BACxB,CAAC;wBACH,CAAC,EACD,GAAG,EAAE,QAAQ,EACb,GAAG,EAAE,GAAG,GACR,IACD,CACJ,EAEA,KAAK,CAAC,CAAC,CAAC,CACP,cAAK,SAAS,EAAC,wBAAwB,YACpC,KAAK,GACF,CACP,CAAC,CAAC,CAAC,IAAI,IACJ,CACP,CAAA;IAED,MAAM,OAAO,GAAG,CACd,sBAAY,SAAS,EAAC,4BAA4B,aAChD,eAAM,SAAS,EAAC,iEAAiE,YAC9E,KAAK,GACD,EAEN,QAAQ,CAAC,CAAC,CAAC,CACV,eAAM,SAAS,EAAC,qCAAqC,YAClD,QAAQ,GACJ,CACR,CAAC,CAAC,CAAC,IAAI,IACG,CACd,CAAA;IAED,OAAO,CACL,oBACM,WAAW,EACf,SAAS,EAAE,WAAW,CACpB,0BAA0B,EAC1B,SAAS,CACV,YAEA,IAAI,CAAC,CAAC,CAAC,CAON,2BACc,KAAK,EACjB,SAAS,EAAC,iLAAiL,EAC3L,IAAI,EAAE,IAAI,aAET,KAAK,EAEL,OAAO,IACN,CACL,CAAC,CAAC,CAAC,CACF,8BACG,KAAK,EAEL,OAAO,IACP,CACJ,GACM,CACV,CAAA;AACH,CAAC,CAAA"}
1
+ {"version":3,"file":"MediaTile.js","sourceRoot":"","sources":["../../src/MediaTile/MediaTile.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "2.16.0",
3
+ "version": "2.18.0",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
@@ -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 link carries `aria-label={title}` and the image
44
- goes `alt=""` — an agent writing `getByRole("link", { name: "Blade Runner" })` should not
45
- have to know whether a subtitle happened to be rendered, and two names for one link is a
46
- screen reader reading it twice.
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 } = composeStories(stories)
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
- <figcaption className="flex flex-col gap-0.5 pt-2">
182
- <span className="truncate font-medium text-content-primary text-sm cq-sm:text-md">
183
- {title}
184
- </span>
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
- ) : null}
191
- </figcaption>
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={title}
211
- className="group flex 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"
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}
@@ -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)