@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.
- package/README.md +13 -0
- package/dist/GroupedList/GroupedList.d.ts +130 -0
- package/dist/GroupedList/GroupedList.d.ts.map +1 -0
- package/dist/GroupedList/GroupedList.js +71 -0
- package/dist/GroupedList/GroupedList.js.map +1 -0
- package/dist/GroupedList/GroupedListRow.d.ts +29 -0
- package/dist/GroupedList/GroupedListRow.d.ts.map +1 -0
- package/dist/GroupedList/GroupedListRow.js +38 -0
- package/dist/GroupedList/GroupedListRow.js.map +1 -0
- package/dist/GroupedList/groupedListStickyHeading.d.ts +20 -0
- package/dist/GroupedList/groupedListStickyHeading.d.ts.map +1 -0
- package/dist/GroupedList/groupedListStickyHeading.js +20 -0
- package/dist/GroupedList/groupedListStickyHeading.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/GroupedList/GroupedList.mdx +95 -0
- package/src/GroupedList/GroupedList.stories.tsx +562 -0
- package/src/GroupedList/GroupedList.test.tsx +390 -0
- package/src/GroupedList/GroupedList.tsx +235 -0
- package/src/GroupedList/GroupedListRow.tsx +117 -0
- package/src/GroupedList/groupedListStickyHeading.ts +24 -0
- package/src/index.ts +12 -0
- package/src/sourceRules.test.ts +4 -1
- package/src/Button/Button.test.tsx +0 -114
|
@@ -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"
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
})
|