From 8072694fc3e1d60240041dfc9b9aeccb66a7e80a Mon Sep 17 00:00:00 2001 From: arnoson Date: Fri, 24 Mar 2023 18:07:30 +0100 Subject: [PATCH] feat!: allow `options` to define refs and props --- .vscode/settings.json | 3 +- index.html | 13 +++++++ package.json | 1 + pnpm-lock.yaml | 8 ++++ src/dev.ts | 11 ++++++ src/index.ts | 3 +- src/mountComponent.ts | 53 ++++++++++++++++++++++++-- src/props.ts | 57 ---------------------------- src/registerComponent.ts | 38 ++++++++++++++----- src/types.ts | 80 ++++++++++++++++++++++++++++++++-------- src/utils.ts | 2 + tests/index.test.ts | 13 +++++-- 12 files changed, 190 insertions(+), 92 deletions(-) create mode 100644 index.html create mode 100644 src/dev.ts delete mode 100644 src/props.ts create mode 100644 src/utils.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index ad92582..1b6457c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,4 @@ { - "editor.formatOnSave": true + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" } diff --git a/index.html b/index.html new file mode 100644 index 0000000..272f7f6 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Document + + +
+ + + diff --git a/package.json b/package.json index eb94b62..21f795d 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "devDependencies": { "bumpp": "^8.2.1", "jsdom": "^20.0.0", + "prettier": "^2.8.7", "shx": "^0.3.4", "tsup": "^6.0.1", "typescript": "^4.8.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a94f65..92178a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3,6 +3,7 @@ lockfileVersion: 5.4 specifiers: bumpp: ^8.2.1 jsdom: ^20.0.0 + prettier: ^2.8.7 shx: ^0.3.4 tsup: ^6.0.1 typescript: ^4.8.4 @@ -12,6 +13,7 @@ specifiers: devDependencies: bumpp: 8.2.1 jsdom: 20.0.3 + prettier: 2.8.7 shx: 0.3.4 tsup: 6.6.3_typescript@4.9.5 typescript: 4.9.5 @@ -1308,6 +1310,12 @@ packages: engines: {node: '>= 0.8.0'} dev: true + /prettier/2.8.7: + resolution: {integrity: sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==} + engines: {node: '>=10.13.0'} + hasBin: true + dev: true + /prompts/2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} diff --git a/src/dev.ts b/src/dev.ts new file mode 100644 index 0000000..796299a --- /dev/null +++ b/src/dev.ts @@ -0,0 +1,11 @@ +import { registerComponent, mountComponents } from '.' + +const options = { + props: { message: String, propWithDefault: 123 } +} + +registerComponent('test', options, ({ props }) => { + console.log(props.propWithDefault) +}) + +mountComponents() diff --git a/src/index.ts b/src/index.ts index 70572d3..5d99462 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,3 @@ -export { registerComponent } from './registerComponent' +export { registerComponent, defineOptions } from './registerComponent' export { mountComponent, mountComponents } from './mountComponent' -export { defineProps } from './props' export * from './types' diff --git a/src/mountComponent.ts b/src/mountComponent.ts index 1a8219f..8f1e10b 100644 --- a/src/mountComponent.ts +++ b/src/mountComponent.ts @@ -1,9 +1,47 @@ import { getComponent } from './registerComponent' import { SimpleElement, SimpleRefs, SimpleRefsAll } from './types' +import { isBuiltInTypeConstructor } from './utils' import { walkComponent } from './walkComponent' -const getRefsAll = (el: HTMLElement): SimpleRefsAll => { - const refs: SimpleRefsAll = {} +const parseProp = (value: any, type: string) => + type === 'string' ? String(value) : JSON.parse(value) + +const stringifyProp = (value: any) => + typeof value === 'string' ? value : JSON.stringify(value) + +const getDefaultProp = (definition: any) => + definition instanceof Function ? definition() : definition + +const createPropsProxy = ( + el: HTMLElement, + definitions: Record +) => { + const get = (_: Object, key: string) => { + const value = el.dataset[key] + const definition = definitions[key] + if (!definition) return value + + const isConstructor = isBuiltInTypeConstructor(definition) + const providesDefault = !isConstructor + if (value === undefined && providesDefault) + return getDefaultProp(definition) + + const type = isConstructor + ? definition.prototype.constructor.name.toLowerCase() + : typeof definition + return parseProp(value, type) + } + + const set = (_: Object, key: string, value: unknown) => { + el.dataset[key] = stringifyProp(value) + return true + } + + return new Proxy({}, { get, set }) +} + +const getRefsAll = (el: HTMLElement): SimpleRefsAll => { + const refs: SimpleRefsAll = {} walkComponent(el, el => { const { ref } = el.dataset if (ref) { @@ -33,10 +71,17 @@ export const mountComponent = (el: HTMLElement, isChild = false) => { const component = getComponent(el) if (component) { - const simpleEl = el as SimpleElement> - simpleEl.$component = component({ el, refs, refsAll }) || {} + const simpleEl = el as SimpleElement + const propsDefinitions = component.options.props + + const props = propsDefinitions + ? createPropsProxy(el, propsDefinitions) + : el.dataset + + simpleEl.$props = props simpleEl.$refs = refs simpleEl.$refsAll = refsAll + simpleEl.$component = component.setup({ el, refs, refsAll, props }) || {} } } diff --git a/src/props.ts b/src/props.ts deleted file mode 100644 index caff69f..0000000 --- a/src/props.ts +++ /dev/null @@ -1,57 +0,0 @@ -type Constructor = - | NumberConstructor - | StringConstructor - | ArrayConstructor - | ObjectConstructor - | BooleanConstructor - -type Props = { - [K in keyof T]: T[K] extends Constructor - ? ReturnType | undefined - : T[K] extends () => any - ? ReturnType - : T[K] -} - -const isConstructor = (value: any) => - [Number, String, Boolean, Array, Object].includes(value) - -const parse = (value: any, type: string) => - type === 'string' - ? String(value) - : type === 'number' - ? Number(value) - : type === 'boolean' - ? value !== 'false' - : JSON.parse(value) - -const getDefaultValue = (definition: any) => - definition instanceof Function ? definition() : definition - -export const defineProps = >(definitions: T) => { - return (el: HTMLElement) => { - const props = Object.fromEntries( - Object.entries(definitions).map(([key, definition]) => { - const serializedValue = el.dataset[key] - const providesDefault = !isConstructor(definition) - - const defaultValue = providesDefault - ? getDefaultValue(definition) - : undefined - - const type = providesDefault - ? typeof defaultValue - : definition.prototype.constructor.name.toLowerCase() - - const value = - serializedValue === undefined - ? defaultValue - : parse(serializedValue, type) - - return [key, value] - }) - ) - - return props as Props - } -} diff --git a/src/registerComponent.ts b/src/registerComponent.ts index 16df9f2..eda0d15 100644 --- a/src/registerComponent.ts +++ b/src/registerComponent.ts @@ -1,4 +1,8 @@ -import { SimpleComponent } from './types' +import { + SimpleComponent, + SimpleComponentOptions, + SimpleComponentSetup +} from './types' export const components: Record> = {} export const getComponent = (el: HTMLElement) => { @@ -6,16 +10,30 @@ export const getComponent = (el: HTMLElement) => { return name ? components[name] : undefined } -export const registerComponent = < - T extends HTMLElement, - C extends SimpleComponent ->( - name: string, - component: C -) => { - if (typeof component !== 'function') { +export const defineOptions = (options: SimpleComponentOptions) => options + +export function registerComponent< + Setup extends SimpleComponentSetup, + Options extends SimpleComponentOptions = {} +>(name: string, setup: Setup): SimpleComponent + +export function registerComponent< + Setup extends SimpleComponentSetup, + Options extends SimpleComponentOptions = {} +>(name: string, options: Options, setup: Setup): SimpleComponent + +export function registerComponent< + Setup extends SimpleComponentSetup, + Options extends SimpleComponentOptions = {} +>(name: string, arg1: Options | Setup, arg2?: Setup): SimpleComponent { + const hasBothArgs = arg2 !== undefined + const options = (hasBothArgs ? arg1 : {}) as Options + const setup = (hasBothArgs ? arg2 : arg1) as Setup + + if (typeof setup !== 'function') throw new Error(`Component ${name} is not a function.`) - } + + const component = { options, setup } components[name] = component return component } diff --git a/src/types.ts b/src/types.ts index ce0cbc3..81c28b2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,25 +1,75 @@ -export type SimpleRefs = Record +type HTMLElementConstructor = typeof HTMLElement -export type SimpleRefsAll = Record +type HTMLElementType = T['prototype'] -export type DefineRefs = SimpleRefs & Partial +type HTMLElementsTypes> = { + [K in keyof T]: HTMLElementType +} + +type BuiltInType = Number | String | Array | Object | Boolean -export type DefineRefsAll = SimpleRefsAll & T +type BuiltInTypeConstructor = + | NumberConstructor + | StringConstructor + | ArrayConstructor + | ObjectConstructor + | BooleanConstructor + +type PropTypes = { + [K in keyof Definitions]: Definitions[K] extends BuiltInTypeConstructor + ? ReturnType | undefined + : Definitions[K] extends () => any + ? ReturnType + : Definitions[K] +} -export type SimpleInstance = ReturnType +export type SimpleRefs = Record -export type SimpleElement = T & { - $component: SimpleInstance - $refs: SimpleRefs, - $refsAll: SimpleRefsAll +export type SimpleRefsAll = { + [K in keyof R]: R[K][] } -export interface SimpleComponentPayload { - el: T - refs: SimpleRefs - refsAll: SimpleRefsAll +export interface SimpleComponentOptions { + el?: HTMLElementConstructor + props?: Record + refs?: Record + events?: Record +} + +export type SimpleComponentPayload< + Options extends SimpleComponentOptions, + Refs extends SimpleRefs = HTMLElementsTypes> +> = { + el: Options['el'] extends HTMLElementConstructor + ? HTMLElementType + : HTMLElement + + props: PropTypes + + refs: Record & Partial + + refsAll: Record & SimpleRefsAll } -export type SimpleComponent = ( - payload: SimpleComponentPayload +export type SimpleComponentSetup = ( + payload: SimpleComponentPayload ) => any + +export type SimpleComponent = { + setup: SimpleComponentSetup + options: Options +} + +export type SimpleInstance> = ReturnType< + Component['setup'] +> + +export type SimpleElement< + Component extends SimpleComponent, + Options extends SimpleComponentOptions = Component['options'], + Payload extends SimpleComponentPayload = SimpleComponentPayload +> = Payload['el'] & { + $component: SimpleInstance + $refs: Payload['refs'] + $refsAll: Payload['refsAll'] +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..bcd1c44 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,2 @@ +export const isBuiltInTypeConstructor = (value: any) => + [Number, String, Boolean, Array, Object].includes(value) diff --git a/tests/index.test.ts b/tests/index.test.ts index d2ddb23..803a8f0 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -15,7 +15,12 @@ it('mounts a component', () => { div.dataset.simpleComponent = 'test' mountComponent(div) - expect(component).toBeCalledWith({ el: div, refs: {}, refsAll: {} }) + expect(component).toBeCalledWith({ + el: div, + refs: {}, + refsAll: {}, + props: {} + }) }) it('mounts multiple components', () => { @@ -99,7 +104,9 @@ it('provides a record of groups of refs with the same name', () => { document.querySelector('#ref2'), document.querySelector('#ref3') ] - expect(component).toBeCalledWith(expect.objectContaining({ refsAll: { myRef } })) + expect(component).toBeCalledWith( + expect.objectContaining({ refsAll: { myRef } }) + ) }) it(`parses props`, () => { @@ -187,7 +194,7 @@ it(`exposes the component's refsAll`, () => { const myRef = [ document.getElementById('ref1'), - document.getElementById('ref2'), + document.getElementById('ref2') ] mountComponents(document.body)