@charcuterie/ui 4.7.1 → 4.9.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/src/Menu/Menu.mdx CHANGED
@@ -59,6 +59,42 @@ So the rule the other three teach is narrower than it looked:
59
59
  A menu has one, so the requirement moves to the trigger — which `Button` and `IconButton`
60
60
  already enforce.
61
61
 
62
+ ## A context menu is this component with no trigger
63
+
64
+ Press and hold a card on a phone and the menu opens at the finger. That is the same
65
+ component in **anchor mode**: `anchorRef` replaces `trigger`, and the panel hangs off a
66
+ **virtual element** — an object that knows only its own rectangle, which here is the
67
+ zero-sized rect at the point pressed. `flip` and `shift` treat it exactly like a button,
68
+ so a press near the bottom of the window still gets a menu that fits.
69
+
70
+ `useLongPress` in `@charcuterie/logic` is the other half. It watches the pointer, cancels
71
+ on a 10px drift so a scroll that starts on the card is a scroll, and hands back the point.
72
+ Two details in it are not obvious:
73
+
74
+ - **The native `contextmenu` is a second trigger, not only something to suppress.** Chrome
75
+ on Android recognises its own long press at about the same 500ms and fires `pointercancel`
76
+ at the sequence, which clears the timer a beat before it would have run. Whichever arrives
77
+ first fires the callback, once.
78
+ - **Touch and pen only, by default.** A mouse already has a context menu, and the browser's
79
+ own carries "Open link in new tab" and "Copy link address" — taking that over to show four
80
+ app actions is a loss on a desktop and a gain on a phone. Pass `["mouse"]` deliberately,
81
+ for a surface that is not a link.
82
+
83
+ The CSS half belongs to the consumer: `-webkit-touch-callout: none` on the pressed element,
84
+ or iOS shows its link preview sheet over the menu. Safari fires no `contextmenu`, so no
85
+ handler can reach it.
86
+
87
+ **`label` is required here**, and that is the rule above read from the other side. With the
88
+ trigger gone nothing names the panel, so the prop that was inert in trigger mode becomes the
89
+ only name the menu has. The type enforces it: `anchorRef` and `trigger` are mutually
90
+ exclusive, and `label` belongs to exactly one of them.
91
+
92
+ Dismissal needs no special case. floating-ui watches `pointerdown` for an outside press, and
93
+ the press that opened a context menu is over before the panel exists — so it cannot close
94
+ itself on the way in.
95
+
96
+ <Canvas of={MenuStories.ContextMenu} />
97
+
62
98
  ## One kind, and that is the other half of the `Tabs` argument
63
99
 
64
100
  `Tabs` needed **two** state kinds because a tab is chosen *and* focused, and in `manual`
@@ -1,8 +1,12 @@
1
- import { useVisibility } from "@charcuterie/logic"
1
+ import {
2
+ useLongPress,
3
+ useVisibility,
4
+ } from "@charcuterie/logic"
2
5
  import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
3
6
  import type { ControlSize } from "@charcuterie/tokens"
4
7
  import type { Meta, StoryObj } from "@storybook/react"
5
8
  import type { ReactNode } from "react"
9
+ import { useRef } from "react"
6
10
 
7
11
  import {
8
12
  controlSizeArgType,
@@ -18,6 +22,7 @@ import {
18
22
  SettingsIcon,
19
23
  UndoIcon,
20
24
  } from "../icons.storyHelpers.tsx"
25
+ import type { AnchoredOverlayAnchor } from "../Overlay/useAnchoredOverlay.ts"
21
26
  import { Tooltip } from "../Tooltip/Tooltip.tsx"
22
27
  import type { MenuEntry, MenuItem } from "./Menu.tsx"
23
28
  import { Menu } from "./Menu.tsx"
@@ -148,6 +153,65 @@ const TooltipTriggerHarness = ({
148
153
  )
149
154
  }
150
155
 
156
+ /**
157
+ * Anchor mode — the same menu with its trigger taken away, opened
158
+ * by a gesture over a surface and positioned at the point that was
159
+ * pressed.
160
+ *
161
+ * The anchor is a **virtual element**: an object that knows only
162
+ * its own rectangle, which here is the zero-sized rect at the
163
+ * finger. `flip` and `shift` treat it exactly like a real one, so
164
+ * a press near the bottom of the window still gets a menu that
165
+ * fits.
166
+ *
167
+ * `pointerTypes` includes `mouse` here, which the hook's default
168
+ * deliberately does not: this surface is not a link, so there is
169
+ * no browser menu worth keeping, and a story nobody can open on a
170
+ * desktop demonstrates nothing.
171
+ */
172
+ const ContextMenuHarness = ({
173
+ surfaceLabel,
174
+ }: {
175
+ surfaceLabel: string
176
+ }): ReactNode => {
177
+ const { hide, isVisible, show } = useVisibility()
178
+
179
+ const anchorRef = useRef<AnchoredOverlayAnchor | null>(
180
+ null,
181
+ )
182
+
183
+ const longPressHandlers = useLongPress({
184
+ onLongPress: ({ clientX, clientY }) => {
185
+ anchorRef.current = {
186
+ getBoundingClientRect: () =>
187
+ new DOMRect(clientX, clientY, 0, 0),
188
+ }
189
+
190
+ show()
191
+ },
192
+ pointerTypes: ["mouse", "pen", "touch"],
193
+ })
194
+
195
+ return (
196
+ <>
197
+ <div
198
+ {...longPressHandlers}
199
+ className="flex h-32 w-64 select-none items-center justify-center rounded-xl border border-border-default bg-surface-raised text-content-secondary text-sm"
200
+ >
201
+ {surfaceLabel}
202
+ </div>
203
+
204
+ <Menu
205
+ anchorRef={anchorRef}
206
+ isVisible={isVisible}
207
+ items={BAY_ACTIONS}
208
+ label={`Actions for ${surfaceLabel}`}
209
+ onDismiss={hide}
210
+ />
211
+ </>
212
+ )
213
+ }
214
+
151
215
  const meta = {
152
216
  title: "Components/Actions/Menu",
153
217
  component: Menu,
@@ -273,6 +337,40 @@ export const Interactive: Story = {
273
337
  ),
274
338
  }
275
339
 
340
+ /**
341
+ * **A context menu is this component with no trigger.** Press and
342
+ * hold the card — or right-click it, which this story enables on
343
+ * purpose — and the menu opens at the point that was pressed.
344
+ *
345
+ * Two pieces make the gesture. `useLongPress`
346
+ * (`@charcuterie/logic`) watches the pointer and hands back the
347
+ * point; `anchorRef` takes a **virtual element** holding that
348
+ * point, so `flip` and `shift` behave exactly as they do against a
349
+ * button.
350
+ *
351
+ * `label` is **required** in this mode and absent in the other,
352
+ * which is the one thing that changes with the trigger gone:
353
+ * nothing is left to name the panel through `aria-labelledby`.
354
+ *
355
+ * The hold is 500ms and a 10px drift cancels it, so a scroll that
356
+ * starts on the card is a scroll. On Android the browser's own
357
+ * `contextmenu` arrives first and is used as the trigger rather
358
+ * than fought with — see the hook.
359
+ */
360
+ export const ContextMenu: Story = {
361
+ args: {
362
+ isVisible: false,
363
+ onDismiss: noop,
364
+ trigger: <Button appearance="outline">Actions</Button>,
365
+ },
366
+ parameters: { layout: "padded" },
367
+ render: () => (
368
+ <div className="p-16">
369
+ <ContextMenuHarness surfaceLabel="Press and hold me" />
370
+ </div>
371
+ ),
372
+ }
373
+
276
374
  /**
277
375
  * `items` is a union — a `group` (`role="group"`, named by its
278
376
  * label) and a `separator` (`role="separator"`) opt in by their
@@ -16,6 +16,7 @@ import * as stories from "./Menu.stories.tsx"
16
16
  const {
17
17
  AllStates,
18
18
  AllVariants,
19
+ ContextMenu,
19
20
  Empty,
20
21
  Grouped,
21
22
  Interactive,
@@ -551,3 +552,122 @@ test("a long label wraps rather than pushing the panel off screen", async () =>
551
552
  await setViewport(DESKTOP)
552
553
  }
553
554
  })
555
+
556
+ /**
557
+ * Anchor mode, driven with the events a finger produces.
558
+ *
559
+ * `pointerdown` then `contextmenu` is the **Android** sequence —
560
+ * Chrome recognises its own long press and fires `contextmenu`
561
+ * while the finger is still down, which `useLongPress` uses as the
562
+ * trigger rather than fighting. It is also the sequence a test can
563
+ * drive without holding a timer open for half a second.
564
+ */
565
+ const pressAndHold = async (
566
+ element: HTMLElement,
567
+ {
568
+ clientX,
569
+ clientY,
570
+ }: { clientX: number; clientY: number },
571
+ ) => {
572
+ element.dispatchEvent(
573
+ new PointerEvent("pointerdown", {
574
+ bubbles: true,
575
+ clientX,
576
+ clientY,
577
+ pointerType: "touch",
578
+ }),
579
+ )
580
+
581
+ element.dispatchEvent(
582
+ new MouseEvent("contextmenu", {
583
+ bubbles: true,
584
+ cancelable: true,
585
+ clientX,
586
+ clientY,
587
+ }),
588
+ )
589
+ }
590
+
591
+ test("a press and hold opens the menu at the point pressed", async () => {
592
+ const { body, canvas } = await mountStory(ContextMenu)
593
+
594
+ const surface = canvas.getByText("Press and hold me")
595
+
596
+ await pressAndHold(surface, {
597
+ clientX: 220,
598
+ clientY: 180,
599
+ })
600
+
601
+ const menu = await waitFor(() =>
602
+ expectAgentDrivable(body, {
603
+ // Named by its own `label`, because anchor mode has no
604
+ // trigger to borrow a name from. A menu that reaches this
605
+ // assertion through `aria-labelledby` would be pointing at
606
+ // an id that is on no element in the document.
607
+ name: "Actions for Press and hold me",
608
+ role: "menu",
609
+ }),
610
+ )
611
+
612
+ // The virtual anchor is a zero-sized rect at the finger, and the
613
+ // placement is `bottom-start` with a 4px offset — so the panel's
614
+ // own corner lands on the point that was pressed. Anchoring that
615
+ // silently failed would leave it at the top-left of the viewport,
616
+ // which is the failure this measurement exists to catch.
617
+ //
618
+ // `waitFor`, because floating-ui computes the first position in an
619
+ // effect of its own: the panel is in the document a tick before it
620
+ // knows where to be.
621
+ await waitFor(() => {
622
+ const panel = menu.getBoundingClientRect()
623
+
624
+ expect(Math.round(panel.left)).toBe(220)
625
+
626
+ expect(Math.round(panel.top)).toBe(184)
627
+ })
628
+
629
+ await expectNoAxeViolations(menu)
630
+ })
631
+
632
+ test("the press that opened the menu does not also click through", async () => {
633
+ const { body, canvas } = await mountStory(ContextMenu)
634
+
635
+ const surface = canvas.getByText("Press and hold me")
636
+
637
+ await pressAndHold(surface, {
638
+ clientX: 200,
639
+ clientY: 200,
640
+ })
641
+
642
+ await waitFor(() => {
643
+ expect(
644
+ body.queryAllByRole("menuitem").length,
645
+ ).toBeGreaterThan(0)
646
+ })
647
+
648
+ // floating-ui watches `pointerdown` for an outside press, and the
649
+ // press that opened this menu was over before the panel existed —
650
+ // so the menu cannot dismiss itself on the way in. The finger
651
+ // lifting is what would prove it.
652
+ surface.dispatchEvent(
653
+ new PointerEvent("pointerup", {
654
+ bubbles: true,
655
+ clientX: 200,
656
+ clientY: 200,
657
+ pointerType: "touch",
658
+ }),
659
+ )
660
+
661
+ surface.dispatchEvent(
662
+ new MouseEvent("click", {
663
+ bubbles: true,
664
+ cancelable: true,
665
+ }),
666
+ )
667
+
668
+ await waitFor(() => {
669
+ expect(
670
+ body.queryAllByRole("menuitem").length,
671
+ ).toBeGreaterThan(0)
672
+ })
673
+ })
package/src/Menu/Menu.tsx CHANGED
@@ -9,11 +9,16 @@ import {
9
9
  FloatingFocusManager,
10
10
  FloatingPortal,
11
11
  } from "@floating-ui/react"
12
- import type { ReactElement, ReactNode } from "react"
12
+ import type {
13
+ ReactElement,
14
+ ReactNode,
15
+ RefObject,
16
+ } from "react"
13
17
  import { useEffect, useRef } from "react"
14
18
 
15
19
  import { PANEL_ITEM_SIZE_CLASS } from "../controlStyles.ts"
16
20
  import { PANEL_SURFACE_CLASS } from "../Overlay/overlayPanelClass.ts"
21
+ import type { AnchoredOverlayAnchor } from "../Overlay/useAnchoredOverlay.ts"
17
22
  import { useAnchoredOverlay } from "../Overlay/useAnchoredOverlay.ts"
18
23
  import { usePanelItemSize } from "../Overlay/usePanelItemSize.ts"
19
24
  import { toClassName } from "../toClassName.ts"
@@ -64,7 +69,48 @@ export type MenuGroup = {
64
69
  */
65
70
  export type MenuEntry = MenuGroup | MenuItem | MenuSeparator
66
71
 
67
- export type MenuProps = {
72
+ /**
73
+ * The two ways a menu can be summoned, and they are mutually
74
+ * exclusive at the type level rather than by a note nobody reads.
75
+ *
76
+ * **Trigger mode** is the menu button: the control is cloned, it
77
+ * gains `aria-haspopup`/`aria-expanded`, and it **names the
78
+ * panel** through `aria-labelledby` — which is why there is no
79
+ * `label` prop here and why adding one did nothing (see the
80
+ * component's own notes).
81
+ *
82
+ * **Anchor mode** has no trigger at all. It is the context menu:
83
+ * summoned by a gesture over a surface, positioned against an
84
+ * element or a bare point. Nothing names it, so `label` stops
85
+ * being inert and becomes **required** — a `role="menu"` that no
86
+ * `aria-labelledby` reaches is an unnamed menu, and a screen
87
+ * reader announces "menu, 4 items" with no idea what of.
88
+ */
89
+ type MenuAnchorProps = {
90
+ /**
91
+ * What the panel hangs off: the pressed element, or a virtual
92
+ * element holding the point that was pressed
93
+ * (`AnchoredOverlayAnchor`). Read when the menu becomes
94
+ * visible, so the same ref can be re-aimed before each open.
95
+ */
96
+ anchorRef: RefObject<AnchoredOverlayAnchor | null>
97
+ /** Names the panel. Required here; see above. */
98
+ label: string
99
+ trigger?: never
100
+ }
101
+
102
+ type MenuTriggerProps = {
103
+ anchorRef?: never
104
+ /**
105
+ * Absent on purpose. The trigger names the panel, and a `label`
106
+ * here would be silently discarded by `aria-labelledby`.
107
+ */
108
+ label?: never
109
+ /** The control the menu hangs off. **Cloned, not wrapped.** */
110
+ trigger: ReactElement
111
+ }
112
+
113
+ type MenuBaseProps = {
68
114
  className?: string
69
115
  /**
70
116
  * Shown when there is no item to show — a `group` with no members
@@ -92,10 +138,11 @@ export type MenuProps = {
92
138
  /** Outside press, Escape, and choosing an item all land here. */
93
139
  onDismiss: () => void
94
140
  placement?: Placement
95
- /** The control the menu hangs off. **Cloned, not wrapped.** */
96
- trigger: ReactElement
97
141
  }
98
142
 
143
+ export type MenuProps = MenuBaseProps &
144
+ (MenuAnchorProps | MenuTriggerProps)
145
+
99
146
  /**
100
147
  * A group heading is not a row — it has no height of its own to take
101
148
  * — so it borrows only the inline padding, which is the one thing it
@@ -188,6 +235,27 @@ const hasAnyItem = (items: MenuEntry[]): boolean =>
188
235
  * note has to be one, `aria-disabled` and out of the roving group:
189
236
  * announced as "No actions available, dimmed", focusable by nothing.
190
237
  *
238
+ * ### Anchor mode: the same menu, summoned by a gesture
239
+ *
240
+ * A context menu is this component with its trigger taken away.
241
+ * `anchorRef` replaces it — pointing at the pressed element, or at
242
+ * a **virtual** element that is only a rectangle, which is how the
243
+ * panel opens at the finger rather than at the corner of the card
244
+ * underneath. `useLongPress` in `@charcuterie/logic` is the other
245
+ * half; it hands back the point.
246
+ *
247
+ * One thing changes with the trigger gone, and it is the a11y
248
+ * half: nothing names the panel any more, so `label` becomes
249
+ * **required** in this mode and inert in the other. That is the
250
+ * narrowed M3/M4 rule read the other way round — an overlay with
251
+ * no trigger relationship needs its own name, and this is a menu
252
+ * that has none.
253
+ *
254
+ * Dismissal needs no special case. floating-ui's outside-press
255
+ * watches `pointerdown`, and the press that opened a context menu
256
+ * is over before the panel exists, so it cannot close itself on
257
+ * the way in.
258
+ *
191
259
  * ### No type-ahead, deliberately
192
260
  *
193
261
  * The APG lists type-ahead as optional for a menu and required for a
@@ -197,11 +265,13 @@ const hasAnyItem = (items: MenuEntry[]): boolean =>
197
265
  * a menu you can cross in three arrow presses.
198
266
  */
199
267
  export const Menu = ({
268
+ anchorRef,
200
269
  className,
201
270
  emptyState,
202
271
  isVisible,
203
272
  items,
204
273
  itemSize: requestedItemSize = "lg",
274
+ label,
205
275
  onDismiss,
206
276
  placement = "bottom-start",
207
277
  trigger,
@@ -227,7 +297,9 @@ export const Menu = ({
227
297
  floatingStyles,
228
298
  getFloatingProps,
229
299
  setFloating,
300
+ triggerId,
230
301
  } = useAnchoredOverlay({
302
+ anchorRef,
231
303
  // A menu had no clamp at all, which was survivable while a row was
232
304
  // 32px and is not now: nine `lg` rows are 400px, and on a short
233
305
  // window the panel simply ran off the bottom of the screen with no
@@ -387,6 +459,19 @@ export const Menu = ({
387
459
  >
388
460
  <div
389
461
  {...getFloatingProps()}
462
+ // Anchor mode has no trigger, so `useRole`'s
463
+ // `aria-labelledby` points at an id that is on no
464
+ // element in the document — a dangling reference,
465
+ // which is an **unnamed** menu rather than a
466
+ // wrongly named one. The `label` this mode
467
+ // requires takes over, and the dangling attribute
468
+ // is dropped rather than left beside it: an
469
+ // `aria-labelledby` that resolves to nothing still
470
+ // beats `aria-label` in some engines.
471
+ aria-label={anchorRef ? label : undefined}
472
+ aria-labelledby={
473
+ anchorRef ? undefined : triggerId
474
+ }
390
475
  className={toClassName(
391
476
  PANEL_SURFACE_CLASS,
392
477
  // The height is clamped to the space the viewport left,
@@ -2,7 +2,10 @@ import {
2
2
  useClonedChild,
3
3
  useUniqueId,
4
4
  } from "@charcuterie/logic"
5
- import type { Placement } from "@floating-ui/react"
5
+ import type {
6
+ Placement,
7
+ VirtualElement,
8
+ } from "@floating-ui/react"
6
9
  import {
7
10
  autoUpdate,
8
11
  flip,
@@ -15,7 +18,7 @@ import {
15
18
  useRole,
16
19
  } from "@floating-ui/react"
17
20
  import type { ReactElement, RefObject } from "react"
18
- import { createElement, useEffect } from "react"
21
+ import { createElement, useLayoutEffect } from "react"
19
22
 
20
23
  /**
21
24
  * The floating-ui block every anchored overlay — `Popover`, `Menu`,
@@ -67,6 +70,21 @@ export type AnchoredOverlayRole =
67
70
  | "listbox"
68
71
  | "menu"
69
72
 
73
+ /**
74
+ * What a panel can hang off in anchor mode: a real element, or a
75
+ * **virtual** one — an object that only knows its own rectangle.
76
+ *
77
+ * The virtual half is what a context menu needs. A menu opened by
78
+ * a press-and-hold belongs at the finger, not at the corner of the
79
+ * card underneath it, and floating-ui positions against a rect
80
+ * without caring where the rect came from. `flip` and `shift` work
81
+ * unchanged, which is the whole reason to express a point this way
82
+ * rather than with `top`/`left` of our own.
83
+ */
84
+ export type AnchoredOverlayAnchor =
85
+ | HTMLElement
86
+ | VirtualElement
87
+
70
88
  export type UseAnchoredOverlayOptions = {
71
89
  /**
72
90
  * Anchor the panel to a **pre-existing** element the consumer already
@@ -75,8 +93,11 @@ export type UseAnchoredOverlayOptions = {
75
93
  * value and the query) — there is nothing to clone. Supply exactly one
76
94
  * of `anchorRef` / `trigger`; with `anchorRef`, `clonedTrigger` is
77
95
  * `null`.
96
+ *
97
+ * A **virtual** anchor is allowed here too, which is how a context
98
+ * menu opens at the point that was pressed.
78
99
  */
79
- anchorRef?: RefObject<HTMLElement | null>
100
+ anchorRef?: RefObject<AnchoredOverlayAnchor | null>
80
101
  /**
81
102
  * Both default **on** — Escape and outside-press are what makes an
82
103
  * overlay an overlay. Switch one off for the honest exception (a
@@ -289,17 +310,39 @@ export const useAnchoredOverlay = ({
289
310
  (referenceProps.id as string | undefined) ??
290
311
  generatedTriggerId
291
312
 
292
- // In anchor mode floating-ui's reference is the consumer's element, set
293
- // imperatively — there is no cloned trigger to carry `refs.setReference`.
294
- // `setReference` is stable; re-running on `isVisible` re-applies it when
295
- // the panel opens (and after the anchor element mounts).
296
- useEffect(() => {
297
- if (!isAnchored) {
313
+ /**
314
+ * In anchor mode floating-ui's reference is the consumer's own
315
+ * element, set imperatively — there is no cloned trigger to carry
316
+ * `refs.setReference`.
317
+ *
318
+ * ⚠️ **`isVisible` is a dependency, and leaving it out is a bug
319
+ * that hides.** A `Combobox`'s anchor is mounted before the panel
320
+ * ever opens, so reading the ref once was enough for the one
321
+ * consumer this had; a **context menu** aims its anchor at the
322
+ * point that was just pressed, milliseconds before it opens, and a
323
+ * read that only happened on mount saw `null`. floating-ui with no
324
+ * reference positions at the top-left corner of the viewport —
325
+ * which is the same picture as "the panel is not anchored", and
326
+ * says nothing about why.
327
+ *
328
+ * ⚠️ And it is a **layout** effect. An ordinary effect sets the
329
+ * reference after the browser has already painted the panel at
330
+ * `0, 0`, so the menu flashes in the corner on its way to the
331
+ * finger.
332
+ *
333
+ * ⚠️ The `isVisible` guard is what **keeps** the dependency.
334
+ * Written as a bare re-read it is a dependency the effect never
335
+ * mentions, and `exhaustive-deps --fix` deletes it — which is
336
+ * how the bug came back once, silently, between a green test and
337
+ * a lint pass.
338
+ */
339
+ useLayoutEffect(() => {
340
+ if (!isAnchored || !isVisible) {
298
341
  return
299
342
  }
300
343
 
301
344
  refs.setReference(anchorRef.current)
302
- }, [isAnchored, anchorRef, refs])
345
+ }, [isAnchored, anchorRef, isVisible, refs])
303
346
 
304
347
  const clonedChild = useClonedChild(
305
348
  trigger ?? PLACEHOLDER_TRIGGER,
@@ -35,6 +35,8 @@ export type ProgressBarProps = Omit<
35
35
  /** Required. A progressbar with no name is a grey rectangle. */
36
36
  label: string
37
37
  max?: number
38
+ /** Content above a full-height, muted track. It stays outside the ARIA widget. */
39
+ overlay?: ReactNode
38
40
  size?: ProgressBarSize
39
41
  thresholds?: readonly ProgressThreshold[]
40
42
  value?: number
@@ -80,6 +82,7 @@ export const ProgressBar = ({
80
82
  isValueShown = false,
81
83
  label,
82
84
  max = 100,
85
+ overlay,
83
86
  size = "md",
84
87
  thresholds,
85
88
  value = 0,
@@ -133,38 +136,59 @@ export const ProgressBar = ({
133
136
  )}
134
137
 
135
138
  <div
136
- aria-busy={isIndeterminate || undefined}
137
- aria-labelledby={labelId}
138
- aria-valuemax={max}
139
- aria-valuemin={0}
140
- aria-valuenow={
141
- isIndeterminate ? undefined : clampedValue
139
+ className={
140
+ overlay == null
141
+ ? undefined
142
+ : "grid overflow-hidden rounded-xl"
142
143
  }
143
- className={toClassName(
144
- "relative w-full overflow-hidden rounded-full bg-surface-sunken",
145
- TRACK_SIZE_CLASS[size],
146
- )}
147
- role="progressbar"
148
144
  >
149
- {isIndeterminate ? (
150
- <span
151
- className={toClassName(
152
- // The sweep animates `inset-inline-start`, so it runs
153
- // the other way in RTL for free — and its
154
- // reduced-motion fallback hatches the whole track
155
- // rather than parking at one end.
156
- "charcuterie-sweep absolute inset-y-0 w-2/5 rounded-full",
157
- INTENT_SOLID_FILL_CLASS[fillIntent],
158
- )}
159
- />
160
- ) : (
161
- <span
162
- className={toClassName(
163
- "block h-full rounded-full transition-[inline-size] duration-(--duration-normal) ease-standard",
164
- INTENT_SOLID_FILL_CLASS[fillIntent],
165
- )}
166
- style={{ inlineSize: `${percent}%` }}
167
- />
145
+ <div
146
+ aria-busy={isIndeterminate || undefined}
147
+ aria-labelledby={labelId}
148
+ aria-valuemax={max}
149
+ aria-valuemin={0}
150
+ aria-valuenow={
151
+ isIndeterminate ? undefined : clampedValue
152
+ }
153
+ className={toClassName(
154
+ "relative w-full overflow-hidden rounded-full bg-surface-sunken",
155
+ overlay == null
156
+ ? TRACK_SIZE_CLASS[size]
157
+ : "h-full rounded-xl [grid-area:1/1]",
158
+ )}
159
+ role="progressbar"
160
+ >
161
+ {isIndeterminate ? (
162
+ <span
163
+ className={toClassName(
164
+ // The sweep animates `inset-inline-start`, so it runs
165
+ // the other way in RTL for free — and its
166
+ // reduced-motion fallback hatches the whole track
167
+ // rather than parking at one end.
168
+ "charcuterie-sweep absolute inset-y-0 w-2/5 rounded-full",
169
+ INTENT_SOLID_FILL_CLASS[fillIntent],
170
+ overlay == null
171
+ ? undefined
172
+ : "rounded-none opacity-30",
173
+ )}
174
+ />
175
+ ) : (
176
+ <span
177
+ className={toClassName(
178
+ "block h-full rounded-full transition-[inline-size] duration-(--duration-normal) ease-standard",
179
+ INTENT_SOLID_FILL_CLASS[fillIntent],
180
+ overlay == null
181
+ ? undefined
182
+ : "rounded-none opacity-30",
183
+ )}
184
+ style={{ inlineSize: `${percent}%` }}
185
+ />
186
+ )}
187
+ </div>
188
+ {overlay == null ? null : (
189
+ <div className="relative min-w-0 [grid-area:1/1]">
190
+ {overlay}
191
+ </div>
168
192
  )}
169
193
  </div>
170
194
  </div>