anki/ts/sveltelib/dynamic-slotting.ts
Henrik Giesel 30bbbaf00b
Use eslint for sorting our imports (#1637)
* Make eslint sort our imports

* fix missing deps in eslint rule (dae)

Caught on Linux due to the stricter sandboxing

* Remove exports-last eslint rule (for now?)

* Adjust browserslist settings

- We use ResizeObserver which is not supported in browsers like KaiOS,
  Baidu or Android UC

* Raise minimum iOS version 13.4

- It's the first version that supports ResizeObserver

* Apply new eslint rules to sort imports
2022-02-04 18:36:34 +10:00

301 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 type { ChildrenAccess } from "../lib/children-access";
import childrenAccess from "../lib/children-access";
import { nodeIsElement } from "../lib/dom";
import type { Callback } from "../lib/helpers";
import { removeItem } from "../lib/helpers";
import { promiseWithResolver } from "../lib/promise";
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 };