@charcuterie/ui 0.2.0 → 1.0.1
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/dist/Field/Field.d.ts +41 -6
- package/dist/Field/Field.d.ts.map +1 -1
- package/dist/Field/Field.js +50 -8
- package/dist/Field/Field.js.map +1 -1
- 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/LogViewer/LogViewer.d.ts +3 -1
- package/dist/LogViewer/LogViewer.d.ts.map +1 -1
- package/dist/LogViewer/LogViewer.js +62 -1
- package/dist/LogViewer/LogViewer.js.map +1 -1
- package/dist/Select/Select.d.ts +10 -2
- package/dist/Select/Select.d.ts.map +1 -1
- package/dist/Select/Select.js.map +1 -1
- package/dist/Tooltip/Tooltip.d.ts +16 -2
- package/dist/Tooltip/Tooltip.d.ts.map +1 -1
- package/dist/Tooltip/Tooltip.js +23 -3
- package/dist/Tooltip/Tooltip.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/slotProps.d.ts +121 -0
- package/dist/slotProps.d.ts.map +1 -0
- package/dist/slotProps.js +55 -0
- package/dist/slotProps.js.map +1 -0
- package/dist/slotWiring.d.ts +32 -0
- package/dist/slotWiring.d.ts.map +1 -0
- package/dist/slotWiring.js +115 -0
- package/dist/slotWiring.js.map +1 -0
- package/package.json +3 -3
- package/src/Field/Field.mdx +73 -3
- package/src/Field/Field.stories.tsx +136 -0
- package/src/Field/Field.test.tsx +207 -5
- package/src/Field/Field.tsx +70 -21
- package/src/Field/FieldGroup.tsx +138 -0
- package/src/LogViewer/LogViewer.mdx +36 -1
- package/src/LogViewer/LogViewer.stories.tsx +34 -0
- package/src/LogViewer/LogViewer.test.tsx +65 -2
- package/src/LogViewer/LogViewer.tsx +67 -1
- package/src/Menu/Menu.mdx +23 -0
- package/src/Menu/Menu.stories.tsx +67 -0
- package/src/Menu/Menu.test.tsx +113 -2
- package/src/Popover/Popover.mdx +5 -0
- package/src/Select/Select.stories.tsx +10 -2
- package/src/Select/Select.tsx +10 -2
- package/src/Tooltip/Tooltip.tsx +29 -5
- package/src/index.ts +4 -0
- package/src/slotProps.ts +163 -0
- package/src/slotWiring.ts +173 -0
|
@@ -35,7 +35,9 @@ export type LogViewerProps = {
|
|
|
35
35
|
* Three repos render a scrolling log pane —
|
|
36
36
|
* mux-magic's `JobLogsDisclosure` and `StepLogs`, rip-deck's
|
|
37
37
|
* `LogModal` — and between them they have four defects that this
|
|
38
|
-
* component exists to not have.
|
|
38
|
+
* component exists to not have. A fifth, below the effects, is not
|
|
39
|
+
* theirs: it is defect 1 coming back through the front door, in this
|
|
40
|
+
* component, found by the first app to consume it.
|
|
39
41
|
*
|
|
40
42
|
* ### 1. The one that follows nothing
|
|
41
43
|
*
|
|
@@ -121,6 +123,70 @@ export const LogViewer = ({
|
|
|
121
123
|
// for defect 1, and the dependency *is* the fix.
|
|
122
124
|
}, [isFollowing, shownLines])
|
|
123
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Defect 1 again, and the effect above cannot see it: **a pane can
|
|
128
|
+
* be measured before it has a box.**
|
|
129
|
+
*
|
|
130
|
+
* `AccordionSection` renders its panel with `hidden` rather than
|
|
131
|
+
* unmounting it, deliberately — "a panel that unmounts loses a
|
|
132
|
+
* scroll position, a partially typed form, and any subscription its
|
|
133
|
+
* content opened — and the fleet's log panes are exactly that." A
|
|
134
|
+
* `hidden` subtree has no layout, so the effect above runs on mount
|
|
135
|
+
* with `scrollHeight 0` and writes `scrollTop = 0`, and then never
|
|
136
|
+
* runs again: neither `isFollowing` nor `shownLines` changes when
|
|
137
|
+
* the section is opened. Measured in mux-magic on a 60-line pane:
|
|
138
|
+
*
|
|
139
|
+
* ```
|
|
140
|
+
* while collapsed : scrollTop 0 scrollHeight 0 clientHeight 0
|
|
141
|
+
* after expanding : scrollTop 0 scrollHeight 976 clientHeight 254
|
|
142
|
+
* ```
|
|
143
|
+
*
|
|
144
|
+
* That is this component's own `}, [])` bug, rebuilt out of two
|
|
145
|
+
* components whose individual decisions are both right, and
|
|
146
|
+
* invisible to both of their test suites — `LogViewer`'s mounted
|
|
147
|
+
* visible, `Accordion`'s holding content that never measures
|
|
148
|
+
* itself. mux-magic hit it in its first week as a consumer and
|
|
149
|
+
* worked around it downstream, in a `DisclosedLogViewer` that
|
|
150
|
+
* withheld the pane until the section had been opened once. This is
|
|
151
|
+
* that workaround coming into the library so it can be deleted.
|
|
152
|
+
*
|
|
153
|
+
* ### Why the box and not the viewport
|
|
154
|
+
*
|
|
155
|
+
* An `IntersectionObserver` answers "is it on screen", which is a
|
|
156
|
+
* different question with two wrong answers here: a pane below the
|
|
157
|
+
* fold on a long page is *not* intersecting and has perfectly good
|
|
158
|
+
* layout, and scrolling the page later would re-fire for no reason.
|
|
159
|
+
* `ResizeObserver` answers "does it have a box, and is that box
|
|
160
|
+
* still the same size" — which is the precondition the measurement
|
|
161
|
+
* above actually needs. Per spec it does not fire at `observe()`
|
|
162
|
+
* time for an element that is not being rendered, so gaining a box
|
|
163
|
+
* *is* the first callback, and that is the reveal.
|
|
164
|
+
*
|
|
165
|
+
* It pays for itself twice: a window resize re-wraps the lines and
|
|
166
|
+
* changes what is at the bottom, and a following pane should be at
|
|
167
|
+
* the new bottom rather than a few lines above it.
|
|
168
|
+
*
|
|
169
|
+
* Only while following. A user who scrolled up to read an error is
|
|
170
|
+
* not dragged back by resizing their window.
|
|
171
|
+
*/
|
|
172
|
+
useEffect(() => {
|
|
173
|
+
const pane = paneRef.current
|
|
174
|
+
|
|
175
|
+
if (!pane || !isFollowing) {
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const observer = new ResizeObserver(() => {
|
|
180
|
+
pane.scrollTop = pane.scrollHeight
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
observer.observe(pane)
|
|
184
|
+
|
|
185
|
+
return () => {
|
|
186
|
+
observer.disconnect()
|
|
187
|
+
}
|
|
188
|
+
}, [isFollowing])
|
|
189
|
+
|
|
124
190
|
return (
|
|
125
191
|
<div
|
|
126
192
|
className={toClassName(
|
package/src/Menu/Menu.mdx
CHANGED
|
@@ -101,3 +101,26 @@ a keystroke buffer and a timer in a component that has neither, for a menu you c
|
|
|
101
101
|
three arrow presses.
|
|
102
102
|
|
|
103
103
|
<Canvas of={MenuStories.Interactive} />
|
|
104
|
+
|
|
105
|
+
## One trigger, two slots
|
|
106
|
+
|
|
107
|
+
A `Menu` and a `Tooltip` on the same button — the obvious thing to write, and until 1.0.1
|
|
108
|
+
it did not work.
|
|
109
|
+
|
|
110
|
+
Both components clone onto the trigger and both hand it a floating-ui
|
|
111
|
+
`refs.setReference` — the element each of them positions its panel against. Every merge in
|
|
112
|
+
the library was last-write-wins, so the inner `Tooltip` wrote its ref and the `Menu`'s was
|
|
113
|
+
gone. A menu with no reference element is not an error: floating-ui simply leaves the panel
|
|
114
|
+
at `left: 0; top: 0`, in the corner of the viewport, which reads as a CSS bug.
|
|
115
|
+
|
|
116
|
+
A `ref` is a subscription and an `on*` is a listener, and neither survives being replaced.
|
|
117
|
+
So both are **merged** now — between two slots by `mergeSlotWiring`, and between a slot and
|
|
118
|
+
the element you wrote by `useClonedChild`, which is what makes
|
|
119
|
+
`<Menu trigger={<Button ref={buttonRef} onClick={toggle} />} />` keep your ref *and* your
|
|
120
|
+
handler.
|
|
121
|
+
|
|
122
|
+
`SharedTrigger` is padded so the trigger sits away from the viewport origin: at the corner,
|
|
123
|
+
an anchored panel and an unanchored one land in the same place, and the test asserts that
|
|
124
|
+
padding is still there before it asserts anything else.
|
|
125
|
+
|
|
126
|
+
<Canvas of={MenuStories.SharedTrigger} />
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
SettingsIcon,
|
|
14
14
|
UndoIcon,
|
|
15
15
|
} from "../icons.storyHelpers.tsx"
|
|
16
|
+
import { Tooltip } from "../Tooltip/Tooltip.tsx"
|
|
16
17
|
import type { MenuItem } from "./Menu.tsx"
|
|
17
18
|
import { Menu } from "./Menu.tsx"
|
|
18
19
|
|
|
@@ -72,6 +73,40 @@ const MenuHarness = ({
|
|
|
72
73
|
)
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The same harness, with a `Tooltip` between the `Menu` and the
|
|
78
|
+
* button — two components cloning onto **one** trigger, which is what
|
|
79
|
+
* image-viewer wanted and could not have.
|
|
80
|
+
*/
|
|
81
|
+
const TooltipTriggerHarness = ({
|
|
82
|
+
isInitiallyVisible = false,
|
|
83
|
+
tip,
|
|
84
|
+
triggerLabel,
|
|
85
|
+
}: {
|
|
86
|
+
isInitiallyVisible?: boolean
|
|
87
|
+
tip: string
|
|
88
|
+
triggerLabel: string
|
|
89
|
+
}): ReactNode => {
|
|
90
|
+
const { hide, isVisible, toggle } = useVisibility({
|
|
91
|
+
isVisible: isInitiallyVisible,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<Menu
|
|
96
|
+
isVisible={isVisible}
|
|
97
|
+
items={BAY_ACTIONS}
|
|
98
|
+
onDismiss={hide}
|
|
99
|
+
trigger={
|
|
100
|
+
<Tooltip label={tip}>
|
|
101
|
+
<Button appearance="outline" onClick={toggle}>
|
|
102
|
+
{triggerLabel}
|
|
103
|
+
</Button>
|
|
104
|
+
</Tooltip>
|
|
105
|
+
}
|
|
106
|
+
/>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
|
|
75
110
|
const meta = {
|
|
76
111
|
title: "Components/Menu",
|
|
77
112
|
component: Menu,
|
|
@@ -143,6 +178,38 @@ export const AllStates: Story = {
|
|
|
143
178
|
),
|
|
144
179
|
}
|
|
145
180
|
|
|
181
|
+
/**
|
|
182
|
+
* **One trigger, two slots.** `Menu` and `Tooltip` both clone onto
|
|
183
|
+
* the button and both hand it a `ref` — floating-ui's
|
|
184
|
+
* `refs.setReference`, which is the anchor each of them positions
|
|
185
|
+
* against. Until 1.0.1 the second clone's ref simply replaced the
|
|
186
|
+
* first's, so the panel it belonged to had no anchor and rendered in
|
|
187
|
+
* the top-left corner of the viewport.
|
|
188
|
+
*
|
|
189
|
+
* Hover the button and open the menu: the tip and the panel are both
|
|
190
|
+
* attached to it.
|
|
191
|
+
*/
|
|
192
|
+
export const SharedTrigger: Story = {
|
|
193
|
+
args: {
|
|
194
|
+
isVisible: false,
|
|
195
|
+
onDismiss: noop,
|
|
196
|
+
trigger: <Button appearance="outline">Actions</Button>,
|
|
197
|
+
},
|
|
198
|
+
render: () => (
|
|
199
|
+
// Padded, and the padding is load-bearing rather than pretty: an
|
|
200
|
+
// anchor that has been dropped leaves floating-ui at
|
|
201
|
+
// `left: 0; top: 0`, so a trigger sitting in the corner of the
|
|
202
|
+
// viewport is a trigger where "anchored" and "not anchored" look
|
|
203
|
+
// identical. `Menu.test.tsx` asserts the padding is still here.
|
|
204
|
+
<div className="p-16">
|
|
205
|
+
<TooltipTriggerHarness
|
|
206
|
+
tip="Everything that can be done to this bay."
|
|
207
|
+
triggerLabel="Bay 5"
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
),
|
|
211
|
+
}
|
|
212
|
+
|
|
146
213
|
/**
|
|
147
214
|
* Open it and arrow down. Focus lands on the first item **on
|
|
148
215
|
* opening** — a menu that leaves the caret on the trigger is one a
|
package/src/Menu/Menu.test.tsx
CHANGED
|
@@ -7,8 +7,12 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./Menu.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const {
|
|
11
|
-
|
|
10
|
+
const {
|
|
11
|
+
AllStates,
|
|
12
|
+
AllVariants,
|
|
13
|
+
Interactive,
|
|
14
|
+
SharedTrigger,
|
|
15
|
+
} = composeStories(stories)
|
|
12
16
|
|
|
13
17
|
test("the menu is named, and its items are menuitems", async () => {
|
|
14
18
|
const { canvas, canvasElement } =
|
|
@@ -200,3 +204,110 @@ test("the menu is not portalled out of its canvas", async () => {
|
|
|
200
204
|
|
|
201
205
|
await expectNoAxeViolations(canvasElement)
|
|
202
206
|
})
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* **One trigger, two slots** — the defect image-viewer reported, and
|
|
210
|
+
* the one shape `mergeSlotProps` did not cover when it shipped in
|
|
211
|
+
* 1.0.0.
|
|
212
|
+
*
|
|
213
|
+
* `Menu` and `Tooltip` both clone onto the button and both hand it a
|
|
214
|
+
* floating-ui `refs.setReference`. That is not an attribute, so
|
|
215
|
+
* last-write-wins destroyed one of them: the inner `Tooltip` wrote
|
|
216
|
+
* its own and the `Menu` was left with **no reference element at
|
|
217
|
+
* all**. Nothing threw, nothing was unnamed, axe was clean, and the
|
|
218
|
+
* panel rendered at `left: 0; top: 0` — a positioning bug that reads
|
|
219
|
+
* as CSS.
|
|
220
|
+
*
|
|
221
|
+
* So the assertion is geometric, and it is the only kind that can
|
|
222
|
+
* see this: the panel is where the trigger is. Nothing in `Menu`
|
|
223
|
+
* writes that number — floating-ui computes it from the reference
|
|
224
|
+
* element, and a dropped ref means no reference element.
|
|
225
|
+
*/
|
|
226
|
+
const expectAnchoredTrigger = (trigger: HTMLElement) => {
|
|
227
|
+
const triggerRect = trigger.getBoundingClientRect()
|
|
228
|
+
|
|
229
|
+
// The precondition, stated rather than assumed. An unanchored
|
|
230
|
+
// panel lands at the viewport origin, so a trigger that also sits
|
|
231
|
+
// at the origin makes every assertion below pass for the wrong
|
|
232
|
+
// reason. The story's padding is what keeps them apart, and this
|
|
233
|
+
// is where its removal fails.
|
|
234
|
+
expect(triggerRect.left).toBeGreaterThan(32)
|
|
235
|
+
|
|
236
|
+
expect(triggerRect.top).toBeGreaterThan(32)
|
|
237
|
+
|
|
238
|
+
return triggerRect
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
test("a menu sharing its trigger with a tooltip is still anchored to it", async () => {
|
|
242
|
+
const { canvas, canvasElement } =
|
|
243
|
+
await mountStory(SharedTrigger)
|
|
244
|
+
|
|
245
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
246
|
+
name: "Bay 5",
|
|
247
|
+
role: "button",
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
const triggerRect = expectAnchoredTrigger(trigger)
|
|
251
|
+
|
|
252
|
+
await userEvent.click(trigger)
|
|
253
|
+
|
|
254
|
+
const menu = await waitFor(() =>
|
|
255
|
+
expectAgentDrivable(canvas, { role: "menu" }),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
// `bottom-start` with `offset(4)`: flush with the trigger's
|
|
259
|
+
// inline start, four pixels below it. Before the fix this read
|
|
260
|
+
// `left 0, top 0` — and it read that *forever*, so waiting for it
|
|
261
|
+
// costs a timeout rather than hiding a failure. The wait is for
|
|
262
|
+
// floating-ui's first position, which lands a frame after the
|
|
263
|
+
// panel does.
|
|
264
|
+
await waitFor(() => {
|
|
265
|
+
const menuRect = menu.getBoundingClientRect()
|
|
266
|
+
|
|
267
|
+
expect(Math.round(menuRect.left)).toBe(
|
|
268
|
+
Math.round(triggerRect.left),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
expect(Math.round(menuRect.top)).toBe(
|
|
272
|
+
Math.round(triggerRect.bottom) + 4,
|
|
273
|
+
)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
await expectNoAxeViolations(canvasElement)
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The other ref, in the same composition — because a fix that
|
|
281
|
+
* reversed the priority instead of merging would leave *this* one
|
|
282
|
+
* pointing at nothing and the test above would still pass.
|
|
283
|
+
*/
|
|
284
|
+
test("the tooltip sharing that trigger is anchored to it too", async () => {
|
|
285
|
+
const { canvas } = await mountStory(SharedTrigger)
|
|
286
|
+
|
|
287
|
+
const trigger = expectAgentDrivable(canvas, {
|
|
288
|
+
name: "Bay 5",
|
|
289
|
+
role: "button",
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
const triggerRect = expectAnchoredTrigger(trigger)
|
|
293
|
+
|
|
294
|
+
trigger.focus()
|
|
295
|
+
|
|
296
|
+
const tip = await waitFor(() =>
|
|
297
|
+
expectAgentDrivable(canvas, { role: "tooltip" }),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
// The **main axis** only: `top` with `offset(6)`. The cross axis
|
|
301
|
+
// is not asserted because `shift({ padding: 8 })` legitimately
|
|
302
|
+
// moves it — a 229px tip over a 71px button cannot be centred
|
|
303
|
+
// this close to the edge — and an assertion that has to allow for
|
|
304
|
+
// that is one an unanchored tip at the viewport origin would also
|
|
305
|
+
// satisfy. This one it cannot: unanchored reads `bottom 24`.
|
|
306
|
+
await waitFor(() => {
|
|
307
|
+
const tipRect = tip.getBoundingClientRect()
|
|
308
|
+
|
|
309
|
+
expect(Math.round(tipRect.bottom)).toBe(
|
|
310
|
+
Math.round(triggerRect.top) - 6,
|
|
311
|
+
)
|
|
312
|
+
})
|
|
313
|
+
})
|
package/src/Popover/Popover.mdx
CHANGED
|
@@ -85,6 +85,11 @@ announces nothing.
|
|
|
85
85
|
have. An extra `<div>` around it is how a toolbar's layout quietly changes, and it is why
|
|
86
86
|
this is the pattern v1 chose and M2 ported.
|
|
87
87
|
|
|
88
|
+
It **composes** rather than overwrites: your button's own `ref` and its own `onClick` are
|
|
89
|
+
still called, with the popover's added. Until 1.0.1 they were replaced — silently, because a
|
|
90
|
+
ref that is never called and a handler that never runs both look exactly like nothing
|
|
91
|
+
happening.
|
|
92
|
+
|
|
88
93
|
## Escape *is* pressable here
|
|
89
94
|
|
|
90
95
|
Unlike `Modal`'s. `useDismiss` listens for a keydown rather than relying on a UA default
|
|
@@ -9,11 +9,19 @@ import { Field } from "../Field/Field.tsx"
|
|
|
9
9
|
import type { SelectItem } from "./Select.tsx"
|
|
10
10
|
import { Select } from "./Select.tsx"
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* `as const satisfies`, which is a **typecheck** of the `readonly`
|
|
14
|
+
* widening as much as it is a story: a consumer's options table is
|
|
15
|
+
* usually a constant, and until 1.0.1 handing one to `options`
|
|
16
|
+
* failed with `TS4104` — image-viewer copied the array at every call
|
|
17
|
+
* site to get past it. If that widening is ever reverted, this line
|
|
18
|
+
* is where `yarn typecheck` says so.
|
|
19
|
+
*/
|
|
20
|
+
const PROFILES = [
|
|
13
21
|
{ label: "Lossless remux", value: "lossless" },
|
|
14
22
|
{ label: "Compressed 1080p", value: "compressed" },
|
|
15
23
|
{ isDisabled: true, label: "Dolby Vision", value: "dv" },
|
|
16
|
-
]
|
|
24
|
+
] as const satisfies readonly SelectItem[]
|
|
17
25
|
|
|
18
26
|
const GROUPED: SelectItem[] = [
|
|
19
27
|
{
|
package/src/Select/Select.tsx
CHANGED
|
@@ -23,7 +23,7 @@ export type SelectOption = {
|
|
|
23
23
|
|
|
24
24
|
export type SelectOptionGroup = {
|
|
25
25
|
label: string
|
|
26
|
-
options: SelectOption[]
|
|
26
|
+
options: readonly SelectOption[]
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export type SelectItem = SelectOption | SelectOptionGroup
|
|
@@ -56,7 +56,15 @@ export type SelectProps = Omit<
|
|
|
56
56
|
*/
|
|
57
57
|
label?: string
|
|
58
58
|
onChange?: (value: string) => void
|
|
59
|
-
|
|
59
|
+
/**
|
|
60
|
+
* `readonly`, because a caller's list is usually a constant and
|
|
61
|
+
* TypeScript will not hand a `readonly` array to a mutable
|
|
62
|
+
* parameter — image-viewer hit `TS4104` on an `as const` options
|
|
63
|
+
* table and had to spread a copy at every call. This component
|
|
64
|
+
* only ever `.map`s over it, so demanding a mutable array was
|
|
65
|
+
* asking for a permission it never uses.
|
|
66
|
+
*/
|
|
67
|
+
options: readonly SelectItem[]
|
|
60
68
|
/**
|
|
61
69
|
* A disabled first entry, not a real option. Reads as a prompt to
|
|
62
70
|
* a sighted user and cannot be submitted, which is what a
|
package/src/Tooltip/Tooltip.tsx
CHANGED
|
@@ -17,9 +17,11 @@ import {
|
|
|
17
17
|
} from "@floating-ui/react"
|
|
18
18
|
import type { ReactElement, ReactNode } from "react"
|
|
19
19
|
|
|
20
|
+
import type { SlotProps } from "../slotProps.ts"
|
|
21
|
+
import { mergeSlotProps } from "../slotProps.ts"
|
|
20
22
|
import { toClassName } from "../toClassName.ts"
|
|
21
23
|
|
|
22
|
-
export type TooltipProps = {
|
|
24
|
+
export type TooltipProps = SlotProps & {
|
|
23
25
|
/**
|
|
24
26
|
* The control the tip describes. **Cloned, not wrapped** — the
|
|
25
27
|
* same slot contract as `Popover`'s trigger.
|
|
@@ -83,6 +85,19 @@ export type TooltipProps = {
|
|
|
83
85
|
* anything a touch user *must* read belongs in a `Field`'s
|
|
84
86
|
* `description` instead. Building a long-press would make the tip
|
|
85
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.
|
|
86
101
|
*/
|
|
87
102
|
export const Tooltip = ({
|
|
88
103
|
children,
|
|
@@ -90,6 +105,7 @@ export const Tooltip = ({
|
|
|
90
105
|
delay = 200,
|
|
91
106
|
label,
|
|
92
107
|
placement = "top",
|
|
108
|
+
...receivedSlotProps
|
|
93
109
|
}: TooltipProps): ReactNode => {
|
|
94
110
|
const { isVisible, setIsVisible } = useVisibility()
|
|
95
111
|
|
|
@@ -121,10 +137,18 @@ export const Tooltip = ({
|
|
|
121
137
|
useRole(context, { role: "tooltip" }),
|
|
122
138
|
])
|
|
123
139
|
|
|
124
|
-
const clonedTrigger = useClonedChild(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
+
)
|
|
128
152
|
|
|
129
153
|
return (
|
|
130
154
|
<>
|
package/src/index.ts
CHANGED
|
@@ -68,6 +68,8 @@ export type {
|
|
|
68
68
|
export { EmptyState } from "./EmptyState/EmptyState.tsx"
|
|
69
69
|
export type { FieldProps } from "./Field/Field.tsx"
|
|
70
70
|
export { Field } from "./Field/Field.tsx"
|
|
71
|
+
export type { FieldGroupProps } from "./Field/FieldGroup.tsx"
|
|
72
|
+
export { FieldGroup } from "./Field/FieldGroup.tsx"
|
|
71
73
|
export type { FileDropZoneProps } from "./FileDropZone/FileDropZone.tsx"
|
|
72
74
|
export { FileDropZone } from "./FileDropZone/FileDropZone.tsx"
|
|
73
75
|
export type { IconButtonProps } from "./IconButton/IconButton.tsx"
|
|
@@ -145,6 +147,8 @@ export type {
|
|
|
145
147
|
export { SortableTableHeader } from "./SortableTableHeader/SortableTableHeader.tsx"
|
|
146
148
|
export type { SpinnerProps } from "./Spinner/Spinner.tsx"
|
|
147
149
|
export { Spinner } from "./Spinner/Spinner.tsx"
|
|
150
|
+
export type { SlotProps } from "./slotProps.ts"
|
|
151
|
+
export { mergeSlotProps } from "./slotProps.ts"
|
|
148
152
|
export {
|
|
149
153
|
getAsyncIntent,
|
|
150
154
|
getAsyncLabel,
|
package/src/slotProps.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import type { InputHTMLAttributes } from "react"
|
|
2
|
+
|
|
3
|
+
import { mergeSlotWiring } from "./slotWiring.ts"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The props a **cloning ancestor** injects into the control below it.
|
|
7
|
+
*
|
|
8
|
+
* `Field` and `Tooltip` both take one child and `cloneElement` onto
|
|
9
|
+
* it — "cloned, not wrapped", which is what lets a `Field` go around
|
|
10
|
+
* an `<input>`, a `Select`, or an app's own control without changing
|
|
11
|
+
* that control's layout. The contract has a hole in it the moment two
|
|
12
|
+
* of them are nested:
|
|
13
|
+
*
|
|
14
|
+
* ```tsx
|
|
15
|
+
* <Field label="Pattern" isRequired>
|
|
16
|
+
* <Tooltip label="A JavaScript regular expression">
|
|
17
|
+
* <input />
|
|
18
|
+
* </Tooltip>
|
|
19
|
+
* </Field>
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* `Field` clones onto the **`Tooltip` element**, so `id`,
|
|
23
|
+
* `aria-describedby`, `aria-invalid` and `required` are handed to a
|
|
24
|
+
* component that declares none of them — and `cloneElement` does not
|
|
25
|
+
* care. React drops them without a warning, TypeScript never sees
|
|
26
|
+
* them (`Children.only` returns a `ReactElement` with `any` props),
|
|
27
|
+
* every test still passes, and the render is pixel-identical. The
|
|
28
|
+
* `<label>` then points at an `id` that is nowhere in the document.
|
|
29
|
+
*
|
|
30
|
+
* **Silent prop-dropping is the worst version of this**, so the rule
|
|
31
|
+
* is: *a slot component is a pass-through*. Anything in `SlotProps`
|
|
32
|
+
* that arrives from above is forwarded to the child this component
|
|
33
|
+
* clones onto, so a chain of slots behaves like the one control at
|
|
34
|
+
* the bottom of it.
|
|
35
|
+
*
|
|
36
|
+
* ### The list is closed, the forwarding is not
|
|
37
|
+
*
|
|
38
|
+
* Worth being exact, because the two are easy to conflate. This type
|
|
39
|
+
* is a closed list of five keys rather than an index signature: it is
|
|
40
|
+
* exactly the wiring `Field` writes plus the wiring `Tooltip` writes,
|
|
41
|
+
* it is greppable, and it is the set whose **merge semantics are
|
|
42
|
+
* defined** — which for four of them is last-write-wins and for
|
|
43
|
+
* `aria-describedby` is a join.
|
|
44
|
+
*
|
|
45
|
+
* The *runtime* forwards everything. Both components collect their
|
|
46
|
+
* received props with a rest spread and `mergeSlotProps` starts from
|
|
47
|
+
* `...receivedProps`, so an ancestor's `onPointerEnter`, its
|
|
48
|
+
* `onFocus`, its `ref` — none of which are named here — reach the
|
|
49
|
+
* control at the bottom too. That is not an oversight, it is load
|
|
50
|
+
* bearing: a `Tooltip` around a `Field` hands down `useHover`,
|
|
51
|
+
* `useFocus`, `useDismiss` and `refs.setReference`, and a `Field`
|
|
52
|
+
* that forwarded only the five keys below would leave the tip with no
|
|
53
|
+
* trigger and no anchor. A closed *runtime* filter would be
|
|
54
|
+
* mux-magic's `FieldTooltip` defect rebuilt inside the fix for it.
|
|
55
|
+
*
|
|
56
|
+
* The five keys, and who writes them:
|
|
57
|
+
*
|
|
58
|
+
* | Key | Written by |
|
|
59
|
+
* | --- | --- |
|
|
60
|
+
* | `aria-describedby` | `Field` (its description and its error) **and** `Tooltip` (its tip) — the one key that is a list, and the reason `mergeSlotProps` exists |
|
|
61
|
+
* | `aria-invalid` | `Field`, derived from `error` being present |
|
|
62
|
+
* | `aria-required` | `Field`, from `isRequired` — the announced constraint, for a composite control the browser does not validate |
|
|
63
|
+
* | `id` | `Field`, so its `<label htmlFor>` has something to point at. A caller may also pass one directly, which is how a consumer pins a stable id for a deep link or an autofill hint |
|
|
64
|
+
* | `required` | `Field`, from `isRequired` — the constraint the browser validates |
|
|
65
|
+
*
|
|
66
|
+
* ### Why these are `Pick`ed and not written out
|
|
67
|
+
*
|
|
68
|
+
* Three of the five are booleans whose names cannot start with `is`
|
|
69
|
+
* or `has`, because they are not our names — they are the DOM's, and
|
|
70
|
+
* they are spread onto a DOM node verbatim. The house rule has
|
|
71
|
+
* [no carve-out](../../../docs/decisions/2026-07-29-is-has-rule-has-no-external-api-carve-out.md)
|
|
72
|
+
* for that: a *type property* is a shape we own, an object literal
|
|
73
|
+
* key is someone else's contract, and no `eslint-disable` is
|
|
74
|
+
* available because unused disable directives are themselves an
|
|
75
|
+
* error.
|
|
76
|
+
*
|
|
77
|
+
* So this type does not declare a shape. It **selects** one out of
|
|
78
|
+
* React's own `InputHTMLAttributes`, which is the honest description
|
|
79
|
+
* of what it is: five keys of the props React already types for an
|
|
80
|
+
* `<input>`, forwarded unchanged. The same reasoning as the
|
|
81
|
+
* `Record<string, boolean>` in `createReactAdapter` — we are not
|
|
82
|
+
* describing React's shape, we are naming the keys of it we touch —
|
|
83
|
+
* and it has the side benefit that `aria-invalid` gets React's real
|
|
84
|
+
* type (`"grammar"` and `"spelling"` are legal values) rather than
|
|
85
|
+
* the `boolean` a hand-written version would have narrowed it to.
|
|
86
|
+
*/
|
|
87
|
+
export type SlotProps = Pick<
|
|
88
|
+
InputHTMLAttributes<HTMLElement>,
|
|
89
|
+
| "aria-describedby"
|
|
90
|
+
| "aria-invalid"
|
|
91
|
+
| "aria-required"
|
|
92
|
+
| "id"
|
|
93
|
+
| "required"
|
|
94
|
+
>
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Merge what arrived from above with what this component writes
|
|
98
|
+
* itself.
|
|
99
|
+
*
|
|
100
|
+
* Everything here is a single value and last-write-wins is correct
|
|
101
|
+
* for it — **except `aria-describedby`, which is a list**. Two slot
|
|
102
|
+
* components each writing their own onto one control is the entire
|
|
103
|
+
* nesting problem in one attribute: a `Field` naming its description
|
|
104
|
+
* and its error, and a `Tooltip` naming its tip. A plain spread keeps
|
|
105
|
+
* one of them and loses the other, which is the same silent drop by a
|
|
106
|
+
* shorter route.
|
|
107
|
+
*
|
|
108
|
+
* **Outer first.** A screen reader reads the list in order, and the
|
|
109
|
+
* field's own description and error are the control's primary
|
|
110
|
+
* explanation; a tip is supplementary by construction. The ordering
|
|
111
|
+
* inside a single `Field` — description, then error — is `Field`'s
|
|
112
|
+
* own concern and is unaffected.
|
|
113
|
+
*
|
|
114
|
+
* `undefined` in `ownProps` is *absence*, not an instruction to
|
|
115
|
+
* clear. Both callers spell their optional wiring as
|
|
116
|
+
* `isRequired || undefined`, so a plain spread would let an inner
|
|
117
|
+
* `Field` with no error erase an outer one's `aria-invalid` — the
|
|
118
|
+
* same silent drop, one level down.
|
|
119
|
+
*
|
|
120
|
+
* ### The wiring is not a value, and does not last-write-win
|
|
121
|
+
*
|
|
122
|
+
* `ref` and the `on*` handlers are merged rather than replaced, by
|
|
123
|
+
* `mergeSlotWiring`. This was the 1.0.0 hole: `mergeSlotProps`
|
|
124
|
+
* settled the five *attributes* and left the two props that are not
|
|
125
|
+
* attributes on last-write-wins, so a `Menu` and a `Tooltip` on one
|
|
126
|
+
* trigger each handed it a floating-ui `refs.setReference` and the
|
|
127
|
+
* inner one won. The outer panel had no anchor and rendered in the
|
|
128
|
+
* corner of the viewport — no error, no failing test, and it looks
|
|
129
|
+
* like a CSS bug.
|
|
130
|
+
*/
|
|
131
|
+
export const mergeSlotProps = <
|
|
132
|
+
OwnProps extends Record<string, unknown>,
|
|
133
|
+
>(
|
|
134
|
+
receivedProps: SlotProps,
|
|
135
|
+
ownProps: OwnProps,
|
|
136
|
+
): OwnProps & SlotProps => {
|
|
137
|
+
const definedOwnProps = Object.fromEntries(
|
|
138
|
+
Object.entries(ownProps).filter(
|
|
139
|
+
([, value]) => value !== undefined,
|
|
140
|
+
),
|
|
141
|
+
) as OwnProps
|
|
142
|
+
|
|
143
|
+
const describedBy = [
|
|
144
|
+
receivedProps["aria-describedby"],
|
|
145
|
+
ownProps["aria-describedby"],
|
|
146
|
+
]
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.join(" ")
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
...receivedProps,
|
|
152
|
+
...definedOwnProps,
|
|
153
|
+
"aria-describedby":
|
|
154
|
+
describedBy === "" ? undefined : describedBy,
|
|
155
|
+
// Last, because it is the only merge that has to beat
|
|
156
|
+
// `definedOwnProps` — it is composing what that spread would
|
|
157
|
+
// otherwise have thrown away.
|
|
158
|
+
...mergeSlotWiring(
|
|
159
|
+
receivedProps as Record<string, unknown>,
|
|
160
|
+
definedOwnProps,
|
|
161
|
+
),
|
|
162
|
+
}
|
|
163
|
+
}
|