@charcuterie/ui 4.22.1 → 4.24.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.
@@ -0,0 +1,117 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { useIsTextClipped } from "../Badge/useIsTextClipped.ts"
4
+ import { toClassName } from "../toClassName.ts"
5
+ import type {
6
+ GroupedListItem,
7
+ GroupedListSize,
8
+ } from "./GroupedList.tsx"
9
+
10
+ export type GroupedListRowProps = {
11
+ isTextTruncated: boolean
12
+ item: GroupedListItem
13
+ size: GroupedListSize
14
+ }
15
+
16
+ const ROW_SIZE_CLASS: Record<GroupedListSize, string> = {
17
+ md: "px-4 py-3",
18
+ sm: "px-3 py-2",
19
+ }
20
+
21
+ /**
22
+ * Its own file because each row measures its own text, and a hook
23
+ * cannot run in a loop.
24
+ *
25
+ * ### The tooltip appears only while the text is clipped
26
+ *
27
+ * `useIsTextClipped` is `Badge`'s: an ellipsis leaves no trace in the
28
+ * DOM, so the only way to know a label is cut off is to measure it.
29
+ * A `title` on every row would put a tooltip on forty short names to
30
+ * help the one long one.
31
+ *
32
+ * ### The actions wrap; they never squeeze the text
33
+ *
34
+ * The row is a wrapping flex line. The text column asks for 12rem
35
+ * and grows into the rest, so in a wide list the actions sit at the
36
+ * inline end of the same line, and in a list narrower than the text
37
+ * plus the actions they drop under the text instead of crushing the
38
+ * label to an ellipsis and nothing. No container query: the wrap is
39
+ * decided by the content itself.
40
+ */
41
+ export const GroupedListRow = ({
42
+ isTextTruncated,
43
+ item,
44
+ size,
45
+ }: GroupedListRowProps): ReactNode => {
46
+ const [labelRef, clippedLabel] = useIsTextClipped()
47
+
48
+ const [descriptionRef, clippedDescription] =
49
+ useIsTextClipped()
50
+
51
+ const overflowClass = isTextTruncated
52
+ ? "truncate"
53
+ : "wrap-anywhere"
54
+
55
+ return (
56
+ <li
57
+ className={toClassName(
58
+ "flex flex-wrap items-center gap-x-3 gap-y-2",
59
+ ROW_SIZE_CLASS[size],
60
+ )}
61
+ >
62
+ <div className="flex min-w-0 flex-[1_1_12rem] flex-col gap-0.5">
63
+ <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
64
+ <span
65
+ className={toClassName(
66
+ "min-w-0 max-w-full font-medium text-content-primary",
67
+ size === "sm" ? "text-sm" : "text-md",
68
+ overflowClass,
69
+ )}
70
+ ref={labelRef}
71
+ title={
72
+ isTextTruncated ? clippedLabel : undefined
73
+ }
74
+ >
75
+ {item.label}
76
+ </span>
77
+
78
+ {item.badges ? (
79
+ <span className="flex min-w-0 flex-wrap items-center gap-1">
80
+ {item.badges}
81
+ </span>
82
+ ) : null}
83
+ </div>
84
+
85
+ {item.description ? (
86
+ <span
87
+ className={toClassName(
88
+ "block min-w-0 text-content-secondary",
89
+ size === "sm" ? "text-xs" : "text-sm",
90
+ overflowClass,
91
+ )}
92
+ ref={descriptionRef}
93
+ title={
94
+ isTextTruncated
95
+ ? clippedDescription
96
+ : undefined
97
+ }
98
+ >
99
+ {item.description}
100
+ </span>
101
+ ) : null}
102
+
103
+ {item.content ? (
104
+ <div className="mt-1 min-w-0 wrap-anywhere text-content-secondary text-sm">
105
+ {item.content}
106
+ </div>
107
+ ) : null}
108
+ </div>
109
+
110
+ {item.actions ? (
111
+ <div className="flex shrink-0 items-center gap-2">
112
+ {item.actions}
113
+ </div>
114
+ ) : null}
115
+ </li>
116
+ )
117
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The custom property a stuck heading reads its background from.
3
+ * Set it on any ancestor; it falls back to `--color-surface-base`.
4
+ */
5
+ export const GROUPED_LIST_HEADING_SURFACE_PROPERTY =
6
+ "--charcuterie-grouped-list-heading-surface"
7
+
8
+ /**
9
+ * The custom property a stuck heading reads its distance from the
10
+ * scroll container's top edge from. It falls back to `0px`.
11
+ */
12
+ export const GROUPED_LIST_HEADING_OFFSET_PROPERTY =
13
+ "--charcuterie-grouped-list-heading-offset"
14
+
15
+ /**
16
+ * Written out in full because Tailwind scans source text for whole
17
+ * class strings: an interpolated property name generates nothing.
18
+ *
19
+ * The heading row carries the space under it as padding rather than
20
+ * the group's `gap`, because a gap is transparent and the rows would
21
+ * show through it while the heading is stuck.
22
+ */
23
+ export const STICKY_HEADING_CLASS =
24
+ "sticky top-[var(--charcuterie-grouped-list-heading-offset,0px)] z-[var(--layer-sticky)] bg-[color:var(--charcuterie-grouped-list-heading-surface,var(--color-surface-base))] pb-2"
package/src/index.ts CHANGED
@@ -235,6 +235,18 @@ export type { FieldGroupProps } from "./Field/FieldGroup.tsx"
235
235
  export { FieldGroup } from "./Field/FieldGroup.tsx"
236
236
  export type { FileDropZoneProps } from "./FileDropZone/FileDropZone.tsx"
237
237
  export { FileDropZone } from "./FileDropZone/FileDropZone.tsx"
238
+ export type {
239
+ GroupedListGroup,
240
+ GroupedListHeadingLevel,
241
+ GroupedListItem,
242
+ GroupedListProps,
243
+ GroupedListSize,
244
+ } from "./GroupedList/GroupedList.tsx"
245
+ export { GroupedList } from "./GroupedList/GroupedList.tsx"
246
+ export {
247
+ GROUPED_LIST_HEADING_OFFSET_PROPERTY,
248
+ GROUPED_LIST_HEADING_SURFACE_PROPERTY,
249
+ } from "./GroupedList/groupedListStickyHeading.ts"
238
250
  export type { HeaderProps } from "./Header/Header.tsx"
239
251
  export { Header } from "./Header/Header.tsx"
240
252
  export type { IconButtonProps } from "./IconButton/IconButton.tsx"
@@ -623,7 +623,10 @@ test("the barrel is the only place components are re-exported", async () => {
623
623
  // TableViewport owns readable comparisons and full-window expansion.
624
624
  // MetricCard shares prominent values and category-coloured icons through Card.
625
625
  // ComparisonChart and TableColumns share compact comparisons and column controls.
626
- expect(componentNames.length).toBe(77)
626
+ // GroupedList owns rows under group headings; castkit, tally-marks
627
+ // and whereabouts each drew it by hand. `GroupedListRow.tsx` is its
628
+ // member and stays out of this count by the `<Name>/<Name>.tsx` rule.
629
+ expect(componentNames.length).toBe(78)
627
630
 
628
631
  for (const name of componentNames) {
629
632
  expect(barrel).toContain(`export { ${name} }`)
@@ -1,114 +0,0 @@
1
- import {
2
- composeStories,
3
- composeStory,
4
- } from "@storybook/react"
5
- import { expect, fn, userEvent } from "storybook/test"
6
- import { test } from "vitest"
7
-
8
- import { mountStory } from "../mountStory.testHelpers.ts"
9
- import { expectAgentDrivable } from "../testing/index.ts"
10
- import meta, * as stories from "./Button.stories.tsx"
11
-
12
- const { AllStates, Playground, Loading } =
13
- composeStories(stories)
14
-
15
- test("is drivable by role and name", async () => {
16
- const { canvas } = await mountStory(Playground)
17
-
18
- expectAgentDrivable(canvas, {
19
- name: "Start rip",
20
- role: "button",
21
- })
22
- })
23
-
24
- /**
25
- * The keyboard contract, asserted rather than assumed: Tab reaches
26
- * it, Enter activates, Space activates. Every hand-rolled
27
- * `<div onClick>` in the fleet fails all three.
28
- */
29
- test("Tab reaches it, and Enter and Space both activate", async () => {
30
- const onClick = fn()
31
-
32
- const { canvas } = await mountStory(
33
- composeStory(
34
- { args: { children: "Start rip", onClick } },
35
- meta,
36
- ),
37
- )
38
-
39
- const button = expectAgentDrivable(canvas, {
40
- name: "Start rip",
41
- role: "button",
42
- })
43
-
44
- await userEvent.tab()
45
-
46
- await expect(button).toHaveFocus()
47
-
48
- await userEvent.keyboard("{Enter}")
49
-
50
- await expect(onClick).toHaveBeenCalledTimes(1)
51
-
52
- await userEvent.keyboard(" ")
53
-
54
- await expect(onClick).toHaveBeenCalledTimes(2)
55
- })
56
-
57
- test("a disabled button is skipped by Tab and ignores a click", async () => {
58
- const onClick = fn()
59
-
60
- const { canvas } = await mountStory(
61
- composeStory(
62
- {
63
- args: {
64
- children: "Start rip",
65
- isDisabled: true,
66
- onClick,
67
- },
68
- },
69
- meta,
70
- ),
71
- )
72
-
73
- const button = canvas.getByRole("button", {
74
- name: "Start rip",
75
- })
76
-
77
- await userEvent.click(button)
78
-
79
- // A `<div role="button">` with a guard clause looks the same and
80
- // is not the same: a real `disabled` is skipped by Tab, ignored
81
- // by click, and reported to AT.
82
- await expect(onClick).not.toHaveBeenCalled()
83
- await expect(button).toBeDisabled()
84
-
85
- await userEvent.tab()
86
-
87
- await expect(button).not.toHaveFocus()
88
- })
89
-
90
- test("loading disables the button and announces the work", async () => {
91
- const { canvas } = await mountStory(Loading)
92
-
93
- const button = expectAgentDrivable(canvas, {
94
- name: /Ripping disc 3/,
95
- role: "button",
96
- })
97
-
98
- await expect(button).toBeDisabled()
99
- await expect(button).toHaveAttribute("aria-busy", "true")
100
-
101
- // The spinner's label lives in a `role="status"`, so a screen
102
- // reader hears the work start rather than only seeing it.
103
- await expect(
104
- canvas.getByRole("status"),
105
- ).toBeInTheDocument()
106
- })
107
-
108
- test("the states board really renders a busy button", async () => {
109
- const { canvas } = await mountStory(AllStates)
110
-
111
- await expect(
112
- canvas.getByRole("button", { name: /loading/i }),
113
- ).toHaveAttribute("aria-busy", "true")
114
- })