300 lines
9.0 KiB
TypeScript
300 lines
9.0 KiB
TypeScript
|
// Copyright: Ankitects Pty Ltd and contributors
|
||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||
|
import type { SvelteComponent } from "svelte";
|
||
|
import type { Readable, Writable } from "svelte/store";
|
||
|
import { writable } from "svelte/store";
|
||
|
import type { Identifier } from "../lib/children-access";
|
||
|
import { promiseWithResolver } from "../lib/promise";
|
||
|
import type { ChildrenAccess } from "../lib/children-access";
|
||
|
import type { Callback } from "../lib/helpers";
|
||
|
import { removeItem } from "../lib/helpers";
|
||
|
import childrenAccess from "../lib/children-access";
|
||
|
import { nodeIsElement } from "../lib/dom";
|
||
|
|
||
|
export interface DynamicSvelteComponent {
|
||
|
component: typeof SvelteComponent;
|
||
|
/**
|
||
|
* Props that are passed to the component
|
||
|
*/
|
||
|
props?: Record<string, unknown>;
|
||
|
/**
|
||
|
* 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<boolean>;
|
||
|
}
|
||
|
|
||
|
export interface CreateInterfaceAPI<T extends SlotHostProps, U extends Element> {
|
||
|
addComponent(
|
||
|
component: DynamicSvelteComponent,
|
||
|
reinsert: (newElement: U, access: ChildrenAccess<U>) => number,
|
||
|
): Promise<{ destroy: Callback }>;
|
||
|
updateProps(update: (hostProps: T) => T, identifier: Identifier): Promise<boolean>;
|
||
|
}
|
||
|
|
||
|
export interface GetSlotHostProps<T> {
|
||
|
getProps(): T;
|
||
|
}
|
||
|
|
||
|
export interface DynamicSlotted<T extends SlotHostProps = SlotHostProps> {
|
||
|
component: DynamicSvelteComponent;
|
||
|
hostProps: T;
|
||
|
}
|
||
|
|
||
|
export interface DynamicSlottingAPI<
|
||
|
T extends SlotHostProps,
|
||
|
U extends Element,
|
||
|
X extends Record<string, unknown>,
|
||
|
> {
|
||
|
/**
|
||
|
* 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<DynamicSlotted<T>[]>;
|
||
|
slotsInterface: X;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Allow add-on developers to dynamically extend/modify components our components
|
||
|
*
|
||
|
* @remarks
|
||
|
* It allows to insert elements inbetween 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<string, unknown>,
|
||
|
>(
|
||
|
/**
|
||
|
* 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<T>) => void,
|
||
|
createInterface: (api: CreateInterfaceAPI<T, U>) => X,
|
||
|
): DynamicSlottingAPI<T, U, X> {
|
||
|
const slotted = writable<T[]>([]);
|
||
|
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<U>();
|
||
|
const accessPromise = elementPromise.then(childrenAccess);
|
||
|
|
||
|
const dynamicSlotted = writable<DynamicSlotted<T>[]>([]);
|
||
|
|
||
|
async function addComponent(
|
||
|
component: DynamicSvelteComponent,
|
||
|
reinsert: (newElement: U, access: ChildrenAccess<U>) => 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<void> {
|
||
|
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<T>[]): DynamicSlotted<T>[] => {
|
||
|
dynamicSlotted.push(dynamicSlot);
|
||
|
return dynamicSlotted;
|
||
|
},
|
||
|
);
|
||
|
|
||
|
await dynamicallySlottedMounted;
|
||
|
|
||
|
return {
|
||
|
destroy() {
|
||
|
dynamicSlotted.update(
|
||
|
(dynamicSlotted: DynamicSlotted<T>[]): DynamicSlotted<T>[] => {
|
||
|
// 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<boolean> {
|
||
|
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<string, unknown> {
|
||
|
insert(
|
||
|
button: DynamicSvelteComponent,
|
||
|
position?: Identifier,
|
||
|
): Promise<{ destroy: Callback }>;
|
||
|
append(
|
||
|
button: DynamicSvelteComponent,
|
||
|
position?: Identifier,
|
||
|
): Promise<{ destroy: Callback }>;
|
||
|
show(position: Identifier): Promise<boolean>;
|
||
|
hide(position: Identifier): Promise<boolean>;
|
||
|
toggle(position: Identifier): Promise<boolean>;
|
||
|
}
|
||
|
|
||
|
export function defaultInterface<T extends SlotHostProps, U extends Element>({
|
||
|
addComponent,
|
||
|
updateProps,
|
||
|
}: CreateInterfaceAPI<T, U>): DefaultSlotInterface {
|
||
|
function insert(
|
||
|
component: DynamicSvelteComponent,
|
||
|
id: Identifier = 0,
|
||
|
): Promise<{ destroy: Callback }> {
|
||
|
return addComponent(component, (element: Element, access: ChildrenAccess<U>) =>
|
||
|
access.insertElement(element, id),
|
||
|
);
|
||
|
}
|
||
|
|
||
|
function append(
|
||
|
component: DynamicSvelteComponent,
|
||
|
id: Identifier = -1,
|
||
|
): Promise<{ destroy: Callback }> {
|
||
|
return addComponent(component, (element: Element, access: ChildrenAccess<U>) =>
|
||
|
access.appendElement(element, id),
|
||
|
);
|
||
|
}
|
||
|
|
||
|
function show(id: Identifier): Promise<boolean> {
|
||
|
return updateProps((props: T): T => {
|
||
|
props.detach.set(false);
|
||
|
return props;
|
||
|
}, id);
|
||
|
}
|
||
|
|
||
|
function hide(id: Identifier): Promise<boolean> {
|
||
|
return updateProps((props: T): T => {
|
||
|
props.detach.set(true);
|
||
|
return props;
|
||
|
}, id);
|
||
|
}
|
||
|
|
||
|
function toggle(id: Identifier): Promise<boolean> {
|
||
|
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<GetSlotHostProps<SlotHostProps>>(key);
|
||
|
|
||
|
export { defaultSlotHostContext, setSlotHostContext };
|