-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Simplify prop merging with slots (#18721)
* Implement new prop merging * Change files * Update packages/react-utilities/src/compose/getSlots.test.tsx Co-authored-by: ling1726 <lingfangao@hotmail.com> * Update packages/react-utilities/src/compose/getSlots.test.tsx Co-authored-by: ling1726 <lingfangao@hotmail.com> Co-authored-by: ling1726 <lingfangao@hotmail.com>
- Loading branch information
1 parent
bf0ded4
commit 6c37a1c
Showing
11 changed files
with
434 additions
and
112 deletions.
There are no files selected for viewing
7 changes: 7 additions & 0 deletions
7
change/@fluentui-react-utilities-9ad70a44-bc51-4fc6-9145-2a5dd01c742c.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
{ | ||
"type": "prerelease", | ||
"comment": "Add new prop mergin mechanism", | ||
"packageName": "@fluentui/react-utilities", | ||
"email": "bsunderhus@microsoft.com", | ||
"dependentChangeType": "patch" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,148 @@ | ||
import * as React from 'react'; | ||
import { getSlots } from './getSlots'; | ||
import { nullRender } from './nullRender'; | ||
import { ComponentState } from './types'; | ||
|
||
describe('getSlots', () => { | ||
const Foo = (props: { id?: string }) => <div />; | ||
|
||
it('returns div for root if the as prop is not provided', () => { | ||
expect(getSlots({})).toEqual({ | ||
slots: { root: 'div' }, | ||
slotProps: { root: {} }, | ||
}); | ||
}); | ||
|
||
it('returns root slot as a span with no props', () => { | ||
expect(getSlots({ as: 'span' } as ComponentState)).toEqual({ | ||
slots: { root: 'span' }, | ||
slotProps: { root: {} }, | ||
}); | ||
}); | ||
|
||
it('omits invalid props for the rendered element', () => { | ||
expect( | ||
getSlots<{}>({ as: 'button', id: 'id', href: 'href' } as ComponentState), | ||
).toEqual({ | ||
slots: { root: 'button' }, | ||
slotProps: { root: { id: 'id' } }, | ||
}); | ||
}); | ||
|
||
it('returns root slot as an anchor, leaving the href intact', () => { | ||
expect(getSlots({ as: 'a', id: 'id', href: 'href' } as ComponentState)).toEqual({ | ||
slots: { root: 'a' }, | ||
slotProps: { root: { id: 'id', href: 'href' } }, | ||
}); | ||
}); | ||
|
||
it('retains all props, when root is a component,', () => { | ||
expect( | ||
getSlots({ as: 'div', id: 'id', href: 'href', blah: 1, components: { root: Foo } } as ComponentState), | ||
).toEqual({ | ||
slots: { root: Foo }, | ||
slotProps: { root: { as: 'div', id: 'id', href: 'href', blah: 1, components: { root: Foo } } }, | ||
}); | ||
}); | ||
|
||
it('returns null for primitive slots with no children', () => { | ||
expect(getSlots({ as: 'div', icon: { as: 'span' } } as ComponentState<{ icon: {} }>, ['icon'])).toEqual({ | ||
slots: { root: 'div', icon: nullRender }, | ||
slotProps: { root: {} }, | ||
}); | ||
}); | ||
|
||
it('returns a component slot with no children', () => { | ||
type ShorthandProps = { | ||
icon: React.HTMLAttributes<HTMLElement>; | ||
}; | ||
expect( | ||
getSlots<ShorthandProps>({ as: 'div', icon: {}, components: { icon: Foo } }, ['icon']), | ||
).toEqual({ | ||
slots: { root: 'div', icon: Foo }, | ||
slotProps: { root: {}, icon: {} }, | ||
}); | ||
}); | ||
|
||
it('returns slot as button and omits unsupported props (href)', () => { | ||
type ShorthandProps = { | ||
icon: React.ButtonHTMLAttributes<HTMLElement>; | ||
}; | ||
expect( | ||
getSlots<ShorthandProps>( | ||
{ | ||
as: 'div', | ||
icon: { as: 'button', id: 'id', children: 'children' }, | ||
}, | ||
['icon'], | ||
), | ||
).toEqual({ | ||
slots: { root: 'div', icon: 'button' }, | ||
slotProps: { root: {}, icon: { id: 'id', children: 'children' } }, | ||
}); | ||
}); | ||
|
||
it('returns slot as anchor and includes supported props (href)', () => { | ||
type ShorthandProps = { | ||
icon: React.AnchorHTMLAttributes<HTMLElement>; | ||
}; | ||
expect( | ||
getSlots<ShorthandProps>({ as: 'div', icon: { as: 'a', id: 'id', href: 'href', children: 'children' } }, [ | ||
'icon', | ||
]), | ||
).toEqual({ | ||
slots: { root: 'div', icon: 'a' }, | ||
slotProps: { root: {}, icon: { id: 'id', href: 'href', children: 'children' } }, | ||
}); | ||
}); | ||
|
||
it('returns a component and includes all props', () => { | ||
type ShorthandProps = { | ||
icon: React.AnchorHTMLAttributes<HTMLElement>; | ||
}; | ||
expect( | ||
getSlots<ShorthandProps>( | ||
{ components: { icon: Foo }, as: 'div', icon: { id: 'id', href: 'href', children: 'children' } }, | ||
['icon'], | ||
), | ||
).toEqual({ | ||
slots: { root: 'div', icon: Foo }, | ||
slotProps: { root: {}, icon: { id: 'id', href: 'href', children: 'children' } }, | ||
}); | ||
}); | ||
|
||
it('can use slot children functions to replace default slot rendering', () => { | ||
expect( | ||
getSlots( | ||
{ | ||
components: { icon: Foo }, | ||
as: 'div', | ||
icon: { id: 'bar', children: (C: React.ElementType, p: {}) => <C {...p} /> }, | ||
}, | ||
['icon'], | ||
), | ||
).toEqual({ | ||
slots: { root: 'div', icon: React.Fragment }, | ||
slotProps: { root: {}, icon: { children: <Foo id="bar" /> } }, | ||
}); | ||
}); | ||
|
||
it('can render a primitive input with no children', () => { | ||
type ShorthandProps = { | ||
input: React.AnchorHTMLAttributes<HTMLElement>; | ||
}; | ||
expect( | ||
getSlots<ShorthandProps>({ as: 'div', input: { as: 'input', children: null } }, ['input']), | ||
).toEqual({ | ||
slots: { root: 'div', input: 'input' }, | ||
slotProps: { root: {}, input: { children: null } }, | ||
}); | ||
}); | ||
|
||
it('should use `div` as default root element', () => { | ||
expect(getSlots({ icon: { children: 'foo' }, customProp: 'bar' }, ['icon'])).toEqual({ | ||
slots: { root: 'div', icon: 'div' }, | ||
slotProps: { root: {}, icon: { children: 'foo' } }, | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
import * as React from 'react'; | ||
|
||
import { ComponentState, ShorthandRenderFunction, SlotPropsRecord } from './types'; | ||
import { nullRender } from './nullRender'; | ||
import { getNativeElementProps, omit } from '../utils/index'; | ||
|
||
function getSlot( | ||
defaultComponent: React.ElementType | undefined, | ||
userComponent: keyof JSX.IntrinsicElements | undefined, | ||
) { | ||
if (defaultComponent === undefined || typeof defaultComponent === 'string') { | ||
return userComponent || defaultComponent || 'div'; | ||
} | ||
return defaultComponent; | ||
} | ||
|
||
/** | ||
* Given the state and an array of slot names, will break out `slots` and `slotProps` | ||
* collections. | ||
* | ||
* The root is derived from a mix of `components` props and `as` prop. | ||
* | ||
* Slots will render as null if they are rendered as primitives with undefined children. | ||
* | ||
* The slotProps will always omit the `as` prop within them, and for slots that are string | ||
* primitives, the props will be filtered according the the slot type. For example, if the | ||
* slot is rendered `as: 'a'`, the props will be filtered for acceptable anchor props. | ||
* | ||
* @param state - State including slot definitions | ||
* @param slotNames - Name of which props are slots | ||
* @returns An object containing the `slots` map and `slotProps` map. | ||
*/ | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
export function getSlots<SlotProps extends SlotPropsRecord = {}>(state: ComponentState<any>, slotNames?: string[]) { | ||
/** | ||
* force typings on state, this should not be added directly in parameters to avoid type inference | ||
*/ | ||
const typedState = state as ComponentState<SlotProps>; | ||
/** | ||
* force typings on slotNames, this should not be added directly in parameters to avoid type inference | ||
*/ | ||
const typedSlotNames = slotNames as Array<keyof SlotProps>; | ||
|
||
type Slots = { [K in keyof SlotProps]: React.ElementType<SlotProps[K]> }; | ||
|
||
const slots = ({ | ||
root: getSlot(typedState.components ? typedState.components.root : undefined, typedState.as), | ||
} as Slots & { root: React.ElementType }) as Slots; | ||
|
||
const slotProps = ({ | ||
root: typeof slots.root === 'string' ? getNativeElementProps(slots.root, typedState) : typedState, | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
} as SlotProps & { root: any }) as SlotProps; | ||
|
||
if (typedSlotNames) { | ||
for (const name of typedSlotNames) { | ||
const { as, children } = typedState[name]; | ||
|
||
const slot = getSlot(typedState.components ? typedState.components[name] : undefined, as) as Slots[typeof name]; | ||
|
||
// TODO: rethink null rendering scenario. This fails in some cases, e.g: CompoundButton, AccordionHeader | ||
if (typeof slot === 'string' && children === undefined) { | ||
slots[name] = nullRender; | ||
continue; | ||
} else { | ||
slots[name] = slot; | ||
} | ||
|
||
if (typeof children === 'function') { | ||
const render = children as ShorthandRenderFunction<SlotProps[keyof SlotProps]>; | ||
// TODO: converting to unknown might be harmful | ||
slotProps[name] = ({ | ||
children: render( | ||
slots[name], | ||
omit(typedState[name], ['children']) as ComponentState<SlotProps>[keyof SlotProps], | ||
), | ||
} as unknown) as SlotProps[keyof SlotProps]; | ||
slots[name] = React.Fragment; | ||
} else { | ||
slotProps[name] = | ||
typeof slots[name] === 'string' | ||
? (omit(typedState[name], ['as']) as ComponentState<SlotProps>[keyof SlotProps]) | ||
: typedState[name]; | ||
} | ||
} | ||
} | ||
|
||
return { | ||
slots: slots as Slots & { root: React.ElementType }, | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
slotProps: slotProps as SlotProps & { root: any }, | ||
} as const; | ||
} |
Oops, something went wrong.