// Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html /* eslint @typescript-eslint/no-explicit-any: "off", */ import type { Identifier } from "@tslib/children-access"; import type { ChildrenAccess } from "@tslib/children-access"; import childrenAccess from "@tslib/children-access"; import { nodeIsElement } from "@tslib/dom"; import type { Callback } from "@tslib/helpers"; import { removeItem } from "@tslib/helpers"; import { promiseWithResolver } from "@tslib/promise"; import type { SvelteComponent } from "svelte"; import type { Readable, Writable } from "svelte/store"; import { writable } from "svelte/store"; export interface DynamicSvelteComponent { component: typeof SvelteComponent; /** * Props that are passed to the component */ props?: Record; /** * ID that will be assigned to the component that hosts * the dynamic component (slot host) */ id?: string; } /** * Props that will be passed to the slot host, e.g. ButtonGroupItem. */ export interface SlotHostProps { detach: Writable; } export interface CreateInterfaceAPI { addComponent( component: DynamicSvelteComponent, reinsert: (newElement: U, access: ChildrenAccess) => number, ): Promise<{ destroy: Callback }>; updateProps(update: (hostProps: T) => T, identifier: Identifier): Promise; } export interface GetSlotHostProps { getProps(): T; } export interface DynamicSlotted { component: DynamicSvelteComponent; hostProps: T; } export interface DynamicSlottingAPI< T extends SlotHostProps, U extends Element, X extends Record, > { /** * This should be used as an action on the element that hosts the slot hosts. */ resolveSlotContainer: (element: U) => void; /** * Contains the props for the DynamicSlot component */ dynamicSlotted: Readable[]>; slotsInterface: X; } /** * Allow add-on developers to dynamically extend/modify components our components * * @remarks * It allows to insert elements in between the components, or modify their props. * Practically speaking, we let Svelte do the initial insertion of an element, * but then immediately move it to its destination, and save a reference to it. * * @experimental */ function dynamicSlotting< T extends SlotHostProps, U extends Element, X extends Record, >( /** * A function which will create props which are passed to the dynamically * slotted component's host component, the slot host, e.g. `ButtonGroupItem` */ makeProps: () => T, /** * This is called on *all* items whenever any item updates */ updatePropsList: (propsList: T[]) => T[], /** * A function to create an interface to interact with slotted components */ setSlotHostContext: (callback: GetSlotHostProps) => void, createInterface: (api: CreateInterfaceAPI) => X, ): DynamicSlottingAPI { const slotted = writable([]); slotted.subscribe(updatePropsList); function addDynamicallySlotted(index: number, props: T): void { slotted.update((slotted: T[]): T[] => { slotted.splice(index, 0, props); return slotted; }); } const [elementPromise, resolveSlotContainer] = promiseWithResolver(); const accessPromise = elementPromise.then(childrenAccess); const dynamicSlotted = writable[]>([]); async function addComponent( component: DynamicSvelteComponent, reinsert: (newElement: U, access: ChildrenAccess) => number, ): Promise<{ destroy: Callback }> { const [dynamicallySlottedMounted, resolveDynamicallySlotted] = promiseWithResolver(); const access = await accessPromise; const hostProps = makeProps(); function elementIsDynamicComponent(element: Element): boolean { return !component.id || element.id === component.id; } async function callback( mutations: MutationRecord[], observer: MutationObserver, ): Promise { for (const mutation of mutations) { for (const addedNode of mutation.addedNodes) { if ( !nodeIsElement(addedNode) || !elementIsDynamicComponent(addedNode) ) { continue; } const theElement = addedNode as U; const index = reinsert(theElement, access); if (index >= 0) { addDynamicallySlotted(index, hostProps); } resolveDynamicallySlotted(undefined); return observer.disconnect(); } } } const observer = new MutationObserver(callback); observer.observe(access.parent, { childList: true }); const dynamicSlot = { component, hostProps, }; dynamicSlotted.update( (dynamicSlotted: DynamicSlotted[]): DynamicSlotted[] => { dynamicSlotted.push(dynamicSlot); return dynamicSlotted; }, ); await dynamicallySlottedMounted; return { destroy() { dynamicSlotted.update( (dynamicSlotted: DynamicSlotted[]): DynamicSlotted[] => { // TODO needs testing, if Svelte actually correctly removes the element removeItem(dynamicSlotted, dynamicSlot); return dynamicSlotted; }, ); }, }; } async function updateProps( update: (props: T) => T, identifier: Identifier, ): Promise { const access = await accessPromise; return access.updateElement((_element: U, index: number): void => { slotted.update((slottedProps: T[]) => { slottedProps[index] = update(slottedProps[index]); return slottedProps; }); }, identifier); } const slotsInterface = createInterface({ addComponent, updateProps }); function getSlotHostProps(): T { const props = makeProps(); slotted.update((slotted: T[]): T[] => { slotted.push(props); return slotted; }); return props; } setSlotHostContext({ getProps: getSlotHostProps }); return { dynamicSlotted, resolveSlotContainer, slotsInterface, }; } export default dynamicSlotting; /** Convenient default functions for dynamic slotting */ export function defaultProps(): SlotHostProps { return { detach: writable(false), }; } export interface DefaultSlotInterface extends Record { insert( button: DynamicSvelteComponent, position?: Identifier, ): Promise<{ destroy: Callback }>; append( button: DynamicSvelteComponent, position?: Identifier, ): Promise<{ destroy: Callback }>; show(position: Identifier): Promise; hide(position: Identifier): Promise; toggle(position: Identifier): Promise; } export function defaultInterface({ addComponent, updateProps, }: CreateInterfaceAPI): DefaultSlotInterface { function insert( component: DynamicSvelteComponent, id: Identifier = 0, ): Promise<{ destroy: Callback }> { return addComponent( component, (element: Element, access: ChildrenAccess) => access.insertElement(element, id), ); } function append( component: DynamicSvelteComponent, id: Identifier = -1, ): Promise<{ destroy: Callback }> { return addComponent( component, (element: Element, access: ChildrenAccess) => access.appendElement(element, id), ); } function show(id: Identifier): Promise { return updateProps((props: T): T => { props.detach.set(false); return props; }, id); } function hide(id: Identifier): Promise { return updateProps((props: T): T => { props.detach.set(true); return props; }, id); } function toggle(id: Identifier): Promise { return updateProps((props: T): T => { props.detach.update((detached: boolean) => !detached); return props; }, id); } return { insert, append, show, hide, toggle, }; } import contextProperty from "./context-property"; const key = Symbol("dynamicSlotting"); const [defaultSlotHostContext, setSlotHostContext] = contextProperty>(key); export { defaultSlotHostContext, setSlotHostContext };