shelving 1.282.2 → 1.284.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.
Files changed (240) hide show
  1. package/package.json +2 -2
  2. package/ui/README.md +2 -1
  3. package/ui/block/Address.d.ts +7 -7
  4. package/ui/block/Address.js +6 -6
  5. package/ui/block/Address.tsx +10 -9
  6. package/ui/block/Block.d.ts +3 -3
  7. package/ui/block/Block.js +3 -2
  8. package/ui/block/Block.tsx +5 -4
  9. package/ui/block/Blockquote.d.ts +3 -3
  10. package/ui/block/Blockquote.js +2 -2
  11. package/ui/block/Blockquote.tsx +4 -3
  12. package/ui/block/Caption.d.ts +3 -3
  13. package/ui/block/Caption.js +3 -3
  14. package/ui/block/Caption.tsx +5 -4
  15. package/ui/block/Card.d.ts +3 -2
  16. package/ui/block/Card.js +2 -2
  17. package/ui/block/Card.tsx +4 -2
  18. package/ui/block/Column.d.ts +3 -3
  19. package/ui/block/Column.js +2 -2
  20. package/ui/block/Column.tsx +4 -3
  21. package/ui/block/Definitions.d.ts +3 -3
  22. package/ui/block/Definitions.js +2 -2
  23. package/ui/block/Definitions.tsx +4 -3
  24. package/ui/block/Details.d.ts +3 -2
  25. package/ui/block/Details.js +2 -2
  26. package/ui/block/Details.tsx +4 -3
  27. package/ui/block/Divider.d.ts +3 -2
  28. package/ui/block/Divider.js +2 -2
  29. package/ui/block/Divider.tsx +4 -2
  30. package/ui/block/Heading.d.ts +3 -3
  31. package/ui/block/Heading.js +2 -2
  32. package/ui/block/Heading.tsx +4 -3
  33. package/ui/block/Image.d.ts +3 -2
  34. package/ui/block/Image.js +2 -2
  35. package/ui/block/Image.tsx +4 -2
  36. package/ui/block/Label.d.ts +1 -1
  37. package/ui/block/Label.js +2 -2
  38. package/ui/block/Label.tsx +2 -1
  39. package/ui/block/List.d.ts +3 -2
  40. package/ui/block/List.js +4 -4
  41. package/ui/block/List.tsx +6 -4
  42. package/ui/block/Panel.d.ts +3 -3
  43. package/ui/block/Panel.js +2 -2
  44. package/ui/block/Panel.tsx +4 -3
  45. package/ui/block/Paragraph.d.ts +3 -3
  46. package/ui/block/Paragraph.js +2 -2
  47. package/ui/block/Paragraph.tsx +4 -3
  48. package/ui/block/Preformatted.d.ts +3 -3
  49. package/ui/block/Preformatted.js +2 -2
  50. package/ui/block/Preformatted.tsx +4 -3
  51. package/ui/block/Row.d.ts +3 -3
  52. package/ui/block/Row.js +2 -2
  53. package/ui/block/Row.tsx +4 -3
  54. package/ui/block/Section.js +1 -1
  55. package/ui/block/Section.tsx +1 -0
  56. package/ui/block/Subheading.d.ts +1 -1
  57. package/ui/block/Subheading.js +2 -2
  58. package/ui/block/Subheading.tsx +2 -1
  59. package/ui/block/Title.d.ts +1 -1
  60. package/ui/block/Title.js +2 -2
  61. package/ui/block/Title.tsx +2 -1
  62. package/ui/block/Video.d.ts +3 -3
  63. package/ui/block/Video.js +2 -2
  64. package/ui/block/Video.tsx +4 -3
  65. package/ui/button/Button.d.ts +3 -2
  66. package/ui/button/Button.js +2 -2
  67. package/ui/button/Button.md +28 -0
  68. package/ui/button/Button.module.css +23 -5
  69. package/ui/button/Button.tsx +4 -3
  70. package/ui/button/Clickable.d.ts +3 -3
  71. package/ui/button/Clickable.js +6 -4
  72. package/ui/button/Clickable.tsx +9 -9
  73. package/ui/button/FullscreenButton.d.ts +3 -5
  74. package/ui/button/FullscreenButton.tsx +3 -2
  75. package/ui/button/RetryButton.d.ts +2 -2
  76. package/ui/button/RetryButton.tsx +2 -2
  77. package/ui/button/SubmitButton.d.ts +3 -3
  78. package/ui/button/SubmitButton.js +3 -2
  79. package/ui/button/SubmitButton.tsx +5 -3
  80. package/ui/dialog/Dialog.d.ts +6 -4
  81. package/ui/dialog/Dialog.js +7 -4
  82. package/ui/dialog/Dialog.tsx +19 -7
  83. package/ui/dialog/Modal.d.ts +3 -3
  84. package/ui/dialog/Modal.js +3 -3
  85. package/ui/dialog/Modal.tsx +5 -5
  86. package/ui/docs/DocumentationButtons.d.ts +3 -2
  87. package/ui/docs/DocumentationButtons.js +2 -2
  88. package/ui/docs/DocumentationButtons.tsx +4 -1
  89. package/ui/form/Field.d.ts +3 -3
  90. package/ui/form/Field.js +2 -2
  91. package/ui/form/Field.tsx +4 -3
  92. package/ui/form/Form.d.ts +2 -2
  93. package/ui/form/Form.js +3 -2
  94. package/ui/form/Form.tsx +5 -3
  95. package/ui/form/FormFields.d.ts +1 -1
  96. package/ui/form/FormFields.js +2 -2
  97. package/ui/form/FormFields.tsx +2 -2
  98. package/ui/form/FormFooter.d.ts +3 -3
  99. package/ui/form/FormFooter.js +2 -2
  100. package/ui/form/FormFooter.tsx +4 -4
  101. package/ui/form/FormMessage.d.ts +2 -2
  102. package/ui/form/FormMessage.tsx +2 -3
  103. package/ui/form/Progress.d.ts +3 -2
  104. package/ui/form/Progress.js +2 -2
  105. package/ui/form/Progress.tsx +4 -2
  106. package/ui/inline/Code.d.ts +3 -3
  107. package/ui/inline/Code.js +2 -2
  108. package/ui/inline/Code.tsx +4 -3
  109. package/ui/inline/Deleted.d.ts +3 -3
  110. package/ui/inline/Deleted.js +2 -2
  111. package/ui/inline/Deleted.tsx +4 -3
  112. package/ui/inline/Emphasis.d.ts +3 -3
  113. package/ui/inline/Emphasis.js +2 -2
  114. package/ui/inline/Emphasis.tsx +4 -3
  115. package/ui/inline/Inserted.d.ts +3 -3
  116. package/ui/inline/Inserted.js +2 -2
  117. package/ui/inline/Inserted.tsx +4 -3
  118. package/ui/inline/Link.d.ts +3 -2
  119. package/ui/inline/Link.js +2 -2
  120. package/ui/inline/Link.tsx +4 -2
  121. package/ui/inline/Mark.d.ts +3 -3
  122. package/ui/inline/Mark.js +2 -2
  123. package/ui/inline/Mark.tsx +4 -3
  124. package/ui/inline/Small.d.ts +3 -3
  125. package/ui/inline/Small.js +2 -2
  126. package/ui/inline/Small.tsx +4 -3
  127. package/ui/inline/Span.d.ts +3 -3
  128. package/ui/inline/Span.js +3 -2
  129. package/ui/inline/Span.tsx +5 -4
  130. package/ui/inline/Strong.d.ts +3 -3
  131. package/ui/inline/Strong.js +2 -2
  132. package/ui/inline/Strong.tsx +4 -3
  133. package/ui/inline/Subscript.d.ts +3 -3
  134. package/ui/inline/Subscript.js +3 -3
  135. package/ui/inline/Subscript.tsx +5 -5
  136. package/ui/inline/Superscript.d.ts +3 -3
  137. package/ui/inline/Superscript.js +3 -3
  138. package/ui/inline/Superscript.tsx +5 -5
  139. package/ui/input/ArrayInput.js +2 -2
  140. package/ui/input/ArrayInput.tsx +2 -1
  141. package/ui/input/ArrayRadioInputs.d.ts +1 -1
  142. package/ui/input/ArrayRadioInputs.js +2 -2
  143. package/ui/input/ArrayRadioInputs.tsx +2 -1
  144. package/ui/input/ButtonInput.d.ts +1 -1
  145. package/ui/input/ButtonInput.js +2 -2
  146. package/ui/input/ButtonInput.tsx +2 -1
  147. package/ui/input/CheckboxInput.d.ts +1 -1
  148. package/ui/input/CheckboxInput.js +2 -2
  149. package/ui/input/CheckboxInput.tsx +2 -0
  150. package/ui/input/ChoiceRadioInputs.js +2 -2
  151. package/ui/input/ChoiceRadioInputs.tsx +2 -1
  152. package/ui/input/DataInput.js +2 -2
  153. package/ui/input/DataInput.tsx +2 -1
  154. package/ui/input/DateInput.d.ts +1 -1
  155. package/ui/input/DateInput.js +2 -2
  156. package/ui/input/DateInput.tsx +2 -1
  157. package/ui/input/DictionaryInput.js +2 -2
  158. package/ui/input/DictionaryInput.tsx +2 -1
  159. package/ui/input/FileInput.d.ts +1 -1
  160. package/ui/input/FileInput.js +3 -2
  161. package/ui/input/FileInput.tsx +3 -1
  162. package/ui/input/Input.d.ts +10 -3
  163. package/ui/input/Input.js +2 -2
  164. package/ui/input/Input.tsx +11 -4
  165. package/ui/input/NumberInput.d.ts +1 -1
  166. package/ui/input/NumberInput.js +2 -2
  167. package/ui/input/NumberInput.tsx +2 -1
  168. package/ui/input/OutputInput.d.ts +1 -1
  169. package/ui/input/OutputInput.js +2 -2
  170. package/ui/input/OutputInput.tsx +2 -2
  171. package/ui/input/Popover.d.ts +4 -2
  172. package/ui/input/Popover.js +4 -3
  173. package/ui/input/Popover.tsx +6 -3
  174. package/ui/input/PopoverButton.d.ts +28 -0
  175. package/ui/input/{ButtonPopover.js → PopoverButton.js} +10 -8
  176. package/ui/input/PopoverButton.tsx +44 -0
  177. package/ui/input/PopoverButtonInput.d.ts +27 -0
  178. package/ui/input/{ButtonInputPopover.js → PopoverButtonInput.js} +10 -8
  179. package/ui/input/PopoverButtonInput.tsx +44 -0
  180. package/ui/input/QueryInput.d.ts +1 -1
  181. package/ui/input/QueryInput.js +2 -2
  182. package/ui/input/QueryInput.tsx +2 -0
  183. package/ui/input/RadioInput.d.ts +1 -1
  184. package/ui/input/RadioInput.js +2 -2
  185. package/ui/input/RadioInput.tsx +2 -0
  186. package/ui/input/SchemaInput.d.ts +1 -1
  187. package/ui/input/SchemaInput.js +2 -2
  188. package/ui/input/SchemaInput.tsx +2 -2
  189. package/ui/input/SelectInput.js +2 -2
  190. package/ui/input/SelectInput.tsx +2 -1
  191. package/ui/input/TextInput.d.ts +1 -1
  192. package/ui/input/TextInput.js +3 -3
  193. package/ui/input/TextInput.tsx +3 -2
  194. package/ui/input/index.d.ts +2 -2
  195. package/ui/input/index.js +2 -2
  196. package/ui/input/index.ts +2 -2
  197. package/ui/menu/Menu.d.ts +5 -5
  198. package/ui/menu/Menu.js +4 -4
  199. package/ui/menu/Menu.tsx +7 -6
  200. package/ui/misc/Icon.d.ts +2 -1
  201. package/ui/misc/Icon.js +2 -1
  202. package/ui/misc/Icon.tsx +3 -1
  203. package/ui/misc/Loading.d.ts +2 -3
  204. package/ui/misc/Loading.js +2 -2
  205. package/ui/misc/Loading.tsx +10 -3
  206. package/ui/misc/Tag.d.ts +3 -2
  207. package/ui/misc/Tag.js +2 -2
  208. package/ui/misc/Tag.tsx +4 -3
  209. package/ui/notice/Message.d.ts +3 -3
  210. package/ui/notice/Message.js +2 -2
  211. package/ui/notice/Message.tsx +4 -3
  212. package/ui/notice/Notice.d.ts +3 -3
  213. package/ui/notice/Notice.js +2 -2
  214. package/ui/notice/Notice.tsx +4 -2
  215. package/ui/notice/Notices.d.ts +3 -2
  216. package/ui/notice/Notices.js +2 -2
  217. package/ui/notice/Notices.tsx +4 -3
  218. package/ui/table/Cell.d.ts +3 -3
  219. package/ui/table/Cell.js +2 -2
  220. package/ui/table/Cell.tsx +4 -3
  221. package/ui/table/Table.d.ts +3 -3
  222. package/ui/table/Table.js +2 -2
  223. package/ui/table/Table.tsx +4 -3
  224. package/ui/tree/TreeBreadcrumbs.d.ts +3 -2
  225. package/ui/tree/TreeBreadcrumbs.js +2 -2
  226. package/ui/tree/TreeBreadcrumbs.tsx +4 -2
  227. package/ui/tree/TreeButton.d.ts +2 -1
  228. package/ui/tree/TreeButton.tsx +2 -1
  229. package/ui/util/ClassProps.md +56 -0
  230. package/ui/util/css.d.ts +1 -1
  231. package/ui/util/css.js +2 -2
  232. package/ui/util/css.test.ts +29 -0
  233. package/ui/util/css.ts +2 -2
  234. package/ui/util/props.d.ts +12 -0
  235. package/ui/util/props.test.tsx +100 -0
  236. package/ui/util/props.ts +13 -0
  237. package/ui/input/ButtonInputPopover.d.ts +0 -25
  238. package/ui/input/ButtonInputPopover.tsx +0 -41
  239. package/ui/input/ButtonPopover.d.ts +0 -25
  240. package/ui/input/ButtonPopover.tsx +0 -40
@@ -0,0 +1,56 @@
1
+ # ClassProps
2
+
3
+ The shared `className` prop most components accept — an app class merged *after* the classes the component computes for itself.
4
+
5
+ **Things to know:**
6
+
7
+ - CSS-module class names are hashed, so an app stylesheet can't target a component instance on its own. `className` is the sanctioned way to attach an app class to a specific instance.
8
+ - It's merged **last**, and an app's global stylesheet is unlayered, so its rules beat the library's `@layer components` rules without a specificity fight.
9
+ - It accepts any `Classes` value — a string, a nested array, or a dictionary whose `true` keys become class names — because it goes through `getClass()` like every other class the component composes.
10
+ - It lands on the component's **own root element**. A component that renders more than one element (`<Card>`'s stretched overlay link, `<Field>`'s inner input) keeps the class on the outer one. `<Popover>` is the one deliberate exception: its class goes on the floating panel, not the wrapper that anchors it.
11
+ - **Not every component takes one.** Formatting-only components (`<When>`, `<Prose>`, `<Scroll>`) exist to format what's already there, the `*Layout` components are structural (write a new layout rather than bending an existing one), and `<Dialog>` is nearly always rendered from the dialogs state object. Style the content inside them instead.
12
+ - Tokens and per-property hooks (`--card-background`, `--button-radius`, …) remain the primary theming surface. Reach for `className` for a composed one-off treatment those can't express — a keyframe animation, a bespoke gradient, a transform on press — not to restyle a component everywhere.
13
+
14
+ ## Usage
15
+
16
+ ### Attaching an app class
17
+
18
+ ```tsx
19
+ import { Button } from "shelving/ui";
20
+
21
+ // `.spongy-press` lives in the app's own global stylesheet.
22
+ <Button className="spongy-press" strong onClick={claim}>
23
+ Claim
24
+ </Button>;
25
+ ```
26
+
27
+ ```css
28
+ /* style.css — unlayered, so it beats the library's `@layer components` rules. */
29
+ .spongy-press:active {
30
+ transform: scale(0.96);
31
+ }
32
+ ```
33
+
34
+ ### Conditional classes
35
+
36
+ ```tsx
37
+ import { Card } from "shelving/ui";
38
+
39
+ // Arrays and `true`-flag dictionaries work too — anything `Classes` allows.
40
+ <Card className={["claim-card", { sparkle: isNew }]} href={path}>
41
+ {content}
42
+ </Card>;
43
+ ```
44
+
45
+ ### Adding it to your own component
46
+
47
+ ```tsx
48
+ import { type ClassProps, getClass } from "shelving/ui";
49
+
50
+ export interface AddressProps extends BlockVariants, ChildProps, ClassProps {}
51
+
52
+ export function Address({ children, className, ...props }: AddressProps): ReactElement {
53
+ // Merge `className` last so a caller's class wins.
54
+ return <address className={getClass(ADDRESS_CLASS, getBlockClass(props), className)}>{children}</address>;
55
+ }
56
+ ```
package/ui/util/css.d.ts CHANGED
@@ -47,7 +47,7 @@ export declare function getClass(...classes: unknown[]): string;
47
47
  * - This allows this situation to be handled gracefully and classes will be silently ignored in this environment.
48
48
  *
49
49
  * @param classes Class keys/values to merge.
50
- * @returns The merged string classname, or `undefined` when `module` is a string (unprocessed CSS module).
50
+ * @returns The merged string classname, or `undefined` when `module` is a string (unprocessed CSS module) or no classes match.
51
51
  * @see https://shelving.cc/ui/getModuleClass
52
52
  */
53
53
  export declare function getModuleClass(module: CSSModule | string, ...classes: unknown[]): string | undefined;
package/ui/util/css.js CHANGED
@@ -42,12 +42,12 @@ function* getClasses(classes) {
42
42
  * - This allows this situation to be handled gracefully and classes will be silently ignored in this environment.
43
43
  *
44
44
  * @param classes Class keys/values to merge.
45
- * @returns The merged string classname, or `undefined` when `module` is a string (unprocessed CSS module).
45
+ * @returns The merged string classname, or `undefined` when `module` is a string (unprocessed CSS module) or no classes match.
46
46
  * @see https://shelving.cc/ui/getModuleClass
47
47
  */
48
48
  export function getModuleClass(module, ...classes) {
49
49
  if (isDictionary(module))
50
- return Array.from(getModuleClasses(module, classes)).join(" ");
50
+ return Array.from(getModuleClasses(module, classes)).join(" ") || undefined;
51
51
  }
52
52
  /** Yield the items in a list of possible `className` strings that match a `CSSModule` dictionary. */
53
53
  function* getModuleClasses(module, classes) {
@@ -0,0 +1,29 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { getClass, getModuleClass } from "shelving/ui";
3
+
4
+ describe("getClass", () => {
5
+ test("joins strings, arrays, and true-valued variant keys", () => {
6
+ expect(getClass("a", ["b", "c"], { d: true, e: false })).toBe("a b c d");
7
+ });
8
+
9
+ test("ignores `null` and `undefined`", () => {
10
+ expect(getClass("a", null, undefined, "b")).toBe("a b");
11
+ });
12
+ });
13
+
14
+ describe("getModuleClass", () => {
15
+ test("maps class keys through the module dictionary", () => {
16
+ expect(getModuleClass({ track: "abc123" }, "track")).toBe("abc123");
17
+ expect(getModuleClass({ track: "abc123", spin: "def456" }, "track", "spin")).toBe("abc123 def456");
18
+ });
19
+
20
+ test("returns `undefined` when the module is a string (unprocessed CSS module)", () => {
21
+ expect(getModuleClass("./Loading.module.css", "track")).toBeUndefined();
22
+ });
23
+
24
+ test('returns `undefined` when no classes match, so no empty `class=""` attribute renders', () => {
25
+ // Some environments (e.g. `bun test` v1.4+) import a `.module.css` as an empty object.
26
+ expect(getModuleClass({}, "track")).toBeUndefined();
27
+ expect(getModuleClass({ track: "abc123" }, "missing")).toBeUndefined();
28
+ });
29
+ });
package/ui/util/css.ts CHANGED
@@ -77,11 +77,11 @@ function* getClasses(classes: unknown): Iterable<string> {
77
77
  * - This allows this situation to be handled gracefully and classes will be silently ignored in this environment.
78
78
  *
79
79
  * @param classes Class keys/values to merge.
80
- * @returns The merged string classname, or `undefined` when `module` is a string (unprocessed CSS module).
80
+ * @returns The merged string classname, or `undefined` when `module` is a string (unprocessed CSS module) or no classes match.
81
81
  * @see https://shelving.cc/ui/getModuleClass
82
82
  */
83
83
  export function getModuleClass(module: CSSModule | string, ...classes: unknown[]): string | undefined {
84
- if (isDictionary(module)) return Array.from(getModuleClasses(module, classes)).join(" ");
84
+ if (isDictionary(module)) return Array.from(getModuleClasses(module, classes)).join(" ") || undefined;
85
85
  }
86
86
 
87
87
  /** Yield the items in a list of possible `className` strings that match a `CSSModule` dictionary. */
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import type { Classes } from "./css.js";
2
3
  /**
3
4
  * Props for a component that requires `children`.
4
5
  *
@@ -15,3 +16,14 @@ export interface ChildProps {
15
16
  export interface OptionalChildProps {
16
17
  readonly children?: ReactNode | undefined;
17
18
  }
19
+ /**
20
+ * Props for a component that accepts an additional `className`, merged _after_ the classes the component computes for itself.
21
+ * - Accepts anything `Classes` allows — a string, a nested array of strings, or a `Variants` dictionary of `true` flags.
22
+ * - Merged last so an app's own (unlayered) stylesheet reliably beats the library's `@layer components` rules.
23
+ * - Component hooks (`--card-background` etc.) and the tint ladder remain the primary theming surface; `className` is the escape hatch for one-off treatments they can't express.
24
+ *
25
+ * @see https://shelving.cc/ui/ClassProps
26
+ */
27
+ export interface ClassProps {
28
+ readonly className?: Classes | undefined;
29
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ReactNode } from "react";
3
+ import { renderToStaticMarkup } from "react-dom/server";
4
+ import {
5
+ Button,
6
+ Card,
7
+ Cell,
8
+ createMeta,
9
+ Field,
10
+ Loading,
11
+ MetaContext,
12
+ Paragraph,
13
+ Popover,
14
+ PopoverButtonInput,
15
+ Span,
16
+ Tag,
17
+ TextInput,
18
+ } from "shelving/ui";
19
+
20
+ /** Render `children` inside the meta context that link-rendering components need. */
21
+ function render(children: ReactNode): string {
22
+ return renderToStaticMarkup(<MetaContext value={createMeta({ root: "http://x.com/", url: "./" })}>{children}</MetaContext>);
23
+ }
24
+
25
+ describe("ClassProps", () => {
26
+ test("component renders a `className` string on its root element", () => {
27
+ expect(render(<Paragraph className="spongy">Hi</Paragraph>)).toContain('<p class="spongy">');
28
+ expect(render(<Span className="spongy">Hi</Span>)).toContain('<span class="spongy">');
29
+ expect(render(<Cell className="spongy">Hi</Cell>)).toContain('<td class="spongy">');
30
+ });
31
+
32
+ test("component accepts every `Classes` form, not just a string", () => {
33
+ expect(render(<Paragraph className={["spongy", "sparkle"]}>Hi</Paragraph>)).toContain('<p class="spongy sparkle">');
34
+ expect(render(<Paragraph className={{ spongy: true, sparkle: false }}>Hi</Paragraph>)).toContain('<p class="spongy">');
35
+ });
36
+
37
+ test("clickable component renders its `className` on the rendered element", () => {
38
+ // `Button` renders through `Clickable`, which picks `<button>` for `onClick` and `<a>` for `href`.
39
+ expect(render(<Button className="spongy" onClick={() => undefined} />)).toContain('<button type="button" class="spongy"');
40
+ expect(render(<Button className="spongy" href="/about" />)).toContain('class="spongy"');
41
+ expect(render(<Tag className="spongy">New</Tag>)).toContain('class="spongy"');
42
+ });
43
+
44
+ test("clickable wrapper renders a working `<button>` when only `onClick` is set", () => {
45
+ // `Card` spreads `href={href}` into `Clickable` even when unset — dispatch must pick `<button>` from the defined `onClick`, not a dead `<a>`.
46
+ const html = render(<Card onClick={() => undefined}>Hi</Card>);
47
+ expect(html).toContain("<button");
48
+ expect(html).not.toContain("<a ");
49
+ });
50
+
51
+ test("`className` lands on the component's own root, not on its inner elements", () => {
52
+ // `Card` renders the class on its `<article>`, not on the stretched overlay `<a>` it also renders.
53
+ const html = render(
54
+ <Card className="spongy" href="/about">
55
+ Hi
56
+ </Card>,
57
+ );
58
+ expect(html).toContain('<article class="spongy">');
59
+ expect(html).not.toContain('<a class="spongy"');
60
+ });
61
+
62
+ test("`Popover` styles its floating panel rather than its wrapper", () => {
63
+ const html = render(
64
+ <Popover className="spongy">
65
+ <button type="button">Open</button>
66
+ <div>Panel</div>
67
+ </Popover>,
68
+ );
69
+ expect(html).toContain('<section class="spongy"');
70
+ });
71
+
72
+ test("input component renders its `className` on the input element", () => {
73
+ expect(render(<TextInput className="spongy" name="name" onValue={() => undefined} />)).toContain('class="spongy"');
74
+ });
75
+
76
+ test("popover wrapper components render their `className` on the trigger, not the floating panel", () => {
77
+ // `PopoverButtonInput` (and `PopoverButton`/`QueryInput`) style their always-visible trigger — only a bare `<Popover>` styles the panel.
78
+ const html = render(
79
+ <PopoverButtonInput name="pick" className="spongy">
80
+ {["Pick", "Panel"]}
81
+ </PopoverButtonInput>,
82
+ );
83
+ expect(html).toContain('class="spongy"');
84
+ expect(html).toContain("<button");
85
+ });
86
+
87
+ test("omitted `className` renders the same markup as `className={undefined}`", () => {
88
+ expect(render(<Field title="Name">Hi</Field>)).toBe(
89
+ render(
90
+ <Field title="Name" className={undefined}>
91
+ Hi
92
+ </Field>,
93
+ ),
94
+ );
95
+ expect(render(<Paragraph>Hi</Paragraph>)).not.toContain("spongy");
96
+ // A component with no class of its own renders no `class` attribute at all, rather than an empty one.
97
+ expect(render(<Loading />)).not.toContain("class=");
98
+ expect(render(<Loading className="spongy" />)).toContain('class="spongy"');
99
+ });
100
+ });
package/ui/util/props.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from "react";
2
+ import type { Classes } from "./css.js";
2
3
 
3
4
  /**
4
5
  * Props for a component that requires `children`.
@@ -17,3 +18,15 @@ export interface ChildProps {
17
18
  export interface OptionalChildProps {
18
19
  readonly children?: ReactNode | undefined;
19
20
  }
21
+
22
+ /**
23
+ * Props for a component that accepts an additional `className`, merged _after_ the classes the component computes for itself.
24
+ * - Accepts anything `Classes` allows — a string, a nested array of strings, or a `Variants` dictionary of `true` flags.
25
+ * - Merged last so an app's own (unlayered) stylesheet reliably beats the library's `@layer components` rules.
26
+ * - Component hooks (`--card-background` etc.) and the tint ladder remain the primary theming surface; `className` is the escape hatch for one-off treatments they can't express.
27
+ *
28
+ * @see https://shelving.cc/ui/ClassProps
29
+ */
30
+ export interface ClassProps {
31
+ readonly className?: Classes | undefined;
32
+ }
@@ -1,25 +0,0 @@
1
- /** biome-ignore-all lint/a11y/noStaticElementInteractions: This is fine we're only using this to listen for clicks on child buttons. */
2
- /** biome-ignore-all lint/a11y/useKeyWithClickEvents: This is fine we're only using this to listen for clicks on child buttons. */
3
- import { type ReactElement } from "react";
4
- import type { InputProps } from "./Input.js";
5
- import { type PopoverChildren } from "./Popover.js";
6
- /**
7
- * Props for `ButtonInputPopover`, an input button that toggles an adjacent popover.
8
- *
9
- * @see https://shelving.cc/ui/ButtonInputPopoverProps
10
- */
11
- export interface ButtonInputPopoverProps extends InputProps {
12
- children: PopoverChildren;
13
- }
14
- /**
15
- * An input button that, when clicked, shows a popover next to it when clicked or focused.
16
- * - The first element passed to `children` is used as the content for the button, the rest is the content of the popover.
17
- *
18
- * DH: Would love to use new HTML `popover="auto"` functionality for this but the anchor positioning it needs is not supported everywhere yet.
19
- *
20
- * @returns A `Popover` wrapping a `ButtonInput` that toggles it open and closed.
21
- * @example <ButtonInputPopover name="filter">{label}{panel}</ButtonInputPopover>
22
- * @see https://shelving.cc/ui/ButtonInputPopover
23
- */
24
- export declare function ButtonInputPopover({ children: [buttonChildren, ...popoverChildren], //
25
- ...props }: ButtonInputPopoverProps): ReactElement;
@@ -1,41 +0,0 @@
1
- /** biome-ignore-all lint/a11y/noStaticElementInteractions: This is fine we're only using this to listen for clicks on child buttons. */
2
- /** biome-ignore-all lint/a11y/useKeyWithClickEvents: This is fine we're only using this to listen for clicks on child buttons. */
3
-
4
- import { type ReactElement, useState } from "react";
5
- import { ButtonInput } from "./ButtonInput.js";
6
- import type { InputProps } from "./Input.js";
7
- import { Popover, type PopoverChildren } from "./Popover.js";
8
-
9
- /**
10
- * Props for `ButtonInputPopover`, an input button that toggles an adjacent popover.
11
- *
12
- * @see https://shelving.cc/ui/ButtonInputPopoverProps
13
- */
14
- export interface ButtonInputPopoverProps extends InputProps {
15
- children: PopoverChildren;
16
- }
17
-
18
- /**
19
- * An input button that, when clicked, shows a popover next to it when clicked or focused.
20
- * - The first element passed to `children` is used as the content for the button, the rest is the content of the popover.
21
- *
22
- * DH: Would love to use new HTML `popover="auto"` functionality for this but the anchor positioning it needs is not supported everywhere yet.
23
- *
24
- * @returns A `Popover` wrapping a `ButtonInput` that toggles it open and closed.
25
- * @example <ButtonInputPopover name="filter">{label}{panel}</ButtonInputPopover>
26
- * @see https://shelving.cc/ui/ButtonInputPopover
27
- */
28
- export function ButtonInputPopover({
29
- children: [buttonChildren, ...popoverChildren], //
30
- ...props
31
- }: ButtonInputPopoverProps): ReactElement {
32
- const [open, setOpen] = useState(false);
33
- return (
34
- <Popover open={open} onClose={() => setOpen(false)}>
35
- <ButtonInput onClick={() => setOpen(!open)} {...props}>
36
- {buttonChildren}
37
- </ButtonInput>
38
- {popoverChildren}
39
- </Popover>
40
- );
41
- }
@@ -1,25 +0,0 @@
1
- /** biome-ignore-all lint/a11y/noStaticElementInteractions: This is fine we're only using this to listen for clicks on child buttons. */
2
- /** biome-ignore-all lint/a11y/useKeyWithClickEvents: This is fine we're only using this to listen for clicks on child buttons. */
3
- import { type ReactElement } from "react";
4
- import { type ButtonVariants } from "../button/Button.js";
5
- import { type PopoverChildren } from "./Popover.js";
6
- /**
7
- * Props for `ButtonPopover`, a button that toggles an adjacent popover.
8
- *
9
- * @see https://shelving.cc/ui/ButtonPopoverProps
10
- */
11
- export interface ButtonPopoverProps extends ButtonVariants {
12
- children: PopoverChildren;
13
- }
14
- /**
15
- * A button that, when clicked, shows a popover next to it when clicked or focused.
16
- * - The first element passed to `children` is used as the content for the button, the rest is the content of the popover.
17
- *
18
- * DH: Would love to use new HTML `popover="auto"` functionality for this but the anchor positioning it needs is not supported everywhere yet.
19
- *
20
- * @returns A `Popover` wrapping a `Button` that toggles it open and closed.
21
- * @example <ButtonPopover>{label}{panel}</ButtonPopover>
22
- * @see https://shelving.cc/ui/ButtonPopover
23
- */
24
- export declare function ButtonPopover({ children: [buttonChildren, ...popoverChildren], //
25
- ...props }: ButtonPopoverProps): ReactElement;
@@ -1,40 +0,0 @@
1
- /** biome-ignore-all lint/a11y/noStaticElementInteractions: This is fine we're only using this to listen for clicks on child buttons. */
2
- /** biome-ignore-all lint/a11y/useKeyWithClickEvents: This is fine we're only using this to listen for clicks on child buttons. */
3
-
4
- import { type ReactElement, useState } from "react";
5
- import { Button, type ButtonVariants } from "../button/Button.js";
6
- import { Popover, type PopoverChildren } from "./Popover.js";
7
-
8
- /**
9
- * Props for `ButtonPopover`, a button that toggles an adjacent popover.
10
- *
11
- * @see https://shelving.cc/ui/ButtonPopoverProps
12
- */
13
- export interface ButtonPopoverProps extends ButtonVariants {
14
- children: PopoverChildren;
15
- }
16
-
17
- /**
18
- * A button that, when clicked, shows a popover next to it when clicked or focused.
19
- * - The first element passed to `children` is used as the content for the button, the rest is the content of the popover.
20
- *
21
- * DH: Would love to use new HTML `popover="auto"` functionality for this but the anchor positioning it needs is not supported everywhere yet.
22
- *
23
- * @returns A `Popover` wrapping a `Button` that toggles it open and closed.
24
- * @example <ButtonPopover>{label}{panel}</ButtonPopover>
25
- * @see https://shelving.cc/ui/ButtonPopover
26
- */
27
- export function ButtonPopover({
28
- children: [buttonChildren, ...popoverChildren], //
29
- ...props
30
- }: ButtonPopoverProps): ReactElement {
31
- const [open, setOpen] = useState(false);
32
- return (
33
- <Popover open={open} onClose={() => setOpen(false)}>
34
- <Button onClick={() => setOpen(!open)} {...props}>
35
- {buttonChildren}
36
- </Button>
37
- {popoverChildren}
38
- </Popover>
39
- );
40
- }