Skip to content

Commit 2c8cbaa

Browse files
committed
feat(react): add useSlot hook and migrate ModalContent to slots/slotProps API
Introduces `useSlot`, an internal hook for creating component slots with controlled prop merging. Migrates `ModalContent` from `TransitionComponent`/ `TransitionProps` to the new `slots`/`slotProps` API with deprecation warnings. - Add `packages/react/src/utils/useSlot.js` with `props → slotProps` merge order, ref merging via `useMergeRefs`, and dev-only warnings for missing `slot`/`slotProps` - Migrate `ModalContent` to use `useSlot` for the `transition` slot; chain DOM event handlers with `callEventHandlers` and lifecycle callbacks with `callAll` - Add comprehensive tests for slot resolution, props merging, ref merging, and dev warnings - Add `.claude/skills/tonic-ui-use-slot/` migration guide skill
1 parent d7ae486 commit 2c8cbaa

4 files changed

Lines changed: 465 additions & 27 deletions

File tree

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
# useSlot Migration Skill
2+
3+
Migrate Tonic UI components from `TransitionComponent`/`TransitionProps` (and `PopperComponent`/`PopperProps`) to the `useSlot` hook with `slots`/`slotProps` API.
4+
5+
## When to Use
6+
7+
- Migrating a component that uses `TransitionComponent` / `TransitionProps`
8+
- Migrating a component that uses `PopperComponent` / `PopperProps`
9+
- Adding slot support to a new component
10+
11+
## The useSlot API
12+
13+
```js
14+
const [SlotElement, slotProps] = useSlot({
15+
name, // Optional — slot name (e.g. 'transition') for dev error messages
16+
ownerDisplayName, // Optional — parent component displayName for dev error messages
17+
props, // Optional — internal component props (include ref here); slotProps take precedence
18+
slot, // The resolved element type for this slot
19+
slotProps, // The resolved slot props from the consumer (e.g. slotProps.transition ?? TransitionProps)
20+
});
21+
```
22+
23+
Returns: `[ElementType, mergedProps]`
24+
25+
**Merge order (later wins):** `props``slotProps`
26+
27+
**Import:** `useSlot` lives in `'../utils/useSlot'` — it is internal to the `react` package, not exported from `@tonic-ui/react-hooks`.
28+
29+
```js
30+
import useSlot from '../utils/useSlot';
31+
```
32+
33+
## Handler Placement
34+
35+
### Static internal props → `props`
36+
37+
Non-handler props that the component sets internally:
38+
39+
```js
40+
props: {
41+
ref: combinedRef,
42+
appear: !!modalContext,
43+
'aria-modal': ariaAttr(true),
44+
role: 'dialog',
45+
tabIndex,
46+
},
47+
```
48+
49+
### Coordinated handlers → after the spread on the JSX element
50+
51+
Event handlers that must chain with the user's version are placed **after** `{...transitionSlotProps}` on the element:
52+
53+
- Use **`callAll`** when both handlers must always fire (e.g. lifecycle callbacks like `onExited`)
54+
- Use **`callEventHandlers`** when the chain should stop if `event.preventDefault()` is called (e.g. DOM event handlers like `onClick`, `onKeyDown`)
55+
56+
```jsx
57+
<TransitionSlot
58+
{...transitionSlotProps}
59+
in={isOpen}
60+
onClick={callEventHandlers(transitionSlotProps.onClick, (event) => event.stopPropagation())}
61+
onKeyDown={callEventHandlers(transitionSlotProps.onKeyDown, (event) => { /* escape */ })}
62+
onExited={callAll(safeToRemove, transitionSlotProps.onExited)}
63+
/>
64+
```
65+
66+
**Critical rule: preserve original handler chaining exactly.**
67+
68+
| Original code | Slot approach |
69+
|---|---|
70+
| `onExited={callAll(internalFn, TransitionProps?.onExited)}` | After spread: `onExited={callAll(internalFn, transitionSlotProps.onExited)}` |
71+
| `onClick={internalFn}` | After spread: `onClick={callEventHandlers(transitionSlotProps.onClick, internalFn)}` |
72+
| `onKeyDown={internalFn}` | After spread: `onKeyDown={callEventHandlers(transitionSlotProps.onKeyDown, internalFn)}` |
73+
| `appear={!!context}` | `props`: `appear: !!context` |
74+
| `role="dialog"`, `tabIndex`, aria attrs | `props` |
75+
| component's forwarded `ref` | `props`: `ref: combinedRef` |
76+
77+
### Where does `in` go?
78+
79+
Always set explicitly after the spread — the component owns open/close state:
80+
81+
```jsx
82+
<TransitionSlot {...transitionSlotProps} in={isOpen} />
83+
```
84+
85+
## Step-by-Step Migration
86+
87+
### 1. Accept new props, deprecate old ones
88+
89+
```js
90+
const {
91+
TransitionComponent, // deprecated
92+
TransitionProps, // deprecated
93+
slots = {},
94+
slotProps = {},
95+
children,
96+
...rest
97+
} = useDefaultProps({ props: inProps, name: 'ComponentName' });
98+
99+
{ // deprecation warning
100+
const prefix = `${ComponentName.displayName}:`;
101+
useOnceWhen(() => {
102+
warnDeprecatedProps('TransitionComponent', {
103+
prefix,
104+
alternative: 'slots.transition',
105+
willRemove: true,
106+
});
107+
}, TransitionComponent !== undefined);
108+
useOnceWhen(() => {
109+
warnDeprecatedProps('TransitionProps', {
110+
prefix,
111+
alternative: 'slotProps.transition',
112+
willRemove: true,
113+
});
114+
}, TransitionProps !== undefined);
115+
}
116+
```
117+
118+
### 2. Replace TransitionComponent JSX with useSlot
119+
120+
Resolve `slot` and `slotProps` inline — new API takes precedence over deprecated props:
121+
122+
**Before:**
123+
```jsx
124+
<TransitionComponent
125+
appear={true}
126+
{...TransitionProps}
127+
ref={combinedRef}
128+
in={isOpen}
129+
onExited={callAll(safeToRemove, TransitionProps?.onExited)}
130+
>
131+
{children}
132+
</TransitionComponent>
133+
```
134+
135+
**After:**
136+
```jsx
137+
const [TransitionSlot, transitionSlotProps] = useSlot({
138+
name: 'transition',
139+
ownerDisplayName: ComponentName.displayName,
140+
props: {
141+
ref: combinedRef,
142+
appear: true,
143+
// other non-coordinated internal props
144+
},
145+
slot: slots.transition ?? TransitionComponent ?? DefaultTransition,
146+
slotProps: slotProps.transition ?? TransitionProps,
147+
});
148+
149+
return (
150+
<TransitionSlot
151+
{...transitionSlotProps}
152+
in={isOpen}
153+
onExited={callAll(safeToRemove, transitionSlotProps.onExited)}
154+
>
155+
{children}
156+
</TransitionSlot>
157+
);
158+
```
159+
160+
### 3. For Popper + Transition components (Menu, Tooltip, Popover)
161+
162+
The Popper's `onEnter`/`onExited` come from its render prop. Chain them with the user's transition handlers after the spread:
163+
164+
```jsx
165+
const [TransitionSlot, transitionSlotProps] = useSlot({
166+
name: 'transition',
167+
ownerDisplayName: ComponentName.displayName,
168+
props: {
169+
ref: combinedRef,
170+
appear: true,
171+
},
172+
slot: slots.transition ?? TransitionComponent ?? DefaultTransition,
173+
slotProps: slotProps.transition ?? TransitionProps,
174+
});
175+
176+
// Inside Popper render prop:
177+
<PopperComponent ...>
178+
{({ transition }) => {
179+
const { in: inProp, onEnter: popperOnEnter, onExited: popperOnExited } = transition;
180+
return (
181+
<TransitionSlot
182+
{...transitionSlotProps}
183+
in={inProp}
184+
onEnter={callAll(popperOnEnter, transitionSlotProps.onEnter)}
185+
onExited={callAll(popperOnExited, transitionSlotProps.onExited)}
186+
>
187+
{children}
188+
</TransitionSlot>
189+
);
190+
}}
191+
</PopperComponent>
192+
```
193+
194+
### 4. Update imports
195+
196+
```js
197+
// Internal useSlot — NOT from react-hooks
198+
import useSlot from '../utils/useSlot';
199+
200+
// Add to react-hooks import (only what's actually used)
201+
import { useMergeRefs, useOnceWhen } from '@tonic-ui/react-hooks';
202+
203+
// Add to utils import (only what's actually used)
204+
import { callAll, callEventHandlers, warnDeprecatedProps } from '@tonic-ui/utils';
205+
```
206+
207+
## Components to Migrate
208+
209+
| Component | File | Slots needed |
210+
|---|---|---|
211+
| ModalContent | `modal/ModalContent.js` | `transition` ✅ done |
212+
| DrawerContent | `drawer/DrawerContent.js` | `transition` |
213+
| AccordionContent | `accordion/AccordionContent.js` | `transition` |
214+
| MenuContent | `menu/MenuContent.js` | `transition`, `popper` |
215+
| TooltipContent | `tooltip/TooltipContent.js` | `transition`, `popper` |
216+
| PopoverContent | `popover/PopoverContent.js` | `transition`, `popper` |
217+
218+
## Reference Implementation
219+
220+
`ModalContent` (`packages/react/src/modal/ModalContent.js`) is the canonical example.
221+
222+
Key rules carried from the migration:
223+
1. `slots.transition` overrides `TransitionComponent` — new API takes precedence (`slots.transition ?? TransitionComponent ?? Default`)
224+
2. `slotProps.transition` overrides `TransitionProps` — new API takes precedence (`slotProps.transition ?? TransitionProps`)
225+
3. `in` is always set after `{...transitionSlotProps}` — component owns open/close state
226+
4. The component's forwarded `ref` goes inside `props` (`props: { ref: combinedRef, ... }`)
227+
5. Static internal props (ref, aria attrs, role, tabIndex, appear) go in `props`
228+
6. DOM event handlers go after the spread using `callEventHandlers(transitionSlotProps.handler, internalFn)`
229+
7. Lifecycle callbacks (onExited, onEnter) go after the spread using `callAll(internalFn, transitionSlotProps.handler)`
230+
8. `useSlot` is an internal utility (`../utils/useSlot`), not exported from `@tonic-ui/react-hooks`

packages/react/src/modal/ModalContent.js

Lines changed: 56 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useMergeRefs } from '@tonic-ui/react-hooks';
2-
import { ariaAttr, callAll } from '@tonic-ui/utils';
1+
import { useMergeRefs, useOnceWhen } from '@tonic-ui/react-hooks';
2+
import { ariaAttr, callAll, callEventHandlers, warnDeprecatedProps } from '@tonic-ui/utils';
33
import React, { forwardRef } from 'react';
4+
import useSlot from '../utils/useSlot';
45
import { useDefaultProps } from '../default-props';
56
import { Fade } from '../transitions';
67
import { useAnimatePresence } from '../utils/animate-presence';
@@ -12,11 +13,32 @@ import useModal from './useModal';
1213

1314
const ModalContent = forwardRef((inProps, ref) => {
1415
const {
15-
TransitionComponent = Fade,
16-
TransitionProps,
16+
TransitionComponent, // deprecated
17+
TransitionProps, // deprecated
18+
slots = {},
19+
slotProps = {},
1720
children,
1821
...rest
1922
} = useDefaultProps({ props: inProps, name: 'ModalContent' });
23+
24+
{ // deprecation warning
25+
const prefix = `${ModalContent.displayName}:`;
26+
useOnceWhen(() => {
27+
warnDeprecatedProps('TransitionComponent', {
28+
prefix,
29+
alternative: 'slots.transition',
30+
willRemove: true,
31+
});
32+
}, TransitionComponent !== undefined);
33+
useOnceWhen(() => {
34+
warnDeprecatedProps('TransitionProps', {
35+
prefix,
36+
alternative: 'slotProps.transition',
37+
willRemove: true,
38+
});
39+
}, TransitionProps !== undefined);
40+
}
41+
2042
const [, safeToRemove] = useAnimatePresence();
2143
const modalContext = useModal(); // context might be an undefined value
2244
const {
@@ -32,39 +54,46 @@ const ModalContent = forwardRef((inProps, ref) => {
3254
const combinedRef = useMergeRefs(contentRef, ref);
3355
const tabIndex = -1;
3456
const styleProps = useModalContentStyle({ placement, scrollBehavior, size, tabIndex });
35-
const contentProps = {
36-
'aria-modal': ariaAttr(true),
37-
ref: combinedRef,
38-
role: 'dialog',
39-
tabIndex,
40-
onClick: event => event.stopPropagation(),
41-
onKeyDown: event => {
42-
if (event.key === 'Escape') {
43-
event.stopPropagation();
4457

45-
const shouldClose = Boolean(closeOnEsc);
46-
if (shouldClose) {
47-
onClose?.(event);
48-
}
49-
}
58+
const [TransitionSlot, transitionSlotProps] = useSlot({
59+
name: 'transition',
60+
ownerDisplayName: ModalContent.displayName,
61+
props: {
62+
ref: combinedRef,
63+
appear: !!modalContext,
64+
'aria-modal': ariaAttr(true),
65+
role: 'dialog',
66+
tabIndex,
5067
},
51-
...styleProps,
52-
...rest,
53-
};
68+
slot: slots.transition ?? TransitionComponent ?? Fade,
69+
slotProps: slotProps.transition ?? TransitionProps,
70+
});
5471

5572
return (
56-
<TransitionComponent
57-
appear={!!modalContext}
58-
{...TransitionProps}
59-
{...contentProps}
73+
<TransitionSlot
74+
{...transitionSlotProps}
75+
{...styleProps}
76+
{...rest}
6077
in={modalContext ? isOpen : true}
61-
onExited={callAll(safeToRemove, TransitionProps?.onExited)}
78+
onExited={callAll(safeToRemove, transitionSlotProps.onExited)}
79+
// Event handlers
80+
onClick={callEventHandlers(transitionSlotProps.onClick, (event) => event.stopPropagation())}
81+
onKeyDown={callEventHandlers(transitionSlotProps.onKeyDown, (event) => {
82+
if (event.key === 'Escape') {
83+
event.stopPropagation();
84+
85+
const shouldClose = Boolean(closeOnEsc);
86+
if (shouldClose) {
87+
onClose?.(event);
88+
}
89+
}
90+
})}
6291
>
6392
{children}
6493
{!!isClosable && (
6594
<ModalCloseButton />
6695
)}
67-
</TransitionComponent>
96+
</TransitionSlot>
6897
);
6998
});
7099

0 commit comments

Comments
 (0)