@charcuterie/ui 0.1.1 → 1.0.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 +104 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +115 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/Field/FieldGroup.d.ts +58 -0
- package/dist/Field/FieldGroup.d.ts.map +1 -0
- package/dist/Field/FieldGroup.js +65 -0
- package/dist/Field/FieldGroup.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 +90 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +165 -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 +82 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +124 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -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/dist/slotProps.d.ts +110 -0
- package/dist/slotProps.d.ts.map +1 -0
- package/dist/slotProps.js +39 -0
- package/dist/slotProps.js.map +1 -0
- package/package.json +3 -3
- 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 +144 -0
- package/src/Field/Field.stories.tsx +280 -0
- package/src/Field/Field.test.tsx +344 -0
- package/src/Field/Field.tsx +212 -0
- package/src/Field/FieldGroup.tsx +138 -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 +123 -0
- package/src/LogViewer/LogViewer.stories.tsx +169 -0
- package/src/LogViewer/LogViewer.test.tsx +236 -0
- package/src/LogViewer/LogViewer.tsx +261 -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 +193 -0
- package/src/index.ts +47 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/slotProps.ts +143 -0
- package/src/sourceRules.test.ts +221 -30
|
@@ -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,193 @@
|
|
|
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 type { SlotProps } from "../slotProps.ts"
|
|
21
|
+
import { mergeSlotProps } from "../slotProps.ts"
|
|
22
|
+
import { toClassName } from "../toClassName.ts"
|
|
23
|
+
|
|
24
|
+
export type TooltipProps = SlotProps & {
|
|
25
|
+
/**
|
|
26
|
+
* The control the tip describes. **Cloned, not wrapped** — the
|
|
27
|
+
* same slot contract as `Popover`'s trigger.
|
|
28
|
+
*
|
|
29
|
+
* It has to be something focusable. A tooltip on a `<span>` is
|
|
30
|
+
* pointer-only, which is the state mux-magic's is in today.
|
|
31
|
+
*/
|
|
32
|
+
children: ReactElement
|
|
33
|
+
className?: string
|
|
34
|
+
/** Milliseconds of hover before it opens. */
|
|
35
|
+
delay?: number
|
|
36
|
+
/**
|
|
37
|
+
* Text, and text only. `role="tooltip"` has no way to move focus
|
|
38
|
+
* into itself, so a link or a button inside one is unreachable by
|
|
39
|
+
* keyboard and unreadable by touch — if it needs a control, it is
|
|
40
|
+
* a `Popover`.
|
|
41
|
+
*/
|
|
42
|
+
label: string
|
|
43
|
+
placement?: Placement
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Replaces mux-magic's `FieldTooltip`, which is 130 lines of
|
|
48
|
+
* hand-rolled positioning — a `computePosition` that reimplements
|
|
49
|
+
* `flip` and `shift` against `window.innerHeight` — and has three
|
|
50
|
+
* defects that all render perfectly:
|
|
51
|
+
*
|
|
52
|
+
* 1. **Nothing references the tip.** It sets `role="tooltip"` and
|
|
53
|
+
* no `aria-describedby` anywhere, so the control it belongs to
|
|
54
|
+
* never mentions it. A screen reader reads the button, says
|
|
55
|
+
* nothing about the tip, and the tip itself is a floating node
|
|
56
|
+
* nobody is pointed at. `role="tooltip"` is not a live region;
|
|
57
|
+
* it does nothing on its own.
|
|
58
|
+
* 2. **Pointer only.** `onPointerEnter`/`onPointerLeave` with no
|
|
59
|
+
* `onFocus`/`onBlur`, so a keyboard user cannot open it at all.
|
|
60
|
+
* 3. **No dismiss.** Escape does nothing, which WCAG 1.4.13
|
|
61
|
+
* requires of any content shown on hover.
|
|
62
|
+
*
|
|
63
|
+
* All three come from the same place: the ARIA is written by hand
|
|
64
|
+
* beside the behaviour instead of falling out of it. Here `useRole`
|
|
65
|
+
* writes `aria-describedby` on the trigger and `role` + `id` on the
|
|
66
|
+
* tip from one source, `useFocus` is what makes it keyboard-openable,
|
|
67
|
+
* and `useDismiss` is Escape.
|
|
68
|
+
*
|
|
69
|
+
* ### Not a `title`, and not a substitute for a name
|
|
70
|
+
*
|
|
71
|
+
* A `title` attribute is unstyleable, has a ~1s delay nobody can
|
|
72
|
+
* change, and does not exist on touch. `Badge` still uses one — for
|
|
73
|
+
* a *truncation* readout, where the full text is already in
|
|
74
|
+
* `textContent` and the tip is a convenience
|
|
75
|
+
* ([decision](../../../docs/decisions/2026-07-30-a-badge-truncates-with-text-overflow.md)).
|
|
76
|
+
* That is the line: a `title` may repeat something already
|
|
77
|
+
* available, and a `Tooltip` may add something, but **neither may be
|
|
78
|
+
* the only place a control's name exists**. An icon button still
|
|
79
|
+
* needs its `aria-label`; the tip is extra.
|
|
80
|
+
*
|
|
81
|
+
* ### Touch has no hover, and this component does not pretend
|
|
82
|
+
*
|
|
83
|
+
* There is no long-press fallback here. A tooltip is supplementary
|
|
84
|
+
* by construction, so on the kiosk it simply never appears, and
|
|
85
|
+
* anything a touch user *must* read belongs in a `Field`'s
|
|
86
|
+
* `description` instead. Building a long-press would make the tip
|
|
87
|
+
* load-bearing on exactly the surface where it is least reliable.
|
|
88
|
+
*
|
|
89
|
+
* ### A slot is a pass-through
|
|
90
|
+
*
|
|
91
|
+
* A `Tooltip` is the natural thing to put between a `Field` and its
|
|
92
|
+
* control — mux-magic's `FieldLabel` renders a `FieldTooltip` for
|
|
93
|
+
* exactly that reason. Both components clone onto their one child, so
|
|
94
|
+
* an outer `Field` used to hand *this component* the `id`,
|
|
95
|
+
* `aria-describedby`, `aria-invalid` and `required` meant for the
|
|
96
|
+
* `<input>`, and this component dropped all four without a word.
|
|
97
|
+
*
|
|
98
|
+
* Anything in `SlotProps` that arrives from above is forwarded to the
|
|
99
|
+
* trigger, and `aria-describedby` is **merged** with the tip's own id
|
|
100
|
+
* rather than overwriting it. `slotProps.ts` has the reasoning.
|
|
101
|
+
*/
|
|
102
|
+
export const Tooltip = ({
|
|
103
|
+
children,
|
|
104
|
+
className,
|
|
105
|
+
delay = 200,
|
|
106
|
+
label,
|
|
107
|
+
placement = "top",
|
|
108
|
+
...receivedSlotProps
|
|
109
|
+
}: TooltipProps): ReactNode => {
|
|
110
|
+
const { isVisible, setIsVisible } = useVisibility()
|
|
111
|
+
|
|
112
|
+
const { context, floatingStyles, refs } = useFloating({
|
|
113
|
+
// Read-only, as everywhere: floating-ui is told what is true and
|
|
114
|
+
// never decides it.
|
|
115
|
+
open: isVisible,
|
|
116
|
+
onOpenChange: setIsVisible,
|
|
117
|
+
placement,
|
|
118
|
+
middleware: [offset(6), flip(), shift({ padding: 8 })],
|
|
119
|
+
strategy: "fixed",
|
|
120
|
+
whileElementsMounted: autoUpdate,
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const { getFloatingProps, getReferenceProps } =
|
|
124
|
+
useInteractions([
|
|
125
|
+
useHover(context, {
|
|
126
|
+
// `move: false` — a tooltip that re-opens because the
|
|
127
|
+
// pointer twitched inside the trigger is the flicker every
|
|
128
|
+
// hand-rolled one has.
|
|
129
|
+
move: false,
|
|
130
|
+
restMs: delay,
|
|
131
|
+
}),
|
|
132
|
+
// The line mux-magic is missing. Without it the tip is
|
|
133
|
+
// pointer-only, which is a WCAG 2.1.1 failure on a control
|
|
134
|
+
// whose explanation lives there.
|
|
135
|
+
useFocus(context),
|
|
136
|
+
useDismiss(context, { referencePress: false }),
|
|
137
|
+
useRole(context, { role: "tooltip" }),
|
|
138
|
+
])
|
|
139
|
+
|
|
140
|
+
const clonedTrigger = useClonedChild(
|
|
141
|
+
children,
|
|
142
|
+
// `mergeSlotProps` rather than a spread, because
|
|
143
|
+
// `getReferenceProps()` writes an `aria-describedby` of its own —
|
|
144
|
+
// `useRole` pointing the trigger at the tip — and an outer
|
|
145
|
+
// `Field` writes one naming its description and its error. A
|
|
146
|
+
// spread keeps whichever went last.
|
|
147
|
+
mergeSlotProps(receivedSlotProps, {
|
|
148
|
+
...getReferenceProps(),
|
|
149
|
+
ref: refs.setReference,
|
|
150
|
+
}),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<>
|
|
155
|
+
{clonedTrigger}
|
|
156
|
+
|
|
157
|
+
{isVisible ? (
|
|
158
|
+
<div
|
|
159
|
+
{...getFloatingProps()}
|
|
160
|
+
className={toClassName(
|
|
161
|
+
// `intent-neutral-solid` + its own `on-solid`, rather
|
|
162
|
+
// than `surface-inverse` + `content-on-accent`. The
|
|
163
|
+
// second pair looks like the obvious one and is not a
|
|
164
|
+
// *pair* at all — nothing in the token set promises those
|
|
165
|
+
// two contrast, and in `daylight` dark they land at
|
|
166
|
+
// 2.9:1. The `solid`/`on-solid` roles exist precisely to
|
|
167
|
+
// be a guaranteed couple, and the contrast gate checks
|
|
168
|
+
// them as one.
|
|
169
|
+
"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",
|
|
170
|
+
className,
|
|
171
|
+
)}
|
|
172
|
+
// No `popover="manual"` and no `FloatingFocusManager`,
|
|
173
|
+
// unlike `Popover`. Both exist to manage focus, and focus
|
|
174
|
+
// never enters a tooltip — moving it here is what makes
|
|
175
|
+
// one unescapable. The trade is that a tooltip is not in
|
|
176
|
+
// the top layer, so it can be clipped by an
|
|
177
|
+
// `overflow: hidden` ancestor; `strategy: "fixed"` keeps
|
|
178
|
+
// that to ancestors that actually clip fixed descendants.
|
|
179
|
+
ref={refs.setFloating}
|
|
180
|
+
// Duplicated from `getFloatingProps()` deliberately, and
|
|
181
|
+
// the two cannot differ — `useRole(context, { role:
|
|
182
|
+
// "tooltip" })` above is where it comes from. Stated here
|
|
183
|
+
// because a linter cannot see a role through a spread, and
|
|
184
|
+
// neither can the next reader.
|
|
185
|
+
role="tooltip"
|
|
186
|
+
style={floatingStyles}
|
|
187
|
+
>
|
|
188
|
+
{label}
|
|
189
|
+
</div>
|
|
190
|
+
) : null}
|
|
191
|
+
</>
|
|
192
|
+
)
|
|
193
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -31,6 +31,12 @@
|
|
|
31
31
|
* this package's README has always told it to do.
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
+
export type {
|
|
35
|
+
AccordionHeadingLevel,
|
|
36
|
+
AccordionItem,
|
|
37
|
+
AccordionProps,
|
|
38
|
+
} from "./Accordion/Accordion.tsx"
|
|
39
|
+
export { Accordion } from "./Accordion/Accordion.tsx"
|
|
34
40
|
export type {
|
|
35
41
|
AlertProps,
|
|
36
42
|
AlertSize,
|
|
@@ -60,6 +66,12 @@ export type {
|
|
|
60
66
|
EmptyStateSize,
|
|
61
67
|
} from "./EmptyState/EmptyState.tsx"
|
|
62
68
|
export { EmptyState } from "./EmptyState/EmptyState.tsx"
|
|
69
|
+
export type { FieldProps } from "./Field/Field.tsx"
|
|
70
|
+
export { Field } from "./Field/Field.tsx"
|
|
71
|
+
export type { FieldGroupProps } from "./Field/FieldGroup.tsx"
|
|
72
|
+
export { FieldGroup } from "./Field/FieldGroup.tsx"
|
|
73
|
+
export type { FileDropZoneProps } from "./FileDropZone/FileDropZone.tsx"
|
|
74
|
+
export { FileDropZone } from "./FileDropZone/FileDropZone.tsx"
|
|
63
75
|
export type { IconButtonProps } from "./IconButton/IconButton.tsx"
|
|
64
76
|
export { IconButton } from "./IconButton/IconButton.tsx"
|
|
65
77
|
export type { IntentAppearance } from "./intentStyles.ts"
|
|
@@ -76,6 +88,11 @@ export type {
|
|
|
76
88
|
LiveStatusIndicatorSize,
|
|
77
89
|
} from "./LiveStatusIndicator/LiveStatusIndicator.tsx"
|
|
78
90
|
export { LiveStatusIndicator } from "./LiveStatusIndicator/LiveStatusIndicator.tsx"
|
|
91
|
+
export type {
|
|
92
|
+
LogLine,
|
|
93
|
+
LogViewerProps,
|
|
94
|
+
} from "./LogViewer/LogViewer.tsx"
|
|
95
|
+
export { LogViewer } from "./LogViewer/LogViewer.tsx"
|
|
79
96
|
export type {
|
|
80
97
|
MediaTileProps,
|
|
81
98
|
MediaTileRatio,
|
|
@@ -83,6 +100,11 @@ export type {
|
|
|
83
100
|
export { MediaTile } from "./MediaTile/MediaTile.tsx"
|
|
84
101
|
export type { MediaStatus } from "./MediaTile/mediaStatus.ts"
|
|
85
102
|
export { mediaTransitions } from "./MediaTile/mediaStatus.ts"
|
|
103
|
+
export type {
|
|
104
|
+
MenuItem,
|
|
105
|
+
MenuProps,
|
|
106
|
+
} from "./Menu/Menu.tsx"
|
|
107
|
+
export { Menu } from "./Menu/Menu.tsx"
|
|
86
108
|
export type {
|
|
87
109
|
ModalProps,
|
|
88
110
|
ModalSize,
|
|
@@ -106,13 +128,27 @@ export type {
|
|
|
106
128
|
SegmentedItem,
|
|
107
129
|
} from "./SegmentedControl/SegmentedControl.tsx"
|
|
108
130
|
export { SegmentedControl } from "./SegmentedControl/SegmentedControl.tsx"
|
|
131
|
+
export type {
|
|
132
|
+
SelectItem,
|
|
133
|
+
SelectOption,
|
|
134
|
+
SelectOptionGroup,
|
|
135
|
+
SelectProps,
|
|
136
|
+
} from "./Select/Select.tsx"
|
|
137
|
+
export { Select } from "./Select/Select.tsx"
|
|
109
138
|
export type {
|
|
110
139
|
SkeletonProps,
|
|
111
140
|
SkeletonShape,
|
|
112
141
|
} from "./Skeleton/Skeleton.tsx"
|
|
113
142
|
export { Skeleton } from "./Skeleton/Skeleton.tsx"
|
|
143
|
+
export type {
|
|
144
|
+
SortableTableHeaderProps,
|
|
145
|
+
SortDirection,
|
|
146
|
+
} from "./SortableTableHeader/SortableTableHeader.tsx"
|
|
147
|
+
export { SortableTableHeader } from "./SortableTableHeader/SortableTableHeader.tsx"
|
|
114
148
|
export type { SpinnerProps } from "./Spinner/Spinner.tsx"
|
|
115
149
|
export { Spinner } from "./Spinner/Spinner.tsx"
|
|
150
|
+
export type { SlotProps } from "./slotProps.ts"
|
|
151
|
+
export { mergeSlotProps } from "./slotProps.ts"
|
|
116
152
|
export {
|
|
117
153
|
getAsyncIntent,
|
|
118
154
|
getAsyncLabel,
|
|
@@ -127,6 +163,17 @@ export type {
|
|
|
127
163
|
TabsProps,
|
|
128
164
|
} from "./Tabs/Tabs.tsx"
|
|
129
165
|
export { Tabs } from "./Tabs/Tabs.tsx"
|
|
166
|
+
export type { ToastProps } from "./Toast/Toast.tsx"
|
|
167
|
+
export { Toast } from "./Toast/Toast.tsx"
|
|
168
|
+
export type {
|
|
169
|
+
ToastRecord,
|
|
170
|
+
ToastRegionProps,
|
|
171
|
+
} from "./Toast/ToastRegion.tsx"
|
|
172
|
+
export { ToastRegion } from "./Toast/ToastRegion.tsx"
|
|
173
|
+
export type { ToastStatus } from "./Toast/toastLifecycle.ts"
|
|
174
|
+
export { toastTransitions } from "./Toast/toastLifecycle.ts"
|
|
175
|
+
export type { TooltipProps } from "./Tooltip/Tooltip.tsx"
|
|
176
|
+
export { Tooltip } from "./Tooltip/Tooltip.tsx"
|
|
130
177
|
export { toClassName } from "./toClassName.ts"
|
|
131
178
|
export type { VisuallyHiddenProps } from "./VisuallyHidden/VisuallyHidden.tsx"
|
|
132
179
|
export { VisuallyHidden } from "./VisuallyHidden/VisuallyHidden.tsx"
|
|
@@ -25,16 +25,46 @@ type MountableStory = {
|
|
|
25
25
|
* drift from it. It is also what triggers the a11y addon's
|
|
26
26
|
* `afterEach`, so every mount here is an axe run.
|
|
27
27
|
*/
|
|
28
|
+
/**
|
|
29
|
+
* Every canvas this helper has created, so the next mount can take
|
|
30
|
+
* the previous ones out of the document.
|
|
31
|
+
*
|
|
32
|
+
* They used to accumulate — one detached-but-attached `<div>` per
|
|
33
|
+
* test, for the whole file — and nothing noticed for three
|
|
34
|
+
* milestones, because every component up to M6 lays out **in
|
|
35
|
+
* flow**: a stale copy sits harmlessly below the live one and every
|
|
36
|
+
* query is scoped to its own canvas anyway.
|
|
37
|
+
*
|
|
38
|
+
* `ToastRegion` is the first `position: fixed` component in the
|
|
39
|
+
* library, and it broke that immediately. A previous test's toast
|
|
40
|
+
* stack is pinned to the same corner of the viewport as the live
|
|
41
|
+
* one, so `userEvent.click` — which clicks by **coordinates** —
|
|
42
|
+
* drove the wrong element while every query still returned the
|
|
43
|
+
* right one. The test failed with "the toast did not go away", one
|
|
44
|
+
* test after the toast it was actually clicking.
|
|
45
|
+
*
|
|
46
|
+
* The stale tree is removed rather than unmounted because portable
|
|
47
|
+
* stories expose no unmount. Detaching is enough: it takes the node
|
|
48
|
+
* out of the layout, which is the entire problem.
|
|
49
|
+
*/
|
|
50
|
+
const mountedCanvases: HTMLElement[] = []
|
|
51
|
+
|
|
28
52
|
export const mountStory = async (
|
|
29
53
|
story: MountableStory,
|
|
30
54
|
): Promise<{
|
|
31
55
|
canvas: Canvas
|
|
32
56
|
canvasElement: HTMLElement
|
|
33
57
|
}> => {
|
|
58
|
+
for (const previous of mountedCanvases.splice(0)) {
|
|
59
|
+
previous.remove()
|
|
60
|
+
}
|
|
61
|
+
|
|
34
62
|
const canvasElement = document.createElement("div")
|
|
35
63
|
|
|
36
64
|
document.body.append(canvasElement)
|
|
37
65
|
|
|
66
|
+
mountedCanvases.push(canvasElement)
|
|
67
|
+
|
|
38
68
|
await story.run({ canvasElement })
|
|
39
69
|
|
|
40
70
|
return { canvas: within(canvasElement), canvasElement }
|