Skip to content

Commit

Permalink
feat(react): implement plugin infrastructure and move react support t…
Browse files Browse the repository at this point in the history
…o a plugin
  • Loading branch information
tchak committed Apr 29, 2024
1 parent c410aca commit c120f29
Show file tree
Hide file tree
Showing 20 changed files with 538 additions and 460 deletions.
3 changes: 2 additions & 1 deletion oranda.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"build": {
"path_prefix": "coldwired",
"additional_pages": {
"Actions": "./packages/actions/README.md"
"Actions": "./packages/actions/README.md",
"React": "./packages/react/README.md"
}
},
"components": {
Expand Down
11 changes: 7 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
"description": "An implementation of @hotwired/turbo based on remix and morphdom",
"license": "MIT",
"repository": "tchak/coldwired",
"keywords": ["turbo", "stimulus"],
"keywords": [
"turbo",
"stimulus"
],
"scripts": {
"turbo": "turbo run test lint build",
"build": "turbo run build --force",
Expand All @@ -19,7 +22,7 @@
"devDependencies": {
"@axodotdev/oranda": "^0.6.1",
"@changesets/cli": "^2.27.1",
"@testing-library/dom": "^10.0.0",
"@testing-library/dom": "^10.1.0",
"@typescript-eslint/eslint-plugin": "^7.7.1",
"@typescript-eslint/parser": "^7.7.1",
"@vitest/browser": "^1.5.2",
Expand All @@ -31,8 +34,8 @@
"npm-run-all": "^4.1.5",
"playwright": "^1.43.1",
"prettier": "^3.2.5",
"rollup": "^4.16.4",
"turbo": "^1.13.2",
"rollup": "^4.17.1",
"turbo": "^1.13.3",
"typescript": "^5.4.5",
"vite": "^5.2.10",
"vitest": "^1.5.2"
Expand Down
37 changes: 8 additions & 29 deletions packages/actions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
"name": "@coldwired/actions",
"description": "DOM manipulation actions based on morphdom",
"license": "MIT",
"files": [
"dist"
],
"files": ["dist"],
"main": "./dist/index.cjs.js",
"module": "./dist/index.es.js",
"types": "./dist/types/index.d.ts",
Expand All @@ -16,9 +14,7 @@
},
"type": "module",
"version": "0.12.2",
"keywords": [
"turbo"
],
"keywords": ["turbo"],
"scripts": {
"build": "run-s clean build:*",
"build:vite": "vite build",
Expand All @@ -38,6 +34,9 @@
"@coldwired/utils": "^0.12.0",
"morphdom": "^2.7.2"
},
"devDependencies": {
"tiny-invariant": "^1.3.2"
},
"engines": {
"node": ">=16"
},
Expand All @@ -49,24 +48,15 @@
"eslintConfig": {
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"prettier"
],
"plugins": ["@typescript-eslint"],
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-redeclare": "off"
},
"overrides": [
{
"files": [
"vite.config.js",
"vitest.config.ts"
],
"files": ["vite.config.js", "vitest.config.ts"],
"env": {
"node": true
}
Expand All @@ -84,16 +74,5 @@
"github": {
"release": true
}
},
"peerDependencies": {
"@coldwired/react": "^0.12.2"
},
"devDependencies": {
"@coldwired/react": "^0.12.2",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"tiny-invariant": "^1.3.2"
}
}
54 changes: 18 additions & 36 deletions packages/actions/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ import {
focusNextElement,
parseHTMLFragment,
} from '@coldwired/utils';
import type { Container, ContainerOptions } from '@coldwired/react';

import { ClassListObserver, ClassListObserverDelegate } from './class-list-observer';
import { AttributeObserver, AttributeObserverDelegate } from './attribute-observer';
import { Metadata } from './metadata';
import { morph } from './morph';
import { Schema, defaultSchema } from './schema';
import type { Plugin } from './plugin';

const voidActionNames = ['remove', 'focus', 'enable', 'disable', 'hide', 'show'] as const;
const fragmentActionNames = ['after', 'before', 'append', 'prepend', 'replace', 'update'] as const;
Expand Down Expand Up @@ -55,9 +55,9 @@ type PinnedAction =

export type ActionsOptions = {
element?: Element;
schema?: Schema;
schema?: Partial<Schema>;
debug?: boolean;
container?: Omit<ContainerOptions, 'fragmentTagName' | 'loadingClassName'>;
plugins?: Plugin[];
};

export class Actions {
Expand All @@ -69,8 +69,7 @@ export class Actions {

#metadata = new Metadata();
#controller = new AbortController();
#containerOptions?: Omit<ContainerOptions, 'fragmentTagName' | 'loadingClassName'>;
#container?: Container;
#plugins: Plugin[] = [];

#pending = new Set<Promise<void>>();
#pinned = new Map<string, PinnedAction[]>();
Expand All @@ -81,50 +80,36 @@ export class Actions {
this.#element = options?.element ?? document.documentElement;
this.#schema = { ...defaultSchema, ...options?.schema };
this.#debug = options?.debug ?? false;
this.#plugins = options?.plugins ?? [];
this.#delegate = {
handleEvent: this.handleEvent.bind(this),
classListChanged: this.classListChanged.bind(this),
attributeChanged: this.attributeChanged.bind(this),
};
this.#classListObserver = new ClassListObserver(this.#element, this.#delegate);
this.#attributeObserver = new AttributeObserver(this.#element, this.#delegate);
this.#containerOptions = options?.container;
}

async ready() {
await Promise.all(this.#pending);
const pendingPlugins = this.#plugins.map((plugin) => plugin.ready());
await Promise.all([...this.#pending, ...pendingPlugins]);
}

get element() {
return this.#element;
}

get plugins() {
return this.#plugins;
}

observe() {
this.#classListObserver.observe();
this.#attributeObserver.observe();
this.#element.addEventListener('input', this.#delegate);
this.#element.addEventListener('change', this.#delegate);
this.#element.addEventListener(ACTIONS_EVENT_TYPE, this.#delegate);
}

async mount(root: Element, Layout?: Parameters<Container['mount']>[1]) {
if (!this.#containerOptions) {
throw new Error('No container provided');
}
const mod = await import('@coldwired/react');
const container = mod.createContainer({
fragmentTagName: this.#schema.fragmentTagName,
loadingClassName: this.#schema.loadingClassName,
...this.#containerOptions,
});
await this.ready();
await container.mount(root, Layout);
await container.render(this.#element);
this.#container = container;
}

get container() {
return this.#container;
this.#plugins.forEach((plugin) => plugin.init(this.#element));
}

disconnect() {
Expand All @@ -142,8 +127,6 @@ export class Actions {
this.#pending.clear();
this.#pinned.clear();
this.#metadata.clear();
this.#container?.destroy();
this.#container = undefined;
}

applyActions(actions: (Action | MaterializedAction)[]) {
Expand Down Expand Up @@ -289,15 +272,14 @@ export class Actions {
private _applyActions(actions: MaterializedAction[]) {
this._debugApplyActions(actions);
for (const action of actions) {
if (
this.#container &&
action.targets.some((element) => this.#container?.isInsideFragment(element))
) {
throw new Error('Cannot apply action inside fragment');
}
if (isFragmentAction(action)) {
this[`_${action.action}`](action);
} else {
if (action.action != 'focus') {
action.targets.forEach((element) => {
this.#plugins.forEach((plugin) => plugin.validate?.(element));
});
}
this[`_${action.action}`](action);
}
}
Expand Down Expand Up @@ -354,7 +336,7 @@ export class Actions {
) {
morph(from, to, {
metadata: this.#metadata,
container: this.#container,
plugins: this.#plugins,
...this.#schema,
...options,
});
Expand Down
1 change: 1 addition & 0 deletions packages/actions/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './actions';
export * from './schema';
export * from './plugin';

import { dispatchAction } from './actions';

Expand Down
27 changes: 14 additions & 13 deletions packages/actions/src/morph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,24 @@ import {
focusNextElement,
type FocusNextOptions,
} from '@coldwired/utils';
import type { Container } from '@coldwired/react';

import { Metadata } from './metadata';
import { Plugin } from './plugin';

type MorphOptions = FocusNextOptions & {
childrenOnly?: boolean;
forceAttribute?: string;
metadata?: Metadata;
container?: Container;
plugins?: Plugin[];
};

export function morph(
fromElementOrDocument: Element | Document,
toElementOrDocument: string | Element | Document | DocumentFragment,
options?: MorphOptions,
) {
options?.plugins?.forEach((plugin) => plugin.validate?.(fromElementOrDocument));

if (fromElementOrDocument instanceof Document) {
invariant(toElementOrDocument instanceof Document, 'Cannot morph document to element');
morphDocument(fromElementOrDocument, toElementOrDocument, options);
Expand Down Expand Up @@ -56,9 +58,10 @@ function morphToDocumentFragment(
toDocumentFragment.normalize();

if (options?.childrenOnly) {
if (options.container?.isFragment(fromElement)) {
options.container.render(fromElement, toDocumentFragment);
} else {
const pluginRendered = options?.plugins?.some((plugin) =>
plugin.onBeforeUpdateElement?.(fromElement, toDocumentFragment),
);
if (!pluginRendered) {
const wrapper = toDocumentFragment.ownerDocument.createElement('div');
wrapper.append(toDocumentFragment);
morphToElement(fromElement, wrapper, options);
Expand Down Expand Up @@ -89,15 +92,13 @@ function morphToDocumentFragment(
function morphToElement(fromElement: Element, toElement: Element, options?: MorphOptions): void {
const forceAttribute = options?.forceAttribute;

if (options?.container?.isInsideFragment(fromElement)) {
throw new Error('Cannot morph element inside fragment');
}

morphdom(fromElement, toElement, {
childrenOnly: options?.childrenOnly,
onBeforeElUpdated(fromElement, toElement) {
if (options?.container?.isFragment(fromElement)) {
options.container.render(fromElement, toElement);
const pluginRendered = options?.plugins?.some((plugin) =>
plugin.onBeforeUpdateElement?.(fromElement, toElement),
);
if (pluginRendered) {
return false;
}

Expand Down Expand Up @@ -155,14 +156,14 @@ function morphToElement(fromElement: Element, toElement: Element, options?: Morp
},
onBeforeNodeDiscarded(node) {
if (isElement(node)) {
options?.plugins?.forEach((plugin) => plugin.onBeforeDestroyElement?.(node));
focusNextElement(node, options);
options?.container?.remove(node);
}
return true;
},
onNodeAdded(node) {
if (isElement(node)) {
options?.container?.render(node);
options?.plugins?.forEach((plugin) => plugin.onCreateElement?.(node));
}
return node;
},
Expand Down
8 changes: 8 additions & 0 deletions packages/actions/src/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface Plugin {
ready(): Promise<void>;
init(element: Element): void;
validate?(element: Element | Document): void;
onCreateElement?(element: Element): boolean;
onBeforeUpdateElement?(element: Element, toElement: Element | DocumentFragment): boolean;
onBeforeDestroyElement?(element: Element): boolean;
}
4 changes: 0 additions & 4 deletions packages/actions/src/schema.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
export type Schema = {
fragmentTagName: string;
forceAttribute: string;
focusGroupAttribute: string;
focusDirectionAttribute: string;
hiddenClassName: string;
loadingClassName: string;
};

export const defaultSchema: Schema = {
fragmentTagName: 'turbo-fragment',
forceAttribute: 'data-turbo-force',
focusGroupAttribute: 'data-turbo-focus-group',
focusDirectionAttribute: 'data-turbo-focus-direction',
hiddenClassName: 'hidden',
loadingClassName: 'loading',
};
2 changes: 1 addition & 1 deletion packages/actions/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default defineConfig({
formats: ['cjs', 'es'],
},
rollupOptions: {
external: ['@coldwired/utils', '@coldwired/react', 'morphdom'],
external: ['@coldwired/utils', 'morphdom'],
},
},
});
Loading

0 comments on commit c120f29

Please sign in to comment.