@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.
Files changed (105) hide show
  1. package/README.md +9 -0
  2. package/dist/Accordion/Accordion.d.ts +82 -0
  3. package/dist/Accordion/Accordion.d.ts.map +1 -0
  4. package/dist/Accordion/Accordion.js +108 -0
  5. package/dist/Accordion/Accordion.js.map +1 -0
  6. package/dist/Accordion/AccordionSection.d.ts +28 -0
  7. package/dist/Accordion/AccordionSection.d.ts.map +1 -0
  8. package/dist/Accordion/AccordionSection.js +53 -0
  9. package/dist/Accordion/AccordionSection.js.map +1 -0
  10. package/dist/Field/Field.d.ts +69 -0
  11. package/dist/Field/Field.d.ts.map +1 -0
  12. package/dist/Field/Field.js +73 -0
  13. package/dist/Field/Field.js.map +1 -0
  14. package/dist/FileDropZone/FileDropZone.d.ts +63 -0
  15. package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
  16. package/dist/FileDropZone/FileDropZone.js +122 -0
  17. package/dist/FileDropZone/FileDropZone.js.map +1 -0
  18. package/dist/LogViewer/LogViewer.d.ts +88 -0
  19. package/dist/LogViewer/LogViewer.d.ts.map +1 -0
  20. package/dist/LogViewer/LogViewer.js +104 -0
  21. package/dist/LogViewer/LogViewer.js.map +1 -0
  22. package/dist/Menu/Menu.d.ts +81 -0
  23. package/dist/Menu/Menu.d.ts.map +1 -0
  24. package/dist/Menu/Menu.js +199 -0
  25. package/dist/Menu/Menu.js.map +1 -0
  26. package/dist/Menu/MenuAction.d.ts +24 -0
  27. package/dist/Menu/MenuAction.d.ts.map +1 -0
  28. package/dist/Menu/MenuAction.js +37 -0
  29. package/dist/Menu/MenuAction.js.map +1 -0
  30. package/dist/Select/Select.d.ts +87 -0
  31. package/dist/Select/Select.d.ts.map +1 -0
  32. package/dist/Select/Select.js +51 -0
  33. package/dist/Select/Select.js.map +1 -0
  34. package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
  35. package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
  36. package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
  37. package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
  38. package/dist/Toast/Toast.d.ts +54 -0
  39. package/dist/Toast/Toast.d.ts.map +1 -0
  40. package/dist/Toast/Toast.js +121 -0
  41. package/dist/Toast/Toast.js.map +1 -0
  42. package/dist/Toast/ToastRegion.d.ts +57 -0
  43. package/dist/Toast/ToastRegion.d.ts.map +1 -0
  44. package/dist/Toast/ToastRegion.js +41 -0
  45. package/dist/Toast/ToastRegion.js.map +1 -0
  46. package/dist/Toast/toastLifecycle.d.ts +34 -0
  47. package/dist/Toast/toastLifecycle.d.ts.map +1 -0
  48. package/dist/Toast/toastLifecycle.js +25 -0
  49. package/dist/Toast/toastLifecycle.js.map +1 -0
  50. package/dist/Tooltip/Tooltip.d.ts +68 -0
  51. package/dist/Tooltip/Tooltip.d.ts.map +1 -0
  52. package/dist/Tooltip/Tooltip.js +104 -0
  53. package/dist/Tooltip/Tooltip.js.map +1 -0
  54. package/dist/index.d.ts +22 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +11 -0
  57. package/dist/index.js.map +1 -1
  58. package/dist/mountStory.testHelpers.d.ts +0 -11
  59. package/dist/mountStory.testHelpers.d.ts.map +1 -1
  60. package/dist/mountStory.testHelpers.js +27 -0
  61. package/dist/mountStory.testHelpers.js.map +1 -1
  62. package/package.json +1 -1
  63. package/src/Accordion/Accordion.mdx +83 -0
  64. package/src/Accordion/Accordion.stories.tsx +146 -0
  65. package/src/Accordion/Accordion.test.tsx +163 -0
  66. package/src/Accordion/Accordion.tsx +182 -0
  67. package/src/Accordion/AccordionSection.tsx +163 -0
  68. package/src/Field/Field.mdx +74 -0
  69. package/src/Field/Field.stories.tsx +144 -0
  70. package/src/Field/Field.test.tsx +142 -0
  71. package/src/Field/Field.tsx +163 -0
  72. package/src/FileDropZone/FileDropZone.mdx +78 -0
  73. package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
  74. package/src/FileDropZone/FileDropZone.test.tsx +230 -0
  75. package/src/FileDropZone/FileDropZone.tsx +234 -0
  76. package/src/LogViewer/LogViewer.mdx +88 -0
  77. package/src/LogViewer/LogViewer.stories.tsx +135 -0
  78. package/src/LogViewer/LogViewer.test.tsx +173 -0
  79. package/src/LogViewer/LogViewer.tsx +195 -0
  80. package/src/Menu/Menu.mdx +103 -0
  81. package/src/Menu/Menu.stories.tsx +161 -0
  82. package/src/Menu/Menu.test.tsx +202 -0
  83. package/src/Menu/Menu.tsx +300 -0
  84. package/src/Menu/MenuAction.tsx +95 -0
  85. package/src/Select/Select.mdx +74 -0
  86. package/src/Select/Select.stories.tsx +126 -0
  87. package/src/Select/Select.test.tsx +129 -0
  88. package/src/Select/Select.tsx +200 -0
  89. package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
  90. package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
  91. package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
  92. package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
  93. package/src/Toast/Toast.mdx +84 -0
  94. package/src/Toast/Toast.stories.tsx +192 -0
  95. package/src/Toast/Toast.test.tsx +217 -0
  96. package/src/Toast/Toast.tsx +224 -0
  97. package/src/Toast/ToastRegion.tsx +98 -0
  98. package/src/Toast/toastLifecycle.ts +45 -0
  99. package/src/Tooltip/Tooltip.mdx +69 -0
  100. package/src/Tooltip/Tooltip.stories.tsx +104 -0
  101. package/src/Tooltip/Tooltip.test.tsx +138 -0
  102. package/src/Tooltip/Tooltip.tsx +169 -0
  103. package/src/index.ts +43 -0
  104. package/src/mountStory.testHelpers.ts +30 -0
  105. 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
+ }