@charcuterie/ui 0.1.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.
Files changed (223) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +161 -0
  3. package/dist/Alert/Alert.d.ts +88 -0
  4. package/dist/Alert/Alert.d.ts.map +1 -0
  5. package/dist/Alert/Alert.js +67 -0
  6. package/dist/Alert/Alert.js.map +1 -0
  7. package/dist/Badge/Badge.d.ts +54 -0
  8. package/dist/Badge/Badge.d.ts.map +1 -0
  9. package/dist/Badge/Badge.js +54 -0
  10. package/dist/Badge/Badge.js.map +1 -0
  11. package/dist/Badge/useIsTextClipped.d.ts +22 -0
  12. package/dist/Badge/useIsTextClipped.d.ts.map +1 -0
  13. package/dist/Badge/useIsTextClipped.js +57 -0
  14. package/dist/Badge/useIsTextClipped.js.map +1 -0
  15. package/dist/Button/Button.d.ts +58 -0
  16. package/dist/Button/Button.d.ts.map +1 -0
  17. package/dist/Button/Button.js +37 -0
  18. package/dist/Button/Button.js.map +1 -0
  19. package/dist/Card/Card.d.ts +44 -0
  20. package/dist/Card/Card.d.ts.map +1 -0
  21. package/dist/Card/Card.js +59 -0
  22. package/dist/Card/Card.js.map +1 -0
  23. package/dist/EmptyState/EmptyState.d.ts +42 -0
  24. package/dist/EmptyState/EmptyState.d.ts.map +1 -0
  25. package/dist/EmptyState/EmptyState.js +37 -0
  26. package/dist/EmptyState/EmptyState.js.map +1 -0
  27. package/dist/IconButton/IconButton.d.ts +34 -0
  28. package/dist/IconButton/IconButton.d.ts.map +1 -0
  29. package/dist/IconButton/IconButton.js +23 -0
  30. package/dist/IconButton/IconButton.js.map +1 -0
  31. package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts +37 -0
  32. package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts.map +1 -0
  33. package/dist/LiveStatusIndicator/LiveStatusIndicator.js +39 -0
  34. package/dist/LiveStatusIndicator/LiveStatusIndicator.js.map +1 -0
  35. package/dist/MediaTile/MediaTile.d.ts +46 -0
  36. package/dist/MediaTile/MediaTile.d.ts.map +1 -0
  37. package/dist/MediaTile/MediaTile.js +79 -0
  38. package/dist/MediaTile/MediaTile.js.map +1 -0
  39. package/dist/MediaTile/mediaStatus.d.ts +22 -0
  40. package/dist/MediaTile/mediaStatus.d.ts.map +1 -0
  41. package/dist/MediaTile/mediaStatus.js +24 -0
  42. package/dist/MediaTile/mediaStatus.js.map +1 -0
  43. package/dist/Modal/Modal.d.ts +66 -0
  44. package/dist/Modal/Modal.d.ts.map +1 -0
  45. package/dist/Modal/Modal.js +111 -0
  46. package/dist/Modal/Modal.js.map +1 -0
  47. package/dist/Modal/lockScrollBehind.d.ts +22 -0
  48. package/dist/Modal/lockScrollBehind.d.ts.map +1 -0
  49. package/dist/Modal/lockScrollBehind.js +48 -0
  50. package/dist/Modal/lockScrollBehind.js.map +1 -0
  51. package/dist/Popover/Popover.d.ts +66 -0
  52. package/dist/Popover/Popover.d.ts.map +1 -0
  53. package/dist/Popover/Popover.js +88 -0
  54. package/dist/Popover/Popover.js.map +1 -0
  55. package/dist/ProgressBar/ProgressBar.d.ts +51 -0
  56. package/dist/ProgressBar/ProgressBar.d.ts.map +1 -0
  57. package/dist/ProgressBar/ProgressBar.js +50 -0
  58. package/dist/ProgressBar/ProgressBar.js.map +1 -0
  59. package/dist/ProgressBar/progressValue.d.ts +44 -0
  60. package/dist/ProgressBar/progressValue.d.ts.map +1 -0
  61. package/dist/ProgressBar/progressValue.js +55 -0
  62. package/dist/ProgressBar/progressValue.js.map +1 -0
  63. package/dist/SegmentedControl/SegmentedControl.d.ts +59 -0
  64. package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -0
  65. package/dist/SegmentedControl/SegmentedControl.js +129 -0
  66. package/dist/SegmentedControl/SegmentedControl.js.map +1 -0
  67. package/dist/SegmentedControl/SegmentedOption.d.ts +28 -0
  68. package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -0
  69. package/dist/SegmentedControl/SegmentedOption.js +40 -0
  70. package/dist/SegmentedControl/SegmentedOption.js.map +1 -0
  71. package/dist/Skeleton/Skeleton.d.ts +30 -0
  72. package/dist/Skeleton/Skeleton.d.ts.map +1 -0
  73. package/dist/Skeleton/Skeleton.js +43 -0
  74. package/dist/Skeleton/Skeleton.js.map +1 -0
  75. package/dist/Spinner/Spinner.d.ts +37 -0
  76. package/dist/Spinner/Spinner.d.ts.map +1 -0
  77. package/dist/Spinner/Spinner.js +33 -0
  78. package/dist/Spinner/Spinner.js.map +1 -0
  79. package/dist/Tabs/TabTrigger.d.ts +32 -0
  80. package/dist/Tabs/TabTrigger.d.ts.map +1 -0
  81. package/dist/Tabs/TabTrigger.js +62 -0
  82. package/dist/Tabs/TabTrigger.js.map +1 -0
  83. package/dist/Tabs/Tabs.d.ts +95 -0
  84. package/dist/Tabs/Tabs.d.ts.map +1 -0
  85. package/dist/Tabs/Tabs.js +195 -0
  86. package/dist/Tabs/Tabs.js.map +1 -0
  87. package/dist/VisuallyHidden/VisuallyHidden.d.ts +25 -0
  88. package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
  89. package/dist/VisuallyHidden/VisuallyHidden.js +23 -0
  90. package/dist/VisuallyHidden/VisuallyHidden.js.map +1 -0
  91. package/dist/controlStyles.d.ts +50 -0
  92. package/dist/controlStyles.d.ts.map +1 -0
  93. package/dist/controlStyles.js +60 -0
  94. package/dist/controlStyles.js.map +1 -0
  95. package/dist/expectNoAxeViolations.testHelpers.d.ts +19 -0
  96. package/dist/expectNoAxeViolations.testHelpers.d.ts.map +1 -0
  97. package/dist/expectNoAxeViolations.testHelpers.js +32 -0
  98. package/dist/expectNoAxeViolations.testHelpers.js.map +1 -0
  99. package/dist/index.d.ts +75 -0
  100. package/dist/index.d.ts.map +1 -0
  101. package/dist/index.js +55 -0
  102. package/dist/index.js.map +1 -0
  103. package/dist/intentStyles.d.ts +76 -0
  104. package/dist/intentStyles.d.ts.map +1 -0
  105. package/dist/intentStyles.js +150 -0
  106. package/dist/intentStyles.js.map +1 -0
  107. package/dist/mountStory.testHelpers.d.ts +30 -0
  108. package/dist/mountStory.testHelpers.d.ts.map +1 -0
  109. package/dist/mountStory.testHelpers.js +19 -0
  110. package/dist/mountStory.testHelpers.js.map +1 -0
  111. package/dist/statusIntent.d.ts +33 -0
  112. package/dist/statusIntent.d.ts.map +1 -0
  113. package/dist/statusIntent.js +99 -0
  114. package/dist/statusIntent.js.map +1 -0
  115. package/dist/styles.css +130 -0
  116. package/dist/testing/expectAgentDrivable.d.ts +53 -0
  117. package/dist/testing/expectAgentDrivable.d.ts.map +1 -0
  118. package/dist/testing/expectAgentDrivable.js +163 -0
  119. package/dist/testing/expectAgentDrivable.js.map +1 -0
  120. package/dist/testing/index.d.ts +11 -0
  121. package/dist/testing/index.d.ts.map +1 -0
  122. package/dist/testing/index.js +10 -0
  123. package/dist/testing/index.js.map +1 -0
  124. package/dist/toClassName.d.ts +12 -0
  125. package/dist/toClassName.d.ts.map +1 -0
  126. package/dist/toClassName.js +12 -0
  127. package/dist/toClassName.js.map +1 -0
  128. package/dist/tokens.d.ts +16 -0
  129. package/dist/tokens.d.ts.map +1 -0
  130. package/dist/tokens.js +16 -0
  131. package/dist/tokens.js.map +1 -0
  132. package/package.json +70 -0
  133. package/src/Alert/Alert.mdx +70 -0
  134. package/src/Alert/Alert.stories.tsx +218 -0
  135. package/src/Alert/Alert.test.tsx +112 -0
  136. package/src/Alert/Alert.tsx +166 -0
  137. package/src/Badge/Badge.mdx +43 -0
  138. package/src/Badge/Badge.stories.tsx +229 -0
  139. package/src/Badge/Badge.test.tsx +153 -0
  140. package/src/Badge/Badge.tsx +130 -0
  141. package/src/Badge/useIsTextClipped.ts +77 -0
  142. package/src/Button/Button.mdx +44 -0
  143. package/src/Button/Button.stories.tsx +175 -0
  144. package/src/Button/Button.test.tsx +114 -0
  145. package/src/Button/Button.tsx +125 -0
  146. package/src/Card/Card.mdx +53 -0
  147. package/src/Card/Card.stories.tsx +207 -0
  148. package/src/Card/Card.test.tsx +56 -0
  149. package/src/Card/Card.tsx +142 -0
  150. package/src/EmptyState/EmptyState.mdx +40 -0
  151. package/src/EmptyState/EmptyState.stories.tsx +183 -0
  152. package/src/EmptyState/EmptyState.test.tsx +41 -0
  153. package/src/EmptyState/EmptyState.tsx +114 -0
  154. package/src/IconButton/IconButton.mdx +34 -0
  155. package/src/IconButton/IconButton.stories.tsx +191 -0
  156. package/src/IconButton/IconButton.test.tsx +70 -0
  157. package/src/IconButton/IconButton.tsx +54 -0
  158. package/src/LiveStatusIndicator/LiveStatusIndicator.mdx +43 -0
  159. package/src/LiveStatusIndicator/LiveStatusIndicator.stories.tsx +199 -0
  160. package/src/LiveStatusIndicator/LiveStatusIndicator.test.tsx +63 -0
  161. package/src/LiveStatusIndicator/LiveStatusIndicator.tsx +116 -0
  162. package/src/MediaTile/MediaTile.mdx +51 -0
  163. package/src/MediaTile/MediaTile.stories.tsx +211 -0
  164. package/src/MediaTile/MediaTile.test.tsx +41 -0
  165. package/src/MediaTile/MediaTile.tsx +227 -0
  166. package/src/MediaTile/mediaStatus.ts +28 -0
  167. package/src/Modal/Modal.mdx +113 -0
  168. package/src/Modal/Modal.stories.tsx +272 -0
  169. package/src/Modal/Modal.test.tsx +248 -0
  170. package/src/Modal/Modal.tsx +228 -0
  171. package/src/Modal/lockScrollBehind.ts +59 -0
  172. package/src/Popover/Popover.mdx +102 -0
  173. package/src/Popover/Popover.stories.tsx +247 -0
  174. package/src/Popover/Popover.test.tsx +191 -0
  175. package/src/Popover/Popover.tsx +164 -0
  176. package/src/ProgressBar/ProgressBar.mdx +48 -0
  177. package/src/ProgressBar/ProgressBar.stories.tsx +232 -0
  178. package/src/ProgressBar/ProgressBar.test.tsx +74 -0
  179. package/src/ProgressBar/ProgressBar.tsx +172 -0
  180. package/src/ProgressBar/progressValue.test.ts +101 -0
  181. package/src/ProgressBar/progressValue.ts +87 -0
  182. package/src/SegmentedControl/SegmentedControl.mdx +71 -0
  183. package/src/SegmentedControl/SegmentedControl.stories.tsx +150 -0
  184. package/src/SegmentedControl/SegmentedControl.test.tsx +208 -0
  185. package/src/SegmentedControl/SegmentedControl.tsx +219 -0
  186. package/src/SegmentedControl/SegmentedOption.tsx +94 -0
  187. package/src/Skeleton/Skeleton.mdx +30 -0
  188. package/src/Skeleton/Skeleton.stories.tsx +130 -0
  189. package/src/Skeleton/Skeleton.test.tsx +47 -0
  190. package/src/Skeleton/Skeleton.tsx +106 -0
  191. package/src/Spinner/Spinner.mdx +36 -0
  192. package/src/Spinner/Spinner.stories.tsx +118 -0
  193. package/src/Spinner/Spinner.test.tsx +38 -0
  194. package/src/Spinner/Spinner.tsx +92 -0
  195. package/src/Tabs/TabTrigger.tsx +126 -0
  196. package/src/Tabs/Tabs.mdx +126 -0
  197. package/src/Tabs/Tabs.stories.tsx +187 -0
  198. package/src/Tabs/Tabs.test.tsx +283 -0
  199. package/src/Tabs/Tabs.tsx +333 -0
  200. package/src/VisuallyHidden/VisuallyHidden.mdx +29 -0
  201. package/src/VisuallyHidden/VisuallyHidden.stories.tsx +58 -0
  202. package/src/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
  203. package/src/VisuallyHidden/VisuallyHidden.tsx +43 -0
  204. package/src/argTypes.storyHelpers.ts +115 -0
  205. package/src/board.storyHelpers.tsx +150 -0
  206. package/src/controlStyles.ts +79 -0
  207. package/src/expectNoAxeViolations.testHelpers.ts +41 -0
  208. package/src/icons.storyHelpers.tsx +116 -0
  209. package/src/index.ts +132 -0
  210. package/src/intentStyles.ts +207 -0
  211. package/src/mdxReferences.test.ts +95 -0
  212. package/src/mountStory.testHelpers.ts +41 -0
  213. package/src/sourceRules.test.ts +303 -0
  214. package/src/statusIntent.test.ts +136 -0
  215. package/src/statusIntent.ts +137 -0
  216. package/src/storyControls.test.ts +128 -0
  217. package/src/styles.css +130 -0
  218. package/src/tailwindCandidates.test.ts +377 -0
  219. package/src/testing/expectAgentDrivable.test.tsx +71 -0
  220. package/src/testing/expectAgentDrivable.ts +273 -0
  221. package/src/testing/index.ts +17 -0
  222. package/src/toClassName.ts +13 -0
  223. package/src/tokens.ts +16 -0
@@ -0,0 +1,66 @@
1
+ import type { ComponentPropsWithRef, ReactNode } from "react";
2
+ export type ModalSize = "full" | "lg" | "md" | "sm" | "xl";
3
+ export type ModalProps = Omit<ComponentPropsWithRef<"dialog">, "onClose" | "open"> & {
4
+ /** The footer row. Buttons, usually. */
5
+ footer?: ReactNode;
6
+ heading: string;
7
+ headingLevel?: 2 | 3 | 4;
8
+ /**
9
+ * Escape and a backdrop press both close. Off for the one
10
+ * honest case — a destructive confirm the user has to answer —
11
+ * and off means Escape is *cancelled* rather than ignored, so
12
+ * the browser cannot close a dialog the app still owns.
13
+ */
14
+ isDismissable?: boolean;
15
+ isVisible: boolean;
16
+ /**
17
+ * Every route out lands here: the close button, Escape, a
18
+ * backdrop press. Wire it to a `useVisibility()`'s `hide`.
19
+ */
20
+ onClose: () => void;
21
+ size?: ModalSize;
22
+ };
23
+ /**
24
+ * Four repos, and **plex-channels and ripdeck independently
25
+ * converged on native `<dialog>`** — which is the design here
26
+ * rather than a shortcut. `showModal()` gives a focus trap,
27
+ * Escape, `::backdrop`, `inert` on everything behind it, and the
28
+ * **top layer**, from the platform, correctly, in every browser
29
+ * the fleet runs.
30
+ *
31
+ * What is not free — and the reason a component exists at all —
32
+ * is that a native `<dialog>` is a *self-owning* control: call
33
+ * `showModal()` and it decides it is open. That is the same
34
+ * conflict Radix has, and it gets the same answer as everywhere
35
+ * else in this library: **`isVisible` is the truth, and an effect
36
+ * makes the element agree with it.** Nothing here reads
37
+ * `dialogElement.open` as state; it is read only to avoid calling
38
+ * a method that would throw.
39
+ *
40
+ * ### Why it is not portalled
41
+ *
42
+ * The top layer is a *paint* concept: a `<dialog>` renders above
43
+ * everything while staying exactly where it is in the DOM. So it
44
+ * needs no portal — and that matters well past tidiness, because a
45
+ * portal moves the node to `document.body`, out of whatever
46
+ * element a test or an agent scoped its queries to.
47
+ * `canvas.getByRole("dialog")` finds this one. It would not find a
48
+ * portalled one, and the story would have to reach for `screen`
49
+ * and lose its scoping.
50
+ *
51
+ * ### `open:flex`, not `flex`
52
+ *
53
+ * A closed `<dialog>` is hidden by a UA rule that any
54
+ * `display: flex` of ours would beat — so the modal would render
55
+ * permanently, in the page flow, with no error. The layout is
56
+ * therefore behind Tailwind's `open:` variant, which is the
57
+ * `[open]` attribute the browser manages.
58
+ *
59
+ * ### The accessible name
60
+ *
61
+ * `heading` is required and is a string, not a `ReactNode`. A
62
+ * dialog with no name is announced as "dialog" and nothing else,
63
+ * which is what every hand-rolled modal in the inventory does.
64
+ */
65
+ export declare const Modal: ({ children, className, footer, heading, headingLevel, isDismissable, isVisible, onClose, size, ...dialogProps }: ModalProps) => ReactNode;
66
+ //# sourceMappingURL=Modal.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Modal.d.ts","sourceRoot":"","sources":["../../src/Modal/Modal.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAOd,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAA;AAE1D,MAAM,MAAM,UAAU,GAAG,IAAI,CAC3B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,SAAS,GAAG,MAAM,CACnB,GAAG;IACF,wCAAwC;IACxC,MAAM,CAAC,EAAE,SAAS,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf,YAAY,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IACxB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,SAAS,EAAE,OAAO,CAAA;IAClB;;;OAGG;IACH,OAAO,EAAE,MAAM,IAAI,CAAA;IACnB,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB,CAAA;AAqBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,eAAO,MAAM,KAAK,GAAI,iHAWnB,UAAU,KAAG,SAsHf,CAAA"}
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useUniqueId } from "@charcuterie/logic";
3
+ import { useEffect, useRef } from "react";
4
+ import { Button } from "../Button/Button.js";
5
+ import { toClassName } from "../toClassName.js";
6
+ import { lockScrollBehind } from "./lockScrollBehind.js";
7
+ /**
8
+ * `85dvh` rather than `85vh` — on a phone the two differ by the
9
+ * address bar, and a dialog whose footer is under it is a dialog
10
+ * with no buttons.
11
+ */
12
+ const SIZE_CLASS = {
13
+ sm: "max-h-[85dvh] w-full max-w-sm",
14
+ md: "max-h-[85dvh] w-full max-w-md",
15
+ lg: "max-h-[85dvh] w-full max-w-2xl",
16
+ // M5 added this, because the scale jumped from 42rem straight to
17
+ // the whole viewport and rip-deck's capture tail lands in the
18
+ // gap: a MakeMKV robot log is fixed-width `MSG:5072,0,1,"…"`
19
+ // lines that wrap badly at 42rem, and a full-screen dialog over a
20
+ // nine-bay dashboard hides the thing being diagnosed. 56rem is
21
+ // what that app's hand-rolled `<dialog>` used before the swap.
22
+ xl: "max-h-[85dvh] w-full max-w-4xl",
23
+ full: "h-[100dvh] max-h-[100dvh] w-full max-w-full rounded-none",
24
+ };
25
+ /**
26
+ * Four repos, and **plex-channels and ripdeck independently
27
+ * converged on native `<dialog>`** — which is the design here
28
+ * rather than a shortcut. `showModal()` gives a focus trap,
29
+ * Escape, `::backdrop`, `inert` on everything behind it, and the
30
+ * **top layer**, from the platform, correctly, in every browser
31
+ * the fleet runs.
32
+ *
33
+ * What is not free — and the reason a component exists at all —
34
+ * is that a native `<dialog>` is a *self-owning* control: call
35
+ * `showModal()` and it decides it is open. That is the same
36
+ * conflict Radix has, and it gets the same answer as everywhere
37
+ * else in this library: **`isVisible` is the truth, and an effect
38
+ * makes the element agree with it.** Nothing here reads
39
+ * `dialogElement.open` as state; it is read only to avoid calling
40
+ * a method that would throw.
41
+ *
42
+ * ### Why it is not portalled
43
+ *
44
+ * The top layer is a *paint* concept: a `<dialog>` renders above
45
+ * everything while staying exactly where it is in the DOM. So it
46
+ * needs no portal — and that matters well past tidiness, because a
47
+ * portal moves the node to `document.body`, out of whatever
48
+ * element a test or an agent scoped its queries to.
49
+ * `canvas.getByRole("dialog")` finds this one. It would not find a
50
+ * portalled one, and the story would have to reach for `screen`
51
+ * and lose its scoping.
52
+ *
53
+ * ### `open:flex`, not `flex`
54
+ *
55
+ * A closed `<dialog>` is hidden by a UA rule that any
56
+ * `display: flex` of ours would beat — so the modal would render
57
+ * permanently, in the page flow, with no error. The layout is
58
+ * therefore behind Tailwind's `open:` variant, which is the
59
+ * `[open]` attribute the browser manages.
60
+ *
61
+ * ### The accessible name
62
+ *
63
+ * `heading` is required and is a string, not a `ReactNode`. A
64
+ * dialog with no name is announced as "dialog" and nothing else,
65
+ * which is what every hand-rolled modal in the inventory does.
66
+ */
67
+ export const Modal = ({ children, className, footer, heading, headingLevel = 2, isDismissable = true, isVisible, onClose, size = "md", ...dialogProps }) => {
68
+ const dialogRef = useRef(null);
69
+ const headingId = useUniqueId();
70
+ const Heading = `h${headingLevel}`;
71
+ useEffect(() => {
72
+ const dialogElement = dialogRef.current;
73
+ if (!dialogElement) {
74
+ return;
75
+ }
76
+ // Guarded both ways. `showModal()` on an already-open dialog
77
+ // throws `InvalidStateError`, and `close()` on a closed one
78
+ // fires a spurious `close` event — either turns a re-render
79
+ // into a bug.
80
+ if (isVisible && !dialogElement.open) {
81
+ dialogElement.showModal();
82
+ }
83
+ if (!isVisible && dialogElement.open) {
84
+ dialogElement.close();
85
+ }
86
+ }, [isVisible]);
87
+ useEffect(() => (isVisible ? lockScrollBehind() : undefined), [isVisible]);
88
+ return (_jsxs("dialog", { ...dialogProps, "aria-labelledby": headingId, className: toClassName(
89
+ // `m-auto` and `p-0` undo the UA stylesheet, which centres
90
+ // by margin and adds padding of its own.
91
+ "m-auto flex-col overflow-hidden rounded-lg border border-border-default bg-surface-overlay p-0 text-content-primary shadow-high backdrop:bg-scrim open:flex", SIZE_CLASS[size], className), onCancel: (cancelEvent) => {
92
+ // Escape. Cancelled rather than ignored when the dialog is
93
+ // not dismissable: letting the browser close it would
94
+ // leave `isVisible` true and the element shut, which is
95
+ // precisely the desync this component exists to prevent.
96
+ cancelEvent.preventDefault();
97
+ if (isDismissable) {
98
+ onClose();
99
+ }
100
+ }, onClick: (clickEvent) => {
101
+ // The backdrop is not an element — a press on it targets
102
+ // the `<dialog>` itself, while a press on the content
103
+ // targets something inside. Comparing target to
104
+ // currentTarget is the whole test.
105
+ if (isDismissable &&
106
+ clickEvent.target === clickEvent.currentTarget) {
107
+ onClose();
108
+ }
109
+ }, ref: dialogRef, children: [_jsxs("header", { className: "flex shrink-0 items-start justify-between gap-3 border-b border-border-subtle p-4", children: [_jsx(Heading, { className: "font-semibold text-lg leading-tight", id: headingId, children: heading }), isDismissable ? (_jsx(Button, { appearance: "ghost", intent: "neutral", onClick: onClose, size: "sm", children: "Close" })) : null] }), _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4", children: children }), footer ? (_jsx("footer", { className: "flex shrink-0 flex-wrap justify-end gap-2 border-t border-border-subtle p-4", children: footer })) : null] }));
110
+ };
111
+ //# sourceMappingURL=Modal.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Modal.js","sourceRoot":"","sources":["../../src/Modal/Modal.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The one thing `showModal()` does not do.
3
+ *
4
+ * A native modal makes everything behind it `inert` — unclickable,
5
+ * untabbable, invisible to a screen reader — and then lets you
6
+ * scroll it anyway. On a phone that reads as the dialog sliding
7
+ * around; on a long page it loses the user's place entirely.
8
+ *
9
+ * Counted rather than boolean, because nested dialogs are legal
10
+ * (`showModal()` stacks in the top layer) and a naive
11
+ * lock/unlock pair has the inner one's cleanup unlock the page
12
+ * while the outer is still open. The original value is captured
13
+ * on the *first* lock, so restoring is a restore rather than a
14
+ * guess at what an app's own stylesheet wanted.
15
+ *
16
+ * Not a React hook: this is process-wide state, and two components
17
+ * sharing it through module scope is the honest expression of
18
+ * that. `Modal` calls it from an effect and returns the release as
19
+ * the cleanup.
20
+ */
21
+ export declare const lockScrollBehind: () => (() => void);
22
+ //# sourceMappingURL=lockScrollBehind.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lockScrollBehind.d.ts","sourceRoot":"","sources":["../../src/Modal/lockScrollBehind.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAMH,eAAO,MAAM,gBAAgB,QAAO,CAAC,MAAM,IAAI,CAiC9C,CAAA"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The one thing `showModal()` does not do.
3
+ *
4
+ * A native modal makes everything behind it `inert` — unclickable,
5
+ * untabbable, invisible to a screen reader — and then lets you
6
+ * scroll it anyway. On a phone that reads as the dialog sliding
7
+ * around; on a long page it loses the user's place entirely.
8
+ *
9
+ * Counted rather than boolean, because nested dialogs are legal
10
+ * (`showModal()` stacks in the top layer) and a naive
11
+ * lock/unlock pair has the inner one's cleanup unlock the page
12
+ * while the outer is still open. The original value is captured
13
+ * on the *first* lock, so restoring is a restore rather than a
14
+ * guess at what an app's own stylesheet wanted.
15
+ *
16
+ * Not a React hook: this is process-wide state, and two components
17
+ * sharing it through module scope is the honest expression of
18
+ * that. `Modal` calls it from an effect and returns the release as
19
+ * the cleanup.
20
+ */
21
+ let lockCount = 0;
22
+ let previousOverflow = "";
23
+ export const lockScrollBehind = () => {
24
+ if (typeof document === "undefined") {
25
+ return () => { };
26
+ }
27
+ const { documentElement } = document;
28
+ if (lockCount === 0) {
29
+ previousOverflow = documentElement.style.overflow;
30
+ documentElement.style.overflow = "hidden";
31
+ }
32
+ lockCount += 1;
33
+ let isReleased = false;
34
+ return () => {
35
+ // Idempotent, because React 19's StrictMode runs an effect's
36
+ // cleanup twice and a double decrement would unlock the page
37
+ // under a dialog that is still open.
38
+ if (isReleased) {
39
+ return;
40
+ }
41
+ isReleased = true;
42
+ lockCount -= 1;
43
+ if (lockCount === 0) {
44
+ documentElement.style.overflow = previousOverflow;
45
+ }
46
+ };
47
+ };
48
+ //# sourceMappingURL=lockScrollBehind.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lockScrollBehind.js","sourceRoot":"","sources":["../../src/Modal/lockScrollBehind.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,IAAI,SAAS,GAAG,CAAC,CAAA;AAEjB,IAAI,gBAAgB,GAAG,EAAE,CAAA;AAEzB,MAAM,CAAC,MAAM,gBAAgB,GAAG,GAAiB,EAAE;IACjD,IAAI,OAAO,QAAQ,KAAK,WAAW,EAAE,CAAC;QACpC,OAAO,GAAG,EAAE,GAAE,CAAC,CAAA;IACjB,CAAC;IAED,MAAM,EAAE,eAAe,EAAE,GAAG,QAAQ,CAAA;IAEpC,IAAI,SAAS,KAAK,CAAC,EAAE,CAAC;QACpB,gBAAgB,GAAG,eAAe,CAAC,KAAK,CAAC,QAAQ,CAAA;QAEjD,eAAe,CAAC,KAAK,CAAC,QAAQ,GAAG,QAAQ,CAAA;IAC3C,CAAC;IAED,SAAS,IAAI,CAAC,CAAA;IAEd,IAAI,UAAU,GAAG,KAAK,CAAA;IAEtB,OAAO,GAAG,EAAE;QACV,6DAA6D;QAC7D,6DAA6D;QAC7D,qCAAqC;QACrC,IAAI,UAAU,EAAE,CAAC;YACf,OAAM;QACR,CAAC;QAED,UAAU,GAAG,IAAI,CAAA;QAEjB,SAAS,IAAI,CAAC,CAAA;QAEd,IAAI,SAAS,KAAK,CAAC,EAAE,CAAC;YACpB,eAAe,CAAC,KAAK,CAAC,QAAQ,GAAG,gBAAgB,CAAA;QACnD,CAAC;IACH,CAAC,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,66 @@
1
+ import type { Placement } from "@floating-ui/react";
2
+ import type { ReactElement, ReactNode } from "react";
3
+ export type PopoverProps = {
4
+ children: ReactNode;
5
+ className?: string;
6
+ /**
7
+ * Required, and it becomes the panel's `aria-label`. A
8
+ * `role="dialog"` takes **no accessible name from its content**,
9
+ * so an unnamed one is announced as "dialog" and is unfindable
10
+ * by `getByRole("dialog", { name })` — the same trap `Spinner`
11
+ * walked into in M3 with `role="status"`.
12
+ */
13
+ heading: string;
14
+ isVisible: boolean;
15
+ /**
16
+ * Outside press and Escape both land here, through floating-ui's
17
+ * `useDismiss`. Wire it to a `useVisibility()`'s `hide`.
18
+ */
19
+ onDismiss: () => void;
20
+ placement?: Placement;
21
+ /**
22
+ * The control the panel hangs off. **Cloned, not wrapped** — an
23
+ * extra `<div>` around a button is how a toolbar's layout
24
+ * quietly changes.
25
+ */
26
+ trigger: ReactElement;
27
+ };
28
+ /**
29
+ * Replaces mux-magic's `@radix-ui/react-popover` re-export, and it
30
+ * is a rewrite rather than a swap for one reason: Radix owns
31
+ * `open`, so wrapping it leaves `useVisibility` and Radix both
32
+ * believing they hold it. `@floating-ui/react` is the layer Radix,
33
+ * Base UI, and Ark UI are all *built on*, and it is controlled by
34
+ * construction — you pass state in and it never stores any.
35
+ *
36
+ * What floating-ui is used for is what only it can do: collision
37
+ * handling (`flip`, `shift`), a real dismiss layer, and focus
38
+ * management. Positioning strategy, the top layer, and the ARIA
39
+ * wiring come from the platform and from `useRole`.
40
+ *
41
+ * ### The top layer, from the platform — not a portal
42
+ *
43
+ * `popover="manual"` puts the panel in the top layer, which is
44
+ * what a portal is normally reached for: no `z-index` contest, and
45
+ * no `overflow: hidden` ancestor clipping it. Unlike a portal it
46
+ * **stays where it is in the DOM**, so `canvas.getByRole("dialog")`
47
+ * still finds it and a story keeps its scoping. Same argument as
48
+ * `Modal`, one layer down.
49
+ *
50
+ * `manual`, not `auto`, and that is the state-ownership decision
51
+ * again: `auto` brings the UA's own light-dismiss, which closes the
52
+ * element by itself and leaves `isVisible` true. `useDismiss` does
53
+ * the same job through our callback, so Charcuterie stays the only
54
+ * thing that decides.
55
+ *
56
+ * ### Two UA defaults that have to be undone
57
+ *
58
+ * `[popover]` ships `position: fixed; inset: 0; margin: auto`,
59
+ * which centres the panel in the viewport and fights every
60
+ * coordinate floating-ui computes — hence `inset-auto m-0`, and
61
+ * `strategy: "fixed"` so the numbers are viewport-relative like the
62
+ * top layer itself. Getting either wrong reads as a positioning
63
+ * bug rather than as a UA default.
64
+ */
65
+ export declare const Popover: ({ children, className, heading, isVisible, onDismiss, placement, trigger, }: PopoverProps) => ReactNode;
66
+ //# sourceMappingURL=Popover.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Popover.d.ts","sourceRoot":"","sources":["../../src/Popover/Popover.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAYnD,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAIpD,MAAM,MAAM,YAAY,GAAG;IACzB,QAAQ,EAAE,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;OAMG;IACH,OAAO,EAAE,MAAM,CAAA;IACf,SAAS,EAAE,OAAO,CAAA;IAClB;;;OAGG;IACH,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB;;;;OAIG;IACH,OAAO,EAAE,YAAY,CAAA;CACtB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,eAAO,MAAM,OAAO,GAAI,6EAQrB,YAAY,KAAG,SA2EjB,CAAA"}
@@ -0,0 +1,88 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useClonedChild } from "@charcuterie/logic";
3
+ import { autoUpdate, FloatingFocusManager, flip, offset, shift, useDismiss, useFloating, useInteractions, useRole, } from "@floating-ui/react";
4
+ import { toClassName } from "../toClassName.js";
5
+ /**
6
+ * Replaces mux-magic's `@radix-ui/react-popover` re-export, and it
7
+ * is a rewrite rather than a swap for one reason: Radix owns
8
+ * `open`, so wrapping it leaves `useVisibility` and Radix both
9
+ * believing they hold it. `@floating-ui/react` is the layer Radix,
10
+ * Base UI, and Ark UI are all *built on*, and it is controlled by
11
+ * construction — you pass state in and it never stores any.
12
+ *
13
+ * What floating-ui is used for is what only it can do: collision
14
+ * handling (`flip`, `shift`), a real dismiss layer, and focus
15
+ * management. Positioning strategy, the top layer, and the ARIA
16
+ * wiring come from the platform and from `useRole`.
17
+ *
18
+ * ### The top layer, from the platform — not a portal
19
+ *
20
+ * `popover="manual"` puts the panel in the top layer, which is
21
+ * what a portal is normally reached for: no `z-index` contest, and
22
+ * no `overflow: hidden` ancestor clipping it. Unlike a portal it
23
+ * **stays where it is in the DOM**, so `canvas.getByRole("dialog")`
24
+ * still finds it and a story keeps its scoping. Same argument as
25
+ * `Modal`, one layer down.
26
+ *
27
+ * `manual`, not `auto`, and that is the state-ownership decision
28
+ * again: `auto` brings the UA's own light-dismiss, which closes the
29
+ * element by itself and leaves `isVisible` true. `useDismiss` does
30
+ * the same job through our callback, so Charcuterie stays the only
31
+ * thing that decides.
32
+ *
33
+ * ### Two UA defaults that have to be undone
34
+ *
35
+ * `[popover]` ships `position: fixed; inset: 0; margin: auto`,
36
+ * which centres the panel in the viewport and fights every
37
+ * coordinate floating-ui computes — hence `inset-auto m-0`, and
38
+ * `strategy: "fixed"` so the numbers are viewport-relative like the
39
+ * top layer itself. Getting either wrong reads as a positioning
40
+ * bug rather than as a UA default.
41
+ */
42
+ export const Popover = ({ children, className, heading, isVisible, onDismiss, placement = "bottom-start", trigger, }) => {
43
+ const { context, floatingStyles, refs } = useFloating({
44
+ // Read-only. Charcuterie remains the sole owner; floating-ui
45
+ // is told what is true and never decides it.
46
+ open: isVisible,
47
+ onOpenChange: (isNextVisible) => {
48
+ if (!isNextVisible) {
49
+ onDismiss();
50
+ }
51
+ },
52
+ placement,
53
+ middleware: [offset(8), flip(), shift({ padding: 8 })],
54
+ strategy: "fixed",
55
+ whileElementsMounted: autoUpdate,
56
+ });
57
+ // `useRole` writes `aria-expanded`, `aria-haspopup`, and
58
+ // `aria-controls` on the trigger and `id` + `role="dialog"` on
59
+ // the panel, from one source. Hand-writing them alongside is how
60
+ // an `aria-controls` ends up pointing at an id that moved.
61
+ const { getFloatingProps, getReferenceProps } = useInteractions([
62
+ useDismiss(context),
63
+ useRole(context, { role: "dialog" }),
64
+ ]);
65
+ const clonedTrigger = useClonedChild(trigger, {
66
+ ...getReferenceProps(),
67
+ ref: refs.setReference,
68
+ });
69
+ return (_jsxs(_Fragment, { children: [clonedTrigger, isVisible ? (_jsx(FloatingFocusManager, { context: context, modal: false, children: _jsx("div", { ...getFloatingProps(), "aria-label": heading, className: toClassName("inset-auto m-0 max-w-xs rounded-md border border-border-default bg-surface-overlay p-3 text-content-primary text-sm shadow-medium", className), popover: "manual",
70
+ // Duplicated from `getFloatingProps()` on purpose, and
71
+ // the two cannot differ — `useRole(context, { role:
72
+ // "dialog" })` above is where it comes from. Stated
73
+ // here because a linter cannot see a role through a
74
+ // spread, and neither can the next reader.
75
+ role: "dialog", ref: (node) => {
76
+ refs.setFloating(node);
77
+ // In a ref callback rather than an effect, because a
78
+ // `[popover]` is `display: none` until it is shown —
79
+ // and `FloatingFocusManager` is a *child*, so its
80
+ // effect runs first and would be focusing into a
81
+ // hidden element. Refs are set during commit, before
82
+ // any of that.
83
+ if (node && !node.matches(":popover-open")) {
84
+ node.showPopover();
85
+ }
86
+ }, style: floatingStyles, children: children }) })) : null] }));
87
+ };
88
+ //# sourceMappingURL=Popover.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Popover.js","sourceRoot":"","sources":["../../src/Popover/Popover.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAA;AAEnD,OAAO,EACL,UAAU,EACV,oBAAoB,EACpB,IAAI,EACJ,MAAM,EACN,KAAK,EACL,UAAU,EACV,WAAW,EACX,eAAe,EACf,OAAO,GACR,MAAM,oBAAoB,CAAA;AAG3B,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AA4B/C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,MAAM,CAAC,MAAM,OAAO,GAAG,CAAC,EACtB,QAAQ,EACR,SAAS,EACT,OAAO,EACP,SAAS,EACT,SAAS,EACT,SAAS,GAAG,cAAc,EAC1B,OAAO,GACM,EAAa,EAAE;IAC5B,MAAM,EAAE,OAAO,EAAE,cAAc,EAAE,IAAI,EAAE,GAAG,WAAW,CAAC;QACpD,6DAA6D;QAC7D,6CAA6C;QAC7C,IAAI,EAAE,SAAS;QACf,YAAY,EAAE,CAAC,aAAa,EAAE,EAAE;YAC9B,IAAI,CAAC,aAAa,EAAE,CAAC;gBACnB,SAAS,EAAE,CAAA;YACb,CAAC;QACH,CAAC;QACD,SAAS;QACT,UAAU,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,CAAC,CAAC;QACtD,QAAQ,EAAE,OAAO;QACjB,oBAAoB,EAAE,UAAU;KACjC,CAAC,CAAA;IAEF,yDAAyD;IACzD,+DAA+D;IAC/D,iEAAiE;IACjE,2DAA2D;IAC3D,MAAM,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,GAC3C,eAAe,CAAC;QACd,UAAU,CAAC,OAAO,CAAC;QACnB,OAAO,CAAC,OAAO,EAAE,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;KACrC,CAAC,CAAA;IAEJ,MAAM,aAAa,GAAG,cAAc,CAAC,OAAO,EAAE;QAC5C,GAAG,iBAAiB,EAAE;QACtB,GAAG,EAAE,IAAI,CAAC,YAAY;KACvB,CAAC,CAAA;IAEF,OAAO,CACL,8BACG,aAAa,EAEb,SAAS,CAAC,CAAC,CAAC,CACX,KAAC,oBAAoB,IACnB,OAAO,EAAE,OAAO,EAChB,KAAK,EAAE,KAAK,YAEZ,iBACM,gBAAgB,EAAE,gBACV,OAAO,EACnB,SAAS,EAAE,WAAW,CACpB,mIAAmI,EACnI,SAAS,CACV,EACD,OAAO,EAAC,QAAQ;oBAChB,uDAAuD;oBACvD,oDAAoD;oBACpD,oDAAoD;oBACpD,oDAAoD;oBACpD,2CAA2C;oBAC3C,IAAI,EAAC,QAAQ,EACb,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE;wBACZ,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,CAAA;wBAEtB,qDAAqD;wBACrD,qDAAqD;wBACrD,kDAAkD;wBAClD,iDAAiD;wBACjD,qDAAqD;wBACrD,eAAe;wBACf,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,EAAE,CAAC;4BAC3C,IAAI,CAAC,WAAW,EAAE,CAAA;wBACpB,CAAC;oBACH,CAAC,EACD,KAAK,EAAE,cAAc,YAEpB,QAAQ,GACL,GACe,CACxB,CAAC,CAAC,CAAC,IAAI,IACP,CACJ,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,51 @@
1
+ import type { IntentName } from "@charcuterie/tokens";
2
+ import type { ComponentPropsWithRef, ReactNode } from "react";
3
+ import type { ProgressThreshold } from "./progressValue.ts";
4
+ export type ProgressBarSize = "lg" | "md" | "sm";
5
+ export type ProgressBarProps = Omit<ComponentPropsWithRef<"div">, "children"> & {
6
+ intent?: IntentName;
7
+ /**
8
+ * Indeterminate means "working, no measurable progress yet" — not
9
+ * zero and not unknown-therefore-full. rip-deck's is the
10
+ * AACS/BD+ preamble: ~25 s of a real Blu-ray emitting nothing, in
11
+ * which a full bar reads as a finished rip and an empty one as a
12
+ * wedged drive.
13
+ */
14
+ isIndeterminate?: boolean;
15
+ isLabelVisible?: boolean;
16
+ isValueShown?: boolean;
17
+ /** Required. A progressbar with no name is a grey rectangle. */
18
+ label: string;
19
+ max?: number;
20
+ size?: ProgressBarSize;
21
+ thresholds?: readonly ProgressThreshold[];
22
+ value?: number;
23
+ };
24
+ /**
25
+ * The union of what the fleet has: rip-deck's ARIA and its
26
+ * indeterminate sweep, plus threshold colours nobody has yet.
27
+ *
28
+ * Five repos hand-roll a progress bar and **only rip-deck's has
29
+ * `role="progressbar"`** — the other four are a `<div>` with a
30
+ * percentage width, invisible to assistive technology and to
31
+ * `getByRole("progressbar")`.
32
+ *
33
+ * Three corrections to rip-deck's version, which is otherwise the
34
+ * best in the fleet:
35
+ *
36
+ * - **The role goes on the track, not the fill.** rip-deck puts
37
+ * `role="progressbar"` on the inner filled element, so the
38
+ * widget's box is the *filled portion* — a 4% rip is a
39
+ * progressbar 4px wide as far as AT and a Playwright bounding
40
+ * box are concerned.
41
+ * - **The name comes from a real label**, wired with
42
+ * `aria-labelledby` when it is visible and `VisuallyHidden` when
43
+ * it is not, rather than a hardcoded `aria-label` string that
44
+ * cannot be translated or reused.
45
+ * - **`aria-valuenow` is omitted while indeterminate**, which is
46
+ * what tells AT "unknown" rather than "zero". rip-deck gets this
47
+ * right by rendering a separate element; stating it on one
48
+ * element is the same answer without the duplication.
49
+ */
50
+ export declare const ProgressBar: ({ className, intent, isIndeterminate, isLabelVisible, isValueShown, label, max, size, thresholds, value, ...divProps }: ProgressBarProps) => ReactNode;
51
+ //# sourceMappingURL=ProgressBar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProgressBar.d.ts","sourceRoot":"","sources":["../../src/ProgressBar/ProgressBar.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAKd,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAA;AAO3D,MAAM,MAAM,eAAe,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAA;AAEhD,MAAM,MAAM,gBAAgB,GAAG,IAAI,CACjC,qBAAqB,CAAC,KAAK,CAAC,EAC5B,UAAU,CACX,GAAG;IACF,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,gEAAgE;IAChE,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,eAAe,CAAA;IACtB,UAAU,CAAC,EAAE,SAAS,iBAAiB,EAAE,CAAA;IACzC,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAQD;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,eAAO,MAAM,WAAW,GAAI,wHAYzB,gBAAgB,KAAG,SAqFrB,CAAA"}
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useUniqueId } from "@charcuterie/logic";
3
+ import { INTENT_SOLID_FILL_CLASS } from "../intentStyles.js";
4
+ import { toClassName } from "../toClassName.js";
5
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.js";
6
+ import { getProgressIntent, toProgressPercent, toProgressValue, } from "./progressValue.js";
7
+ const TRACK_SIZE_CLASS = {
8
+ sm: "h-1",
9
+ md: "h-2",
10
+ lg: "h-3",
11
+ };
12
+ /**
13
+ * The union of what the fleet has: rip-deck's ARIA and its
14
+ * indeterminate sweep, plus threshold colours nobody has yet.
15
+ *
16
+ * Five repos hand-roll a progress bar and **only rip-deck's has
17
+ * `role="progressbar"`** — the other four are a `<div>` with a
18
+ * percentage width, invisible to assistive technology and to
19
+ * `getByRole("progressbar")`.
20
+ *
21
+ * Three corrections to rip-deck's version, which is otherwise the
22
+ * best in the fleet:
23
+ *
24
+ * - **The role goes on the track, not the fill.** rip-deck puts
25
+ * `role="progressbar"` on the inner filled element, so the
26
+ * widget's box is the *filled portion* — a 4% rip is a
27
+ * progressbar 4px wide as far as AT and a Playwright bounding
28
+ * box are concerned.
29
+ * - **The name comes from a real label**, wired with
30
+ * `aria-labelledby` when it is visible and `VisuallyHidden` when
31
+ * it is not, rather than a hardcoded `aria-label` string that
32
+ * cannot be translated or reused.
33
+ * - **`aria-valuenow` is omitted while indeterminate**, which is
34
+ * what tells AT "unknown" rather than "zero". rip-deck gets this
35
+ * right by rendering a separate element; stating it on one
36
+ * element is the same answer without the duplication.
37
+ */
38
+ export const ProgressBar = ({ className, intent = "accent", isIndeterminate = false, isLabelVisible = false, isValueShown = false, label, max = 100, size = "md", thresholds, value = 0, ...divProps }) => {
39
+ const labelId = useUniqueId();
40
+ const percent = toProgressPercent(value, max);
41
+ const clampedValue = toProgressValue(value, max);
42
+ const fillIntent = getProgressIntent(percent, intent, thresholds);
43
+ return (_jsxs("div", { ...divProps, className: toClassName("flex flex-col gap-1", className), children: [isLabelVisible || isValueShown ? (_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [isLabelVisible ? (_jsx("span", { className: "text-content-secondary", id: labelId, children: label })) : (_jsx(VisuallyHidden, { id: labelId, children: label })), isValueShown && !isIndeterminate ? (_jsxs("span", { className: "text-content-muted tabular-nums", children: [percent, "%"] })) : null] })) : (_jsx(VisuallyHidden, { id: labelId, children: label })), _jsx("div", { "aria-busy": isIndeterminate || undefined, "aria-labelledby": labelId, "aria-valuemax": max, "aria-valuemin": 0, "aria-valuenow": isIndeterminate ? undefined : clampedValue, className: toClassName("relative w-full overflow-hidden rounded-full bg-surface-sunken", TRACK_SIZE_CLASS[size]), role: "progressbar", children: isIndeterminate ? (_jsx("span", { className: toClassName(
44
+ // The sweep animates `inset-inline-start`, so it runs
45
+ // the other way in RTL for free — and its
46
+ // reduced-motion fallback hatches the whole track
47
+ // rather than parking at one end.
48
+ "charcuterie-sweep absolute inset-y-0 w-2/5 rounded-full", INTENT_SOLID_FILL_CLASS[fillIntent]) })) : (_jsx("span", { className: toClassName("block h-full rounded-full transition-[inline-size] duration-(--duration-normal) ease-standard", INTENT_SOLID_FILL_CLASS[fillIntent]), style: { inlineSize: `${percent}%` } })) })] }));
49
+ };
50
+ //# sourceMappingURL=ProgressBar.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProgressBar.js","sourceRoot":"","sources":["../../src/ProgressBar/ProgressBar.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAOhD,OAAO,EAAE,uBAAuB,EAAE,MAAM,oBAAoB,CAAA;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,cAAc,EAAE,MAAM,qCAAsC,CAAA;AAErE,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,eAAe,GAChB,MAAM,oBAAoB,CAAA;AA2B3B,MAAM,gBAAgB,GAAoC;IACxD,EAAE,EAAE,KAAK;IACT,EAAE,EAAE,KAAK;IACT,EAAE,EAAE,KAAK;CACV,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,EAC1B,SAAS,EACT,MAAM,GAAG,QAAQ,EACjB,eAAe,GAAG,KAAK,EACvB,cAAc,GAAG,KAAK,EACtB,YAAY,GAAG,KAAK,EACpB,KAAK,EACL,GAAG,GAAG,GAAG,EACT,IAAI,GAAG,IAAI,EACX,UAAU,EACV,KAAK,GAAG,CAAC,EACT,GAAG,QAAQ,EACM,EAAa,EAAE;IAChC,MAAM,OAAO,GAAG,WAAW,EAAE,CAAA;IAE7B,MAAM,OAAO,GAAG,iBAAiB,CAAC,KAAK,EAAE,GAAG,CAAC,CAAA;IAE7C,MAAM,YAAY,GAAG,eAAe,CAAC,KAAK,EAAE,GAAG,CAAC,CAAA;IAEhD,MAAM,UAAU,GAAG,iBAAiB,CAClC,OAAO,EACP,MAAM,EACN,UAAU,CACX,CAAA;IAED,OAAO,CACL,kBACM,QAAQ,EACZ,SAAS,EAAE,WAAW,CACpB,qBAAqB,EACrB,SAAS,CACV,aAEA,cAAc,IAAI,YAAY,CAAC,CAAC,CAAC,CAChC,eAAK,SAAS,EAAC,mDAAmD,aAC/D,cAAc,CAAC,CAAC,CAAC,CAChB,eACE,SAAS,EAAC,wBAAwB,EAClC,EAAE,EAAE,OAAO,YAEV,KAAK,GACD,CACR,CAAC,CAAC,CAAC,CACF,KAAC,cAAc,IAAC,EAAE,EAAE,OAAO,YACxB,KAAK,GACS,CAClB,EAEA,YAAY,IAAI,CAAC,eAAe,CAAC,CAAC,CAAC,CAClC,gBAAM,SAAS,EAAC,iCAAiC,aAC9C,OAAO,SACH,CACR,CAAC,CAAC,CAAC,IAAI,IACJ,CACP,CAAC,CAAC,CAAC,CACF,KAAC,cAAc,IAAC,EAAE,EAAE,OAAO,YACxB,KAAK,GACS,CAClB,EAED,2BACa,eAAe,IAAI,SAAS,qBACtB,OAAO,mBACT,GAAG,mBACH,CAAC,mBAEd,eAAe,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,YAAY,EAE5C,SAAS,EAAE,WAAW,CACpB,gEAAgE,EAChE,gBAAgB,CAAC,IAAI,CAAC,CACvB,EACD,IAAI,EAAC,aAAa,YAEjB,eAAe,CAAC,CAAC,CAAC,CACjB,eACE,SAAS,EAAE,WAAW;oBACpB,sDAAsD;oBACtD,0CAA0C;oBAC1C,kDAAkD;oBAClD,kCAAkC;oBAClC,yDAAyD,EACzD,uBAAuB,CAAC,UAAU,CAAC,CACpC,GACD,CACH,CAAC,CAAC,CAAC,CACF,eACE,SAAS,EAAE,WAAW,CACpB,+FAA+F,EAC/F,uBAAuB,CAAC,UAAU,CAAC,CACpC,EACD,KAAK,EAAE,EAAE,UAAU,EAAE,GAAG,OAAO,GAAG,EAAE,GACpC,CACH,GACG,IACF,CACP,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,44 @@
1
+ import type { IntentName } from "@charcuterie/tokens";
2
+ /**
3
+ * A threshold is "from this percentage upward, use this intent".
4
+ * Order does not matter — the highest matching entry wins — because
5
+ * a caller sorting a config array by hand is a bug waiting for the
6
+ * day someone appends to it.
7
+ */
8
+ export type ProgressThreshold = {
9
+ from: number;
10
+ intent: IntentName;
11
+ };
12
+ /**
13
+ * Clamped, rounded, and NaN-proof, in a file a Node test can reach
14
+ * without a browser.
15
+ *
16
+ * All three cases are real. rip-deck's `fillPercent` comes from a
17
+ * ripper's stdout, and a progress value that arrives as `-1`,
18
+ * `101`, or `NaN` from a log parse currently renders as a bar that
19
+ * overflows its track or vanishes. `max: 0` is the one that looks
20
+ * hypothetical and is not: it is "0 of 0 files done", which every
21
+ * empty queue produces on its first render.
22
+ */
23
+ export declare const toProgressPercent: (value: number, max: number) => number;
24
+ /**
25
+ * The same clamp in the value's own units, for `aria-valuenow`.
26
+ *
27
+ * Reporting the raw prop there would hand assistive technology a
28
+ * `valuenow` outside its own `valuemin`/`valuemax` — which some
29
+ * screen readers render as a nonsense percentage and others drop
30
+ * entirely — while the bar on screen showed the clamped one. The
31
+ * two must not disagree.
32
+ */
33
+ export declare const toProgressValue: (value: number, max: number) => number;
34
+ /**
35
+ * The threshold colour, or the component's own intent when no
36
+ * threshold applies.
37
+ *
38
+ * This is the piece rip-deck hardcodes as `FILL_CLASS` keyed by a
39
+ * rip state, and mux-magic does not have at all. Generalising it to
40
+ * "percentage → intent" covers both the "green when done" case and
41
+ * the "amber past 80% of the disk" case with one mechanism.
42
+ */
43
+ export declare const getProgressIntent: (percent: number, intent: IntentName, thresholds?: readonly ProgressThreshold[]) => IntentName;
44
+ //# sourceMappingURL=progressValue.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"progressValue.d.ts","sourceRoot":"","sources":["../../src/ProgressBar/progressValue.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AAErD;;;;;GAKG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,MAAM,EAAE,UAAU,CAAA;CACnB,CAAA;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,iBAAiB,GAC5B,OAAO,MAAM,EACb,KAAK,MAAM,WAcZ,CAAA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,GAC1B,OAAO,MAAM,EACb,KAAK,MAAM,WAOZ,CAAA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB,GAC5B,SAAS,MAAM,EACf,QAAQ,UAAU,EAClB,aAAa,SAAS,iBAAiB,EAAE,KACxC,UAWF,CAAA"}