Skip to content

Commit b415e1b

Browse files
committed
feat(render): layout position and style string parsing 👑
- Add applyStyleString to parse inline CSS and set properties via setProperty - Add tests for applyLayout position, applyStyle, and applyStyleString - Document layout position and style scope (HTML vs SVG) in USAGE - Set position to relative when x or y is set and position is static - Wrap arrow function parameters in parens in Create
1 parent bcbfaf8 commit b415e1b

File tree

4 files changed

+134
-15
lines changed

4 files changed

+134
-15
lines changed

USAGE.md

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,24 +52,25 @@ Named exports: `create`, `render`, and all types from `Types` (re-exported). Def
5252

5353
Each node is an object with:
5454

55-
| Key | Type | Required | Description |
56-
| :--------- | :------- | :------- | :---------------------------------------------------------- |
57-
| `type` | `string` | yes | HTML tag name (lowercase), e.g. `div`, `a`, `table`, `svg`. |
58-
| `id` | `string` | no | Element `id` attribute. |
59-
| `layout` | `Layout` | no | Width, height, position, flex, gap → CSS. |
60-
| `style` | `Style` | no | Fill, stroke, font, border → CSS. |
61-
| `attrs` | `Attrs` | no | Arbitrary HTML attributes (href, class, etc.). |
62-
| `content` | `string` | no | Text content for the node. |
63-
| `src` | `string` | no | Image (or similar) source URL; use with `type: 'img'`. |
64-
| `alt` | `string` | no | Alt text for img. |
65-
| `children` | `Node[]` | no | Child nodes. Not allowed on void tags. |
55+
| Key | Type | Required | Description |
56+
| :--------- | :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------ |
57+
| `type` | `string` | yes | HTML or SVG tag name, or custom element (e.g. `div`, `a`, `svg`, `circle`). Lowercased on create. Unknown names yield `HTMLUnknownElement`. |
58+
| `id` | `string` | no | Element `id` attribute. |
59+
| `layout` | `Layout` | no | Width, height, position, flex, gap → CSS. |
60+
| `style` | `Style` | no | Fill, stroke, font, border → CSS. |
61+
| `attrs` | `Attrs` | no | Arbitrary HTML attributes (href, class, etc.). |
62+
| `content` | `string` | no | Text content for the node. |
63+
| `src` | `string` | no | Image (or similar) source URL; use with `type: 'img'`. |
64+
| `alt` | `string` | no | Alt text for img. |
65+
| `children` | `Node[]` | no | Child nodes. Not allowed on void tags. |
6666

6767
Allowed keys are exactly the above. Extra keys cause validation to throw.
6868

6969
## Layout and Style
7070

71-
- **Layout**`width`, `height`, `x`, `y`, `flex`, `gap`. Number values are converted to `Npx`; strings (e.g. `'100%'`, `'auto'`) are used as-is. Applied to the element’s `style` (width, height, left, top, flex, gap). Setting `flex` or `gap` may set `display` to `block` or `flex` when needed.
71+
- **Layout**`width`, `height`, `x`, `y`, `flex`, `gap`. Number values are converted to `Npx`; strings (e.g. `'100%'`, `'auto'`) are used as-is. Applied to the element’s `style` (width, height, left, top, flex, gap). When `x` or `y` is set, `position` is set to `relative` if currently static so `left`/`top` take effect. Setting `flex` or `gap` may set `display` to `block` or `flex` when needed.
7272
- **Style** — All string values, mapped to `element.style` (CSSStyleProperties). Semantic: `fill``backgroundColor`, `stroke``border`. Direct: `font`, `border`, `color`, `padding`, `margin`, `borderRadius`, `boxShadow`, `opacity`, `transition`.
73+
- **Scope** — Layout and style are applied only to **HTML elements**. For SVG nodes (e.g. `circle`, `rect` under `svg`), use `attrs` (e.g. `attrs.style` as CSS string, or SVG attributes like `fill`, `stroke`).
7374

7475
## Void Tags and Special Elements
7576

src/Create.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export default class Create {
1414
*/
1515
static create(definition: Types.Definition | unknown): Types.Schema {
1616
const validated = Validator.validateDefinition(definition)
17-
const frozenRoot = validated.root.map(schemaNode => Create.freezeNode(schemaNode))
17+
const frozenRoot = validated.root.map((schemaNode) => Create.freezeNode(schemaNode))
1818
Object.freeze(frozenRoot)
1919
return Object.freeze({ root: frozenRoot })
2020
}
@@ -36,7 +36,7 @@ export default class Create {
3636
Object.freeze(node.attrs)
3737
}
3838
if (node.children) {
39-
const frozenChildren = node.children.map(childNode => Create.freezeNode(childNode))
39+
const frozenChildren = node.children.map((childNode) => Create.freezeNode(childNode))
4040
Object.freeze(frozenChildren)
4141
return Object.freeze({ ...node, children: frozenChildren }) as Readonly<Types.Node>
4242
}

src/Render.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export default class Render {
2828
continue
2929
}
3030
if (key === 'style' && typeof value === 'string') {
31-
;(element as HTMLElement).style.cssText += value
31+
Render.applyStyleString((element as HTMLElement).style, value)
3232
continue
3333
}
3434
if (
@@ -62,6 +62,32 @@ export default class Render {
6262
}
6363
}
6464

65+
/**
66+
* Append inline CSS string to style.
67+
* @description Parses prop: value pairs and sets each via setProperty.
68+
* @param targetStyle - Target CSS style declaration
69+
* @param inlineCssString - Inline CSS declarations (e.g. color: red; margin: 8px)
70+
*/
71+
static applyStyleString(targetStyle: CSSStyleDeclaration, inlineCssString: string): void {
72+
const declarations = inlineCssString.split(';')
73+
for (const declarationPart of declarations) {
74+
const colonIndex = declarationPart.indexOf(':')
75+
if (colonIndex < 0) {
76+
continue
77+
}
78+
const propertyName = declarationPart.slice(0, colonIndex).trim()
79+
const propertyValue = declarationPart.slice(colonIndex + 1).trim()
80+
if (propertyName === '') {
81+
continue
82+
}
83+
try {
84+
targetStyle.setProperty(propertyName, propertyValue)
85+
} catch {
86+
// ignore invalid property names
87+
}
88+
}
89+
}
90+
6591
/**
6692
* Apply layout to element style.
6793
* @description Sets width, height, left, top, flex, gap.
@@ -76,6 +102,11 @@ export default class Render {
76102
if (layout.height !== undefined) {
77103
elementStyle.height = Render.toCssValue(layout.height)
78104
}
105+
if (layout.x !== undefined || layout.y !== undefined) {
106+
if (!elementStyle.position || elementStyle.position === 'static') {
107+
elementStyle.position = 'relative'
108+
}
109+
}
79110
if (layout.x !== undefined) {
80111
elementStyle.left = Render.toCssValue(layout.x)
81112
}

tests/Render.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,93 @@
11
import { assertEquals } from '@std/assert'
22
import Render from '@app/Render.ts'
33

4+
Deno.test('Render.applyLayout does not override existing non-static position', () => {
5+
const style: Record<string, string> = { position: 'absolute' }
6+
const element = { style } as unknown as HTMLElement
7+
Render.applyLayout(element, { x: 0, y: 0 })
8+
assertEquals(style['position'], 'absolute')
9+
assertEquals(style['left'], '0px')
10+
assertEquals(style['top'], '0px')
11+
})
12+
13+
Deno.test('Render.applyLayout sets only left when only x present', () => {
14+
const style: Record<string, string> = {}
15+
const element = { style } as unknown as HTMLElement
16+
Render.applyLayout(element, { x: 5 })
17+
assertEquals(style['position'], 'relative')
18+
assertEquals(style['left'], '5px')
19+
assertEquals(style['top'], undefined)
20+
})
21+
22+
Deno.test('Render.applyLayout sets position relative when x or y present', () => {
23+
const style: Record<string, string> = {}
24+
const element = { style } as unknown as HTMLElement
25+
Render.applyLayout(element, { x: 10, y: 20 })
26+
assertEquals(style['position'], 'relative')
27+
assertEquals(style['left'], '10px')
28+
assertEquals(style['top'], '20px')
29+
})
30+
31+
Deno.test('Render.applyStyle applies expanded style properties', () => {
32+
const style: Record<string, string> = {}
33+
const element = { style } as unknown as HTMLElement
34+
Render.applyStyle(element, {
35+
color: '#333',
36+
padding: '8px',
37+
margin: '1rem',
38+
borderRadius: '4px',
39+
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
40+
opacity: '0.95',
41+
transition: 'opacity 0.2s ease'
42+
})
43+
assertEquals(style['color'], '#333')
44+
assertEquals(style['padding'], '8px')
45+
assertEquals(style['margin'], '1rem')
46+
assertEquals(style['borderRadius'], '4px')
47+
assertEquals(style['boxShadow'], '0 1px 3px rgba(0,0,0,0.2)')
48+
assertEquals(style['opacity'], '0.95')
49+
assertEquals(style['transition'], 'opacity 0.2s ease')
50+
})
51+
52+
Deno.test('Render.applyStyle applies fill stroke font border', () => {
53+
const style: Record<string, string> = {}
54+
const element = { style } as unknown as HTMLElement
55+
Render.applyStyle(element, {
56+
fill: '#f00',
57+
stroke: '1px solid blue',
58+
font: '16px sans-serif',
59+
border: '2px solid black'
60+
})
61+
assertEquals(style['backgroundColor'], '#f00')
62+
assertEquals(style['border'], '2px solid black')
63+
assertEquals(style['font'], '16px sans-serif')
64+
})
65+
66+
Deno.test('Render.applyStyleString parses declarations and calls setProperty', () => {
67+
const recorded: [string, string][] = []
68+
const targetStyle = {
69+
setProperty(name: string, value: string) {
70+
recorded.push([name, value])
71+
}
72+
} as unknown as CSSStyleDeclaration
73+
Render.applyStyleString(targetStyle, 'color: red; margin: 8px;')
74+
assertEquals(recorded.length, 2)
75+
assertEquals(recorded[0], ['color', 'red'])
76+
assertEquals(recorded[1], ['margin', '8px'])
77+
})
78+
79+
Deno.test('Render.applyStyleString skips empty or invalid parts', () => {
80+
const recorded: [string, string][] = []
81+
const targetStyle = {
82+
setProperty(name: string, value: string) {
83+
recorded.push([name, value])
84+
}
85+
} as unknown as CSSStyleDeclaration
86+
Render.applyStyleString(targetStyle, '; opacity: 0.9 ; ')
87+
assertEquals(recorded.length, 1)
88+
assertEquals(recorded[0], ['opacity', '0.9'])
89+
})
90+
491
Deno.test('Render.toCssValue converts number to px string', () => {
592
assertEquals(Render.toCssValue(0), '0px')
693
assertEquals(Render.toCssValue(16), '16px')

0 commit comments

Comments
 (0)