@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.
Files changed (51) hide show
  1. package/dist/Field/Field.d.ts +41 -6
  2. package/dist/Field/Field.d.ts.map +1 -1
  3. package/dist/Field/Field.js +50 -8
  4. package/dist/Field/Field.js.map +1 -1
  5. package/dist/Field/FieldGroup.d.ts +58 -0
  6. package/dist/Field/FieldGroup.d.ts.map +1 -0
  7. package/dist/Field/FieldGroup.js +65 -0
  8. package/dist/Field/FieldGroup.js.map +1 -0
  9. package/dist/LogViewer/LogViewer.d.ts +3 -1
  10. package/dist/LogViewer/LogViewer.d.ts.map +1 -1
  11. package/dist/LogViewer/LogViewer.js +62 -1
  12. package/dist/LogViewer/LogViewer.js.map +1 -1
  13. package/dist/Select/Select.d.ts +10 -2
  14. package/dist/Select/Select.d.ts.map +1 -1
  15. package/dist/Select/Select.js.map +1 -1
  16. package/dist/Tooltip/Tooltip.d.ts +16 -2
  17. package/dist/Tooltip/Tooltip.d.ts.map +1 -1
  18. package/dist/Tooltip/Tooltip.js +23 -3
  19. package/dist/Tooltip/Tooltip.js.map +1 -1
  20. package/dist/index.d.ts +4 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/slotProps.d.ts +121 -0
  25. package/dist/slotProps.d.ts.map +1 -0
  26. package/dist/slotProps.js +55 -0
  27. package/dist/slotProps.js.map +1 -0
  28. package/dist/slotWiring.d.ts +32 -0
  29. package/dist/slotWiring.d.ts.map +1 -0
  30. package/dist/slotWiring.js +115 -0
  31. package/dist/slotWiring.js.map +1 -0
  32. package/package.json +3 -3
  33. package/src/Field/Field.mdx +73 -3
  34. package/src/Field/Field.stories.tsx +136 -0
  35. package/src/Field/Field.test.tsx +207 -5
  36. package/src/Field/Field.tsx +70 -21
  37. package/src/Field/FieldGroup.tsx +138 -0
  38. package/src/LogViewer/LogViewer.mdx +36 -1
  39. package/src/LogViewer/LogViewer.stories.tsx +34 -0
  40. package/src/LogViewer/LogViewer.test.tsx +65 -2
  41. package/src/LogViewer/LogViewer.tsx +67 -1
  42. package/src/Menu/Menu.mdx +23 -0
  43. package/src/Menu/Menu.stories.tsx +67 -0
  44. package/src/Menu/Menu.test.tsx +113 -2
  45. package/src/Popover/Popover.mdx +5 -0
  46. package/src/Select/Select.stories.tsx +10 -2
  47. package/src/Select/Select.tsx +10 -2
  48. package/src/Tooltip/Tooltip.tsx +29 -5
  49. package/src/index.ts +4 -0
  50. package/src/slotProps.ts +163 -0
  51. package/src/slotWiring.ts +173 -0
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAOH,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA2B,CAAA;AAKrD,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAmB,CAAA;AAEzC,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAmB,CAAA;AAEzC,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAqB,CAAA;AAM5C,OAAO,EAAE,IAAI,EAAE,MAAM,gBAAiB,CAAA;AAEtC,OAAO,EACL,gBAAgB,EAChB,kBAAkB,EAClB,cAAc,EACd,uBAAuB,EACvB,sBAAsB,EACtB,kBAAkB,GACnB,MAAM,oBAAoB,CAAA;AAK3B,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA6B,CAAA;AAExD,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAmB,CAAA;AAEzC,OAAO,EAAE,YAAY,EAAE,MAAM,gCAAiC,CAAA;AAE9D,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA6B,CAAA;AAExD,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,uBAAuB,EACvB,oBAAoB,EACpB,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,mBAAmB,CAAA;AAK1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,8CAA+C,CAAA;AAKnF,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA2B,CAAA;AAKrD,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA2B,CAAA;AAErD,OAAO,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAA;AAK7D,OAAO,EAAE,IAAI,EAAE,MAAM,gBAAiB,CAAA;AAKtC,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAmB,CAAA;AAEzC,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAuB,CAAA;AAK/C,OAAO,EAAE,WAAW,EAAE,MAAM,8BAA+B,CAAA;AAE3D,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,eAAe,GAChB,MAAM,gCAAgC,CAAA;AAKvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,wCAAyC,CAAA;AAO1E,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAqB,CAAA;AAK5C,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAyB,CAAA;AAKlD,OAAO,EAAE,mBAAmB,EAAE,MAAM,8CAA+C,CAAA;AAEnF,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAuB,CAAA;AAC/C,OAAO,EACL,cAAc,EACd,aAAa,EACb,mBAAmB,EACnB,kBAAkB,EAClB,mBAAmB,GACpB,MAAM,mBAAmB,CAAA;AAO1B,OAAO,EAAE,IAAI,EAAE,MAAM,gBAAiB,CAAA;AAEtC,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAmB,CAAA;AAKzC,OAAO,EAAE,WAAW,EAAE,MAAM,wBAAyB,CAAA;AAErD,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAA;AAE5D,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAuB,CAAA;AAC/C,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAE9C,OAAO,EAAE,cAAc,EAAE,MAAM,oCAAqC,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAOH,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA2B,CAAA;AAKrD,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAmB,CAAA;AAEzC,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAmB,CAAA;AAEzC,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAqB,CAAA;AAM5C,OAAO,EAAE,IAAI,EAAE,MAAM,gBAAiB,CAAA;AAEtC,OAAO,EACL,gBAAgB,EAChB,kBAAkB,EAClB,cAAc,EACd,uBAAuB,EACvB,sBAAsB,EACtB,kBAAkB,GACnB,MAAM,oBAAoB,CAAA;AAK3B,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA6B,CAAA;AAExD,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAmB,CAAA;AAEzC,OAAO,EAAE,UAAU,EAAE,MAAM,uBAAwB,CAAA;AAEnD,OAAO,EAAE,YAAY,EAAE,MAAM,gCAAiC,CAAA;AAE9D,OAAO,EAAE,UAAU,EAAE,MAAM,4BAA6B,CAAA;AAExD,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,uBAAuB,EACvB,oBAAoB,EACpB,kBAAkB,EAClB,uBAAuB,GACxB,MAAM,mBAAmB,CAAA;AAK1B,OAAO,EAAE,mBAAmB,EAAE,MAAM,8CAA+C,CAAA;AAKnF,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA2B,CAAA;AAKrD,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA2B,CAAA;AAErD,OAAO,EAAE,gBAAgB,EAAE,MAAM,4BAA4B,CAAA;AAK7D,OAAO,EAAE,IAAI,EAAE,MAAM,gBAAiB,CAAA;AAKtC,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAmB,CAAA;AAEzC,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAuB,CAAA;AAK/C,OAAO,EAAE,WAAW,EAAE,MAAM,8BAA+B,CAAA;AAE3D,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,eAAe,GAChB,MAAM,gCAAgC,CAAA;AAKvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,wCAAyC,CAAA;AAO1E,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAqB,CAAA;AAK5C,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAyB,CAAA;AAKlD,OAAO,EAAE,mBAAmB,EAAE,MAAM,8CAA+C,CAAA;AAEnF,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAuB,CAAA;AAE/C,OAAO,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAA;AAC/C,OAAO,EACL,cAAc,EACd,aAAa,EACb,mBAAmB,EACnB,kBAAkB,EAClB,mBAAmB,GACpB,MAAM,mBAAmB,CAAA;AAO1B,OAAO,EAAE,IAAI,EAAE,MAAM,gBAAiB,CAAA;AAEtC,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAmB,CAAA;AAKzC,OAAO,EAAE,WAAW,EAAE,MAAM,wBAAyB,CAAA;AAErD,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAA;AAE5D,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAuB,CAAA;AAC/C,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAE9C,OAAO,EAAE,cAAc,EAAE,MAAM,oCAAqC,CAAA"}
@@ -0,0 +1,121 @@
1
+ import type { InputHTMLAttributes } from "react";
2
+ /**
3
+ * The props a **cloning ancestor** injects into the control below it.
4
+ *
5
+ * `Field` and `Tooltip` both take one child and `cloneElement` onto
6
+ * it — "cloned, not wrapped", which is what lets a `Field` go around
7
+ * an `<input>`, a `Select`, or an app's own control without changing
8
+ * that control's layout. The contract has a hole in it the moment two
9
+ * of them are nested:
10
+ *
11
+ * ```tsx
12
+ * <Field label="Pattern" isRequired>
13
+ * <Tooltip label="A JavaScript regular expression">
14
+ * <input />
15
+ * </Tooltip>
16
+ * </Field>
17
+ * ```
18
+ *
19
+ * `Field` clones onto the **`Tooltip` element**, so `id`,
20
+ * `aria-describedby`, `aria-invalid` and `required` are handed to a
21
+ * component that declares none of them — and `cloneElement` does not
22
+ * care. React drops them without a warning, TypeScript never sees
23
+ * them (`Children.only` returns a `ReactElement` with `any` props),
24
+ * every test still passes, and the render is pixel-identical. The
25
+ * `<label>` then points at an `id` that is nowhere in the document.
26
+ *
27
+ * **Silent prop-dropping is the worst version of this**, so the rule
28
+ * is: *a slot component is a pass-through*. Anything in `SlotProps`
29
+ * that arrives from above is forwarded to the child this component
30
+ * clones onto, so a chain of slots behaves like the one control at
31
+ * the bottom of it.
32
+ *
33
+ * ### The list is closed, the forwarding is not
34
+ *
35
+ * Worth being exact, because the two are easy to conflate. This type
36
+ * is a closed list of five keys rather than an index signature: it is
37
+ * exactly the wiring `Field` writes plus the wiring `Tooltip` writes,
38
+ * it is greppable, and it is the set whose **merge semantics are
39
+ * defined** — which for four of them is last-write-wins and for
40
+ * `aria-describedby` is a join.
41
+ *
42
+ * The *runtime* forwards everything. Both components collect their
43
+ * received props with a rest spread and `mergeSlotProps` starts from
44
+ * `...receivedProps`, so an ancestor's `onPointerEnter`, its
45
+ * `onFocus`, its `ref` — none of which are named here — reach the
46
+ * control at the bottom too. That is not an oversight, it is load
47
+ * bearing: a `Tooltip` around a `Field` hands down `useHover`,
48
+ * `useFocus`, `useDismiss` and `refs.setReference`, and a `Field`
49
+ * that forwarded only the five keys below would leave the tip with no
50
+ * trigger and no anchor. A closed *runtime* filter would be
51
+ * mux-magic's `FieldTooltip` defect rebuilt inside the fix for it.
52
+ *
53
+ * The five keys, and who writes them:
54
+ *
55
+ * | Key | Written by |
56
+ * | --- | --- |
57
+ * | `aria-describedby` | `Field` (its description and its error) **and** `Tooltip` (its tip) — the one key that is a list, and the reason `mergeSlotProps` exists |
58
+ * | `aria-invalid` | `Field`, derived from `error` being present |
59
+ * | `aria-required` | `Field`, from `isRequired` — the announced constraint, for a composite control the browser does not validate |
60
+ * | `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 |
61
+ * | `required` | `Field`, from `isRequired` — the constraint the browser validates |
62
+ *
63
+ * ### Why these are `Pick`ed and not written out
64
+ *
65
+ * Three of the five are booleans whose names cannot start with `is`
66
+ * or `has`, because they are not our names — they are the DOM's, and
67
+ * they are spread onto a DOM node verbatim. The house rule has
68
+ * [no carve-out](../../../docs/decisions/2026-07-29-is-has-rule-has-no-external-api-carve-out.md)
69
+ * for that: a *type property* is a shape we own, an object literal
70
+ * key is someone else's contract, and no `eslint-disable` is
71
+ * available because unused disable directives are themselves an
72
+ * error.
73
+ *
74
+ * So this type does not declare a shape. It **selects** one out of
75
+ * React's own `InputHTMLAttributes`, which is the honest description
76
+ * of what it is: five keys of the props React already types for an
77
+ * `<input>`, forwarded unchanged. The same reasoning as the
78
+ * `Record<string, boolean>` in `createReactAdapter` — we are not
79
+ * describing React's shape, we are naming the keys of it we touch —
80
+ * and it has the side benefit that `aria-invalid` gets React's real
81
+ * type (`"grammar"` and `"spelling"` are legal values) rather than
82
+ * the `boolean` a hand-written version would have narrowed it to.
83
+ */
84
+ export type SlotProps = Pick<InputHTMLAttributes<HTMLElement>, "aria-describedby" | "aria-invalid" | "aria-required" | "id" | "required">;
85
+ /**
86
+ * Merge what arrived from above with what this component writes
87
+ * itself.
88
+ *
89
+ * Everything here is a single value and last-write-wins is correct
90
+ * for it — **except `aria-describedby`, which is a list**. Two slot
91
+ * components each writing their own onto one control is the entire
92
+ * nesting problem in one attribute: a `Field` naming its description
93
+ * and its error, and a `Tooltip` naming its tip. A plain spread keeps
94
+ * one of them and loses the other, which is the same silent drop by a
95
+ * shorter route.
96
+ *
97
+ * **Outer first.** A screen reader reads the list in order, and the
98
+ * field's own description and error are the control's primary
99
+ * explanation; a tip is supplementary by construction. The ordering
100
+ * inside a single `Field` — description, then error — is `Field`'s
101
+ * own concern and is unaffected.
102
+ *
103
+ * `undefined` in `ownProps` is *absence*, not an instruction to
104
+ * clear. Both callers spell their optional wiring as
105
+ * `isRequired || undefined`, so a plain spread would let an inner
106
+ * `Field` with no error erase an outer one's `aria-invalid` — the
107
+ * same silent drop, one level down.
108
+ *
109
+ * ### The wiring is not a value, and does not last-write-win
110
+ *
111
+ * `ref` and the `on*` handlers are merged rather than replaced, by
112
+ * `mergeSlotWiring`. This was the 1.0.0 hole: `mergeSlotProps`
113
+ * settled the five *attributes* and left the two props that are not
114
+ * attributes on last-write-wins, so a `Menu` and a `Tooltip` on one
115
+ * trigger each handed it a floating-ui `refs.setReference` and the
116
+ * inner one won. The outer panel had no anchor and rendered in the
117
+ * corner of the viewport — no error, no failing test, and it looks
118
+ * like a CSS bug.
119
+ */
120
+ export declare const mergeSlotProps: <OwnProps extends Record<string, unknown>>(receivedProps: SlotProps, ownProps: OwnProps) => OwnProps & SlotProps;
121
+ //# sourceMappingURL=slotProps.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slotProps.d.ts","sourceRoot":"","sources":["../src/slotProps.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,OAAO,CAAA;AAIhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiFG;AACH,MAAM,MAAM,SAAS,GAAG,IAAI,CAC1B,mBAAmB,CAAC,WAAW,CAAC,EAC9B,kBAAkB,GAClB,cAAc,GACd,eAAe,GACf,IAAI,GACJ,UAAU,CACb,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,eAAO,MAAM,cAAc,GACzB,QAAQ,SAAS,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAExC,eAAe,SAAS,EACxB,UAAU,QAAQ,KACjB,QAAQ,GAAG,SA2Bb,CAAA"}
@@ -0,0 +1,55 @@
1
+ import { mergeSlotWiring } from "./slotWiring.js";
2
+ /**
3
+ * Merge what arrived from above with what this component writes
4
+ * itself.
5
+ *
6
+ * Everything here is a single value and last-write-wins is correct
7
+ * for it — **except `aria-describedby`, which is a list**. Two slot
8
+ * components each writing their own onto one control is the entire
9
+ * nesting problem in one attribute: a `Field` naming its description
10
+ * and its error, and a `Tooltip` naming its tip. A plain spread keeps
11
+ * one of them and loses the other, which is the same silent drop by a
12
+ * shorter route.
13
+ *
14
+ * **Outer first.** A screen reader reads the list in order, and the
15
+ * field's own description and error are the control's primary
16
+ * explanation; a tip is supplementary by construction. The ordering
17
+ * inside a single `Field` — description, then error — is `Field`'s
18
+ * own concern and is unaffected.
19
+ *
20
+ * `undefined` in `ownProps` is *absence*, not an instruction to
21
+ * clear. Both callers spell their optional wiring as
22
+ * `isRequired || undefined`, so a plain spread would let an inner
23
+ * `Field` with no error erase an outer one's `aria-invalid` — the
24
+ * same silent drop, one level down.
25
+ *
26
+ * ### The wiring is not a value, and does not last-write-win
27
+ *
28
+ * `ref` and the `on*` handlers are merged rather than replaced, by
29
+ * `mergeSlotWiring`. This was the 1.0.0 hole: `mergeSlotProps`
30
+ * settled the five *attributes* and left the two props that are not
31
+ * attributes on last-write-wins, so a `Menu` and a `Tooltip` on one
32
+ * trigger each handed it a floating-ui `refs.setReference` and the
33
+ * inner one won. The outer panel had no anchor and rendered in the
34
+ * corner of the viewport — no error, no failing test, and it looks
35
+ * like a CSS bug.
36
+ */
37
+ export const mergeSlotProps = (receivedProps, ownProps) => {
38
+ const definedOwnProps = Object.fromEntries(Object.entries(ownProps).filter(([, value]) => value !== undefined));
39
+ const describedBy = [
40
+ receivedProps["aria-describedby"],
41
+ ownProps["aria-describedby"],
42
+ ]
43
+ .filter(Boolean)
44
+ .join(" ");
45
+ return {
46
+ ...receivedProps,
47
+ ...definedOwnProps,
48
+ "aria-describedby": describedBy === "" ? undefined : describedBy,
49
+ // Last, because it is the only merge that has to beat
50
+ // `definedOwnProps` — it is composing what that spread would
51
+ // otherwise have thrown away.
52
+ ...mergeSlotWiring(receivedProps, definedOwnProps),
53
+ };
54
+ };
55
+ //# sourceMappingURL=slotProps.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slotProps.js","sourceRoot":"","sources":["../src/slotProps.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAA;AA6FjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAG5B,aAAwB,EACxB,QAAkB,EACI,EAAE;IACxB,MAAM,eAAe,GAAG,MAAM,CAAC,WAAW,CACxC,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,MAAM,CAC7B,CAAC,CAAC,EAAE,KAAK,CAAC,EAAE,EAAE,CAAC,KAAK,KAAK,SAAS,CACnC,CACU,CAAA;IAEb,MAAM,WAAW,GAAG;QAClB,aAAa,CAAC,kBAAkB,CAAC;QACjC,QAAQ,CAAC,kBAAkB,CAAC;KAC7B;SACE,MAAM,CAAC,OAAO,CAAC;SACf,IAAI,CAAC,GAAG,CAAC,CAAA;IAEZ,OAAO;QACL,GAAG,aAAa;QAChB,GAAG,eAAe;QAClB,kBAAkB,EAChB,WAAW,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW;QAC9C,sDAAsD;QACtD,6DAA6D;QAC7D,8BAA8B;QAC9B,GAAG,eAAe,CAChB,aAAwC,EACxC,eAAe,CAChB;KACF,CAAA;AACH,CAAC,CAAA"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The two props a slot may not overwrite, and how it merges them.
3
+ *
4
+ * `mergeSlotProps` next door settles the five *attributes* two
5
+ * nested slots both write, and last-write-wins is right for four of
6
+ * them. It is not right for the wiring, because neither of these is
7
+ * a value:
8
+ *
9
+ * - **`ref`** is a subscription. `Menu` and `Tooltip` on one trigger
10
+ * both hand it a floating-ui `refs.setReference` — the anchor each
11
+ * of them positions its panel against — and the inner one used to
12
+ * replace the outer one's. Nothing threw. The outer panel simply
13
+ * had no reference element, so floating-ui left it at
14
+ * `left: 0; top: 0` in the corner of the viewport, which reads as
15
+ * a CSS bug and is not one.
16
+ * - **`on*`** is a listener, and listeners are plural.
17
+ *
18
+ * ### Why this is not imported from `@charcuterie/logic`
19
+ *
20
+ * `useClonedChild`'s `mergeClonedProps` applies the same rule one
21
+ * level down — between a slot and the element a caller wrote — and
22
+ * the two are near-identical. Sharing them means a new public export
23
+ * on `@charcuterie/logic`, which is a `minor`; this is a patch, and
24
+ * the duplication is thirty lines against a wrong version number.
25
+ * Queued in `docs/2026-08-02-reported-by-consumers-queued-for-1-1.md`.
26
+ */
27
+ /**
28
+ * The keys of `ownProps` that must not simply replace the ones
29
+ * `receivedProps` arrived with. Spread **last**, over both.
30
+ */
31
+ export declare const mergeSlotWiring: (receivedProps: Record<string, unknown>, ownProps: Record<string, unknown>) => Record<string, unknown>;
32
+ //# sourceMappingURL=slotWiring.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slotWiring.d.ts","sourceRoot":"","sources":["../src/slotWiring.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAuGH;;;GAGG;AACH,eAAO,MAAM,eAAe,GAC1B,eAAe,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACtC,UAAU,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAChC,MAAM,CAAC,MAAM,EAAE,OAAO,CAqCtB,CAAA"}
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The two props a slot may not overwrite, and how it merges them.
3
+ *
4
+ * `mergeSlotProps` next door settles the five *attributes* two
5
+ * nested slots both write, and last-write-wins is right for four of
6
+ * them. It is not right for the wiring, because neither of these is
7
+ * a value:
8
+ *
9
+ * - **`ref`** is a subscription. `Menu` and `Tooltip` on one trigger
10
+ * both hand it a floating-ui `refs.setReference` — the anchor each
11
+ * of them positions its panel against — and the inner one used to
12
+ * replace the outer one's. Nothing threw. The outer panel simply
13
+ * had no reference element, so floating-ui left it at
14
+ * `left: 0; top: 0` in the corner of the viewport, which reads as
15
+ * a CSS bug and is not one.
16
+ * - **`on*`** is a listener, and listeners are plural.
17
+ *
18
+ * ### Why this is not imported from `@charcuterie/logic`
19
+ *
20
+ * `useClonedChild`'s `mergeClonedProps` applies the same rule one
21
+ * level down — between a slot and the element a caller wrote — and
22
+ * the two are near-identical. Sharing them means a new public export
23
+ * on `@charcuterie/logic`, which is a `minor`; this is a patch, and
24
+ * the duplication is thirty lines against a wrong version number.
25
+ * Queued in `docs/2026-08-02-reported-by-consumers-queued-for-1-1.md`.
26
+ */
27
+ const isEventHandlerName = (name) => /^on[A-Z]/.test(name);
28
+ const isMergeableRef = (value) => typeof value === "function" ||
29
+ (typeof value === "object" &&
30
+ value !== null &&
31
+ "current" in value);
32
+ const setRef = (ref, node) => {
33
+ if (typeof ref === "function") {
34
+ return ref(node);
35
+ }
36
+ ref.current = node;
37
+ return undefined;
38
+ };
39
+ /**
40
+ * Every pair of refs ever merged, so the same two always come back
41
+ * as the **same function** — which is load-bearing rather than
42
+ * frugal.
43
+ *
44
+ * React re-runs a callback ref whenever its identity changes between
45
+ * renders, detaching with the old one and attaching with the new.
46
+ * Both refs here are floating-ui `setReference`s, and calling one
47
+ * sets state; a merged ref rebuilt every render would call both
48
+ * twice per render, each call scheduling the render that rebuilds
49
+ * it. `mergeSlotProps` is a plain function called during render, so
50
+ * it cannot reach for `useMemo` — the memo has to be in the identity
51
+ * of the inputs, which is what a `WeakMap` of `WeakMap`s is.
52
+ */
53
+ const mergedRefs = new WeakMap();
54
+ const mergeRefs = (first, second) => {
55
+ const bySecond = mergedRefs.get(first) ??
56
+ new WeakMap();
57
+ mergedRefs.set(first, bySecond);
58
+ const cached = bySecond.get(second);
59
+ if (cached) {
60
+ return cached;
61
+ }
62
+ const merged = (node) => {
63
+ const cleanups = [
64
+ setRef(first, node),
65
+ setRef(second, node),
66
+ ];
67
+ // Per ref: one that returned a cleanup has opted out of being
68
+ // called with `null`, and one that did not still expects it.
69
+ return () => {
70
+ for (const [index, cleanup] of cleanups.entries()) {
71
+ if (typeof cleanup === "function") {
72
+ cleanup();
73
+ }
74
+ else {
75
+ setRef(index === 0 ? first : second, null);
76
+ }
77
+ }
78
+ };
79
+ };
80
+ bySecond.set(second, merged);
81
+ return merged;
82
+ };
83
+ const chainHandlers = (first, second) => (...args) => {
84
+ // Outer first, the same order `aria-describedby` is joined in:
85
+ // the merge order is structural — whoever wrapped whom — and the
86
+ // author of the nesting is the one who chose it.
87
+ first(...args);
88
+ second(...args);
89
+ };
90
+ /**
91
+ * The keys of `ownProps` that must not simply replace the ones
92
+ * `receivedProps` arrived with. Spread **last**, over both.
93
+ */
94
+ export const mergeSlotWiring = (receivedProps, ownProps) => Object.fromEntries(Object.entries(ownProps).flatMap(([name, ownValue]) => {
95
+ const receivedValue = receivedProps[name];
96
+ if (name === "ref" &&
97
+ isMergeableRef(receivedValue) &&
98
+ isMergeableRef(ownValue)) {
99
+ return [
100
+ [name, mergeRefs(receivedValue, ownValue)],
101
+ ];
102
+ }
103
+ if (isEventHandlerName(name) &&
104
+ typeof receivedValue === "function" &&
105
+ typeof ownValue === "function") {
106
+ return [
107
+ [
108
+ name,
109
+ chainHandlers(receivedValue, ownValue),
110
+ ],
111
+ ];
112
+ }
113
+ return [];
114
+ }));
115
+ //# sourceMappingURL=slotWiring.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"slotWiring.js","sourceRoot":"","sources":["../src/slotWiring.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "0.2.0",
3
+ "version": "1.0.1",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -43,8 +43,8 @@
43
43
  "typecheck": "tsc --noEmit"
44
44
  },
45
45
  "dependencies": {
46
- "@charcuterie/logic": "0.1.0",
47
- "@charcuterie/tokens": "0.2.0",
46
+ "@charcuterie/logic": "1.0.1",
47
+ "@charcuterie/tokens": "1.0.0",
48
48
  "@floating-ui/react": "^0.27.20"
49
49
  },
50
50
  "peerDependencies": {
@@ -7,9 +7,23 @@ import * as FieldStories from "./Field.stories";
7
7
  # Field
8
8
 
9
9
  Sixteen files in the fleet spell a label, a control, and a hint, and no two spell the
10
- wiring the same way. mux-magic's `FieldLabel` renders a `<label>` with **no `htmlFor`** —
11
- which looks correct, reads correctly to a sighted user, and gives a screen reader an
12
- unnamed textbox.
10
+ wiring the same way.
11
+
12
+ An earlier version of this page said mux-magic's `FieldLabel` renders a `<label>` with
13
+ **no `htmlFor`**. That is false, and worth correcting rather than quietly deleting: it
14
+ renders ``htmlFor={`${stepId}-${field.name}`}``, and has since it was written. The defect
15
+ is at the *other* end of the pair — **8 of its 16 call sites never render that id on
16
+ anything**. Measured on `mux-magic@master`, the eight that do are `BooleanField`,
17
+ `ChapterSplitsField`, `EnumField`, `NumberField`, `NumberWithLookupField`, `PathField`,
18
+ `StringArrayField` and `StringField`. Of the rest, `RegexWithFlagsField` mints its own
19
+ `patternId` / `flagsId` / `sampleId` and `SubtitleRulesField` spells
20
+ ``${step.command}-hasDefaultRules`` where the label says ``${step.id}-…``.
21
+
22
+ The outcome is the same unnamed textbox by a worse route: a `<label>` pointing at an id
23
+ that is nowhere in the document reads as correct in the label's own file, and is only
24
+ wrong in the *other* file. Half of them are broken and nothing says which half. That is
25
+ the argument for a component rather than a convention — here the id is minted and consumed
26
+ in one place and cannot be spelled twice.
13
27
 
14
28
  <Canvas of={FieldStories.Default} />
15
29
 
@@ -25,6 +39,62 @@ Cloned, not wrapped — the same contract as `Popover`'s trigger. An `<input>`,
25
39
 
26
40
  <Canvas of={FieldStories.AllVariants} />
27
41
 
42
+ ## A slot is a pass-through
43
+
44
+ Which is a rule this component needed and did not have. `Field` and `Tooltip` both clone
45
+ onto their single child, so the obvious thing to write —
46
+
47
+ ```tsx
48
+ <Field label="Rename pattern" isRequired>
49
+ <Tooltip label="A JavaScript regular expression.">
50
+ <input />
51
+ </Tooltip>
52
+ </Field>
53
+ ```
54
+
55
+ — handed `id`, `aria-describedby`, `aria-invalid` and `required` to the **`Tooltip`
56
+ component**, which declares none of them. `cloneElement` does not care. React drops all
57
+ four without a warning, TypeScript never sees it (`Children.only` returns a `ReactElement`
58
+ whose props are `any`), every test still passed, and the render was pixel-identical. The
59
+ only symptom was a `<label htmlFor>` pointing at an id nowhere in the document — the exact
60
+ defect above, reproduced by the component built to prevent it, and this time by the
61
+ library rather than by a consumer.
62
+
63
+ **Silent prop-dropping is the worst version of this**, so anything a slot receives from
64
+ above is forwarded to the child it clones onto, and a chain of slots behaves like the one
65
+ control at the bottom of it. `aria-describedby` — the one prop that is a *list* — is
66
+ merged rather than overwritten, outer first.
67
+
68
+ It works in both orders, and the second is not symmetrical: a `Tooltip` around a `Field`
69
+ hands down not one attribute but a working component — floating-ui's hover, focus and
70
+ dismiss handlers and `refs.setReference`. Those reach the control too, or the tip is a
71
+ floating node with no trigger and no anchor.
72
+
73
+ <Canvas of={FieldStories.Nested} />
74
+
75
+ ## Several controls are a `FieldGroup`, not a `Field`
76
+
77
+ `Field` mints one id and clones it onto one child. An `id` names one element and a
78
+ `<label htmlFor>` points at one, so the moment there are two controls the label names one
79
+ of them and leaves the rest anonymous. Six of mux-magic's sixteen field components are in
80
+ that position — `RegexWithFlagsField` (pattern, flags, sample, and a checkbox),
81
+ `NumberWithLookupField`, `LanguageCodeField`, `LanguageCodesField`, `SubtitleTypesField`
82
+ and `RenameRegexField`.
83
+
84
+ `FieldGroup` renders `<fieldset>` + `<legend>` and does **not** clone. This is the one
85
+ place in the library where `<fieldset>` is right: `AccordionSection` rejected it for its
86
+ panel precisely because that element means *form-control grouping*, and here the content
87
+ really is one. `<legend>` names the group natively, so no `role="group"` and no
88
+ `aria-labelledby`.
89
+
90
+ What a group cannot carry is `aria-invalid`, which has no group form — it belongs on the
91
+ control that is actually invalid, and a `FieldGroup` does not know which one that is. So
92
+ `error` here is **described, not asserted**: it joins the `<fieldset>`'s
93
+ `aria-describedby`. Cloning `aria-invalid` onto every child would mark the valid ones
94
+ invalid. A control that is itself invalid says so in its own `Field`.
95
+
96
+ <Canvas of={FieldStories.Group} />
97
+
28
98
  ## `createLinkedIds` is *not* here, and that is the finding
29
99
 
30
100
  The plan expected `useLinkedIds` to do this wiring. It does not fit, for the same reason it
@@ -5,7 +5,9 @@ import {
5
5
  StoryGrid,
6
6
  } from "../board.storyHelpers.tsx"
7
7
  import { Select } from "../Select/Select.tsx"
8
+ import { Tooltip } from "../Tooltip/Tooltip.tsx"
8
9
  import { Field } from "./Field.tsx"
10
+ import { FieldGroup } from "./FieldGroup.tsx"
9
11
 
10
12
  const TEXT_INPUT_CLASS =
11
13
  "w-full rounded-md border border-border-default bg-surface-raised px-3 py-2 text-content-primary text-sm focus-visible:outline-solid focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset) focus-visible:outline-focus-ring"
@@ -142,3 +144,137 @@ export const AllStates: Story = {
142
144
  </StoryGrid>
143
145
  ),
144
146
  }
147
+
148
+ /**
149
+ * Two slot components around one control, in both orders.
150
+ *
151
+ * `Field` and `Tooltip` each clone onto their single child, so
152
+ * nesting them used to hand `Field`'s `id`, `aria-describedby`,
153
+ * `aria-invalid` and `required` to the `Tooltip` **component** —
154
+ * which declares none of them and dropped all four, silently, with an
155
+ * identical render and a `<label>` pointing at an id that was nowhere
156
+ * in the document.
157
+ *
158
+ * A slot is a pass-through: whatever arrives from above is forwarded
159
+ * to the child at the bottom of the chain, and `aria-describedby` —
160
+ * the one prop that is a *list* — is merged rather than overwritten,
161
+ * outer first.
162
+ */
163
+ export const Nested: Story = {
164
+ args: {
165
+ children: (
166
+ <input className={TEXT_INPUT_CLASS} type="text" />
167
+ ),
168
+ label: "Output directory",
169
+ },
170
+ render: () => (
171
+ <StoryGrid columns={2}>
172
+ <StoryCell label="tooltip inside field">
173
+ <Field
174
+ description="Applied to every title."
175
+ isRequired
176
+ label="Rename pattern"
177
+ >
178
+ <Tooltip label="A JavaScript regular expression.">
179
+ <input
180
+ className={TEXT_INPUT_CLASS}
181
+ defaultValue="^(.*)$"
182
+ type="text"
183
+ />
184
+ </Tooltip>
185
+ </Field>
186
+ </StoryCell>
187
+
188
+ <StoryCell label="field inside tooltip">
189
+ <Tooltip label="Where finished rips are moved.">
190
+ <Field error="Not writable." label="Archive path">
191
+ <input
192
+ className={TEXT_INPUT_CLASS}
193
+ defaultValue="/mnt/readonly"
194
+ type="text"
195
+ />
196
+ </Field>
197
+ </Tooltip>
198
+ </StoryCell>
199
+ </StoryGrid>
200
+ ),
201
+ }
202
+
203
+ /**
204
+ * `FieldGroup` — one label over **several** controls, which is where
205
+ * `Field` cannot go: an `id` names one element and a
206
+ * `<label htmlFor>` points at one.
207
+ *
208
+ * Six of mux-magic's sixteen field components are in this position,
209
+ * and every one of them renders a `FieldLabel` whose `htmlFor` names
210
+ * at best one of the controls under it. The element for the job is a
211
+ * `<fieldset>` with a `<legend>` — the one place in this library
212
+ * where `<fieldset>` is right, because here the content really *is* a
213
+ * form-control grouping.
214
+ */
215
+ export const Group: Story = {
216
+ args: {
217
+ children: (
218
+ <input className={TEXT_INPUT_CLASS} type="text" />
219
+ ),
220
+ label: "Output directory",
221
+ },
222
+ render: () => (
223
+ <StoryGrid columns={2}>
224
+ <StoryCell label="several controls">
225
+ <FieldGroup
226
+ description="Applied to every title in the disc."
227
+ isRequired
228
+ label="Rename pattern"
229
+ >
230
+ <div className="flex gap-2">
231
+ <input
232
+ aria-label="Pattern"
233
+ className={TEXT_INPUT_CLASS}
234
+ defaultValue="^(.*)$"
235
+ type="text"
236
+ />
237
+
238
+ <input
239
+ aria-label="Flags"
240
+ className={TEXT_INPUT_CLASS}
241
+ defaultValue="gi"
242
+ type="text"
243
+ />
244
+ </div>
245
+
246
+ <input
247
+ aria-label="Sample"
248
+ className={TEXT_INPUT_CLASS}
249
+ defaultValue="Disc 1 — Title 04"
250
+ type="text"
251
+ />
252
+ </FieldGroup>
253
+ </StoryCell>
254
+
255
+ <StoryCell label="invalid group">
256
+ <FieldGroup
257
+ error="A pattern needs at least one capture group."
258
+ label="Chapter split"
259
+ >
260
+ <div className="flex gap-2">
261
+ <Select
262
+ label="Unit"
263
+ options={[
264
+ { label: "Chapters", value: "chapters" },
265
+ { label: "Minutes", value: "minutes" },
266
+ ]}
267
+ />
268
+
269
+ <input
270
+ aria-label="Every"
271
+ className={TEXT_INPUT_CLASS}
272
+ defaultValue="5"
273
+ type="number"
274
+ />
275
+ </div>
276
+ </FieldGroup>
277
+ </StoryCell>
278
+ </StoryGrid>
279
+ ),
280
+ }