@charcuterie/ui 1.0.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/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/slotProps.d.ts +11 -0
- package/dist/slotProps.d.ts.map +1 -1
- package/dist/slotProps.js +16 -0
- package/dist/slotProps.js.map +1 -1
- 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 +2 -2
- 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/slotProps.ts +20 -0
- package/src/slotWiring.ts +173 -0
package/dist/Select/Select.d.ts
CHANGED
|
@@ -7,7 +7,7 @@ export type SelectOption = {
|
|
|
7
7
|
};
|
|
8
8
|
export type SelectOptionGroup = {
|
|
9
9
|
label: string;
|
|
10
|
-
options: SelectOption[];
|
|
10
|
+
options: readonly SelectOption[];
|
|
11
11
|
};
|
|
12
12
|
export type SelectItem = SelectOption | SelectOptionGroup;
|
|
13
13
|
/**
|
|
@@ -35,7 +35,15 @@ export type SelectProps = Omit<ComponentPropsWithRef<"select">, "disabled" | "on
|
|
|
35
35
|
*/
|
|
36
36
|
label?: string;
|
|
37
37
|
onChange?: (value: string) => void;
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* `readonly`, because a caller's list is usually a constant and
|
|
40
|
+
* TypeScript will not hand a `readonly` array to a mutable
|
|
41
|
+
* parameter — image-viewer hit `TS4104` on an `as const` options
|
|
42
|
+
* table and had to spread a copy at every call. This component
|
|
43
|
+
* only ever `.map`s over it, so demanding a mutable array was
|
|
44
|
+
* asking for a permission it never uses.
|
|
45
|
+
*/
|
|
46
|
+
options: readonly SelectItem[];
|
|
39
47
|
/**
|
|
40
48
|
* A disabled first entry, not a real option. Reads as a prompt to
|
|
41
49
|
* a sighted user and cannot be submitted, which is what a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Select.d.ts","sourceRoot":"","sources":["../../src/Select/Select.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAEV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAYd,MAAM,MAAM,YAAY,GAAG;IACzB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,YAAY,EAAE,CAAA;
|
|
1
|
+
{"version":3,"file":"Select.d.ts","sourceRoot":"","sources":["../../src/Select/Select.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAEV,qBAAqB,EACrB,SAAS,EACV,MAAM,OAAO,CAAA;AAYd,MAAM,MAAM,YAAY,GAAG;IACzB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,OAAO,EAAE,SAAS,YAAY,EAAE,CAAA;CACjC,CAAA;AAED,MAAM,MAAM,UAAU,GAAG,YAAY,GAAG,iBAAiB,CAAA;AAEzD;;;;;;;;;;;;GAYG;AACH,MAAM,MAAM,WAAW,GAAG,IAAI,CAC5B,qBAAqB,CAAC,QAAQ,CAAC,EAC/B,UAAU,GAAG,UAAU,GAAG,MAAM,GAAG,OAAO,CAC3C,GAAG;IACF,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAClC;;;;;;;OAOG;IACH,OAAO,EAAE,SAAS,UAAU,EAAE,CAAA;IAC9B;;;;;OAKG;IACH,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,IAAI,CAAC,EAAE,WAAW,CAAA;IAClB,qEAAqE;IACrE,KAAK,CAAC,EAAE,MAAM,CAAA;CACf,CAAA;AAMD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,eAAO,MAAM,MAAM,GAAI,+FAUpB,WAAW,KAAG,SA8EhB,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Select.js","sourceRoot":"","sources":["../../src/Select/Select.tsx"],"names":[],"mappings":";AAOA,OAAO,EACL,kBAAkB,EAClB,sBAAsB,GACvB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,cAAc,EACd,gBAAgB,GACjB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;
|
|
1
|
+
{"version":3,"file":"Select.js","sourceRoot":"","sources":["../../src/Select/Select.tsx"],"names":[],"mappings":";AAOA,OAAO,EACL,kBAAkB,EAClB,sBAAsB,GACvB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,cAAc,EACd,gBAAgB,GACjB,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAA;AAgE/C,MAAM,UAAU,GAAG,CACjB,IAAgB,EACW,EAAE,CAAC,SAAS,IAAI,IAAI,CAAA;AAEjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,MAAM,CAAC,MAAM,MAAM,GAAG,CAAC,EACrB,SAAS,EACT,UAAU,GAAG,KAAK,EAClB,KAAK,EACL,QAAQ,EACR,OAAO,EACP,WAAW,EACX,IAAI,GAAG,IAAI,EACX,KAAK,EACL,GAAG,WAAW,EACF,EAAa,EAAE,CAAC,CAC5B,eAAK,SAAS,EAAC,0CAA0C,aACvD,qBACM,WAAW,gBACH,KAAK,EACjB,SAAS,EAAE,WAAW,CACpB,iLAAiL;YACjL,0DAA0D;YAC1D,2DAA2D;YAC3D,2DAA2D;YAC3D,wCAAwC;YACxC,MAAM,EACN,kBAAkB,CAAC,IAAI,CAAC,EACxB,sBAAsB,EACtB,4BAA4B,EAC5B,gBAAgB,EAChB,cAAc,EACd,SAAS,CACV,EACD,YAAY,EACV,KAAK;gBACL,CAAC,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,EAE9C,QAAQ,EAAE,UAAU,EACpB,QAAQ,EAAE,CACR,WAA2C,EAC3C,EAAE;gBACF,QAAQ,EAAE,CAAC,WAAW,CAAC,aAAa,CAAC,KAAK,CAAC,CAAA;YAC7C,CAAC,aAEA,WAAW,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,CAClC,iBAAQ,QAAQ,QAAC,KAAK,EAAC,EAAE,YACtB,WAAW,GACL,CACV,EAEA,OAAO,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CACpB,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CACjB,mBAA2B,KAAK,EAAE,IAAI,CAAC,KAAK,YACzC,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,CAC5B,iBACE,QAAQ,EAAE,MAAM,CAAC,UAAU,EAE3B,KAAK,EAAE,MAAM,CAAC,KAAK,YAElB,MAAM,CAAC,KAAK,IAHR,MAAM,CAAC,KAAK,CAIV,CACV,CAAC,IATW,IAAI,CAAC,KAAK,CAUd,CACZ,CAAC,CAAC,CAAC,CACF,iBACE,QAAQ,EAAE,IAAI,CAAC,UAAU,EAEzB,KAAK,EAAE,IAAI,CAAC,KAAK,YAEhB,IAAI,CAAC,KAAK,IAHN,IAAI,CAAC,KAAK,CAIR,CACV,CACF,IACM,EAET,6BAGc,MAAM,EAClB,SAAS,EAAC,kEAAkE,EAC5E,IAAI,EAAC,MAAM,EACX,SAAS,EAAE,KAAK,EAChB,MAAM,EAAC,cAAc,EACrB,aAAa,EAAC,OAAO,EACrB,cAAc,EAAC,OAAO,EACtB,WAAW,EAAE,IAAI,EACjB,OAAO,EAAC,WAAW,EACnB,KAAK,EAAC,4BAA4B,YAElC,eAAM,CAAC,EAAC,cAAc,GAAG,GACrB,IACF,CACP,CAAA"}
|
package/dist/slotProps.d.ts
CHANGED
|
@@ -105,6 +105,17 @@ export type SlotProps = Pick<InputHTMLAttributes<HTMLElement>, "aria-describedby
|
|
|
105
105
|
* `isRequired || undefined`, so a plain spread would let an inner
|
|
106
106
|
* `Field` with no error erase an outer one's `aria-invalid` — the
|
|
107
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.
|
|
108
119
|
*/
|
|
109
120
|
export declare const mergeSlotProps: <OwnProps extends Record<string, unknown>>(receivedProps: SlotProps, ownProps: OwnProps) => OwnProps & SlotProps;
|
|
110
121
|
//# sourceMappingURL=slotProps.d.ts.map
|
package/dist/slotProps.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slotProps.d.ts","sourceRoot":"","sources":["../src/slotProps.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,OAAO,CAAA;
|
|
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"}
|
package/dist/slotProps.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { mergeSlotWiring } from "./slotWiring.js";
|
|
1
2
|
/**
|
|
2
3
|
* Merge what arrived from above with what this component writes
|
|
3
4
|
* itself.
|
|
@@ -21,6 +22,17 @@
|
|
|
21
22
|
* `isRequired || undefined`, so a plain spread would let an inner
|
|
22
23
|
* `Field` with no error erase an outer one's `aria-invalid` — the
|
|
23
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.
|
|
24
36
|
*/
|
|
25
37
|
export const mergeSlotProps = (receivedProps, ownProps) => {
|
|
26
38
|
const definedOwnProps = Object.fromEntries(Object.entries(ownProps).filter(([, value]) => value !== undefined));
|
|
@@ -34,6 +46,10 @@ export const mergeSlotProps = (receivedProps, ownProps) => {
|
|
|
34
46
|
...receivedProps,
|
|
35
47
|
...definedOwnProps,
|
|
36
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),
|
|
37
53
|
};
|
|
38
54
|
};
|
|
39
55
|
//# sourceMappingURL=slotProps.js.map
|
package/dist/slotProps.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slotProps.js","sourceRoot":"","sources":["../src/slotProps.ts"],"names":[],"mappings":"
|
|
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": "1.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,7 +43,7 @@
|
|
|
43
43
|
"typecheck": "tsc --noEmit"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@charcuterie/logic": "1.0.
|
|
46
|
+
"@charcuterie/logic": "1.0.1",
|
|
47
47
|
"@charcuterie/tokens": "1.0.0",
|
|
48
48
|
"@floating-ui/react": "^0.27.20"
|
|
49
49
|
},
|
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/slotProps.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { InputHTMLAttributes } from "react"
|
|
2
2
|
|
|
3
|
+
import { mergeSlotWiring } from "./slotWiring.ts"
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* The props a **cloning ancestor** injects into the control below it.
|
|
5
7
|
*
|
|
@@ -114,6 +116,17 @@ export type SlotProps = Pick<
|
|
|
114
116
|
* `isRequired || undefined`, so a plain spread would let an inner
|
|
115
117
|
* `Field` with no error erase an outer one's `aria-invalid` — the
|
|
116
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.
|
|
117
130
|
*/
|
|
118
131
|
export const mergeSlotProps = <
|
|
119
132
|
OwnProps extends Record<string, unknown>,
|
|
@@ -139,5 +152,12 @@ export const mergeSlotProps = <
|
|
|
139
152
|
...definedOwnProps,
|
|
140
153
|
"aria-describedby":
|
|
141
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
|
+
),
|
|
142
162
|
}
|
|
143
163
|
}
|
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
const isEventHandlerName = (name: string) =>
|
|
29
|
+
/^on[A-Z]/.test(name)
|
|
30
|
+
|
|
31
|
+
type MergeableRef =
|
|
32
|
+
| ((node: unknown) => unknown)
|
|
33
|
+
| { current: unknown }
|
|
34
|
+
|
|
35
|
+
const isMergeableRef = (
|
|
36
|
+
value: unknown,
|
|
37
|
+
): value is MergeableRef =>
|
|
38
|
+
typeof value === "function" ||
|
|
39
|
+
(typeof value === "object" &&
|
|
40
|
+
value !== null &&
|
|
41
|
+
"current" in value)
|
|
42
|
+
|
|
43
|
+
const setRef = (ref: MergeableRef, node: unknown) => {
|
|
44
|
+
if (typeof ref === "function") {
|
|
45
|
+
return ref(node)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
ref.current = node
|
|
49
|
+
|
|
50
|
+
return undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every pair of refs ever merged, so the same two always come back
|
|
55
|
+
* as the **same function** — which is load-bearing rather than
|
|
56
|
+
* frugal.
|
|
57
|
+
*
|
|
58
|
+
* React re-runs a callback ref whenever its identity changes between
|
|
59
|
+
* renders, detaching with the old one and attaching with the new.
|
|
60
|
+
* Both refs here are floating-ui `setReference`s, and calling one
|
|
61
|
+
* sets state; a merged ref rebuilt every render would call both
|
|
62
|
+
* twice per render, each call scheduling the render that rebuilds
|
|
63
|
+
* it. `mergeSlotProps` is a plain function called during render, so
|
|
64
|
+
* it cannot reach for `useMemo` — the memo has to be in the identity
|
|
65
|
+
* of the inputs, which is what a `WeakMap` of `WeakMap`s is.
|
|
66
|
+
*/
|
|
67
|
+
const mergedRefs = new WeakMap<
|
|
68
|
+
MergeableRef,
|
|
69
|
+
WeakMap<MergeableRef, (node: unknown) => () => void>
|
|
70
|
+
>()
|
|
71
|
+
|
|
72
|
+
const mergeRefs = (
|
|
73
|
+
first: MergeableRef,
|
|
74
|
+
second: MergeableRef,
|
|
75
|
+
) => {
|
|
76
|
+
const bySecond =
|
|
77
|
+
mergedRefs.get(first) ??
|
|
78
|
+
new WeakMap<
|
|
79
|
+
MergeableRef,
|
|
80
|
+
(node: unknown) => () => void
|
|
81
|
+
>()
|
|
82
|
+
|
|
83
|
+
mergedRefs.set(first, bySecond)
|
|
84
|
+
|
|
85
|
+
const cached = bySecond.get(second)
|
|
86
|
+
|
|
87
|
+
if (cached) {
|
|
88
|
+
return cached
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const merged = (node: unknown) => {
|
|
92
|
+
const cleanups = [
|
|
93
|
+
setRef(first, node),
|
|
94
|
+
setRef(second, node),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
// Per ref: one that returned a cleanup has opted out of being
|
|
98
|
+
// called with `null`, and one that did not still expects it.
|
|
99
|
+
return () => {
|
|
100
|
+
for (const [index, cleanup] of cleanups.entries()) {
|
|
101
|
+
if (typeof cleanup === "function") {
|
|
102
|
+
cleanup()
|
|
103
|
+
} else {
|
|
104
|
+
setRef(index === 0 ? first : second, null)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
bySecond.set(second, merged)
|
|
111
|
+
|
|
112
|
+
return merged
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const chainHandlers =
|
|
116
|
+
(
|
|
117
|
+
first: (...args: unknown[]) => unknown,
|
|
118
|
+
second: (...args: unknown[]) => unknown,
|
|
119
|
+
) =>
|
|
120
|
+
(...args: unknown[]) => {
|
|
121
|
+
// Outer first, the same order `aria-describedby` is joined in:
|
|
122
|
+
// the merge order is structural — whoever wrapped whom — and the
|
|
123
|
+
// author of the nesting is the one who chose it.
|
|
124
|
+
first(...args)
|
|
125
|
+
|
|
126
|
+
second(...args)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The keys of `ownProps` that must not simply replace the ones
|
|
131
|
+
* `receivedProps` arrived with. Spread **last**, over both.
|
|
132
|
+
*/
|
|
133
|
+
export const mergeSlotWiring = (
|
|
134
|
+
receivedProps: Record<string, unknown>,
|
|
135
|
+
ownProps: Record<string, unknown>,
|
|
136
|
+
): Record<string, unknown> =>
|
|
137
|
+
Object.fromEntries(
|
|
138
|
+
Object.entries(ownProps).flatMap(
|
|
139
|
+
([name, ownValue]): [string, unknown][] => {
|
|
140
|
+
const receivedValue = receivedProps[name]
|
|
141
|
+
|
|
142
|
+
if (
|
|
143
|
+
name === "ref" &&
|
|
144
|
+
isMergeableRef(receivedValue) &&
|
|
145
|
+
isMergeableRef(ownValue)
|
|
146
|
+
) {
|
|
147
|
+
return [
|
|
148
|
+
[name, mergeRefs(receivedValue, ownValue)],
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (
|
|
153
|
+
isEventHandlerName(name) &&
|
|
154
|
+
typeof receivedValue === "function" &&
|
|
155
|
+
typeof ownValue === "function"
|
|
156
|
+
) {
|
|
157
|
+
return [
|
|
158
|
+
[
|
|
159
|
+
name,
|
|
160
|
+
chainHandlers(
|
|
161
|
+
receivedValue as (
|
|
162
|
+
...args: unknown[]
|
|
163
|
+
) => unknown,
|
|
164
|
+
ownValue as (...args: unknown[]) => unknown,
|
|
165
|
+
),
|
|
166
|
+
],
|
|
167
|
+
]
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return []
|
|
171
|
+
},
|
|
172
|
+
),
|
|
173
|
+
)
|