@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.
@@ -0,0 +1,143 @@
1
+ import type { InputHTMLAttributes } from "react"
2
+
3
+ /**
4
+ * The props a **cloning ancestor** injects into the control below it.
5
+ *
6
+ * `Field` and `Tooltip` both take one child and `cloneElement` onto
7
+ * it — "cloned, not wrapped", which is what lets a `Field` go around
8
+ * an `<input>`, a `Select`, or an app's own control without changing
9
+ * that control's layout. The contract has a hole in it the moment two
10
+ * of them are nested:
11
+ *
12
+ * ```tsx
13
+ * <Field label="Pattern" isRequired>
14
+ * <Tooltip label="A JavaScript regular expression">
15
+ * <input />
16
+ * </Tooltip>
17
+ * </Field>
18
+ * ```
19
+ *
20
+ * `Field` clones onto the **`Tooltip` element**, so `id`,
21
+ * `aria-describedby`, `aria-invalid` and `required` are handed to a
22
+ * component that declares none of them — and `cloneElement` does not
23
+ * care. React drops them without a warning, TypeScript never sees
24
+ * them (`Children.only` returns a `ReactElement` with `any` props),
25
+ * every test still passes, and the render is pixel-identical. The
26
+ * `<label>` then points at an `id` that is nowhere in the document.
27
+ *
28
+ * **Silent prop-dropping is the worst version of this**, so the rule
29
+ * is: *a slot component is a pass-through*. Anything in `SlotProps`
30
+ * that arrives from above is forwarded to the child this component
31
+ * clones onto, so a chain of slots behaves like the one control at
32
+ * the bottom of it.
33
+ *
34
+ * ### The list is closed, the forwarding is not
35
+ *
36
+ * Worth being exact, because the two are easy to conflate. This type
37
+ * is a closed list of five keys rather than an index signature: it is
38
+ * exactly the wiring `Field` writes plus the wiring `Tooltip` writes,
39
+ * it is greppable, and it is the set whose **merge semantics are
40
+ * defined** — which for four of them is last-write-wins and for
41
+ * `aria-describedby` is a join.
42
+ *
43
+ * The *runtime* forwards everything. Both components collect their
44
+ * received props with a rest spread and `mergeSlotProps` starts from
45
+ * `...receivedProps`, so an ancestor's `onPointerEnter`, its
46
+ * `onFocus`, its `ref` — none of which are named here — reach the
47
+ * control at the bottom too. That is not an oversight, it is load
48
+ * bearing: a `Tooltip` around a `Field` hands down `useHover`,
49
+ * `useFocus`, `useDismiss` and `refs.setReference`, and a `Field`
50
+ * that forwarded only the five keys below would leave the tip with no
51
+ * trigger and no anchor. A closed *runtime* filter would be
52
+ * mux-magic's `FieldTooltip` defect rebuilt inside the fix for it.
53
+ *
54
+ * The five keys, and who writes them:
55
+ *
56
+ * | Key | Written by |
57
+ * | --- | --- |
58
+ * | `aria-describedby` | `Field` (its description and its error) **and** `Tooltip` (its tip) — the one key that is a list, and the reason `mergeSlotProps` exists |
59
+ * | `aria-invalid` | `Field`, derived from `error` being present |
60
+ * | `aria-required` | `Field`, from `isRequired` — the announced constraint, for a composite control the browser does not validate |
61
+ * | `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 |
62
+ * | `required` | `Field`, from `isRequired` — the constraint the browser validates |
63
+ *
64
+ * ### Why these are `Pick`ed and not written out
65
+ *
66
+ * Three of the five are booleans whose names cannot start with `is`
67
+ * or `has`, because they are not our names — they are the DOM's, and
68
+ * they are spread onto a DOM node verbatim. The house rule has
69
+ * [no carve-out](../../../docs/decisions/2026-07-29-is-has-rule-has-no-external-api-carve-out.md)
70
+ * for that: a *type property* is a shape we own, an object literal
71
+ * key is someone else's contract, and no `eslint-disable` is
72
+ * available because unused disable directives are themselves an
73
+ * error.
74
+ *
75
+ * So this type does not declare a shape. It **selects** one out of
76
+ * React's own `InputHTMLAttributes`, which is the honest description
77
+ * of what it is: five keys of the props React already types for an
78
+ * `<input>`, forwarded unchanged. The same reasoning as the
79
+ * `Record<string, boolean>` in `createReactAdapter` — we are not
80
+ * describing React's shape, we are naming the keys of it we touch —
81
+ * and it has the side benefit that `aria-invalid` gets React's real
82
+ * type (`"grammar"` and `"spelling"` are legal values) rather than
83
+ * the `boolean` a hand-written version would have narrowed it to.
84
+ */
85
+ export type SlotProps = Pick<
86
+ InputHTMLAttributes<HTMLElement>,
87
+ | "aria-describedby"
88
+ | "aria-invalid"
89
+ | "aria-required"
90
+ | "id"
91
+ | "required"
92
+ >
93
+
94
+ /**
95
+ * Merge what arrived from above with what this component writes
96
+ * itself.
97
+ *
98
+ * Everything here is a single value and last-write-wins is correct
99
+ * for it — **except `aria-describedby`, which is a list**. Two slot
100
+ * components each writing their own onto one control is the entire
101
+ * nesting problem in one attribute: a `Field` naming its description
102
+ * and its error, and a `Tooltip` naming its tip. A plain spread keeps
103
+ * one of them and loses the other, which is the same silent drop by a
104
+ * shorter route.
105
+ *
106
+ * **Outer first.** A screen reader reads the list in order, and the
107
+ * field's own description and error are the control's primary
108
+ * explanation; a tip is supplementary by construction. The ordering
109
+ * inside a single `Field` — description, then error — is `Field`'s
110
+ * own concern and is unaffected.
111
+ *
112
+ * `undefined` in `ownProps` is *absence*, not an instruction to
113
+ * clear. Both callers spell their optional wiring as
114
+ * `isRequired || undefined`, so a plain spread would let an inner
115
+ * `Field` with no error erase an outer one's `aria-invalid` — the
116
+ * same silent drop, one level down.
117
+ */
118
+ export const mergeSlotProps = <
119
+ OwnProps extends Record<string, unknown>,
120
+ >(
121
+ receivedProps: SlotProps,
122
+ ownProps: OwnProps,
123
+ ): OwnProps & SlotProps => {
124
+ const definedOwnProps = Object.fromEntries(
125
+ Object.entries(ownProps).filter(
126
+ ([, value]) => value !== undefined,
127
+ ),
128
+ ) as OwnProps
129
+
130
+ const describedBy = [
131
+ receivedProps["aria-describedby"],
132
+ ownProps["aria-describedby"],
133
+ ]
134
+ .filter(Boolean)
135
+ .join(" ")
136
+
137
+ return {
138
+ ...receivedProps,
139
+ ...definedOwnProps,
140
+ "aria-describedby":
141
+ describedBy === "" ? undefined : describedBy,
142
+ }
143
+ }