@charcuterie/ui 4.24.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.
@@ -1,4 +1,5 @@
1
1
  import { useUniqueId } from "@charcuterie/logic"
2
+ import type { IntentName } from "@charcuterie/tokens"
2
3
  import type { ReactNode } from "react"
3
4
 
4
5
  import { toClassName } from "../toClassName.ts"
@@ -18,24 +19,86 @@ export type GroupedListItem = {
18
19
  * **Name a repeated action after its row.** Ten rows of "Open" are
19
20
  * ten buttons an agent cannot tell apart; `aria-label="Open Lobby"`
20
21
  * on each is one `getByRole` away.
22
+ *
23
+ * In a row with `onSelect` these stay their own targets: they sit
24
+ * above the whole-row button, so pressing one never selects the
25
+ * row.
21
26
  */
22
27
  actions?: ReactNode
28
+ /**
29
+ * A figure that belongs beside the text rather than under it — a
30
+ * payout, a total, a schedule. It sits between the text and the
31
+ * actions in a wide list and drops under the text in a narrow one,
32
+ * the same content-decided wrap the actions use: it asks for at
33
+ * least 11rem beside the text column's 12rem. Never interactive:
34
+ * in a row with `onSelect` the whole-row button sits over it.
35
+ */
36
+ aside?: ReactNode
23
37
  /** `Badge`s, drawn after the label and wrapping onto their own line when they run out of room. */
24
38
  badges?: ReactNode
25
39
  /**
26
40
  * Anything richer than one line of help — a payout, a rule list.
27
41
  * Drawn under the description, inside the text column, and never
28
- * truncated.
42
+ * truncated. Never interactive in a row with `onSelect`; a control
43
+ * belongs in `actions`.
29
44
  */
30
45
  content?: ReactNode
31
46
  /** One muted line under the label: a path, a key, a count. Truncated like the label. */
32
47
  description?: string
48
+ /**
49
+ * The intent `isHighlighted` rings the row in. `accent` by default;
50
+ * an app whose highlight means "this just paid out" passes
51
+ * `success`.
52
+ */
53
+ highlightIntent?: IntentName
54
+ /**
55
+ * `true` rings the row and pulses the ring twice — "this one just
56
+ * changed", for a row a scan, a save or an arrival has touched.
57
+ * The ring stays while the flag does, so the app decides how long
58
+ * it lasts, usually by clearing it on a timer.
59
+ *
60
+ * **To pulse a row that is already highlighted again, change its
61
+ * `key`.** A new key is a new row, and a new row starts the pulse
62
+ * from the beginning; `` `${id}-${sequence}` `` is the usual shape.
63
+ *
64
+ * Under `prefers-reduced-motion` and on ePaper the ring does not
65
+ * pulse; it is simply there.
66
+ */
67
+ isHighlighted?: boolean
68
+ /**
69
+ * `true` draws the row receded — a sunken surface and a quieter
70
+ * label — for an entry that exists but is switched off. It stays
71
+ * fully operable and every colour on it still clears AA, which is
72
+ * why it is a surface and a text role rather than an opacity: a
73
+ * faded row is not a disabled one, and only a disabled control may
74
+ * drop below the contrast floor. Say WHY in a badge too ("off").
75
+ */
76
+ isMuted?: boolean
33
77
  key: string
34
78
  /**
35
79
  * Plain text, because it is what the row is called — the thing a
36
80
  * search matched and the text a clipped row's tooltip repeats.
37
81
  */
38
82
  label: string
83
+ /**
84
+ * Makes the whole row one native `<button>` — "open this one" for a
85
+ * row whose primary action is the row itself. Hover tints the row
86
+ * and rings its edge, a press sinks it (with motion only when the
87
+ * user has not asked for less), and keyboard focus draws the focus
88
+ * ring inside the row's edge. `actions` stay separate targets
89
+ * above it.
90
+ *
91
+ * The button is drawn UNDER the row's text and the text lets the
92
+ * pointer through, so nothing interactive is ever nested inside a
93
+ * button: keep `badges`, `content` and `aside` free of controls.
94
+ */
95
+ onSelect?: () => void
96
+ /**
97
+ * The whole-row button's accessible name. Defaults to `label`.
98
+ * Name the verb when there is one — `"Edit Water the ferns"` —
99
+ * the same rule as a repeated action.
100
+ */
101
+ selectLabel?: string
39
102
  }
40
103
 
41
104
  export type GroupedListGroup = {
@@ -148,6 +211,20 @@ const HEADING_TAG: Record<
148
211
  * know that surface, so `isHeadingSticky` reads it from a custom
149
212
  * property the host can set, falling back to `surface-base`.
150
213
  *
214
+ * ### A row can be the target
215
+ *
216
+ * tally-marks' card list opens a card's editor from anywhere on the
217
+ * card, and the owner asked for that to feel like an app: hover,
218
+ * press, focus, touch. `onSelect` is that, as one real `<button>`
219
+ * stretched under the row's text — not a click handler on an
220
+ * `<li>`, which no keyboard reaches and no screen reader announces.
221
+ * The text lets the pointer through to it; the actions sit above it
222
+ * and keep their own targets, so "Delete" never opens the editor.
223
+ *
224
+ * A row can also be muted (switched off but still there) and
225
+ * highlighted (just touched by a scan or a save) — the two other
226
+ * per-row states the same list drew by hand.
227
+ *
151
228
  * ### Not collapsible
152
229
  *
153
230
  * A group you can close is `Accordion`. This is for a list that is
@@ -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, 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.
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
- <div className="flex min-w-0 flex-[1_1_12rem] flex-col gap-0.5">
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 text-content-primary",
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 ? clippedLabel : undefined
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 className="flex shrink-0 items-center gap-2">
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
  }
package/src/styles.css CHANGED
@@ -1,6 +1,7 @@
1
- /* `@charcuterie/ui/styles.css` — the four looping affordances and
2
- * the token-tinted scrollbar: the visuals a Tailwind utility cannot
3
- * express against our tokens, and nothing else.
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