Skip to content

Instantly share code, notes, and snippets.

@bigmistqke
Last active September 3, 2024 16:19
Show Gist options
  • Save bigmistqke/968ac9d81ae123377e62d7bc0c07feb1 to your computer and use it in GitHub Desktop.
Save bigmistqke/968ac9d81ae123377e62d7bc0c07feb1 to your computer and use it in GitHub Desktop.
solid-slots
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