@charcuterie/ui 0.2.0 → 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.
@@ -4,9 +4,11 @@ import {
4
4
  } from "@charcuterie/logic"
5
5
  import type { ReactElement, ReactNode } from "react"
6
6
 
7
+ import type { SlotProps } from "../slotProps.ts"
8
+ import { mergeSlotProps } from "../slotProps.ts"
7
9
  import { toClassName } from "../toClassName.ts"
8
10
 
9
- export type FieldProps = {
11
+ export type FieldProps = SlotProps & {
10
12
  /**
11
13
  * The control. **Cloned, not wrapped** — the same slot contract
12
14
  * as `Popover`'s trigger, so a `Field` can go around an `<input>`,
@@ -33,10 +35,32 @@ export type FieldProps = {
33
35
 
34
36
  /**
35
37
  * Sixteen files in the fleet spell a label, a control, and a hint,
36
- * and no two spell the wiring the same way. `FieldLabel` in
37
- * mux-magic renders a `<label>` with no `htmlFor` at all — which
38
- * looks correct, reads correctly to a sighted user, and gives a
39
- * screen reader an unnamed textbox.
38
+ * and no two spell the wiring the same way.
39
+ *
40
+ * ### What mux-magic's `FieldLabel` actually gets wrong
41
+ *
42
+ * An earlier version of this docstring said it "renders a `<label>`
43
+ * with no `htmlFor` at all". **That is false** — it renders
44
+ * ``htmlFor={`${stepId}-${field.name}`}``, and has since it was
45
+ * written. The defect is at the other end of the pair: **8 of its 16
46
+ * call sites never render that id on anything**. Measured on
47
+ * `mux-magic@master`, the eight that do are `BooleanField`,
48
+ * `ChapterSplitsField`, `EnumField`, `NumberField`,
49
+ * `NumberWithLookupField`, `PathField`, `StringArrayField` and
50
+ * `StringField`; the other eight either render no id at all or render
51
+ * a different one — `RegexWithFlagsField` mints its own `patternId` /
52
+ * `flagsId` / `sampleId`, and `SubtitleRulesField` spells
53
+ * ``${step.command}-hasDefaultRules`` where the label says
54
+ * ``${step.id}-…``.
55
+ *
56
+ * The outcome is the same unnamed textbox, by a worse route: a
57
+ * `<label>` that points at an id nowhere in the document reads as
58
+ * correct in the label's own file, and is only wrong in the *other*
59
+ * file. Half of them are broken and nothing says which half.
60
+ *
61
+ * That is the argument for a component rather than a convention: here
62
+ * the id is minted and consumed in one place and cannot be spelled
63
+ * twice.
40
64
  *
41
65
  * ### The ids are static, and that is why `createLinkedIds` is not here
42
66
  *
@@ -72,6 +96,18 @@ export type FieldProps = {
72
96
  * once, at the top, with the `Alert` this library already ships —
73
97
  * and then the user tabs into the field and hears the specific
74
98
  * reason.
99
+ *
100
+ * ### A slot is a pass-through
101
+ *
102
+ * A `Tooltip` between the field and its control is the obvious thing
103
+ * to write, and it is what mux-magic's `FieldLabel` does. Both
104
+ * components clone onto their one child, so everything below used to
105
+ * land on the `Tooltip` **component** and be dropped in silence —
106
+ * leaving the `<label htmlFor>` above pointing at nothing, which is
107
+ * precisely the defect this component was built to make impossible.
108
+ * `SlotProps` arriving from an outer slot is forwarded down, and
109
+ * `aria-describedby` is merged rather than replaced.
110
+ * `slotProps.ts` has the reasoning.
75
111
  */
76
112
  export const Field = ({
77
113
  children,
@@ -80,10 +116,19 @@ export const Field = ({
80
116
  error,
81
117
  isRequired = false,
82
118
  label,
119
+ ...receivedSlotProps
83
120
  }: FieldProps): ReactNode => {
84
121
  const baseId = useUniqueId()
85
122
 
86
- const controlId = `${baseId}-control`
123
+ /**
124
+ * A caller's own `id` wins, which is what makes a `Field` usable
125
+ * where the id has to be predictable — an autofill hint, a deep
126
+ * link, a server-rendered error summary linking to the control.
127
+ * `useUniqueId` is still called: hooks are unconditional, and it
128
+ * costs one counter read.
129
+ */
130
+ const controlId =
131
+ receivedSlotProps.id ?? `${baseId}-control`
87
132
 
88
133
  const descriptionId = `${baseId}-description`
89
134
 
@@ -96,21 +141,25 @@ export const Field = ({
96
141
  .filter(Boolean)
97
142
  .join(" ")
98
143
 
99
- const clonedControl = useClonedChild(children, {
100
- "aria-describedby":
101
- describedBy === "" ? undefined : describedBy,
102
- // `aria-invalid` is derived from the error's presence, never
103
- // passed. A control marked invalid with no described reason is
104
- // a screen reader saying "invalid" and stopping.
105
- "aria-invalid": error === undefined ? undefined : true,
106
- // Both, and they are not redundant: `required` is the
107
- // constraint the browser validates and `aria-required` is the
108
- // one it announces on a control the browser does not validate
109
- // — a `role="combobox"`, or anything composite.
110
- "aria-required": isRequired || undefined,
111
- id: controlId,
112
- required: isRequired || undefined,
113
- })
144
+ const clonedControl = useClonedChild(
145
+ children,
146
+ mergeSlotProps(receivedSlotProps, {
147
+ "aria-describedby":
148
+ describedBy === "" ? undefined : describedBy,
149
+ // `aria-invalid` is derived from the error's presence, never
150
+ // passed. A control marked invalid with no described reason is
151
+ // a screen reader saying "invalid" and stopping.
152
+ "aria-invalid":
153
+ error === undefined ? undefined : true,
154
+ // Both, and they are not redundant: `required` is the
155
+ // constraint the browser validates and `aria-required` is the
156
+ // one it announces on a control the browser does not validate
157
+ // — a `role="combobox"`, or anything composite.
158
+ "aria-required": isRequired || undefined,
159
+ id: controlId,
160
+ required: isRequired || undefined,
161
+ }),
162
+ )
114
163
 
115
164
  return (
116
165
  <div
@@ -0,0 +1,138 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import type { ReactNode } from "react"
3
+
4
+ import { toClassName } from "../toClassName.ts"
5
+
6
+ export type FieldGroupProps = {
7
+ /**
8
+ * The controls. **Rendered as-is, not cloned** — that is the whole
9
+ * difference from `Field`, and it is why this is a separate
10
+ * component rather than a boolean on that one.
11
+ */
12
+ children: ReactNode
13
+ className?: string
14
+ /** Standing help for the group as a whole. */
15
+ description?: ReactNode
16
+ /** Present means the group is invalid. */
17
+ error?: ReactNode
18
+ isRequired?: boolean
19
+ label: ReactNode
20
+ }
21
+
22
+ /**
23
+ * A label over **several** controls.
24
+ *
25
+ * `Field` mints one id and clones it onto one child, which is the
26
+ * right shape for a text input and the wrong shape the moment there
27
+ * are two of them: an `id` can only name one element, and a
28
+ * `<label htmlFor>` can only point at one. Six of mux-magic's sixteen
29
+ * field components are in that position — `RegexWithFlagsField`
30
+ * (pattern, flags, sample, and a checkbox), `NumberWithLookupField`
31
+ * (three inputs), `LanguageCodeField`, `LanguageCodesField`,
32
+ * `SubtitleTypesField` (a tag input beside a dropdown) and
33
+ * `RenameRegexField` (a row per rule) — and every one of them renders
34
+ * a `FieldLabel` whose `htmlFor` names, at best, one of them.
35
+ *
36
+ * ### `<fieldset>` and `<legend>`, not a `<label>`
37
+ *
38
+ * This is the one place in the library where `<fieldset>` is right.
39
+ * `AccordionSection` rejected it for its panel and said why — it is a
40
+ * *form-control grouping*, and dragging `<legend>` semantics onto
41
+ * prose is wrong. Here the content **is** a form-control grouping,
42
+ * so the element means what it says, `<legend>` names the group
43
+ * natively, and no `role="group"` and no `aria-labelledby` are
44
+ * needed.
45
+ *
46
+ * A `<label>` here would be the mux-magic defect deliberately: a
47
+ * label pointing at one control, or at none.
48
+ *
49
+ * ### What a group cannot carry
50
+ *
51
+ * `aria-invalid` has no group form — it belongs on the control that
52
+ * is actually invalid, and this component does not know which one
53
+ * that is. So `error` here is *described*, not *asserted*: it joins
54
+ * `aria-describedby` on the `<fieldset>`, which screen readers read
55
+ * as focus enters the group. A control inside that is itself invalid
56
+ * should say so itself, in its own `Field`.
57
+ *
58
+ * That is a real limitation and it is stated rather than papered
59
+ * over — the alternative was to clone `aria-invalid` onto every
60
+ * child, which marks the valid ones invalid.
61
+ */
62
+ export const FieldGroup = ({
63
+ children,
64
+ className,
65
+ description,
66
+ error,
67
+ isRequired = false,
68
+ label,
69
+ }: FieldGroupProps): ReactNode => {
70
+ const baseId = useUniqueId()
71
+
72
+ const descriptionId = `${baseId}-description`
73
+
74
+ const errorId = `${baseId}-error`
75
+
76
+ // Description first, error second, for the reason `Field` gives:
77
+ // a screen reader reads the list in order and the two sentences
78
+ // are not interchangeable.
79
+ const describedBy = [
80
+ description === undefined ? null : descriptionId,
81
+ error === undefined ? null : errorId,
82
+ ]
83
+ .filter(Boolean)
84
+ .join(" ")
85
+
86
+ return (
87
+ <fieldset
88
+ aria-describedby={
89
+ describedBy === "" ? undefined : describedBy
90
+ }
91
+ className={toClassName(
92
+ // A `<fieldset>` arrives with a border, an inline margin, a
93
+ // block padding, and `min-inline-size: min-content` — the
94
+ // last of which makes it refuse to shrink inside a flex or
95
+ // grid parent, which is the reason design systems avoid the
96
+ // element. All four are cleared here, once.
97
+ "m-0 flex min-w-0 flex-col gap-1.5 border-0 p-0",
98
+ className,
99
+ )}
100
+ >
101
+ <legend className="mb-1.5 p-0 font-medium text-content-primary text-sm">
102
+ {label}
103
+
104
+ {isRequired ? (
105
+ <span
106
+ // Decoration, as in `Field`. An asterisk announced as
107
+ // "asterisk" is noise; the controls inside carry their
108
+ // own `required`.
109
+ aria-hidden="true"
110
+ className="ms-1 text-intent-danger-content"
111
+ >
112
+ *
113
+ </span>
114
+ ) : null}
115
+ </legend>
116
+
117
+ {children}
118
+
119
+ {description === undefined ? null : (
120
+ <p
121
+ className="text-content-secondary text-xs"
122
+ id={descriptionId}
123
+ >
124
+ {description}
125
+ </p>
126
+ )}
127
+
128
+ {error === undefined ? null : (
129
+ <p
130
+ className="text-intent-danger-content text-xs"
131
+ id={errorId}
132
+ >
133
+ {error}
134
+ </p>
135
+ )}
136
+ </fieldset>
137
+ )
138
+ }
@@ -8,7 +8,8 @@ import * as LogViewerStories from "./LogViewer.stories";
8
8
 
9
9
  Three repos render a scrolling log pane — mux-magic's `JobLogsDisclosure` and `StepLogs`,
10
10
  rip-deck's `LogModal` — and between them they have four defects this component exists not to
11
- have.
11
+ have. The fifth below is not theirs: it is defect 1 coming back through the front door, in
12
+ this component, found by the first app to consume it.
12
13
 
13
14
  <Canvas of={LogViewerStories.Default} />
14
15
 
@@ -63,6 +64,40 @@ five-line result panel where the lines **are** the outcome.
63
64
 
64
65
  <Canvas of={LogViewerStories.AllVariants} />
65
66
 
67
+ ## 5. A pane measured before it has a box
68
+
69
+ Defect 1 again, and the fix for it cannot see this one. `AccordionSection` renders its panel
70
+ with `hidden` rather than unmounting it, deliberately — an unmounted panel loses a scroll
71
+ position, a partially typed form, and any subscription its content opened, and the fleet's
72
+ log panes are exactly that. A `hidden` subtree has **no layout**, so the mount effect reads
73
+ `scrollHeight 0` and writes `scrollTop = 0`. Measured in mux-magic on a 60-line pane:
74
+
75
+ ```
76
+ while collapsed : scrollTop 0 scrollHeight 0 clientHeight 0
77
+ after expanding : scrollTop 0 scrollHeight 976 clientHeight 254
78
+ ```
79
+
80
+ Neither `isFollowing` nor `lines` changes when the section is opened, so the effect never
81
+ runs again and the log opens on its **first** line. That is this component's own `}, [])`
82
+ bug, rebuilt out of two components whose individual decisions are both right, and invisible
83
+ to both of their test suites — `LogViewer`'s mounted visible, `Accordion`'s holding content
84
+ that never measures itself. mux-magic hit it as a consumer and worked around it downstream,
85
+ in a `DisclosedLogViewer` that withheld the pane until the section had been opened once.
86
+ That workaround is now deletable.
87
+
88
+ A `ResizeObserver` on the pane, live only while following, is the fix. Not an
89
+ `IntersectionObserver`: that answers "is it on screen", which is a different question with
90
+ two wrong answers here — a pane below the fold on a long page is not intersecting and has
91
+ perfectly good layout, and scrolling the page later would re-fire for no reason.
92
+ `ResizeObserver` answers "does it have a box, and is that box still the same size", which is
93
+ the precondition the measurement actually needs; per spec it does not fire at `observe()`
94
+ time for an element that is not being rendered, so **gaining a box is the first callback**.
95
+
96
+ It pays for itself twice: a window resize re-wraps the lines and changes what is at the
97
+ bottom, and a following pane belongs at the new bottom.
98
+
99
+ <Canvas of={LogViewerStories.InsideDisclosure} />
100
+
66
101
  ## Not every boolean is a state kind
67
102
 
68
103
  `isFollowing` is `useState`, not `useVisibility`, and this is the first component in the
@@ -1,5 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react"
2
2
 
3
+ import { Accordion } from "../Accordion/Accordion.tsx"
3
4
  import {
4
5
  StoryCell,
5
6
  StoryGrid,
@@ -133,3 +134,36 @@ export const AllStates: Story = {
133
134
  export const Interactive: Story = {
134
135
  args: { label: "Bay 8 rip log", lines: LONG_LINES },
135
136
  }
137
+
138
+ /**
139
+ * The pane mounted inside a **collapsed** `Accordion` section, which
140
+ * is how the fleet actually renders a log and is the one arrangement
141
+ * where following used to fail.
142
+ *
143
+ * `AccordionSection` renders its panel with `hidden`, deliberately,
144
+ * so the pane keeps its scroll position and its subscriptions across
145
+ * a collapse. A `hidden` subtree has no layout, so a pane that
146
+ * measures itself on mount measures `scrollHeight 0` — and neither
147
+ * `isFollowing` nor `lines` changes when the section is opened.
148
+ *
149
+ * Expand it: the log opens on its **last** line, not its first.
150
+ */
151
+ export const InsideDisclosure: Story = {
152
+ args: { label: "Bay 9 rip log", lines: LONG_LINES },
153
+ render: () => (
154
+ <Accordion
155
+ items={[
156
+ {
157
+ content: (
158
+ <LogViewer
159
+ label="Bay 9 rip log"
160
+ lines={LONG_LINES}
161
+ />
162
+ ),
163
+ key: "logs",
164
+ label: "Bay 9 output",
165
+ },
166
+ ]}
167
+ />
168
+ ),
169
+ }
@@ -7,8 +7,13 @@ import { mountStory } from "../mountStory.testHelpers.ts"
7
7
  import { expectAgentDrivable } from "../testing/index.ts"
8
8
  import * as stories from "./LogViewer.stories.tsx"
9
9
 
10
- const { AllStates, AllVariants, Default, Interactive } =
11
- composeStories(stories)
10
+ const {
11
+ AllStates,
12
+ AllVariants,
13
+ Default,
14
+ InsideDisclosure,
15
+ Interactive,
16
+ } = composeStories(stories)
12
17
 
13
18
  test("the pane is named, and is a log", async () => {
14
19
  const { canvas, canvasElement } =
@@ -62,6 +67,64 @@ test("a full pane is scrolled to the bottom", async () => {
62
67
  })
63
68
  })
64
69
 
70
+ /**
71
+ * The same defect, rebuilt out of two components that are each
72
+ * individually right — and the reason `LogViewer` watches its own box
73
+ * rather than trusting one mount measurement.
74
+ *
75
+ * `AccordionSection` renders `hidden` rather than unmounting, because
76
+ * the fleet's panels hold log panes and an unmounted panel loses its
77
+ * scroll position and its subscription. A `hidden` subtree has no
78
+ * layout box, so the mount effect reads `scrollHeight 0` and writes
79
+ * `scrollTop = 0` — and the effect's dependencies, `isFollowing` and
80
+ * the lines, are both unchanged by the reveal. Measured in mux-magic
81
+ * on a 60-line pane:
82
+ *
83
+ * ```
84
+ * while collapsed : scrollTop 0 scrollHeight 0 clientHeight 0
85
+ * after expanding : scrollTop 0 scrollHeight 976 clientHeight 254
86
+ * ```
87
+ *
88
+ * mux-magic worked around it downstream, in `DisclosedLogViewer`, by
89
+ * not mounting the pane until the section had been opened once. This
90
+ * test is that workaround becoming deletable.
91
+ */
92
+ test("a pane revealed by a disclosure follows the tail", async () => {
93
+ const { canvas } = await mountStory(InsideDisclosure)
94
+
95
+ // `hidden: true` because that is the whole precondition: the pane
96
+ // is in the accessibility tree's shadow, and in the layout tree not
97
+ // at all.
98
+ const pane = canvas.getByRole("log", {
99
+ hidden: true,
100
+ name: "Bay 9 rip log",
101
+ })
102
+
103
+ // Not a rhetorical assertion. If a future `AccordionSection`
104
+ // unmounts its panel instead, or stops using `hidden`, this test
105
+ // silently stops testing anything — so it fails here instead.
106
+ await expect(pane.scrollHeight).toBe(0)
107
+
108
+ expectAgentDrivable(canvas, {
109
+ name: "Bay 9 output",
110
+ role: "button",
111
+ }).click()
112
+
113
+ await waitFor(() => {
114
+ expect(pane.scrollHeight).toBeGreaterThan(
115
+ pane.clientHeight,
116
+ )
117
+ })
118
+
119
+ await waitFor(() => {
120
+ expect(
121
+ pane.scrollHeight -
122
+ pane.scrollTop -
123
+ pane.clientHeight,
124
+ ).toBeLessThan(4)
125
+ })
126
+ })
127
+
65
128
  /**
66
129
  * Following is pinned to the user's own position. Scrolling back to
67
130
  * read the line that mentioned an error must not be undone twice a
@@ -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(
@@ -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(children, {
125
- ...getReferenceProps(),
126
- ref: refs.setReference,
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,