@charcuterie/ui 4.23.0 → 4.25.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/GroupedList/GroupedList.d.ts +104 -2
- package/dist/GroupedList/GroupedList.d.ts.map +1 -1
- package/dist/GroupedList/GroupedList.js +24 -2
- package/dist/GroupedList/GroupedList.js.map +1 -1
- package/dist/GroupedList/GroupedListRow.d.ts +17 -6
- package/dist/GroupedList/GroupedListRow.d.ts.map +1 -1
- package/dist/GroupedList/GroupedListRow.js +75 -8
- package/dist/GroupedList/GroupedListRow.js.map +1 -1
- 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 +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/styles.css +36 -4
- package/package.json +1 -1
- package/src/GroupedList/GroupedList.mdx +70 -1
- package/src/GroupedList/GroupedList.stories.tsx +339 -1
- package/src/GroupedList/GroupedList.test.tsx +510 -0
- package/src/GroupedList/GroupedList.tsx +115 -3
- package/src/GroupedList/GroupedListRow.tsx +135 -10
- package/src/GroupedList/groupedListStickyHeading.ts +24 -0
- package/src/index.ts +4 -0
- package/src/styles.css +36 -4
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { IntentName } from "@charcuterie/tokens"
|
|
1
2
|
import type { ReactNode } from "react"
|
|
2
3
|
|
|
3
4
|
import { useIsTextClipped } from "../Badge/useIsTextClipped.ts"
|
|
@@ -18,6 +19,50 @@ const ROW_SIZE_CLASS: Record<GroupedListSize, string> = {
|
|
|
18
19
|
sm: "px-3 py-2",
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Written out per intent, because Tailwind scans source text for
|
|
24
|
+
* whole class strings: `inset-ring-intent-${intent}-content`
|
|
25
|
+
* generates nothing. `-content` rather than `-border`, because a
|
|
26
|
+
* ring that announces "this just changed" has to clear the 3:1
|
|
27
|
+
* non-text contrast floor against the row, and a border role is
|
|
28
|
+
* tuned to sit quietly under it.
|
|
29
|
+
*/
|
|
30
|
+
const HIGHLIGHT_RING_CLASS: Record<IntentName, string> = {
|
|
31
|
+
accent: "inset-ring-intent-accent-content",
|
|
32
|
+
danger: "inset-ring-intent-danger-content",
|
|
33
|
+
info: "inset-ring-intent-info-content",
|
|
34
|
+
neutral: "inset-ring-intent-neutral-content",
|
|
35
|
+
success: "inset-ring-intent-success-content",
|
|
36
|
+
warning: "inset-ring-intent-warning-content",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The whole-row button. It is the bottom layer of the row: the text
|
|
41
|
+
* columns above it are `pointer-events-none`, so a click on the label
|
|
42
|
+
* lands here, and the actions above it keep their own targets.
|
|
43
|
+
*
|
|
44
|
+
* Every state is a SURFACE plus an edge, never colour alone. Hover is
|
|
45
|
+
* scoped by Tailwind's own `hover:` to devices that hover, so a tap on
|
|
46
|
+
* a phone does not leave a row lit. The focus ring is drawn INSIDE the
|
|
47
|
+
* row's edge (a negative offset) because the group's list clips its
|
|
48
|
+
* rounded corners, and an outside ring would be cut off on the first
|
|
49
|
+
* and last row. It sits one ring-width in from the edge, so it never
|
|
50
|
+
* shares pixels with the hover edge or the highlight ring, which both
|
|
51
|
+
* hug the edge — a highlighted row that has focus shows both.
|
|
52
|
+
*/
|
|
53
|
+
const SELECT_BUTTON_CLASS =
|
|
54
|
+
"absolute inset-0 cursor-pointer appearance-none border-0 bg-transparent p-0 transition-[background-color,box-shadow] duration-(--duration-fast) ease-standard hover:bg-intent-neutral-surface-hover hover:inset-ring hover:inset-ring-intent-accent-border active:bg-intent-neutral-surface-hover focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-[calc(var(--focus-ring-width)*-2)] focus-visible:outline-focus-ring"
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The press. The row sinks a little while its button is held — the
|
|
58
|
+
* motion the owner asked for on tally-marks' cards — and only when the
|
|
59
|
+
* user has not asked the OS for less; the surface change on the button
|
|
60
|
+
* carries the press without it. Keyed on the row's OWN button, so
|
|
61
|
+
* holding an action does not press the row.
|
|
62
|
+
*/
|
|
63
|
+
const SELECTABLE_ROW_CLASS =
|
|
64
|
+
"relative transition-transform duration-(--duration-fast) ease-standard motion-safe:has-[>button:active]:scale-[0.99]"
|
|
65
|
+
|
|
21
66
|
/**
|
|
22
67
|
* Its own file because each row measures its own text, and a hook
|
|
23
68
|
* cannot run in a loop.
|
|
@@ -27,16 +72,27 @@ const ROW_SIZE_CLASS: Record<GroupedListSize, string> = {
|
|
|
27
72
|
* `useIsTextClipped` is `Badge`'s: an ellipsis leaves no trace in the
|
|
28
73
|
* DOM, so the only way to know a label is cut off is to measure it.
|
|
29
74
|
* A `title` on every row would put a tooltip on forty short names to
|
|
30
|
-
* help the one long one.
|
|
75
|
+
* help the one long one. In a selectable row the text lets the
|
|
76
|
+
* pointer through, so the clipped label's tooltip moves to the button
|
|
77
|
+
* the pointer is actually over.
|
|
31
78
|
*
|
|
32
79
|
* ### The actions wrap; they never squeeze the text
|
|
33
80
|
*
|
|
34
81
|
* The row is a wrapping flex line. The text column asks for 12rem
|
|
35
|
-
* and grows into the rest,
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* decided by the content
|
|
82
|
+
* and grows into the rest, and the aside asks for at least 11rem, so
|
|
83
|
+
* in a wide list the aside and the actions sit at the inline end of
|
|
84
|
+
* the same line, and in a list narrower than all of them they drop
|
|
85
|
+
* under the text instead of crushing the label to an ellipsis and
|
|
86
|
+
* nothing. No container query: the wrap is decided by the content
|
|
87
|
+
* itself.
|
|
88
|
+
*
|
|
89
|
+
* ### The layers of a selectable row
|
|
90
|
+
*
|
|
91
|
+
* In tree order, and every one of them positioned, so they paint in
|
|
92
|
+
* that order with no `z-index` to keep in step: the button (bottom),
|
|
93
|
+
* the text and the aside (pointer passes through), the actions
|
|
94
|
+
* (their own targets), and the highlight ring (top, pointer passes
|
|
95
|
+
* through) — the ring is last so a hovered row cannot paint over it.
|
|
40
96
|
*/
|
|
41
97
|
export const GroupedListRow = ({
|
|
42
98
|
isTextTruncated,
|
|
@@ -52,24 +108,62 @@ export const GroupedListRow = ({
|
|
|
52
108
|
? "truncate"
|
|
53
109
|
: "wrap-anywhere"
|
|
54
110
|
|
|
111
|
+
const {
|
|
112
|
+
highlightIntent = "accent",
|
|
113
|
+
isHighlighted = false,
|
|
114
|
+
isMuted = false,
|
|
115
|
+
onSelect,
|
|
116
|
+
} = item
|
|
117
|
+
|
|
118
|
+
const isSelectable = onSelect !== undefined
|
|
119
|
+
|
|
120
|
+
// A layer that sits above the button and must let the pointer
|
|
121
|
+
// through to it. Without a button it is an ordinary column.
|
|
122
|
+
const passThroughClass = isSelectable
|
|
123
|
+
? "pointer-events-none relative"
|
|
124
|
+
: null
|
|
125
|
+
|
|
55
126
|
return (
|
|
56
127
|
<li
|
|
57
128
|
className={toClassName(
|
|
58
129
|
"flex flex-wrap items-center gap-x-3 gap-y-2",
|
|
59
130
|
ROW_SIZE_CLASS[size],
|
|
131
|
+
isSelectable && SELECTABLE_ROW_CLASS,
|
|
132
|
+
isHighlighted && "relative",
|
|
133
|
+
isMuted && "bg-surface-sunken",
|
|
60
134
|
)}
|
|
61
135
|
>
|
|
62
|
-
|
|
136
|
+
{isSelectable ? (
|
|
137
|
+
<button
|
|
138
|
+
aria-label={item.selectLabel ?? item.label}
|
|
139
|
+
className={SELECT_BUTTON_CLASS}
|
|
140
|
+
onClick={onSelect}
|
|
141
|
+
title={isTextTruncated ? clippedLabel : undefined}
|
|
142
|
+
type="button"
|
|
143
|
+
/>
|
|
144
|
+
) : null}
|
|
145
|
+
|
|
146
|
+
<div
|
|
147
|
+
className={toClassName(
|
|
148
|
+
"flex min-w-0 flex-[1_1_12rem] flex-col gap-0.5",
|
|
149
|
+
passThroughClass,
|
|
150
|
+
)}
|
|
151
|
+
>
|
|
63
152
|
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
|
64
153
|
<span
|
|
65
154
|
className={toClassName(
|
|
66
|
-
"min-w-0 max-w-full font-medium
|
|
155
|
+
"min-w-0 max-w-full font-medium",
|
|
156
|
+
isMuted
|
|
157
|
+
? "text-content-secondary"
|
|
158
|
+
: "text-content-primary",
|
|
67
159
|
size === "sm" ? "text-sm" : "text-md",
|
|
68
160
|
overflowClass,
|
|
69
161
|
)}
|
|
70
162
|
ref={labelRef}
|
|
71
163
|
title={
|
|
72
|
-
isTextTruncated
|
|
164
|
+
isTextTruncated && !isSelectable
|
|
165
|
+
? clippedLabel
|
|
166
|
+
: undefined
|
|
73
167
|
}
|
|
74
168
|
>
|
|
75
169
|
{item.label}
|
|
@@ -107,11 +201,42 @@ export const GroupedListRow = ({
|
|
|
107
201
|
) : null}
|
|
108
202
|
</div>
|
|
109
203
|
|
|
204
|
+
{item.aside ? (
|
|
205
|
+
<div
|
|
206
|
+
className={toClassName(
|
|
207
|
+
// An 11rem floor, so a short figure does not squeeze in
|
|
208
|
+
// beside a phone-width label: it sits beside the text only
|
|
209
|
+
// when there is room for a real column, and drops under
|
|
210
|
+
// it otherwise. `min(…, 100%)` keeps the floor from
|
|
211
|
+
// overflowing a list narrower than the floor itself.
|
|
212
|
+
"flex min-w-[min(11rem,100%)] max-w-full flex-[0_1_auto] wrap-anywhere",
|
|
213
|
+
passThroughClass,
|
|
214
|
+
)}
|
|
215
|
+
>
|
|
216
|
+
{item.aside}
|
|
217
|
+
</div>
|
|
218
|
+
) : null}
|
|
219
|
+
|
|
110
220
|
{item.actions ? (
|
|
111
|
-
<div
|
|
221
|
+
<div
|
|
222
|
+
className={toClassName(
|
|
223
|
+
"flex shrink-0 items-center gap-2",
|
|
224
|
+
isSelectable && "relative",
|
|
225
|
+
)}
|
|
226
|
+
>
|
|
112
227
|
{item.actions}
|
|
113
228
|
</div>
|
|
114
229
|
) : null}
|
|
230
|
+
|
|
231
|
+
{isHighlighted ? (
|
|
232
|
+
<span
|
|
233
|
+
aria-hidden
|
|
234
|
+
className={toClassName(
|
|
235
|
+
"charcuterie-highlight pointer-events-none absolute inset-0 inset-ring-2",
|
|
236
|
+
HIGHLIGHT_RING_CLASS[highlightIntent],
|
|
237
|
+
)}
|
|
238
|
+
/>
|
|
239
|
+
) : null}
|
|
115
240
|
</li>
|
|
116
241
|
)
|
|
117
242
|
}
|
|
@@ -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
|
@@ -243,6 +243,10 @@ export type {
|
|
|
243
243
|
GroupedListSize,
|
|
244
244
|
} from "./GroupedList/GroupedList.tsx"
|
|
245
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"
|
|
246
250
|
export type { HeaderProps } from "./Header/Header.tsx"
|
|
247
251
|
export { Header } from "./Header/Header.tsx"
|
|
248
252
|
export type { IconButtonProps } from "./IconButton/IconButton.tsx"
|
package/src/styles.css
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
/* `@charcuterie/ui/styles.css` — the four looping affordances
|
|
2
|
-
* the token-tinted scrollbar: the
|
|
3
|
-
* express against our tokens, and
|
|
1
|
+
/* `@charcuterie/ui/styles.css` — the four looping affordances, the
|
|
2
|
+
* one-shot highlight pulse and the token-tinted scrollbar: the
|
|
3
|
+
* visuals a Tailwind utility cannot express against our tokens, and
|
|
4
|
+
* nothing else.
|
|
4
5
|
*
|
|
5
6
|
* Every other visual in this package is a Tailwind utility reading
|
|
6
7
|
* a token, which is what makes a scheme/density/variant flip a
|
|
@@ -74,6 +75,36 @@
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
/* The highlight pulse: "this row just changed". Two blinks, then
|
|
79
|
+
* steady. Only OPACITY moves, and the last keyframe is fully shown,
|
|
80
|
+
* so the element's own resting style IS the static fallback — under
|
|
81
|
+
* reduced motion the animation is switched off below and the ring is
|
|
82
|
+
* simply there, and on ePaper a 0ms run ends on the same frame. That
|
|
83
|
+
* is the "every moving affordance owes a static fallback" rule met
|
|
84
|
+
* by construction rather than by a second rule. */
|
|
85
|
+
@keyframes charcuterie-highlight {
|
|
86
|
+
0% {
|
|
87
|
+
opacity: 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
25% {
|
|
91
|
+
opacity: 1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
55% {
|
|
95
|
+
opacity: 0.3;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
100% {
|
|
99
|
+
opacity: 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.charcuterie-highlight {
|
|
104
|
+
animation: charcuterie-highlight var(--duration-loop-slow)
|
|
105
|
+
var(--easing-standard) 2;
|
|
106
|
+
}
|
|
107
|
+
|
|
77
108
|
.charcuterie-spin {
|
|
78
109
|
animation: charcuterie-spin var(--duration-loop-fast)
|
|
79
110
|
linear infinite;
|
|
@@ -105,7 +136,8 @@
|
|
|
105
136
|
.charcuterie-spin,
|
|
106
137
|
.charcuterie-shimmer,
|
|
107
138
|
.charcuterie-sweep,
|
|
108
|
-
.charcuterie-pulse
|
|
139
|
+
.charcuterie-pulse,
|
|
140
|
+
.charcuterie-highlight {
|
|
109
141
|
animation: none;
|
|
110
142
|
}
|
|
111
143
|
|