Skip to content

Commit e30593d

Browse files
committed
feat(render): add refs, onNodeMount, and element-specific attrs 🍞
- Export el from Schema2UI.el in index - Add scrollMargin to Style and Types.Style - Add iframe loading (eager/lazy) and label for→htmlFor in Attrs - Add RenderOptions (refs, onNodeMount, signal) and pass through render/renderNode - Alias DOM Element as DomElement in Main to avoid clash with Element class
1 parent a868987 commit e30593d

File tree

5 files changed

+119
-33
lines changed

5 files changed

+119
-33
lines changed

src/Render/Attrs.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ export default class Attrs {
5252
element.decoding = stringifiedValue as 'async' | 'sync' | 'auto'
5353
continue
5454
}
55+
if (
56+
key === 'loading' &&
57+
element instanceof HTMLIFrameElement &&
58+
typeof value === 'string'
59+
) {
60+
element.loading = stringifiedValue as 'eager' | 'lazy'
61+
continue
62+
}
5563
if (key === 'download' && element instanceof HTMLAnchorElement) {
5664
if (value === undefined || value === null || value === false) {
5765
element.download = ''
@@ -61,6 +69,11 @@ export default class Attrs {
6169
}
6270
continue
6371
}
72+
if (key === 'for' && element instanceof HTMLLabelElement) {
73+
element.setAttribute('for', stringifiedValue)
74+
element.htmlFor = stringifiedValue
75+
continue
76+
}
6477
try {
6578
element.setAttribute(key, stringifiedValue)
6679
} catch {

src/Render/Main.ts

Lines changed: 73 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,30 @@
11
import type * as Types from '@app/Types.ts'
22
import Constant from '@app/Constant.ts'
33
import Attrs from '@app/Render/Attrs.ts'
4+
import Element from '@app/Render/Element.ts'
45
import Layout from '@app/Render/Layout.ts'
5-
import RenderElement from '@app/Render/Element.ts'
66
import Style from '@app/Render/Style.ts'
77

8+
/** DOM Element type alias (avoid clash with imported Element class). */
9+
type DomElement = InstanceType<typeof globalThis.Element>
10+
811
/**
912
* Renders schema to DOM.
1013
* @description Builds elements from nodes and applies layout and style.
1114
*/
1215
export default class Render {
1316
/** SVG root tag name. */
14-
static readonly svgTagName: string = RenderElement.svgTagName
17+
static readonly svgTagName: string = Element.svgTagName
1518
/** Template element tag name. */
16-
static readonly templateTagName: string = RenderElement.templateTagName
19+
static readonly templateTagName: string = Element.templateTagName
1720

1821
/**
1922
* Apply attribute map to element.
2023
* @description Sets class, value, style string, and generic attributes.
2124
* @param element - Target DOM element
2225
* @param attrs - Attribute key-value map
2326
*/
24-
static applyAttrs(element: Element, attrs: Types.Attrs): void {
27+
static applyAttrs(element: DomElement, attrs: Types.Attrs): void {
2528
Attrs.applyAttrs(element, attrs)
2629
}
2730

@@ -63,26 +66,55 @@ export default class Render {
6366
* @param isSvg - Whether parent is SVG context
6467
* @returns Created DOM element
6568
*/
66-
static createElementForNode(node: Types.Node, doc: Document, isSvg: boolean): Element {
67-
return RenderElement.createElementForNode(node, doc, isSvg)
69+
static createElementForNode(node: Types.Node, doc: Document, isSvg: boolean): DomElement {
70+
return Element.createElementForNode(node, doc, isSvg)
6871
}
6972

7073
/**
7174
* Render schema root into container.
72-
* @description Appends each root node result to container.
75+
* @description Appends each root node result to container; optional refs and onNodeMount.
7376
* @param schema - Frozen schema
7477
* @param container - Target HTML element
78+
* @param options - Optional refs map and onNodeMount callback
7579
*/
76-
static render(schema: Types.Schema, container: HTMLElement): void {
80+
static render(schema: Types.Schema, container: HTMLElement, options?: Types.RenderOptions): void {
7781
const ownerDoc = container.ownerDocument ?? document
82+
let renderContext:
83+
| {
84+
refs?: Map<string, DomElement>
85+
onNodeMount?: (node: Types.Node, element: DomElement) => void
86+
mounted?: Array<{ node: Types.Node; element: DomElement }>
87+
}
88+
| undefined
89+
if (options) {
90+
renderContext = {}
91+
if (options.refs !== undefined) {
92+
renderContext.refs = options.refs
93+
}
94+
if (options.onNodeMount !== undefined) {
95+
renderContext.onNodeMount = options.onNodeMount
96+
renderContext.mounted = []
97+
}
98+
if (Object.keys(renderContext).length === 0) {
99+
renderContext = undefined
100+
}
101+
}
78102
for (const node of schema.root) {
79-
const result = Render.renderNode(node, ownerDoc, false)
80-
if (result instanceof DocumentFragment) {
81-
while (result.firstChild) {
82-
container.appendChild(result.firstChild)
103+
const nodeResult = Render.renderNode(node, ownerDoc, false, renderContext)
104+
if (nodeResult instanceof DocumentFragment) {
105+
while (nodeResult.firstChild) {
106+
container.appendChild(nodeResult.firstChild)
83107
}
84108
} else {
85-
container.appendChild(result)
109+
container.appendChild(nodeResult)
110+
}
111+
if (renderContext?.refs && node.id) {
112+
renderContext.refs.set(node.id, nodeResult as DomElement)
113+
}
114+
}
115+
if (renderContext?.onNodeMount && renderContext.mounted) {
116+
for (const { node, element } of renderContext.mounted) {
117+
renderContext.onNodeMount(node, element)
86118
}
87119
}
88120
}
@@ -93,16 +125,27 @@ export default class Render {
93125
* @param node - Schema node
94126
* @param doc - Document to create in
95127
* @param parentIsSvg - Whether parent is SVG context
128+
* @param renderContext - Optional context for refs and onNodeMount
96129
* @returns Created element or DocumentFragment for template
97130
*/
98131
static renderNode(
99132
node: Types.Node,
100133
doc: Document,
101-
parentIsSvg: boolean
102-
): Element | DocumentFragment {
103-
const tag = node.type
104-
const isSvg = parentIsSvg || tag === Render.svgTagName
134+
parentIsSvg: boolean,
135+
renderContext?: {
136+
refs?: Map<string, DomElement>
137+
mounted?: Array<{ node: Types.Node; element: DomElement }>
138+
}
139+
): DomElement | DocumentFragment {
140+
const tagName = node.type
141+
const isSvg = parentIsSvg || tagName === Render.svgTagName
105142
const element = Render.createElementForNode(node, doc, isSvg)
143+
if (renderContext?.refs && node.id) {
144+
renderContext.refs.set(node.id, element as DomElement)
145+
}
146+
if (renderContext?.mounted) {
147+
renderContext.mounted.push({ node, element: element as DomElement })
148+
}
106149
if (node.id && element instanceof HTMLElement) {
107150
element.id = node.id
108151
}
@@ -130,21 +173,27 @@ export default class Render {
130173
element.appendChild(doc.createTextNode(node.content))
131174
}
132175
}
133-
if (!Constant.voidTags.has(tag) && node.children && node.children.length > 0) {
134-
let target: DocumentFragment | Element
176+
if (!Constant.voidTags.has(tagName) && node.children && node.children.length > 0) {
177+
let appendTarget: DocumentFragment | DomElement
135178
if (element instanceof HTMLTemplateElement) {
136-
target = element.content
179+
appendTarget = element.content
137180
} else {
138-
target = element
181+
appendTarget = element
139182
}
140183
for (const child of node.children) {
141-
const childResult = Render.renderNode(child, doc, isSvg)
184+
const childResult = Render.renderNode(child, doc, isSvg, renderContext)
142185
if (childResult instanceof DocumentFragment) {
143186
while (childResult.firstChild) {
144-
target.appendChild(childResult.firstChild)
187+
appendTarget.appendChild(childResult.firstChild)
145188
}
146189
} else {
147-
target.appendChild(childResult)
190+
appendTarget.appendChild(childResult)
191+
}
192+
if (renderContext?.refs && child.id) {
193+
renderContext.refs.set(child.id, childResult as DomElement)
194+
}
195+
if (renderContext?.mounted) {
196+
renderContext.mounted.push({ node: child, element: childResult as DomElement })
148197
}
149198
}
150199
}

src/Render/Style.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ export default class Style {
4646
if (style.transition) {
4747
elementStyle.transition = style.transition
4848
}
49+
if (style.scrollMargin) {
50+
elementStyle.scrollMargin = style.scrollMargin
51+
}
4952
}
5053

5154
/**

src/Types.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,28 @@ export type Schema = {
6363
readonly root: readonly Node[]
6464
}
6565

66+
/**
67+
* Options passed to render for refs and lifecycle.
68+
* @description Optional refs map and onNodeMount callback.
69+
*/
70+
export type RenderOptions = {
71+
/** Optional AbortSignal for future listener cleanup. */
72+
signal?: AbortSignal
73+
/** Callback invoked after each node element is mounted (appended to parent). */
74+
onNodeMount?: (node: Node, element: Element) => void
75+
/** Map to populate with node id → element for nodes that have an id. */
76+
refs?: Map<string, Element>
77+
}
78+
6679
/**
6780
* Default export shape: create and render.
6881
* @description Returned when calling Schema2UI().
6982
*/
7083
export type Schema2UIDefault = {
7184
/** Create schema from definition */
7285
create: (definition: Definition | unknown) => Schema
73-
/** Render schema into container element */
74-
render: (schema: Schema, container: HTMLElement) => void
86+
/** Render schema into container element. */
87+
render: (schema: Schema, container: HTMLElement, options?: RenderOptions) => void
7588
}
7689

7790
/**
@@ -107,6 +120,8 @@ export type Style = {
107120
opacity?: string
108121
/** Padding (CSS padding) */
109122
padding?: string
123+
/** Scroll margin (CSS scroll-margin). */
124+
scrollMargin?: string
110125
/** Border or stroke (→ border) */
111126
stroke?: string
112127
/** Transition (CSS transition) */

src/index.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,13 @@ class Schema2UI {
6363

6464
/**
6565
* Render schema into container element.
66-
* @description Appends each root node DOM to container.
66+
* @description Appends root node DOM to container; optional refs and onNodeMount.
6767
* @param schema - Frozen schema
6868
* @param container - Target HTML element
69+
* @param options - Optional refs map and onNodeMount callback
6970
*/
70-
static render(schema: Types.Schema, container: HTMLElement): void {
71-
Render.render(schema, container)
71+
static render(schema: Types.Schema, container: HTMLElement, options?: Types.RenderOptions): void {
72+
Render.render(schema, container, options)
7273
}
7374
}
7475

@@ -87,13 +88,18 @@ export function create(definition: Types.Definition | unknown): Types.Schema {
8788
* @description Delegates to Schema2UI.render.
8889
* @param schema - Frozen schema
8990
* @param container - Target HTML element
91+
* @param options - Optional refs map and onNodeMount callback
9092
*/
91-
export function render(schema: Types.Schema, container: HTMLElement): void {
92-
return Schema2UI.render(schema, container)
93+
export function render(
94+
schema: Types.Schema,
95+
container: HTMLElement,
96+
options?: Types.RenderOptions
97+
): void {
98+
return Schema2UI.render(schema, container, options)
9399
}
94100

95-
/** Element helper namespace (root, node, tag aliases). */
96-
export const el: Types.El = Schema2UI.defaultExport.el
101+
/** Element helper namespace (from Schema2UI.el). */
102+
export const el: Types.El = Schema2UI.el
97103

98104
/** Re-export Types namespace from Types.ts. */
99105
export type * as Types from '@app/Types.ts'

0 commit comments

Comments
 (0)