@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.
- package/CHANGELOG.md +13 -0
- package/README.md +161 -0
- package/dist/Alert/Alert.d.ts +88 -0
- package/dist/Alert/Alert.d.ts.map +1 -0
- package/dist/Alert/Alert.js +67 -0
- package/dist/Alert/Alert.js.map +1 -0
- package/dist/Badge/Badge.d.ts +54 -0
- package/dist/Badge/Badge.d.ts.map +1 -0
- package/dist/Badge/Badge.js +54 -0
- package/dist/Badge/Badge.js.map +1 -0
- package/dist/Badge/useIsTextClipped.d.ts +22 -0
- package/dist/Badge/useIsTextClipped.d.ts.map +1 -0
- package/dist/Badge/useIsTextClipped.js +57 -0
- package/dist/Badge/useIsTextClipped.js.map +1 -0
- package/dist/Button/Button.d.ts +58 -0
- package/dist/Button/Button.d.ts.map +1 -0
- package/dist/Button/Button.js +37 -0
- package/dist/Button/Button.js.map +1 -0
- package/dist/Card/Card.d.ts +44 -0
- package/dist/Card/Card.d.ts.map +1 -0
- package/dist/Card/Card.js +59 -0
- package/dist/Card/Card.js.map +1 -0
- package/dist/EmptyState/EmptyState.d.ts +42 -0
- package/dist/EmptyState/EmptyState.d.ts.map +1 -0
- package/dist/EmptyState/EmptyState.js +37 -0
- package/dist/EmptyState/EmptyState.js.map +1 -0
- package/dist/IconButton/IconButton.d.ts +34 -0
- package/dist/IconButton/IconButton.d.ts.map +1 -0
- package/dist/IconButton/IconButton.js +23 -0
- package/dist/IconButton/IconButton.js.map +1 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts +37 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.d.ts.map +1 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.js +39 -0
- package/dist/LiveStatusIndicator/LiveStatusIndicator.js.map +1 -0
- package/dist/MediaTile/MediaTile.d.ts +46 -0
- package/dist/MediaTile/MediaTile.d.ts.map +1 -0
- package/dist/MediaTile/MediaTile.js +79 -0
- package/dist/MediaTile/MediaTile.js.map +1 -0
- package/dist/MediaTile/mediaStatus.d.ts +22 -0
- package/dist/MediaTile/mediaStatus.d.ts.map +1 -0
- package/dist/MediaTile/mediaStatus.js +24 -0
- package/dist/MediaTile/mediaStatus.js.map +1 -0
- package/dist/Modal/Modal.d.ts +66 -0
- package/dist/Modal/Modal.d.ts.map +1 -0
- package/dist/Modal/Modal.js +111 -0
- package/dist/Modal/Modal.js.map +1 -0
- package/dist/Modal/lockScrollBehind.d.ts +22 -0
- package/dist/Modal/lockScrollBehind.d.ts.map +1 -0
- package/dist/Modal/lockScrollBehind.js +48 -0
- package/dist/Modal/lockScrollBehind.js.map +1 -0
- package/dist/Popover/Popover.d.ts +66 -0
- package/dist/Popover/Popover.d.ts.map +1 -0
- package/dist/Popover/Popover.js +88 -0
- package/dist/Popover/Popover.js.map +1 -0
- package/dist/ProgressBar/ProgressBar.d.ts +51 -0
- package/dist/ProgressBar/ProgressBar.d.ts.map +1 -0
- package/dist/ProgressBar/ProgressBar.js +50 -0
- package/dist/ProgressBar/ProgressBar.js.map +1 -0
- package/dist/ProgressBar/progressValue.d.ts +44 -0
- package/dist/ProgressBar/progressValue.d.ts.map +1 -0
- package/dist/ProgressBar/progressValue.js +55 -0
- package/dist/ProgressBar/progressValue.js.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts +59 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.js +129 -0
- package/dist/SegmentedControl/SegmentedControl.js.map +1 -0
- package/dist/SegmentedControl/SegmentedOption.d.ts +28 -0
- package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -0
- package/dist/SegmentedControl/SegmentedOption.js +40 -0
- package/dist/SegmentedControl/SegmentedOption.js.map +1 -0
- package/dist/Skeleton/Skeleton.d.ts +30 -0
- package/dist/Skeleton/Skeleton.d.ts.map +1 -0
- package/dist/Skeleton/Skeleton.js +43 -0
- package/dist/Skeleton/Skeleton.js.map +1 -0
- package/dist/Spinner/Spinner.d.ts +37 -0
- package/dist/Spinner/Spinner.d.ts.map +1 -0
- package/dist/Spinner/Spinner.js +33 -0
- package/dist/Spinner/Spinner.js.map +1 -0
- package/dist/Tabs/TabTrigger.d.ts +32 -0
- package/dist/Tabs/TabTrigger.d.ts.map +1 -0
- package/dist/Tabs/TabTrigger.js +62 -0
- package/dist/Tabs/TabTrigger.js.map +1 -0
- package/dist/Tabs/Tabs.d.ts +95 -0
- package/dist/Tabs/Tabs.d.ts.map +1 -0
- package/dist/Tabs/Tabs.js +195 -0
- package/dist/Tabs/Tabs.js.map +1 -0
- package/dist/VisuallyHidden/VisuallyHidden.d.ts +25 -0
- package/dist/VisuallyHidden/VisuallyHidden.d.ts.map +1 -0
- package/dist/VisuallyHidden/VisuallyHidden.js +23 -0
- package/dist/VisuallyHidden/VisuallyHidden.js.map +1 -0
- package/dist/controlStyles.d.ts +50 -0
- package/dist/controlStyles.d.ts.map +1 -0
- package/dist/controlStyles.js +60 -0
- package/dist/controlStyles.js.map +1 -0
- package/dist/expectNoAxeViolations.testHelpers.d.ts +19 -0
- package/dist/expectNoAxeViolations.testHelpers.d.ts.map +1 -0
- package/dist/expectNoAxeViolations.testHelpers.js +32 -0
- package/dist/expectNoAxeViolations.testHelpers.js.map +1 -0
- package/dist/index.d.ts +75 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/intentStyles.d.ts +76 -0
- package/dist/intentStyles.d.ts.map +1 -0
- package/dist/intentStyles.js +150 -0
- package/dist/intentStyles.js.map +1 -0
- package/dist/mountStory.testHelpers.d.ts +30 -0
- package/dist/mountStory.testHelpers.d.ts.map +1 -0
- package/dist/mountStory.testHelpers.js +19 -0
- package/dist/mountStory.testHelpers.js.map +1 -0
- package/dist/statusIntent.d.ts +33 -0
- package/dist/statusIntent.d.ts.map +1 -0
- package/dist/statusIntent.js +99 -0
- package/dist/statusIntent.js.map +1 -0
- package/dist/styles.css +130 -0
- package/dist/testing/expectAgentDrivable.d.ts +53 -0
- package/dist/testing/expectAgentDrivable.d.ts.map +1 -0
- package/dist/testing/expectAgentDrivable.js +163 -0
- package/dist/testing/expectAgentDrivable.js.map +1 -0
- package/dist/testing/index.d.ts +11 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +10 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/toClassName.d.ts +12 -0
- package/dist/toClassName.d.ts.map +1 -0
- package/dist/toClassName.js +12 -0
- package/dist/toClassName.js.map +1 -0
- package/dist/tokens.d.ts +16 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +16 -0
- package/dist/tokens.js.map +1 -0
- package/package.json +70 -0
- package/src/Alert/Alert.mdx +70 -0
- package/src/Alert/Alert.stories.tsx +218 -0
- package/src/Alert/Alert.test.tsx +112 -0
- package/src/Alert/Alert.tsx +166 -0
- package/src/Badge/Badge.mdx +43 -0
- package/src/Badge/Badge.stories.tsx +229 -0
- package/src/Badge/Badge.test.tsx +153 -0
- package/src/Badge/Badge.tsx +130 -0
- package/src/Badge/useIsTextClipped.ts +77 -0
- package/src/Button/Button.mdx +44 -0
- package/src/Button/Button.stories.tsx +175 -0
- package/src/Button/Button.test.tsx +114 -0
- package/src/Button/Button.tsx +125 -0
- package/src/Card/Card.mdx +53 -0
- package/src/Card/Card.stories.tsx +207 -0
- package/src/Card/Card.test.tsx +56 -0
- package/src/Card/Card.tsx +142 -0
- package/src/EmptyState/EmptyState.mdx +40 -0
- package/src/EmptyState/EmptyState.stories.tsx +183 -0
- package/src/EmptyState/EmptyState.test.tsx +41 -0
- package/src/EmptyState/EmptyState.tsx +114 -0
- package/src/IconButton/IconButton.mdx +34 -0
- package/src/IconButton/IconButton.stories.tsx +191 -0
- package/src/IconButton/IconButton.test.tsx +70 -0
- package/src/IconButton/IconButton.tsx +54 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.mdx +43 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.stories.tsx +199 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.test.tsx +63 -0
- package/src/LiveStatusIndicator/LiveStatusIndicator.tsx +116 -0
- package/src/MediaTile/MediaTile.mdx +51 -0
- package/src/MediaTile/MediaTile.stories.tsx +211 -0
- package/src/MediaTile/MediaTile.test.tsx +41 -0
- package/src/MediaTile/MediaTile.tsx +227 -0
- package/src/MediaTile/mediaStatus.ts +28 -0
- package/src/Modal/Modal.mdx +113 -0
- package/src/Modal/Modal.stories.tsx +272 -0
- package/src/Modal/Modal.test.tsx +248 -0
- package/src/Modal/Modal.tsx +228 -0
- package/src/Modal/lockScrollBehind.ts +59 -0
- package/src/Popover/Popover.mdx +102 -0
- package/src/Popover/Popover.stories.tsx +247 -0
- package/src/Popover/Popover.test.tsx +191 -0
- package/src/Popover/Popover.tsx +164 -0
- package/src/ProgressBar/ProgressBar.mdx +48 -0
- package/src/ProgressBar/ProgressBar.stories.tsx +232 -0
- package/src/ProgressBar/ProgressBar.test.tsx +74 -0
- package/src/ProgressBar/ProgressBar.tsx +172 -0
- package/src/ProgressBar/progressValue.test.ts +101 -0
- package/src/ProgressBar/progressValue.ts +87 -0
- package/src/SegmentedControl/SegmentedControl.mdx +71 -0
- package/src/SegmentedControl/SegmentedControl.stories.tsx +150 -0
- package/src/SegmentedControl/SegmentedControl.test.tsx +208 -0
- package/src/SegmentedControl/SegmentedControl.tsx +219 -0
- package/src/SegmentedControl/SegmentedOption.tsx +94 -0
- package/src/Skeleton/Skeleton.mdx +30 -0
- package/src/Skeleton/Skeleton.stories.tsx +130 -0
- package/src/Skeleton/Skeleton.test.tsx +47 -0
- package/src/Skeleton/Skeleton.tsx +106 -0
- package/src/Spinner/Spinner.mdx +36 -0
- package/src/Spinner/Spinner.stories.tsx +118 -0
- package/src/Spinner/Spinner.test.tsx +38 -0
- package/src/Spinner/Spinner.tsx +92 -0
- package/src/Tabs/TabTrigger.tsx +126 -0
- package/src/Tabs/Tabs.mdx +126 -0
- package/src/Tabs/Tabs.stories.tsx +187 -0
- package/src/Tabs/Tabs.test.tsx +283 -0
- package/src/Tabs/Tabs.tsx +333 -0
- package/src/VisuallyHidden/VisuallyHidden.mdx +29 -0
- package/src/VisuallyHidden/VisuallyHidden.stories.tsx +58 -0
- package/src/VisuallyHidden/VisuallyHidden.test.tsx +31 -0
- package/src/VisuallyHidden/VisuallyHidden.tsx +43 -0
- package/src/argTypes.storyHelpers.ts +115 -0
- package/src/board.storyHelpers.tsx +150 -0
- package/src/controlStyles.ts +79 -0
- package/src/expectNoAxeViolations.testHelpers.ts +41 -0
- package/src/icons.storyHelpers.tsx +116 -0
- package/src/index.ts +132 -0
- package/src/intentStyles.ts +207 -0
- package/src/mdxReferences.test.ts +95 -0
- package/src/mountStory.testHelpers.ts +41 -0
- package/src/sourceRules.test.ts +303 -0
- package/src/statusIntent.test.ts +136 -0
- package/src/statusIntent.ts +137 -0
- package/src/storyControls.test.ts +128 -0
- package/src/styles.css +130 -0
- package/src/tailwindCandidates.test.ts +377 -0
- package/src/testing/expectAgentDrivable.test.tsx +71 -0
- package/src/testing/expectAgentDrivable.ts +273 -0
- package/src/testing/index.ts +17 -0
- package/src/toClassName.ts +13 -0
- package/src/tokens.ts +16 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @charcuterie/ui
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Initial public release of the Charcuterie fleet library: the design tokens, the five state kinds (Visibility, VisibilityGroup, SinglePicker, MultiplePicker, RovingFocus, Status), the component set, and the shared ESLint + Biome configs.
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies
|
|
12
|
+
- @charcuterie/tokens@0.1.0
|
|
13
|
+
- @charcuterie/logic@0.1.0
|
package/README.md
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# `@charcuterie/ui`
|
|
2
|
+
|
|
3
|
+
The components. Look from `@charcuterie/tokens`, state from `@charcuterie/logic`, and ARIA
|
|
4
|
+
that Playwright and an AI agent can actually drive.
|
|
5
|
+
|
|
6
|
+
M3 ships the **P0 pure-presentation** set: `Spinner`, `Skeleton`, `Button`, `IconButton`,
|
|
7
|
+
`Badge`, `ProgressBar`, `EmptyState`, `Card`, `LiveStatusIndicator`, `MediaTile` — plus
|
|
8
|
+
`VisuallyHidden`, which the first three of those need. M4 adds the overlays: `Modal`,
|
|
9
|
+
`Popover`, `Tabs`.
|
|
10
|
+
|
|
11
|
+
**M5 adds two, and they came from the first consumer rather than from the plan** —
|
|
12
|
+
[the rule](../../docs/decisions/2026-07-30-a-consumer-milestone-adds-components.md):
|
|
13
|
+
|
|
14
|
+
- **`Alert`** — rip-deck spells this shape four times, two of them carrying a
|
|
15
|
+
byte-identical `TONE_CLASS` map of hardcoded hexes. It is the app's largest single
|
|
16
|
+
duplication and no P0 component is a banner.
|
|
17
|
+
- **`SegmentedControl`** — `SinglePicker` + `RovingFocus` with the panels taken away, which
|
|
18
|
+
is a composition that could not be expressed until
|
|
19
|
+
[`Tabs` moved onto `SinglePicker`](../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md).
|
|
20
|
+
|
|
21
|
+
## Install and wire
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { Button, Card } from "@charcuterie/ui"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```css
|
|
28
|
+
@import "tailwindcss";
|
|
29
|
+
@import "@charcuterie/tokens/theme.css"; /* colours, type ramp, radii, motion */
|
|
30
|
+
@import "@charcuterie/tokens/fonts.css"; /* Baloo 2, Outfit, Victor Mono */
|
|
31
|
+
@import "@charcuterie/ui/styles.css"; /* the four looping affordances */
|
|
32
|
+
|
|
33
|
+
@source "../node_modules/@charcuterie/ui/dist";
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
That last line is not optional. Tailwind v4 scans **source text** for complete class
|
|
37
|
+
strings, and it does not scan your dependencies by default — without it every component
|
|
38
|
+
renders unstyled, with no error.
|
|
39
|
+
|
|
40
|
+
**Both package names, and the CSS line above is why.** The token *types and values* are
|
|
41
|
+
re-exported at `@charcuterie/ui/tokens`, so your TypeScript only ever names one package —
|
|
42
|
+
but the *stylesheet* is imported from `@charcuterie/tokens` directly. A `./tokens.css`
|
|
43
|
+
export pointing inside this package resolved to nothing on the first real consumer, because
|
|
44
|
+
a hoisting linker puts `@charcuterie/tokens` at the project root rather than in here, and a
|
|
45
|
+
CSS `@import` that misses fails **silently**: no error, no utilities, an unstyled app.
|
|
46
|
+
|
|
47
|
+
The testing gates are at `@charcuterie/ui/testing`.
|
|
48
|
+
|
|
49
|
+
### While the packages are unpublished: three lines, and you need all three
|
|
50
|
+
|
|
51
|
+
A consumer links by `portal:` until this publishes
|
|
52
|
+
([decision](../../docs/decisions/2026-07-29-consumers-link-tokens-by-portal-until-publish.md)).
|
|
53
|
+
M5 spent an hour on the second and third of these, so M5b and M6 do not have to:
|
|
54
|
+
|
|
55
|
+
```jsonc
|
|
56
|
+
// the app package
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@charcuterie/tokens": "portal:../../../charcuterie/packages/tokens",
|
|
59
|
+
"@charcuterie/ui": "portal:../../../charcuterie/packages/ui"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// the PROJECT ROOT — `ui` declares its siblings as `workspace:*`, and that
|
|
63
|
+
// descriptor cannot resolve outside charcuterie's own workspace. Bare keys, not
|
|
64
|
+
// `@charcuterie/ui/@charcuterie/tokens`: the scoped form yields a different
|
|
65
|
+
// locator string from the app's own dependency on the same directory, and Yarn
|
|
66
|
+
// rejects the pair as conflicting.
|
|
67
|
+
"resolutions": {
|
|
68
|
+
"@charcuterie/logic": "portal:../charcuterie/packages/logic",
|
|
69
|
+
"@charcuterie/tokens": "portal:../charcuterie/packages/tokens"
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
// vite.config.ts AND vitest.config.ts
|
|
75
|
+
resolve: { dedupe: ["react", "react-dom"] }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**That last one is the expensive one.** A `portal:` is a symlink, and Node and Vite both
|
|
79
|
+
resolve a symlinked module from its **real path** — so a component living in this package
|
|
80
|
+
resolves *its own* `react` by walking up from here, landing on charcuterie's copy while
|
|
81
|
+
your app renders with yours. Every component with a hook fails at once with:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
TypeError: Cannot read properties of null (reading 'useRef')
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
which mentions neither symlinks nor React identity. Keep the line after publish: it costs
|
|
88
|
+
nothing with one copy, and it is the difference between a working `yarn link` session and
|
|
89
|
+
an hour of confusion.
|
|
90
|
+
|
|
91
|
+
## How a component is put together
|
|
92
|
+
|
|
93
|
+
| Layer | Where it comes from |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| Colour | `intentStyles.ts` — six intents x four appearances, written out in full |
|
|
96
|
+
| Size | `controlStyles.ts` — `h-(--control-height-md)`, so `[data-density]` decides |
|
|
97
|
+
| Type | `text-sm`/`text-md`/`text-lg`, which are **ours**: `theme.css` bridges `--text-*` onto the density-scaled `--font-size-*` |
|
|
98
|
+
| Motion | `styles.css`, at `--duration-loop-*`, switched off under `prefers-reduced-motion` |
|
|
99
|
+
| State | `@charcuterie/logic` — `useUniqueId` for label wiring, `useStatus` for `MediaTile`'s three image states |
|
|
100
|
+
|
|
101
|
+
**Class names are never interpolated.** `` `bg-intent-${intent}-solid` `` generates nothing
|
|
102
|
+
at all and fails silently, so `intentStyles.ts` is 48 literals rather than a loop, and
|
|
103
|
+
`tailwindCandidates.test.ts` compiles every literal in the package through the real Tailwind
|
|
104
|
+
and fails on any candidate Tailwind cannot generate.
|
|
105
|
+
|
|
106
|
+
## Tests
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
yarn vitest run --project ui # Node: class maps, status switches, clamping, boundaries
|
|
110
|
+
yarn vitest run --project ui-dom # chromium: Component.test.tsx — behaviour, keyboard, ARIA
|
|
111
|
+
yarn vitest run --project storybook # chromium: every story renders, axe at test: "error"
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**Stories are demos and carry no assertions**
|
|
115
|
+
([decision](../../docs/decisions/2026-07-30-stories-are-demos-tests-are-tests.md), which
|
|
116
|
+
supersedes M3's). The DOM half lives in `Component.test.tsx` beside the component, and each
|
|
117
|
+
test mounts the **composed story** through `run()` rather than re-assembling the component —
|
|
118
|
+
so there is still only one rendering stack, and the subject of a test is the story a reader
|
|
119
|
+
sees.
|
|
120
|
+
|
|
121
|
+
Every component's test calls `expectAgentDrivable(canvas, { role, name })`, which is the
|
|
122
|
+
same query an agent will write; `data-testid` appears nowhere in this package and a test
|
|
123
|
+
enforces that.
|
|
124
|
+
|
|
125
|
+
Axe runs once per mount, when `run()` resolves — i.e. before the test has clicked anything.
|
|
126
|
+
A state you had to *drive* to reach (an open dialog, a shown popover) audits itself with
|
|
127
|
+
`expectNoAxeViolations(canvasElement)`.
|
|
128
|
+
|
|
129
|
+
Two rules about the docs panel are tests rather than conventions, because both fail
|
|
130
|
+
invisibly: `storyControls.test.ts` (a prop typed from another package needs an explicit
|
|
131
|
+
`argTypes` entry, or Storybook renders a `{}` textarea for it) and `mdxReferences.test.ts`
|
|
132
|
+
(an `.mdx` may not reference a story that no longer exists — `of` resolves at runtime, so a
|
|
133
|
+
rename breaks only the rendered page).
|
|
134
|
+
|
|
135
|
+
## Adding a component
|
|
136
|
+
|
|
137
|
+
1. `src/<Name>/<Name>.tsx`, `.stories.tsx`, `.mdx`, `.test.tsx` — siblings, matching
|
|
138
|
+
mux-magic. The story shows it; the test drives it. Neither does the other's job.
|
|
139
|
+
2. Colours from `intentStyles.ts`, sizes from `controlStyles.ts`. No hex, no `*-slate-*`
|
|
140
|
+
(a test checks).
|
|
141
|
+
3. The five stories: `Default`, `AllVariants`, `AllStates`, `Responsive` (three container
|
|
142
|
+
widths, via `ContainerBoard`), `Interactive` (the complete keyboard path).
|
|
143
|
+
4. Export from `src/index.ts` — the one sanctioned barrel. Components import each other
|
|
144
|
+
directly, never through it.
|
|
145
|
+
5. **If it is an overlay, use the platform's top layer — never a portal.** `<dialog>` +
|
|
146
|
+
`showModal()`, or `popover="manual"`. Both stay where they are in the DOM, so
|
|
147
|
+
`canvas.getByRole(…)` still finds the panel; a portal moves it to `document.body`, out
|
|
148
|
+
of the element a test or an agent scoped its queries to
|
|
149
|
+
([decision](../../docs/decisions/2026-07-30-overlays-use-the-top-layer-not-a-portal.md)).
|
|
150
|
+
6. **If it is built on a registering kind, look at its first paint.** Members register
|
|
151
|
+
from effects, so before those run a `RovingFocus` has no active value and a
|
|
152
|
+
`SinglePicker` / `VisibilityGroup` has no selected key — only a pending one. Two of M4's
|
|
153
|
+
four bugs were exactly that, and the isolated story runner saw neither —
|
|
154
|
+
`yarn smoke:storybook` did.
|
|
155
|
+
7. **If it declares `@container`, every `StoryCell` holding it needs `align="stretch"`.**
|
|
156
|
+
`container-type: inline-size` forbids the element from being sized by its own
|
|
157
|
+
contents, so a default (shrink-to-fit) cell collapses it to min-content and every
|
|
158
|
+
line wraps after one word. Valid CSS, no error, nothing for axe to say — it only
|
|
159
|
+
shows up in a screenshot, which is how it shipped in M3's `LiveStatusIndicator`
|
|
160
|
+
board. `sourceRules.test.ts` now derives the container-declaring components from
|
|
161
|
+
source and fails on this, so the component M4 adds joins the rule automatically.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { IntentName } from "@charcuterie/tokens";
|
|
2
|
+
import type { ComponentPropsWithRef, ReactNode } from "react";
|
|
3
|
+
export type AlertSize = "md" | "sm";
|
|
4
|
+
export type AlertProps = Omit<ComponentPropsWithRef<"section">, "children"> & {
|
|
5
|
+
/** Buttons. Laid out beside the text at `cq-sm` and up. */
|
|
6
|
+
actions?: ReactNode;
|
|
7
|
+
/** The quiet second line — counts, timestamps, provenance. */
|
|
8
|
+
description?: ReactNode;
|
|
9
|
+
/**
|
|
10
|
+
* The evidence, one line each. A list rather than a paragraph
|
|
11
|
+
* because that is what it is, and because a screen reader then
|
|
12
|
+
* announces "list, 3 items" instead of running three measurements
|
|
13
|
+
* together into one sentence.
|
|
14
|
+
*/
|
|
15
|
+
details?: readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* The sentence. A `ReactNode` rather than a `string` because
|
|
18
|
+
* rip-deck's verdicts qualify themselves inline — *"…(suspected —
|
|
19
|
+
* retry in another drive to confirm)"* — and splitting that across
|
|
20
|
+
* two props would let the qualifier drift away from the claim it
|
|
21
|
+
* qualifies.
|
|
22
|
+
*/
|
|
23
|
+
heading: ReactNode;
|
|
24
|
+
intent?: IntentName;
|
|
25
|
+
/**
|
|
26
|
+
* Present ⇒ this is a **named landmark** and
|
|
27
|
+
* `getByRole("region", { name })` finds it. Absent ⇒ a plain
|
|
28
|
+
* block, because a `<section>` with no accessible name is
|
|
29
|
+
* `generic` rather than `region`. See below; the default is
|
|
30
|
+
* deliberate.
|
|
31
|
+
*/
|
|
32
|
+
label?: string;
|
|
33
|
+
size?: AlertSize;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* The thing four of rip-deck's components each spell differently,
|
|
37
|
+
* and the reason `TONE_CLASS` was declared twice in one package.
|
|
38
|
+
*
|
|
39
|
+
* `TowerAlerts`, `UsbAlertBanner`, `LoadedDiscsBanner` and
|
|
40
|
+
* `VerdictBadge` are one shape: an intent-coloured block holding a
|
|
41
|
+
* sentence, a quieter line under it, and sometimes a list of
|
|
42
|
+
* evidence. Two of them carried a byte-identical `TONE_CLASS` map of
|
|
43
|
+
* hardcoded hexes and `amber-`/`red-` utilities; mux-magic spells the
|
|
44
|
+
* same idea a third way in `statusClassMap`. Here it is
|
|
45
|
+
* `INTENT_APPEARANCE_CLASS`, which every other component in this
|
|
46
|
+
* package already reads.
|
|
47
|
+
*
|
|
48
|
+
* ### It is a block by default, and a landmark only when named
|
|
49
|
+
*
|
|
50
|
+
* `label` is what decides, because the evidence divides cleanly:
|
|
51
|
+
*
|
|
52
|
+
* - The **banners** are one alert at the top of a page and want to
|
|
53
|
+
* be addressable — `getByRole("region", { name: "USB connection
|
|
54
|
+
* alert" })`. They pass a `label`.
|
|
55
|
+
* - The **verdict** renders inside every bay card, and one of its
|
|
56
|
+
* two forms is the single sentence *"Part of the tower-wide
|
|
57
|
+
* problem above."* Nine landmarks sharing one name is axe's
|
|
58
|
+
* `landmark-unique`, so it passes none.
|
|
59
|
+
*
|
|
60
|
+
* A component that always took the landmark would have made the
|
|
61
|
+
* second case fail its own a11y gate, and one that never took it
|
|
62
|
+
* would leave three `<section aria-label>` wrappers hand-rolled in
|
|
63
|
+
* the consumer — which is what rip-deck has today.
|
|
64
|
+
*
|
|
65
|
+
* ### The heading is not a heading element
|
|
66
|
+
*
|
|
67
|
+
* An alert's headline is a *sentence* — "Bay 7 is reading with
|
|
68
|
+
* errors; clean the disc and try again" — not a node in the document
|
|
69
|
+
* outline. Nine bays' worth of those as `<h3>`s builds a table of
|
|
70
|
+
* contents out of nine complaints, which is worse for a screen
|
|
71
|
+
* reader than no outline entry at all. `Card` and `EmptyState` take
|
|
72
|
+
* a real `headingLevel` precisely because their headings *are* short
|
|
73
|
+
* outline nodes; this one is styled text, and `label` is how it
|
|
74
|
+
* becomes findable.
|
|
75
|
+
*
|
|
76
|
+
* ### It does not announce, and that is a component boundary
|
|
77
|
+
*
|
|
78
|
+
* No `role="alert"`, no `aria-live`. A live region takes **no
|
|
79
|
+
* accessible name from its content**
|
|
80
|
+
* ([decision](../../../docs/decisions/2026-07-29-status-regions-carry-an-aria-label.md)),
|
|
81
|
+
* so an announcing variant would need a name that duplicates the
|
|
82
|
+
* sentence it is announcing — and `role="alert"` on a `<section>`
|
|
83
|
+
* overrides the landmark, so it cannot be both. A thing that
|
|
84
|
+
* interrupts you is a `Toast` (M6). This is a thing that is *on the
|
|
85
|
+
* page*.
|
|
86
|
+
*/
|
|
87
|
+
export declare const Alert: ({ actions, className, description, details, heading, intent, label, size, ...sectionProps }: AlertProps) => ReactNode;
|
|
88
|
+
//# sourceMappingURL=Alert.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Alert.d.ts","sourceRoot":"","sources":["../../src/Alert/Alert.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAKd,MAAM,MAAM,SAAS,GAAG,IAAI,GAAG,IAAI,CAAA;AAEnC,MAAM,MAAM,UAAU,GAAG,IAAI,CAC3B,qBAAqB,CAAC,SAAS,CAAC,EAChC,UAAU,CACX,GAAG;IACF,2DAA2D;IAC3D,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,8DAA8D;IAC9D,WAAW,CAAC,EAAE,SAAS,CAAA;IACvB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC3B;;;;;;OAMG;IACH,OAAO,EAAE,SAAS,CAAA;IAClB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB,CAAA;AAOD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,eAAO,MAAM,KAAK,GAAI,6FAUnB,UAAU,KAAG,SAoDf,CAAA"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { INTENT_APPEARANCE_CLASS } from "../intentStyles.js";
|
|
3
|
+
import { toClassName } from "../toClassName.js";
|
|
4
|
+
const SIZE_CLASS = {
|
|
5
|
+
sm: "gap-0.5 rounded-md px-2 py-1 text-sm",
|
|
6
|
+
md: "gap-1 rounded-xl px-3.5 py-2.5 text-md",
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The thing four of rip-deck's components each spell differently,
|
|
10
|
+
* and the reason `TONE_CLASS` was declared twice in one package.
|
|
11
|
+
*
|
|
12
|
+
* `TowerAlerts`, `UsbAlertBanner`, `LoadedDiscsBanner` and
|
|
13
|
+
* `VerdictBadge` are one shape: an intent-coloured block holding a
|
|
14
|
+
* sentence, a quieter line under it, and sometimes a list of
|
|
15
|
+
* evidence. Two of them carried a byte-identical `TONE_CLASS` map of
|
|
16
|
+
* hardcoded hexes and `amber-`/`red-` utilities; mux-magic spells the
|
|
17
|
+
* same idea a third way in `statusClassMap`. Here it is
|
|
18
|
+
* `INTENT_APPEARANCE_CLASS`, which every other component in this
|
|
19
|
+
* package already reads.
|
|
20
|
+
*
|
|
21
|
+
* ### It is a block by default, and a landmark only when named
|
|
22
|
+
*
|
|
23
|
+
* `label` is what decides, because the evidence divides cleanly:
|
|
24
|
+
*
|
|
25
|
+
* - The **banners** are one alert at the top of a page and want to
|
|
26
|
+
* be addressable — `getByRole("region", { name: "USB connection
|
|
27
|
+
* alert" })`. They pass a `label`.
|
|
28
|
+
* - The **verdict** renders inside every bay card, and one of its
|
|
29
|
+
* two forms is the single sentence *"Part of the tower-wide
|
|
30
|
+
* problem above."* Nine landmarks sharing one name is axe's
|
|
31
|
+
* `landmark-unique`, so it passes none.
|
|
32
|
+
*
|
|
33
|
+
* A component that always took the landmark would have made the
|
|
34
|
+
* second case fail its own a11y gate, and one that never took it
|
|
35
|
+
* would leave three `<section aria-label>` wrappers hand-rolled in
|
|
36
|
+
* the consumer — which is what rip-deck has today.
|
|
37
|
+
*
|
|
38
|
+
* ### The heading is not a heading element
|
|
39
|
+
*
|
|
40
|
+
* An alert's headline is a *sentence* — "Bay 7 is reading with
|
|
41
|
+
* errors; clean the disc and try again" — not a node in the document
|
|
42
|
+
* outline. Nine bays' worth of those as `<h3>`s builds a table of
|
|
43
|
+
* contents out of nine complaints, which is worse for a screen
|
|
44
|
+
* reader than no outline entry at all. `Card` and `EmptyState` take
|
|
45
|
+
* a real `headingLevel` precisely because their headings *are* short
|
|
46
|
+
* outline nodes; this one is styled text, and `label` is how it
|
|
47
|
+
* becomes findable.
|
|
48
|
+
*
|
|
49
|
+
* ### It does not announce, and that is a component boundary
|
|
50
|
+
*
|
|
51
|
+
* No `role="alert"`, no `aria-live`. A live region takes **no
|
|
52
|
+
* accessible name from its content**
|
|
53
|
+
* ([decision](../../../docs/decisions/2026-07-29-status-regions-carry-an-aria-label.md)),
|
|
54
|
+
* so an announcing variant would need a name that duplicates the
|
|
55
|
+
* sentence it is announcing — and `role="alert"` on a `<section>`
|
|
56
|
+
* overrides the landmark, so it cannot be both. A thing that
|
|
57
|
+
* interrupts you is a `Toast` (M6). This is a thing that is *on the
|
|
58
|
+
* page*.
|
|
59
|
+
*/
|
|
60
|
+
export const Alert = ({ actions, className, description, details, heading, intent = "neutral", label, size = "md", ...sectionProps }) => {
|
|
61
|
+
return (_jsxs("section", { ...sectionProps, "aria-label": label, className: toClassName(
|
|
62
|
+
// `@container` so the actions drop below the text in a
|
|
63
|
+
// sidebar and sit beside it full-bleed — the case a media
|
|
64
|
+
// query cannot tell apart.
|
|
65
|
+
"@container border", INTENT_APPEARANCE_CLASS[intent].soft, SIZE_CLASS[size], className), children: [_jsxs("div", { className: "flex flex-col gap-2 cq-sm:flex-row cq-sm:items-start cq-sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-col", children: [_jsx("p", { className: "m-0 font-semibold", children: heading }), description ? (_jsx("p", { className: "m-0 text-sm opacity-80", children: description })) : null] }), actions ? (_jsx("div", { className: "flex shrink-0 items-center gap-1", children: actions })) : null] }), details && details.length > 0 ? (_jsx("ul", { className: "m-0 list-none p-0 text-sm opacity-80", children: details.map((line, index) => (_jsx("li", { children: line }, `${String(index)}-${line}`))) })) : null] }));
|
|
66
|
+
};
|
|
67
|
+
//# sourceMappingURL=Alert.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Alert.js","sourceRoot":"","sources":["../../src/Alert/Alert.tsx"],"names":[],"mappings":";AAMA,OAAO,EAAE,uBAAuB,EAAE,MAAM,oBAAoB,CAAA;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAuC/C,MAAM,UAAU,GAA8B;IAC5C,EAAE,EAAE,sCAAsC;IAC1C,EAAE,EAAE,wCAAwC;CAC7C,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmDG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,EACpB,OAAO,EACP,SAAS,EACT,WAAW,EACX,OAAO,EACP,OAAO,EACP,MAAM,GAAG,SAAS,EAClB,KAAK,EACL,IAAI,GAAG,IAAI,EACX,GAAG,YAAY,EACJ,EAAa,EAAE;IAC1B,OAAO,CAQL,sBACM,YAAY,gBACJ,KAAK,EACjB,SAAS,EAAE,WAAW;QACpB,uDAAuD;QACvD,0DAA0D;QAC1D,2BAA2B;QAC3B,mBAAmB,EACnB,uBAAuB,CAAC,MAAM,CAAC,CAAC,IAAI,EACpC,UAAU,CAAC,IAAI,CAAC,EAChB,SAAS,CACV,aAED,eAAK,SAAS,EAAC,4EAA4E,aACzF,eAAK,SAAS,EAAC,uBAAuB,aACpC,YAAG,SAAS,EAAC,mBAAmB,YAAE,OAAO,GAAK,EAE7C,WAAW,CAAC,CAAC,CAAC,CACb,YAAG,SAAS,EAAC,wBAAwB,YAClC,WAAW,GACV,CACL,CAAC,CAAC,CAAC,IAAI,IACJ,EAEL,OAAO,CAAC,CAAC,CAAC,CACT,cAAK,SAAS,EAAC,kCAAkC,YAC9C,OAAO,GACJ,CACP,CAAC,CAAC,CAAC,IAAI,IACJ,EAEL,OAAO,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,CAC/B,aAAI,SAAS,EAAC,sCAAsC,YACjD,OAAO,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,CAI5B,uBAAsC,IAAI,IAAjC,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,IAAI,EAAE,CAAa,CACjD,CAAC,GACC,CACN,CAAC,CAAC,CAAC,IAAI,IACA,CACX,CAAA;AACH,CAAC,CAAA"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { IntentName } from "@charcuterie/tokens";
|
|
2
|
+
import type { ComponentPropsWithRef, ReactNode } from "react";
|
|
3
|
+
import type { BadgeSize } from "../controlStyles.ts";
|
|
4
|
+
import type { IntentAppearance } from "../intentStyles.ts";
|
|
5
|
+
export type BadgeProps = ComponentPropsWithRef<"span"> & {
|
|
6
|
+
appearance?: Exclude<IntentAppearance, "ghost">;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
icon?: ReactNode;
|
|
9
|
+
intent?: IntentName;
|
|
10
|
+
/**
|
|
11
|
+
* What happens when the label is wider than the space it is given.
|
|
12
|
+
*
|
|
13
|
+
* - `truncate` — one line, capped at the container, ellipsis. The
|
|
14
|
+
* default, because a status pill that changes its row's height
|
|
15
|
+
* when a message gets longer breaks the table it sits in.
|
|
16
|
+
* - `wrap` — the pill grows taller and shows everything. For the
|
|
17
|
+
* kiosk and any touch context, where the hover readout that
|
|
18
|
+
* `truncate` relies on does not exist.
|
|
19
|
+
*/
|
|
20
|
+
overflow?: "truncate" | "wrap";
|
|
21
|
+
size?: BadgeSize;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Four repos, and rip-deck declares the **identical** `TONE_CLASS`
|
|
25
|
+
* map twice — `VerdictBadge.tsx` and `TowerAlerts.tsx`, same five
|
|
26
|
+
* hardcoded hexes, no relationship to each other beyond
|
|
27
|
+
* copy-paste. mux-magic's `statusClassMap` is the same idea spelled
|
|
28
|
+
* a third way, keyed by a bare `string` so an unrecognised status
|
|
29
|
+
* renders with no colour at all and no error.
|
|
30
|
+
*
|
|
31
|
+
* `intent` replaces all of it. The **status** half of the problem —
|
|
32
|
+
* which state maps to which intent, and what it is called in
|
|
33
|
+
* English — lives in `statusIntent.ts` as exhaustive switches over
|
|
34
|
+
* `@charcuterie/logic`'s shared machines, so
|
|
35
|
+
* `<Badge intent={getAsyncIntent(status)}>{getAsyncLabel(status)}</Badge>`
|
|
36
|
+
* is the whole of mux-magic's `StatusBadge`, and adding a state to
|
|
37
|
+
* the machine is a compile error rather than a blank pill.
|
|
38
|
+
*
|
|
39
|
+
* **No implicit role.** A badge is a word about something else; it
|
|
40
|
+
* is not a live region, and giving it `role="status"` would make
|
|
41
|
+
* every re-render announce itself. Where a status genuinely needs
|
|
42
|
+
* announcing that is `LiveStatusIndicator`'s job, and where a badge
|
|
43
|
+
* needs a name beyond its text, `aria-label` still works. The story
|
|
44
|
+
* asserts the text is queryable, which is what an agent actually
|
|
45
|
+
* matches on.
|
|
46
|
+
*
|
|
47
|
+
* **It is capped at its container.** `shrink-0` and
|
|
48
|
+
* `whitespace-nowrap` alone let a long label paint straight through
|
|
49
|
+
* the neighbouring column — no clipping, no error, just a pill lying
|
|
50
|
+
* across the next cell. `max-inline-size: 100%` is what stops it, and
|
|
51
|
+
* `overflow` is what happens next.
|
|
52
|
+
*/
|
|
53
|
+
export declare const Badge: ({ appearance, children, className, icon, intent, overflow, size, title, ...spanProps }: BadgeProps) => ReactNode;
|
|
54
|
+
//# sourceMappingURL=Badge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Badge.d.ts","sourceRoot":"","sources":["../../src/Badge/Badge.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,qBAAqB,CAAA;AACrD,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAEd,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAA;AAEpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAK1D,MAAM,MAAM,UAAU,GAAG,qBAAqB,CAAC,MAAM,CAAC,GAAG;IACvD,UAAU,CAAC,EAAE,OAAO,CAAC,gBAAgB,EAAE,OAAO,CAAC,CAAA;IAC/C,QAAQ,EAAE,SAAS,CAAA;IACnB,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB;;;;;;;;;OASG;IACH,QAAQ,CAAC,EAAE,UAAU,GAAG,MAAM,CAAA;IAC9B,IAAI,CAAC,EAAE,SAAS,CAAA;CACjB,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,eAAO,MAAM,KAAK,GAAI,wFAUnB,UAAU,KAAG,SAyDf,CAAA"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { BADGE_SIZE_CLASS } from "../controlStyles.js";
|
|
3
|
+
import { INTENT_APPEARANCE_CLASS } from "../intentStyles.js";
|
|
4
|
+
import { toClassName } from "../toClassName.js";
|
|
5
|
+
import { useIsTextClipped } from "./useIsTextClipped.js";
|
|
6
|
+
/**
|
|
7
|
+
* Four repos, and rip-deck declares the **identical** `TONE_CLASS`
|
|
8
|
+
* map twice — `VerdictBadge.tsx` and `TowerAlerts.tsx`, same five
|
|
9
|
+
* hardcoded hexes, no relationship to each other beyond
|
|
10
|
+
* copy-paste. mux-magic's `statusClassMap` is the same idea spelled
|
|
11
|
+
* a third way, keyed by a bare `string` so an unrecognised status
|
|
12
|
+
* renders with no colour at all and no error.
|
|
13
|
+
*
|
|
14
|
+
* `intent` replaces all of it. The **status** half of the problem —
|
|
15
|
+
* which state maps to which intent, and what it is called in
|
|
16
|
+
* English — lives in `statusIntent.ts` as exhaustive switches over
|
|
17
|
+
* `@charcuterie/logic`'s shared machines, so
|
|
18
|
+
* `<Badge intent={getAsyncIntent(status)}>{getAsyncLabel(status)}</Badge>`
|
|
19
|
+
* is the whole of mux-magic's `StatusBadge`, and adding a state to
|
|
20
|
+
* the machine is a compile error rather than a blank pill.
|
|
21
|
+
*
|
|
22
|
+
* **No implicit role.** A badge is a word about something else; it
|
|
23
|
+
* is not a live region, and giving it `role="status"` would make
|
|
24
|
+
* every re-render announce itself. Where a status genuinely needs
|
|
25
|
+
* announcing that is `LiveStatusIndicator`'s job, and where a badge
|
|
26
|
+
* needs a name beyond its text, `aria-label` still works. The story
|
|
27
|
+
* asserts the text is queryable, which is what an agent actually
|
|
28
|
+
* matches on.
|
|
29
|
+
*
|
|
30
|
+
* **It is capped at its container.** `shrink-0` and
|
|
31
|
+
* `whitespace-nowrap` alone let a long label paint straight through
|
|
32
|
+
* the neighbouring column — no clipping, no error, just a pill lying
|
|
33
|
+
* across the next cell. `max-inline-size: 100%` is what stops it, and
|
|
34
|
+
* `overflow` is what happens next.
|
|
35
|
+
*/
|
|
36
|
+
export const Badge = ({ appearance = "soft", children, className, icon, intent = "neutral", overflow = "truncate", size = "md", title, ...spanProps }) => {
|
|
37
|
+
const [labelRef, clippedText] = useIsTextClipped();
|
|
38
|
+
return (_jsxs("span", { ...spanProps, className: toClassName("inline-flex shrink-0 items-center rounded-full border font-medium",
|
|
39
|
+
// The cap. Without it every other rule here is decoration.
|
|
40
|
+
"max-w-full", overflow === "wrap"
|
|
41
|
+
? // A stadium end-cap on a three-line box reads as a
|
|
42
|
+
// rendering fault rather than a badge, so the wrapping
|
|
43
|
+
// pill relaxes to a rounded rectangle.
|
|
44
|
+
"rounded-2xl"
|
|
45
|
+
: "rounded-full", BADGE_SIZE_CLASS[size], INTENT_APPEARANCE_CLASS[intent][appearance], className),
|
|
46
|
+
// Only when something is actually hidden. A tooltip on every
|
|
47
|
+
// short pill in a bay list is noise, and a `title` that
|
|
48
|
+
// duplicates fully-visible text is a screen-reader duplicate
|
|
49
|
+
// for no gain.
|
|
50
|
+
title: title ?? clippedText, children: [icon ? (_jsx("span", { "aria-hidden": "true", className: "contents shrink-0", children: icon })) : null, _jsx("span", { className: overflow === "wrap"
|
|
51
|
+
? "min-w-0"
|
|
52
|
+
: "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap", ref: labelRef, children: children })] }));
|
|
53
|
+
};
|
|
54
|
+
//# sourceMappingURL=Badge.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Badge.js","sourceRoot":"","sources":["../../src/Badge/Badge.tsx"],"names":[],"mappings":";AAOA,OAAO,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAA;AAEtD,OAAO,EAAE,uBAAuB,EAAE,MAAM,oBAAoB,CAAA;AAC5D,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAC/C,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAA;AAqBxD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,EACpB,UAAU,GAAG,MAAM,EACnB,QAAQ,EACR,SAAS,EACT,IAAI,EACJ,MAAM,GAAG,SAAS,EAClB,QAAQ,GAAG,UAAU,EACrB,IAAI,GAAG,IAAI,EACX,KAAK,EACL,GAAG,SAAS,EACD,EAAa,EAAE;IAC1B,MAAM,CAAC,QAAQ,EAAE,WAAW,CAAC,GAAG,gBAAgB,EAAE,CAAA;IAElD,OAAO,CACL,mBACM,SAAS,EACb,SAAS,EAAE,WAAW,CACpB,mEAAmE;QACnE,2DAA2D;QAC3D,YAAY,EACZ,QAAQ,KAAK,MAAM;YACjB,CAAC,CAAC,mDAAmD;gBACnD,uDAAuD;gBACvD,uCAAuC;gBACvC,aAAa;YACf,CAAC,CAAC,cAAc,EAClB,gBAAgB,CAAC,IAAI,CAAC,EACtB,uBAAuB,CAAC,MAAM,CAAC,CAAC,UAAU,CAAC,EAC3C,SAAS,CACV;QACD,6DAA6D;QAC7D,wDAAwD;QACxD,6DAA6D;QAC7D,eAAe;QACf,KAAK,EAAE,KAAK,IAAI,WAAW,aAE1B,IAAI,CAAC,CAAC,CAAC,CACN,8BACc,MAAM,EAClB,SAAS,EAAC,mBAAmB,YAE5B,IAAI,GACA,CACR,CAAC,CAAC,CAAC,IAAI,EAYR,eACE,SAAS,EACP,QAAQ,KAAK,MAAM;oBACjB,CAAC,CAAC,SAAS;oBACX,CAAC,CAAC,yDAAyD,EAE/D,GAAG,EAAE,QAAQ,YAEZ,QAAQ,GACJ,IACF,CACR,CAAA;AACH,CAAC,CAAA"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The element's full text, but only while that text is wider than
|
|
4
|
+
* the box it was given — i.e. only while an ellipsis is being
|
|
5
|
+
* painted. `undefined` the rest of the time.
|
|
6
|
+
*
|
|
7
|
+
* `text-overflow: ellipsis` leaves no trace in the DOM: the glyph is
|
|
8
|
+
* painted by the layout engine and the whole string stays in
|
|
9
|
+
* `textContent`. That is the good news — selection, copy, and every
|
|
10
|
+
* screen reader still get all of it — and the reason this hook has
|
|
11
|
+
* to exist, because nothing in the DOM says "this one is cut off",
|
|
12
|
+
* so a hover affordance cannot be attached declaratively.
|
|
13
|
+
*
|
|
14
|
+
* Measured rather than assumed, since the alternative — always
|
|
15
|
+
* setting a `title` — puts a tooltip on every short status pill in a
|
|
16
|
+
* bay list, which is worse than the problem it solves.
|
|
17
|
+
*
|
|
18
|
+
* The text is returned rather than a boolean so the caller never
|
|
19
|
+
* reads `ref.current` during render.
|
|
20
|
+
*/
|
|
21
|
+
export declare const useIsTextClipped: () => [RefObject<HTMLSpanElement | null>, string | undefined];
|
|
22
|
+
//# sourceMappingURL=useIsTextClipped.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useIsTextClipped.d.ts","sourceRoot":"","sources":["../../src/Badge/useIsTextClipped.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAGtC;;;;;;;;;;;;;;;;;;GAkBG;AACH,eAAO,MAAM,gBAAgB,QAAO,CAClC,SAAS,CAAC,eAAe,GAAG,IAAI,CAAC,EACjC,MAAM,GAAG,SAAS,CAoDnB,CAAA"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* The element's full text, but only while that text is wider than
|
|
4
|
+
* the box it was given — i.e. only while an ellipsis is being
|
|
5
|
+
* painted. `undefined` the rest of the time.
|
|
6
|
+
*
|
|
7
|
+
* `text-overflow: ellipsis` leaves no trace in the DOM: the glyph is
|
|
8
|
+
* painted by the layout engine and the whole string stays in
|
|
9
|
+
* `textContent`. That is the good news — selection, copy, and every
|
|
10
|
+
* screen reader still get all of it — and the reason this hook has
|
|
11
|
+
* to exist, because nothing in the DOM says "this one is cut off",
|
|
12
|
+
* so a hover affordance cannot be attached declaratively.
|
|
13
|
+
*
|
|
14
|
+
* Measured rather than assumed, since the alternative — always
|
|
15
|
+
* setting a `title` — puts a tooltip on every short status pill in a
|
|
16
|
+
* bay list, which is worse than the problem it solves.
|
|
17
|
+
*
|
|
18
|
+
* The text is returned rather than a boolean so the caller never
|
|
19
|
+
* reads `ref.current` during render.
|
|
20
|
+
*/
|
|
21
|
+
export const useIsTextClipped = () => {
|
|
22
|
+
const elementRef = useRef(null);
|
|
23
|
+
const [clippedText, setClippedText] = useState(undefined);
|
|
24
|
+
// No dependency array: `children` is a `ReactNode`, so there is
|
|
25
|
+
// nothing stable to compare against, and a re-render is exactly
|
|
26
|
+
// when the text may have changed. `setState` bails out on an equal
|
|
27
|
+
// value, so the common case costs one `scrollWidth` read.
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const element = elementRef.current;
|
|
30
|
+
if (!element) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
setClippedText(element.scrollWidth > element.clientWidth
|
|
34
|
+
? (element.textContent ?? undefined)
|
|
35
|
+
: undefined);
|
|
36
|
+
});
|
|
37
|
+
// And a resize of the *container* changes the answer without
|
|
38
|
+
// re-rendering anything — which is the container-query case this
|
|
39
|
+
// whole library is built around.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const element = elementRef.current;
|
|
42
|
+
if (!element) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const observer = new ResizeObserver(() => {
|
|
46
|
+
setClippedText(element.scrollWidth > element.clientWidth
|
|
47
|
+
? (element.textContent ?? undefined)
|
|
48
|
+
: undefined);
|
|
49
|
+
});
|
|
50
|
+
observer.observe(element);
|
|
51
|
+
return () => {
|
|
52
|
+
observer.disconnect();
|
|
53
|
+
};
|
|
54
|
+
}, []);
|
|
55
|
+
return [elementRef, clippedText];
|
|
56
|
+
};
|
|
57
|
+
//# sourceMappingURL=useIsTextClipped.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useIsTextClipped.js","sourceRoot":"","sources":["../../src/Badge/useIsTextClipped.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAEnD;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,GAG9B,EAAE;IACF,MAAM,UAAU,GAAG,MAAM,CAAkB,IAAI,CAAC,CAAA;IAEhD,MAAM,CAAC,WAAW,EAAE,cAAc,CAAC,GAAG,QAAQ,CAE5C,SAAS,CAAC,CAAA;IAEZ,gEAAgE;IAChE,gEAAgE;IAChE,mEAAmE;IACnE,0DAA0D;IAC1D,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,OAAO,GAAG,UAAU,CAAC,OAAO,CAAA;QAElC,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAM;QACR,CAAC;QAED,cAAc,CACZ,OAAO,CAAC,WAAW,GAAG,OAAO,CAAC,WAAW;YACvC,CAAC,CAAC,CAAC,OAAO,CAAC,WAAW,IAAI,SAAS,CAAC;YACpC,CAAC,CAAC,SAAS,CACd,CAAA;IACH,CAAC,CAAC,CAAA;IAEF,6DAA6D;IAC7D,iEAAiE;IACjE,iCAAiC;IACjC,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,OAAO,GAAG,UAAU,CAAC,OAAO,CAAA;QAElC,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,OAAM;QACR,CAAC;QAED,MAAM,QAAQ,GAAG,IAAI,cAAc,CAAC,GAAG,EAAE;YACvC,cAAc,CACZ,OAAO,CAAC,WAAW,GAAG,OAAO,CAAC,WAAW;gBACvC,CAAC,CAAC,CAAC,OAAO,CAAC,WAAW,IAAI,SAAS,CAAC;gBACpC,CAAC,CAAC,SAAS,CACd,CAAA;QACH,CAAC,CAAC,CAAA;QAEF,QAAQ,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;QAEzB,OAAO,GAAG,EAAE;YACV,QAAQ,CAAC,UAAU,EAAE,CAAA;QACvB,CAAC,CAAA;IACH,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,OAAO,CAAC,UAAU,EAAE,WAAW,CAAC,CAAA;AAClC,CAAC,CAAA"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ControlSize, IntentName } from "@charcuterie/tokens";
|
|
2
|
+
import type { ComponentPropsWithRef, ReactNode } from "react";
|
|
3
|
+
import type { IntentAppearance } from "../intentStyles.ts";
|
|
4
|
+
export type ButtonProps = Omit<ComponentPropsWithRef<"button">, "disabled"> & {
|
|
5
|
+
appearance?: IntentAppearance;
|
|
6
|
+
iconEnd?: ReactNode;
|
|
7
|
+
iconStart?: ReactNode;
|
|
8
|
+
intent?: IntentName;
|
|
9
|
+
isDisabled?: boolean;
|
|
10
|
+
isFullWidth?: boolean;
|
|
11
|
+
isLoading?: boolean;
|
|
12
|
+
/** What the spinner announces. Not shown. */
|
|
13
|
+
loadingLabel?: string;
|
|
14
|
+
size?: ControlSize;
|
|
15
|
+
/**
|
|
16
|
+
* Two sizing modes rather than a second component's worth of
|
|
17
|
+
* markup. `icon` is square on the control height with no inline
|
|
18
|
+
* padding, which is what lines an `IconButton` up with the text
|
|
19
|
+
* button beside it in a toolbar.
|
|
20
|
+
*
|
|
21
|
+
* It lives here rather than in `IconButton` because appending a
|
|
22
|
+
* competing `size-*`/`px-0` to this component's `className` would
|
|
23
|
+
* be a specificity coin-flip: two utilities setting the same
|
|
24
|
+
* property, and the winner decided by Tailwind's internal
|
|
25
|
+
* ordering rather than by the caller.
|
|
26
|
+
*/
|
|
27
|
+
sizing?: "control" | "icon";
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Hand-rolled in **seven repos**, each with its own
|
|
31
|
+
* primary/secondary/danger/ghost, and this is the component that
|
|
32
|
+
* erases the most duplication in the fleet.
|
|
33
|
+
*
|
|
34
|
+
* Four decisions worth knowing, all of them fixing something an
|
|
35
|
+
* inventoried repo gets wrong:
|
|
36
|
+
*
|
|
37
|
+
* - **`type="button"` by default.** A `<button>` inside a form
|
|
38
|
+
* submits it unless told otherwise, and "clicking the copy icon
|
|
39
|
+
* reloaded the page" is a bug every app in the fleet has shipped
|
|
40
|
+
* at least once.
|
|
41
|
+
* - **`isLoading` disables and announces.** The button goes
|
|
42
|
+
* `aria-busy`, the spinner replaces `iconStart` rather than
|
|
43
|
+
* being appended (so the label does not jump), and the label
|
|
44
|
+
* itself stays — a spinner that swallows the text leaves the
|
|
45
|
+
* user unsure what they pressed.
|
|
46
|
+
* - **`focus-visible`, never `focus`.** See `FOCUS_RING_CLASS`.
|
|
47
|
+
* - **Sizing comes from the density axis**, not from a prop table:
|
|
48
|
+
* `size="md"` is 2.25rem on a desktop and 3.25rem on the kiosk,
|
|
49
|
+
* with no prop change. `MIN_TOUCH_TARGET_CLASS` is available for
|
|
50
|
+
* a control that must clear 44px regardless.
|
|
51
|
+
*
|
|
52
|
+
* The accessible name is the children. `IconButton` exists because
|
|
53
|
+
* a glyph is not a name, and enforcing that in *this* component's
|
|
54
|
+
* types would mean rejecting `<Button>{icon} Copy</Button>`, which
|
|
55
|
+
* is fine.
|
|
56
|
+
*/
|
|
57
|
+
export declare const Button: ({ appearance, children, className, iconEnd, iconStart, intent, isDisabled, isFullWidth, isLoading, loadingLabel, size, sizing, type, ...buttonProps }: ButtonProps) => ReactNode;
|
|
58
|
+
//# sourceMappingURL=Button.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Button.d.ts","sourceRoot":"","sources":["../../src/Button/Button.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,WAAW,EACX,UAAU,EACX,MAAM,qBAAqB,CAAA;AAC5B,OAAO,KAAK,EACV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAMd,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA;AAU1D,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,CACX,GAAG;IACF,UAAU,CAAC,EAAE,gBAAgB,CAAA;IAC7B,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,SAAS,CAAC,EAAE,SAAS,CAAA;IACrB,MAAM,CAAC,EAAE,UAAU,CAAA;IACnB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,6CAA6C;IAC7C,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB;;;;;;;;;;;OAWG;IACH,MAAM,CAAC,EAAE,SAAS,GAAG,MAAM,CAAA;CAC5B,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,MAAM,GAAI,uJAepB,WAAW,KAAG,SA6BhB,CAAA"}
|