@charcuterie/ui 0.1.1 → 0.2.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/README.md +9 -0
- package/dist/Accordion/Accordion.d.ts +82 -0
- package/dist/Accordion/Accordion.d.ts.map +1 -0
- package/dist/Accordion/Accordion.js +108 -0
- package/dist/Accordion/Accordion.js.map +1 -0
- package/dist/Accordion/AccordionSection.d.ts +28 -0
- package/dist/Accordion/AccordionSection.d.ts.map +1 -0
- package/dist/Accordion/AccordionSection.js +53 -0
- package/dist/Accordion/AccordionSection.js.map +1 -0
- package/dist/Field/Field.d.ts +69 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +73 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/FileDropZone/FileDropZone.d.ts +63 -0
- package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
- package/dist/FileDropZone/FileDropZone.js +122 -0
- package/dist/FileDropZone/FileDropZone.js.map +1 -0
- package/dist/LogViewer/LogViewer.d.ts +88 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +104 -0
- package/dist/LogViewer/LogViewer.js.map +1 -0
- package/dist/Menu/Menu.d.ts +81 -0
- package/dist/Menu/Menu.d.ts.map +1 -0
- package/dist/Menu/Menu.js +199 -0
- package/dist/Menu/Menu.js.map +1 -0
- package/dist/Menu/MenuAction.d.ts +24 -0
- package/dist/Menu/MenuAction.d.ts.map +1 -0
- package/dist/Menu/MenuAction.js +37 -0
- package/dist/Menu/MenuAction.js.map +1 -0
- package/dist/Select/Select.d.ts +87 -0
- package/dist/Select/Select.d.ts.map +1 -0
- package/dist/Select/Select.js +51 -0
- package/dist/Select/Select.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
- package/dist/Toast/Toast.d.ts +54 -0
- package/dist/Toast/Toast.d.ts.map +1 -0
- package/dist/Toast/Toast.js +121 -0
- package/dist/Toast/Toast.js.map +1 -0
- package/dist/Toast/ToastRegion.d.ts +57 -0
- package/dist/Toast/ToastRegion.d.ts.map +1 -0
- package/dist/Toast/ToastRegion.js +41 -0
- package/dist/Toast/ToastRegion.js.map +1 -0
- package/dist/Toast/toastLifecycle.d.ts +34 -0
- package/dist/Toast/toastLifecycle.d.ts.map +1 -0
- package/dist/Toast/toastLifecycle.js +25 -0
- package/dist/Toast/toastLifecycle.js.map +1 -0
- package/dist/Tooltip/Tooltip.d.ts +68 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +104 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +0 -11
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +27 -0
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/package.json +1 -1
- package/src/Accordion/Accordion.mdx +83 -0
- package/src/Accordion/Accordion.stories.tsx +146 -0
- package/src/Accordion/Accordion.test.tsx +163 -0
- package/src/Accordion/Accordion.tsx +182 -0
- package/src/Accordion/AccordionSection.tsx +163 -0
- package/src/Field/Field.mdx +74 -0
- package/src/Field/Field.stories.tsx +144 -0
- package/src/Field/Field.test.tsx +142 -0
- package/src/Field/Field.tsx +163 -0
- package/src/FileDropZone/FileDropZone.mdx +78 -0
- package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
- package/src/FileDropZone/FileDropZone.test.tsx +230 -0
- package/src/FileDropZone/FileDropZone.tsx +234 -0
- package/src/LogViewer/LogViewer.mdx +88 -0
- package/src/LogViewer/LogViewer.stories.tsx +135 -0
- package/src/LogViewer/LogViewer.test.tsx +173 -0
- package/src/LogViewer/LogViewer.tsx +195 -0
- package/src/Menu/Menu.mdx +103 -0
- package/src/Menu/Menu.stories.tsx +161 -0
- package/src/Menu/Menu.test.tsx +202 -0
- package/src/Menu/Menu.tsx +300 -0
- package/src/Menu/MenuAction.tsx +95 -0
- package/src/Select/Select.mdx +74 -0
- package/src/Select/Select.stories.tsx +126 -0
- package/src/Select/Select.test.tsx +129 -0
- package/src/Select/Select.tsx +200 -0
- package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
- package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
- package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
- package/src/Toast/Toast.mdx +84 -0
- package/src/Toast/Toast.stories.tsx +192 -0
- package/src/Toast/Toast.test.tsx +217 -0
- package/src/Toast/Toast.tsx +224 -0
- package/src/Toast/ToastRegion.tsx +98 -0
- package/src/Toast/toastLifecycle.ts +45 -0
- package/src/Tooltip/Tooltip.mdx +69 -0
- package/src/Tooltip/Tooltip.stories.tsx +104 -0
- package/src/Tooltip/Tooltip.test.tsx +138 -0
- package/src/Tooltip/Tooltip.tsx +169 -0
- package/src/index.ts +43 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/sourceRules.test.ts +221 -30
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { IntentName } from "@charcuterie/tokens"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
|
|
4
|
+
import { toClassName } from "../toClassName.ts"
|
|
5
|
+
import { Toast } from "./Toast.tsx"
|
|
6
|
+
|
|
7
|
+
export type ToastRecord = {
|
|
8
|
+
description?: ReactNode
|
|
9
|
+
/** `0` pins this one open. See `Toast`'s `duration`. */
|
|
10
|
+
duration?: number
|
|
11
|
+
intent?: IntentName
|
|
12
|
+
key: string
|
|
13
|
+
title: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ToastRegionProps = {
|
|
17
|
+
className?: string
|
|
18
|
+
/**
|
|
19
|
+
* The region's accessible name. `role="status"` takes **none from
|
|
20
|
+
* its content** — the trap `Spinner` walked into in M3 and
|
|
21
|
+
* `Popover` in M4.
|
|
22
|
+
*/
|
|
23
|
+
label?: string
|
|
24
|
+
onDismiss: (key: string) => void
|
|
25
|
+
toasts: ToastRecord[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The live region, and the list is the caller's.
|
|
30
|
+
*
|
|
31
|
+
* ### One region, and it is polite
|
|
32
|
+
*
|
|
33
|
+
* Not two. The usual arrangement — a polite region and an assertive
|
|
34
|
+
* one, so a danger toast interrupts — was rejected because the
|
|
35
|
+
* interrupting case does not belong in a toast at all. A message
|
|
36
|
+
* that must be heard *now* is one the user has to be able to go back
|
|
37
|
+
* and read, and a toast is gone in five seconds; if it matters that
|
|
38
|
+
* much it is an `Alert` on the page, which this library already
|
|
39
|
+
* ships and which stays put.
|
|
40
|
+
*
|
|
41
|
+
* That is the same line `Field` draws for its error text, and it is
|
|
42
|
+
* worth keeping the two consistent: **this library announces once,
|
|
43
|
+
* in the place the user can act on it.**
|
|
44
|
+
*
|
|
45
|
+
* ### The stack does not own the toasts
|
|
46
|
+
*
|
|
47
|
+
* `toasts` comes in and `onDismiss` goes out — there is no
|
|
48
|
+
* `toast()` function, no imperative queue, and no context provider.
|
|
49
|
+
* An app already has a store (mux-magic has Jotai, rip-deck has its
|
|
50
|
+
* own), and a component library that ships a second one is asking
|
|
51
|
+
* every consumer to keep two lists of the same notifications in
|
|
52
|
+
* sync. The lifecycle *inside* one toast is state a store cannot
|
|
53
|
+
* see, which is why `Toast` owns that and nothing more.
|
|
54
|
+
*
|
|
55
|
+
* ### `pointer-events-none` on the container, `auto` on each toast
|
|
56
|
+
*
|
|
57
|
+
* A fixed overlay across the bottom of the viewport swallows every
|
|
58
|
+
* click in that band, including on the app behind it, and it does so
|
|
59
|
+
* invisibly. The container passes clicks through and each toast
|
|
60
|
+
* takes them back.
|
|
61
|
+
*/
|
|
62
|
+
export const ToastRegion = ({
|
|
63
|
+
className,
|
|
64
|
+
label = "Notifications",
|
|
65
|
+
onDismiss,
|
|
66
|
+
toasts,
|
|
67
|
+
}: ToastRegionProps): ReactNode => (
|
|
68
|
+
<div
|
|
69
|
+
aria-label={label}
|
|
70
|
+
// `role="status"` is `aria-live="polite"` plus
|
|
71
|
+
// `aria-atomic="true"` by default — and atomic is wrong here,
|
|
72
|
+
// because it re-reads the entire stack every time one arrives.
|
|
73
|
+
// Spelling `aria-atomic="false"` next to it is what makes a
|
|
74
|
+
// second toast announce only itself.
|
|
75
|
+
aria-atomic="false"
|
|
76
|
+
className={toClassName(
|
|
77
|
+
"pointer-events-none fixed inset-x-0 bottom-0 z-50 flex flex-col items-center gap-2 p-4",
|
|
78
|
+
className,
|
|
79
|
+
)}
|
|
80
|
+
role="status"
|
|
81
|
+
>
|
|
82
|
+
<ul className="pointer-events-none flex w-full max-w-sm list-none flex-col gap-2 p-0">
|
|
83
|
+
{toasts.map((toast) => (
|
|
84
|
+
<Toast
|
|
85
|
+
duration={toast.duration}
|
|
86
|
+
intent={toast.intent}
|
|
87
|
+
key={toast.key}
|
|
88
|
+
onRemove={() => {
|
|
89
|
+
onDismiss(toast.key)
|
|
90
|
+
}}
|
|
91
|
+
title={toast.title}
|
|
92
|
+
>
|
|
93
|
+
{toast.description}
|
|
94
|
+
</Toast>
|
|
95
|
+
))}
|
|
96
|
+
</ul>
|
|
97
|
+
</div>
|
|
98
|
+
)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { StatusTransitions } from "@charcuterie/logic"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The one machine the plan wrote down and M2 did not build:
|
|
5
|
+
*
|
|
6
|
+
* > | Toast lifecycle | entering → visible → exiting → removed |
|
|
7
|
+
*
|
|
8
|
+
* It is here rather than in `@charcuterie/logic/core` beside
|
|
9
|
+
* `asyncTransitions` and `connectionTransitions` on purpose. Those
|
|
10
|
+
* two describe *data* — a fetch, a socket — and a Preact app with no
|
|
11
|
+
* components at all still wants them. This one describes an
|
|
12
|
+
* **animation**, which only exists because something is being
|
|
13
|
+
* painted, so it belongs to the component layer.
|
|
14
|
+
*/
|
|
15
|
+
export type ToastStatus =
|
|
16
|
+
| "entering"
|
|
17
|
+
| "exiting"
|
|
18
|
+
| "removed"
|
|
19
|
+
| "visible"
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `visible → removed` is deliberately **not** a legal transition,
|
|
23
|
+
* and neither is anything out of `removed`.
|
|
24
|
+
*
|
|
25
|
+
* The first means a toast cannot skip its exit — a dismissal that
|
|
26
|
+
* unmounts the node immediately is the flicker every hand-rolled
|
|
27
|
+
* toast stack has, and making it illegal turns that into a thrown
|
|
28
|
+
* error in a test rather than a jump nobody files.
|
|
29
|
+
*
|
|
30
|
+
* The second makes `removed` terminal, which is what lets the region
|
|
31
|
+
* call `onDismiss` exactly once. `createStatus` throws on an illegal
|
|
32
|
+
* transition, so a second timer firing after the first is a loud
|
|
33
|
+
* failure instead of a duplicate removal.
|
|
34
|
+
*
|
|
35
|
+
* `exiting → visible` **is** legal: pointing at a toast that is
|
|
36
|
+
* already sliding away has to bring it back, or the user is chasing
|
|
37
|
+
* a disappearing message with the cursor.
|
|
38
|
+
*/
|
|
39
|
+
export const toastTransitions: StatusTransitions<ToastStatus> =
|
|
40
|
+
{
|
|
41
|
+
entering: ["visible", "exiting"],
|
|
42
|
+
exiting: ["removed", "visible"],
|
|
43
|
+
removed: [],
|
|
44
|
+
visible: ["exiting"],
|
|
45
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
|
|
2
|
+
|
|
3
|
+
import * as TooltipStories from "./Tooltip.stories";
|
|
4
|
+
|
|
5
|
+
<Meta of={TooltipStories} />
|
|
6
|
+
|
|
7
|
+
# Tooltip
|
|
8
|
+
|
|
9
|
+
Replaces mux-magic's `FieldTooltip` — 130 lines of hand-rolled positioning, including a
|
|
10
|
+
`computePosition` that reimplements `flip` and `shift` against `window.innerHeight`.
|
|
11
|
+
|
|
12
|
+
<Canvas of={TooltipStories.Default} />
|
|
13
|
+
|
|
14
|
+
## Props
|
|
15
|
+
|
|
16
|
+
<Controls />
|
|
17
|
+
|
|
18
|
+
## Three defects, all of which render perfectly
|
|
19
|
+
|
|
20
|
+
1. **Nothing references the tip.** It sets `role="tooltip"` and no `aria-describedby`
|
|
21
|
+
anywhere, so the control it belongs to never mentions it. `role="tooltip"` is **not** a
|
|
22
|
+
live region and does nothing on its own — the tip is a floating node nobody is pointed
|
|
23
|
+
at.
|
|
24
|
+
2. **Pointer only.** `onPointerEnter`/`onPointerLeave` with no `onFocus`/`onBlur`, so a
|
|
25
|
+
keyboard user cannot open it at all. WCAG 2.1.1.
|
|
26
|
+
3. **No dismiss.** Escape does nothing, which WCAG 1.4.13 requires of any content shown on
|
|
27
|
+
hover.
|
|
28
|
+
|
|
29
|
+
All three come from the same place: **the ARIA is written by hand beside the behaviour
|
|
30
|
+
instead of falling out of it.** Here `useRole` writes `aria-describedby` on the trigger and
|
|
31
|
+
`role` + `id` on the tip from one source, `useFocus` is what makes it keyboard-openable, and
|
|
32
|
+
`useDismiss` is Escape.
|
|
33
|
+
|
|
34
|
+
<Canvas of={TooltipStories.AllVariants} />
|
|
35
|
+
|
|
36
|
+
## A tooltip adds; it is never the name
|
|
37
|
+
|
|
38
|
+
| | may be the only place the text exists | why |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `aria-label` | **yes** | it *is* the name |
|
|
41
|
+
| `Field` `description` | **yes** | bound with `aria-describedby`, read on focus |
|
|
42
|
+
| `Badge`'s `title` | no | repeats text already in `textContent` |
|
|
43
|
+
| `Tooltip` | **no** | absent on touch, absent when nothing hovers |
|
|
44
|
+
|
|
45
|
+
An `IconButton` under a tooltip still carries its own `aria-label`. `AllVariants` proves the
|
|
46
|
+
button stays findable by the same query whether or not the tip is open.
|
|
47
|
+
|
|
48
|
+
## No `popover` and no focus manager
|
|
49
|
+
|
|
50
|
+
Unlike `Popover`, which is in the top layer and traps nothing. Focus never enters a tooltip
|
|
51
|
+
— moving it there is what makes one inescapable — so there is no `FloatingFocusManager` and
|
|
52
|
+
no `popover="manual"`.
|
|
53
|
+
|
|
54
|
+
The trade is real and worth stating: a tooltip is therefore **not** in the top layer and can
|
|
55
|
+
be clipped by an `overflow: hidden` ancestor. `strategy: "fixed"` keeps that to ancestors
|
|
56
|
+
which actually clip fixed descendants.
|
|
57
|
+
|
|
58
|
+
<Canvas of={TooltipStories.AllStates} />
|
|
59
|
+
|
|
60
|
+
## Touch has no hover, and this does not pretend otherwise
|
|
61
|
+
|
|
62
|
+
There is no long-press fallback. A tooltip is supplementary by construction, so on the kiosk
|
|
63
|
+
it simply never appears — and anything a touch user *must* read belongs in a `Field`'s
|
|
64
|
+
`description` instead.
|
|
65
|
+
|
|
66
|
+
Building a long-press would make the tip load-bearing on exactly the surface where it is
|
|
67
|
+
least reliable.
|
|
68
|
+
|
|
69
|
+
<Canvas of={TooltipStories.Interactive} />
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react"
|
|
2
|
+
|
|
3
|
+
import { placementArgType } from "../argTypes.storyHelpers.ts"
|
|
4
|
+
import { Button } from "../Button/Button.tsx"
|
|
5
|
+
import {
|
|
6
|
+
StoryCell,
|
|
7
|
+
StoryGrid,
|
|
8
|
+
} from "../board.storyHelpers.tsx"
|
|
9
|
+
import { IconButton } from "../IconButton/IconButton.tsx"
|
|
10
|
+
import { SettingsIcon } from "../icons.storyHelpers.tsx"
|
|
11
|
+
import { Tooltip } from "./Tooltip.tsx"
|
|
12
|
+
|
|
13
|
+
const meta = {
|
|
14
|
+
title: "Components/Tooltip",
|
|
15
|
+
component: Tooltip,
|
|
16
|
+
parameters: { layout: "centered" },
|
|
17
|
+
argTypes: { placement: placementArgType },
|
|
18
|
+
args: {
|
|
19
|
+
delay: 200,
|
|
20
|
+
label: "Re-reads the disc from title 1.",
|
|
21
|
+
placement: "top",
|
|
22
|
+
},
|
|
23
|
+
} satisfies Meta<typeof Tooltip>
|
|
24
|
+
|
|
25
|
+
export default meta
|
|
26
|
+
|
|
27
|
+
type Story = StoryObj<typeof meta>
|
|
28
|
+
|
|
29
|
+
export const Default: Story = {
|
|
30
|
+
args: {
|
|
31
|
+
children: <Button appearance="outline">Retry</Button>,
|
|
32
|
+
label: "Re-reads the disc from title 1.",
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A tooltip **adds**; it never *is* the name. The icon button below
|
|
38
|
+
* still carries its own `aria-label` — the tip is the sentence you
|
|
39
|
+
* would otherwise have had no room for.
|
|
40
|
+
*/
|
|
41
|
+
export const AllVariants: Story = {
|
|
42
|
+
args: {
|
|
43
|
+
children: <Button appearance="outline">Retry</Button>,
|
|
44
|
+
label: "Re-reads the disc from title 1.",
|
|
45
|
+
},
|
|
46
|
+
render: () => (
|
|
47
|
+
<StoryGrid columns={3}>
|
|
48
|
+
<StoryCell label="on a button">
|
|
49
|
+
<Tooltip label="Re-reads the disc from title 1.">
|
|
50
|
+
<Button appearance="outline">Retry</Button>
|
|
51
|
+
</Tooltip>
|
|
52
|
+
</StoryCell>
|
|
53
|
+
|
|
54
|
+
<StoryCell label="on an IconButton">
|
|
55
|
+
<Tooltip label="Opens the bay's rip settings.">
|
|
56
|
+
<IconButton label="Bay settings">
|
|
57
|
+
<SettingsIcon />
|
|
58
|
+
</IconButton>
|
|
59
|
+
</Tooltip>
|
|
60
|
+
</StoryCell>
|
|
61
|
+
|
|
62
|
+
<StoryCell label="placed below">
|
|
63
|
+
<Tooltip
|
|
64
|
+
label="Skips this title and carries on."
|
|
65
|
+
placement="bottom"
|
|
66
|
+
>
|
|
67
|
+
<Button appearance="outline">Skip</Button>
|
|
68
|
+
</Tooltip>
|
|
69
|
+
</StoryCell>
|
|
70
|
+
</StoryGrid>
|
|
71
|
+
),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Closed until something asks for it. There is no "open" prop: a
|
|
76
|
+
* tooltip's visibility is entirely a consequence of hover or focus,
|
|
77
|
+
* which is the one piece of state in this library a consumer has no
|
|
78
|
+
* business holding.
|
|
79
|
+
*/
|
|
80
|
+
export const AllStates: Story = {
|
|
81
|
+
args: {
|
|
82
|
+
children: (
|
|
83
|
+
<Button appearance="outline">
|
|
84
|
+
Hover or Tab to me
|
|
85
|
+
</Button>
|
|
86
|
+
),
|
|
87
|
+
label: "Re-reads the disc from title 1.",
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Tab to the button. The tip opens **on focus** — the line
|
|
93
|
+
* mux-magic's `FieldTooltip` is missing, which makes its version
|
|
94
|
+
* pointer-only and a WCAG 2.1.1 failure on the control whose
|
|
95
|
+
* explanation lives there. Escape closes it, per WCAG 1.4.13.
|
|
96
|
+
*/
|
|
97
|
+
export const Interactive: Story = {
|
|
98
|
+
args: {
|
|
99
|
+
children: (
|
|
100
|
+
<Button appearance="outline">Keyboard tip</Button>
|
|
101
|
+
),
|
|
102
|
+
label: "Opened by focus, closed by Escape.",
|
|
103
|
+
},
|
|
104
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent, waitFor } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
6
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
7
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
|
+
import * as stories from "./Tooltip.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const { AllVariants, Default, Interactive } =
|
|
11
|
+
composeStories(stories)
|
|
12
|
+
|
|
13
|
+
test("nothing is shown until something asks", async () => {
|
|
14
|
+
const { canvas, canvasElement } =
|
|
15
|
+
await mountStory(Default)
|
|
16
|
+
|
|
17
|
+
expectAgentDrivable(canvas, {
|
|
18
|
+
name: "Retry",
|
|
19
|
+
role: "button",
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
await expect(
|
|
23
|
+
canvas.queryAllByRole("tooltip"),
|
|
24
|
+
).toHaveLength(0)
|
|
25
|
+
|
|
26
|
+
await expectNoAxeViolations(canvasElement)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The defect that matters most in mux-magic's `FieldTooltip`: it
|
|
31
|
+
* renders `role="tooltip"` and **no `aria-describedby` anywhere**,
|
|
32
|
+
* so the control it belongs to never mentions it. `role="tooltip"`
|
|
33
|
+
* is not a live region and does nothing on its own — the tip is a
|
|
34
|
+
* floating node nobody is pointed at.
|
|
35
|
+
*/
|
|
36
|
+
test("the trigger is described by the tip that opened", async () => {
|
|
37
|
+
const { canvas, canvasElement } =
|
|
38
|
+
await mountStory(Interactive)
|
|
39
|
+
|
|
40
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
41
|
+
name: "Keyboard tip",
|
|
42
|
+
role: "button",
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
await expect(trigger).not.toHaveAttribute(
|
|
46
|
+
"aria-describedby",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
await userEvent.tab()
|
|
50
|
+
|
|
51
|
+
await waitFor(() => {
|
|
52
|
+
expect(trigger).toHaveFocus()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const tip = await waitFor(() =>
|
|
56
|
+
expectAgentDrivable(canvas, { role: "tooltip" }),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
await expect(trigger).toHaveAttribute(
|
|
60
|
+
"aria-describedby",
|
|
61
|
+
tip.id,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
await expect(tip).toHaveTextContent(
|
|
65
|
+
"Opened by focus, closed by Escape.",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
// Audited in the *driven* state, not at mount. The a11y addon
|
|
69
|
+
// runs axe once, when `run()` resolves — before anything has been
|
|
70
|
+
// focused — so an open tip would never be seen by it.
|
|
71
|
+
await expectNoAxeViolations(canvasElement)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Focus, not just hover. `useFocus` is the line mux-magic is
|
|
76
|
+
* missing, and without it the tip is pointer-only — a WCAG 2.1.1
|
|
77
|
+
* failure on the one control whose explanation lives there.
|
|
78
|
+
*/
|
|
79
|
+
test("focus opens it and blur closes it", async () => {
|
|
80
|
+
const { canvas } = await mountStory(Interactive)
|
|
81
|
+
|
|
82
|
+
await userEvent.tab()
|
|
83
|
+
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
expectAgentDrivable(canvas, { role: "tooltip" })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
await userEvent.tab()
|
|
89
|
+
|
|
90
|
+
await waitFor(() => {
|
|
91
|
+
expect(canvas.queryAllByRole("tooltip")).toHaveLength(0)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test("Escape dismisses it", async () => {
|
|
96
|
+
const { canvas } = await mountStory(Interactive)
|
|
97
|
+
|
|
98
|
+
await userEvent.tab()
|
|
99
|
+
|
|
100
|
+
await waitFor(() => {
|
|
101
|
+
expectAgentDrivable(canvas, { role: "tooltip" })
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// WCAG 1.4.13 requires content shown on hover or focus to be
|
|
105
|
+
// dismissable without moving the pointer or the caret.
|
|
106
|
+
await userEvent.keyboard("{Escape}")
|
|
107
|
+
|
|
108
|
+
await waitFor(() => {
|
|
109
|
+
expect(canvas.queryAllByRole("tooltip")).toHaveLength(0)
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A tooltip **adds**; it is never the name. An `IconButton` still
|
|
115
|
+
* carries its own `aria-label`, and the tip is the sentence there
|
|
116
|
+
* was no room for — so the button stays findable by the same query
|
|
117
|
+
* whether or not the tip is open.
|
|
118
|
+
*/
|
|
119
|
+
test("the tip does not become the control's name", async () => {
|
|
120
|
+
const { canvas } = await mountStory(AllVariants)
|
|
121
|
+
|
|
122
|
+
const iconButton = expectAgentDrivable(canvas, {
|
|
123
|
+
name: "Bay settings",
|
|
124
|
+
role: "button",
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
await userEvent.hover(iconButton)
|
|
128
|
+
|
|
129
|
+
await waitFor(() => {
|
|
130
|
+
expectAgentDrivable(canvas, { role: "tooltip" })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// Still "Bay settings", not "Opens the bay's rip settings."
|
|
134
|
+
expectAgentDrivable(canvas, {
|
|
135
|
+
name: "Bay settings",
|
|
136
|
+
role: "button",
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useClonedChild,
|
|
3
|
+
useVisibility,
|
|
4
|
+
} from "@charcuterie/logic"
|
|
5
|
+
import type { Placement } from "@floating-ui/react"
|
|
6
|
+
import {
|
|
7
|
+
autoUpdate,
|
|
8
|
+
flip,
|
|
9
|
+
offset,
|
|
10
|
+
shift,
|
|
11
|
+
useDismiss,
|
|
12
|
+
useFloating,
|
|
13
|
+
useFocus,
|
|
14
|
+
useHover,
|
|
15
|
+
useInteractions,
|
|
16
|
+
useRole,
|
|
17
|
+
} from "@floating-ui/react"
|
|
18
|
+
import type { ReactElement, ReactNode } from "react"
|
|
19
|
+
|
|
20
|
+
import { toClassName } from "../toClassName.ts"
|
|
21
|
+
|
|
22
|
+
export type TooltipProps = {
|
|
23
|
+
/**
|
|
24
|
+
* The control the tip describes. **Cloned, not wrapped** — the
|
|
25
|
+
* same slot contract as `Popover`'s trigger.
|
|
26
|
+
*
|
|
27
|
+
* It has to be something focusable. A tooltip on a `<span>` is
|
|
28
|
+
* pointer-only, which is the state mux-magic's is in today.
|
|
29
|
+
*/
|
|
30
|
+
children: ReactElement
|
|
31
|
+
className?: string
|
|
32
|
+
/** Milliseconds of hover before it opens. */
|
|
33
|
+
delay?: number
|
|
34
|
+
/**
|
|
35
|
+
* Text, and text only. `role="tooltip"` has no way to move focus
|
|
36
|
+
* into itself, so a link or a button inside one is unreachable by
|
|
37
|
+
* keyboard and unreadable by touch — if it needs a control, it is
|
|
38
|
+
* a `Popover`.
|
|
39
|
+
*/
|
|
40
|
+
label: string
|
|
41
|
+
placement?: Placement
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Replaces mux-magic's `FieldTooltip`, which is 130 lines of
|
|
46
|
+
* hand-rolled positioning — a `computePosition` that reimplements
|
|
47
|
+
* `flip` and `shift` against `window.innerHeight` — and has three
|
|
48
|
+
* defects that all render perfectly:
|
|
49
|
+
*
|
|
50
|
+
* 1. **Nothing references the tip.** It sets `role="tooltip"` and
|
|
51
|
+
* no `aria-describedby` anywhere, so the control it belongs to
|
|
52
|
+
* never mentions it. A screen reader reads the button, says
|
|
53
|
+
* nothing about the tip, and the tip itself is a floating node
|
|
54
|
+
* nobody is pointed at. `role="tooltip"` is not a live region;
|
|
55
|
+
* it does nothing on its own.
|
|
56
|
+
* 2. **Pointer only.** `onPointerEnter`/`onPointerLeave` with no
|
|
57
|
+
* `onFocus`/`onBlur`, so a keyboard user cannot open it at all.
|
|
58
|
+
* 3. **No dismiss.** Escape does nothing, which WCAG 1.4.13
|
|
59
|
+
* requires of any content shown on hover.
|
|
60
|
+
*
|
|
61
|
+
* All three come from the same place: the ARIA is written by hand
|
|
62
|
+
* beside the behaviour instead of falling out of it. Here `useRole`
|
|
63
|
+
* writes `aria-describedby` on the trigger and `role` + `id` on the
|
|
64
|
+
* tip from one source, `useFocus` is what makes it keyboard-openable,
|
|
65
|
+
* and `useDismiss` is Escape.
|
|
66
|
+
*
|
|
67
|
+
* ### Not a `title`, and not a substitute for a name
|
|
68
|
+
*
|
|
69
|
+
* A `title` attribute is unstyleable, has a ~1s delay nobody can
|
|
70
|
+
* change, and does not exist on touch. `Badge` still uses one — for
|
|
71
|
+
* a *truncation* readout, where the full text is already in
|
|
72
|
+
* `textContent` and the tip is a convenience
|
|
73
|
+
* ([decision](../../../docs/decisions/2026-07-30-a-badge-truncates-with-text-overflow.md)).
|
|
74
|
+
* That is the line: a `title` may repeat something already
|
|
75
|
+
* available, and a `Tooltip` may add something, but **neither may be
|
|
76
|
+
* the only place a control's name exists**. An icon button still
|
|
77
|
+
* needs its `aria-label`; the tip is extra.
|
|
78
|
+
*
|
|
79
|
+
* ### Touch has no hover, and this component does not pretend
|
|
80
|
+
*
|
|
81
|
+
* There is no long-press fallback here. A tooltip is supplementary
|
|
82
|
+
* by construction, so on the kiosk it simply never appears, and
|
|
83
|
+
* anything a touch user *must* read belongs in a `Field`'s
|
|
84
|
+
* `description` instead. Building a long-press would make the tip
|
|
85
|
+
* load-bearing on exactly the surface where it is least reliable.
|
|
86
|
+
*/
|
|
87
|
+
export const Tooltip = ({
|
|
88
|
+
children,
|
|
89
|
+
className,
|
|
90
|
+
delay = 200,
|
|
91
|
+
label,
|
|
92
|
+
placement = "top",
|
|
93
|
+
}: TooltipProps): ReactNode => {
|
|
94
|
+
const { isVisible, setIsVisible } = useVisibility()
|
|
95
|
+
|
|
96
|
+
const { context, floatingStyles, refs } = useFloating({
|
|
97
|
+
// Read-only, as everywhere: floating-ui is told what is true and
|
|
98
|
+
// never decides it.
|
|
99
|
+
open: isVisible,
|
|
100
|
+
onOpenChange: setIsVisible,
|
|
101
|
+
placement,
|
|
102
|
+
middleware: [offset(6), flip(), shift({ padding: 8 })],
|
|
103
|
+
strategy: "fixed",
|
|
104
|
+
whileElementsMounted: autoUpdate,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const { getFloatingProps, getReferenceProps } =
|
|
108
|
+
useInteractions([
|
|
109
|
+
useHover(context, {
|
|
110
|
+
// `move: false` — a tooltip that re-opens because the
|
|
111
|
+
// pointer twitched inside the trigger is the flicker every
|
|
112
|
+
// hand-rolled one has.
|
|
113
|
+
move: false,
|
|
114
|
+
restMs: delay,
|
|
115
|
+
}),
|
|
116
|
+
// The line mux-magic is missing. Without it the tip is
|
|
117
|
+
// pointer-only, which is a WCAG 2.1.1 failure on a control
|
|
118
|
+
// whose explanation lives there.
|
|
119
|
+
useFocus(context),
|
|
120
|
+
useDismiss(context, { referencePress: false }),
|
|
121
|
+
useRole(context, { role: "tooltip" }),
|
|
122
|
+
])
|
|
123
|
+
|
|
124
|
+
const clonedTrigger = useClonedChild(children, {
|
|
125
|
+
...getReferenceProps(),
|
|
126
|
+
ref: refs.setReference,
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<>
|
|
131
|
+
{clonedTrigger}
|
|
132
|
+
|
|
133
|
+
{isVisible ? (
|
|
134
|
+
<div
|
|
135
|
+
{...getFloatingProps()}
|
|
136
|
+
className={toClassName(
|
|
137
|
+
// `intent-neutral-solid` + its own `on-solid`, rather
|
|
138
|
+
// than `surface-inverse` + `content-on-accent`. The
|
|
139
|
+
// second pair looks like the obvious one and is not a
|
|
140
|
+
// *pair* at all — nothing in the token set promises those
|
|
141
|
+
// two contrast, and in `daylight` dark they land at
|
|
142
|
+
// 2.9:1. The `solid`/`on-solid` roles exist precisely to
|
|
143
|
+
// be a guaranteed couple, and the contrast gate checks
|
|
144
|
+
// them as one.
|
|
145
|
+
"inset-auto m-0 max-w-xs rounded-md bg-intent-neutral-solid px-2 py-1 text-intent-neutral-on-solid text-xs shadow-medium",
|
|
146
|
+
className,
|
|
147
|
+
)}
|
|
148
|
+
// No `popover="manual"` and no `FloatingFocusManager`,
|
|
149
|
+
// unlike `Popover`. Both exist to manage focus, and focus
|
|
150
|
+
// never enters a tooltip — moving it here is what makes
|
|
151
|
+
// one unescapable. The trade is that a tooltip is not in
|
|
152
|
+
// the top layer, so it can be clipped by an
|
|
153
|
+
// `overflow: hidden` ancestor; `strategy: "fixed"` keeps
|
|
154
|
+
// that to ancestors that actually clip fixed descendants.
|
|
155
|
+
ref={refs.setFloating}
|
|
156
|
+
// Duplicated from `getFloatingProps()` deliberately, and
|
|
157
|
+
// the two cannot differ — `useRole(context, { role:
|
|
158
|
+
// "tooltip" })` above is where it comes from. Stated here
|
|
159
|
+
// because a linter cannot see a role through a spread, and
|
|
160
|
+
// neither can the next reader.
|
|
161
|
+
role="tooltip"
|
|
162
|
+
style={floatingStyles}
|
|
163
|
+
>
|
|
164
|
+
{label}
|
|
165
|
+
</div>
|
|
166
|
+
) : null}
|
|
167
|
+
</>
|
|
168
|
+
)
|
|
169
|
+
}
|