Last active
September 3, 2024 16:19
-
-
Save bigmistqke/968ac9d81ae123377e62d7bc0c07feb1 to your computer and use it in GitHub Desktop.
solid-slots
This file contains hidden or 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
import { | |
children, | |
createRenderEffect, | |
type ParentProps, | |
onCleanup, | |
createMemo, | |
type JSX, | |
mergeProps, | |
} from "solid-js"; | |
const SLOT_MAP = new WeakMap<HTMLElement, ParentProps<{ name: string }>>(); | |
function getSlots<const TSlots extends string[]>( | |
props: ParentProps, | |
expectedSlots?: TSlots, | |
shouldThrow = false, | |
) { | |
const childrenArray = children(() => props.children).toArray; | |
// TODO: does memo run in ssr? | |
const memo = createMemo(() => { | |
let names = new Set<string>(); | |
const result = childrenArray().reduce( | |
(acc, child) => { | |
const slot = child instanceof HTMLElement && SLOT_MAP.get(child); | |
if (slot) { | |
if (expectedSlots && !expectedSlots.includes(slot.name)) { | |
const error = `Received unexpected slot "${slot.name}".`; | |
if (shouldThrow) throw error; | |
else console.error(error); | |
return acc; | |
} | |
if (names.has(slot.name)) { | |
const error = `Multiple occurences of "${slot.name}".`; | |
if (shouldThrow) throw error; | |
else console.error(error); | |
return acc; | |
} | |
acc.slots[slot.name] = slot.children; | |
names.add(slot.name); | |
} else { | |
acc.rest.push(child); | |
} | |
return acc; | |
}, | |
{ slots: {}, rest: [] } as { | |
slots: Partial<Record<TSlots[number], JSX.Element>>; | |
rest: any[]; | |
}, | |
); | |
if (expectedSlots) { | |
const remainingSlots = (expectedSlots as string[]).filter( | |
(slot) => !names.has(slot), | |
); | |
if (remainingSlots && remainingSlots.length > 0) { | |
const error = `Expected slots [${remainingSlots.map((name) => `"${name}"`).join(", ")}] but did not receive.`; | |
if (shouldThrow) throw error; | |
else console.error(error); | |
} | |
} | |
return result; | |
}); | |
return memo | |
} | |
export function slots<const TKeys extends string[]>(...keys: TKeys) { | |
const slots = Object.fromEntries( | |
keys.map((name) => [ | |
name, | |
(props: ParentProps) => ( | |
<div | |
ref={(container) => { | |
createRenderEffect(() => { | |
SLOT_MAP.set(container, { | |
get children() { | |
return props.children; | |
}, | |
name, | |
}); | |
onCleanup(() => SLOT_MAP.delete(container)); | |
}); | |
}} | |
/> | |
), | |
]), | |
); | |
return { | |
use: function <TProps>( | |
component: ( | |
props: ParentProps< | |
TProps & { slots: Record<TKeys[number], JSX.Element> } | |
>, | |
) => JSX.Element, | |
shouldThrow?: boolean, | |
) { | |
function Comp(props: ParentProps<TProps>) { | |
const config = mergeProps(props, getSlots(props, keys, shouldThrow)); | |
return component(config); | |
} | |
// Add slots as compound components. | |
keys.forEach((key) => (Comp[key] = slots[key])); | |
return Comp as { | |
(props: ParentProps<TProps>): JSX.Element; | |
} & { | |
[K in TKeys[number]]: (props: ParentProps) => JSX.Element; | |
}; | |
}, | |
}; | |
} | |
// Usage | |
const Comp = slots("Title").use((props) => ( | |
<> | |
<h1>{props.slots.Title}</h1> | |
{props.children} | |
</> | |
)); | |
const CompWithProps = slots("Title", "Footer").use<{ name: string }>( | |
(props) => ( | |
<> | |
<h1>{props.slots.Title}</h1> | |
<h2>{props.name}</h2> | |
{props.children} | |
<h1>{props.slots.Footer}</h1> | |
</> | |
), | |
); | |
function App() { | |
return ( | |
<> | |
<Comp> | |
<Comp.Title>this is a Title</Comp.Title> | |
whatever | |
</Comp> | |
<CompWithProps name="bigmistqke"> | |
<CompWithProps.Footer>this is a Footer</CompWithProps.Footer> | |
whatever | |
<CompWithProps.Title>this is a Title</CompWithProps.Title> | |
</CompWithProps> | |
</> | |
); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment