packages
core
react-demo
react-plugin
···
13
13
"packages/solid-css",
14
14
"packages/solid-plugin",
15
15
"packages/solid-demo",
16
16
-
"packages/react-plugin"
16
16
+
"packages/react-plugin",
17
17
+
"packages/react-demo"
17
18
]
18
19
},
19
20
"node_modules/@babel/code-frame": {
···
218
219
"@babel/core": "^7.0.0-0"
219
220
}
220
221
},
222
222
+
"node_modules/@babel/plugin-transform-react-jsx-self": {
223
223
+
"version": "7.29.7",
224
224
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
225
225
+
"integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
226
226
+
"dev": true,
227
227
+
"license": "MIT",
228
228
+
"dependencies": {
229
229
+
"@babel/helper-plugin-utils": "^7.29.7"
230
230
+
},
231
231
+
"engines": {
232
232
+
"node": ">=6.9.0"
233
233
+
},
234
234
+
"peerDependencies": {
235
235
+
"@babel/core": "^7.0.0-0"
236
236
+
}
237
237
+
},
238
238
+
"node_modules/@babel/plugin-transform-react-jsx-source": {
239
239
+
"version": "7.29.7",
240
240
+
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
241
241
+
"integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
242
242
+
"dev": true,
243
243
+
"license": "MIT",
244
244
+
"dependencies": {
245
245
+
"@babel/helper-plugin-utils": "^7.29.7"
246
246
+
},
247
247
+
"engines": {
248
248
+
"node": ">=6.9.0"
249
249
+
},
250
250
+
"peerDependencies": {
251
251
+
"@babel/core": "^7.0.0-0"
252
252
+
}
253
253
+
},
221
254
"node_modules/@babel/template": {
222
255
"version": "7.29.7",
223
256
"dev": true,
···
317
350
},
318
351
"node_modules/@nothing-but/utils": {
319
352
"version": "0.17.0",
353
353
+
"dev": true,
354
354
+
"license": "MIT"
355
355
+
},
356
356
+
"node_modules/@rolldown/pluginutils": {
357
357
+
"version": "1.0.0-beta.27",
358
358
+
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
359
359
+
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
320
360
"dev": true,
321
361
"license": "MIT"
322
362
},
···
560
600
"csstype": "^3.2.2"
561
601
}
562
602
},
603
603
+
"node_modules/@types/react-dom": {
604
604
+
"version": "19.2.3",
605
605
+
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
606
606
+
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
607
607
+
"dev": true,
608
608
+
"license": "MIT",
609
609
+
"peerDependencies": {
610
610
+
"@types/react": "^19.2.0"
611
611
+
}
612
612
+
},
563
613
"node_modules/@union-web/core": {
564
614
"resolved": "packages/core",
565
615
"link": true
566
616
},
617
617
+
"node_modules/@union-web/react-demo": {
618
618
+
"resolved": "packages/react-demo",
619
619
+
"link": true
620
620
+
},
567
621
"node_modules/@union-web/react-plugin": {
568
622
"resolved": "packages/react-plugin",
569
623
"link": true
···
580
634
"resolved": "packages/solid-plugin",
581
635
"link": true
582
636
},
637
637
+
"node_modules/@vitejs/plugin-react": {
638
638
+
"version": "4.7.0",
639
639
+
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
640
640
+
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
641
641
+
"dev": true,
642
642
+
"license": "MIT",
643
643
+
"dependencies": {
644
644
+
"@babel/core": "^7.28.0",
645
645
+
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
646
646
+
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
647
647
+
"@rolldown/pluginutils": "1.0.0-beta.27",
648
648
+
"@types/babel__core": "^7.20.5",
649
649
+
"react-refresh": "^0.17.0"
650
650
+
},
651
651
+
"engines": {
652
652
+
"node": "^14.18.0 || >=16.0.0"
653
653
+
},
654
654
+
"peerDependencies": {
655
655
+
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
656
656
+
}
657
657
+
},
583
658
"node_modules/babel-plugin-jsx-dom-expressions": {
584
659
"version": "0.40.7",
585
660
"dev": true,
···
981
1056
"node": ">=0.10.0"
982
1057
}
983
1058
},
1059
1059
+
"node_modules/react-dom": {
1060
1060
+
"version": "19.2.7",
1061
1061
+
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
1062
1062
+
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
1063
1063
+
"license": "MIT",
1064
1064
+
"dependencies": {
1065
1065
+
"scheduler": "^0.27.0"
1066
1066
+
},
1067
1067
+
"peerDependencies": {
1068
1068
+
"react": "^19.2.7"
1069
1069
+
}
1070
1070
+
},
1071
1071
+
"node_modules/react-refresh": {
1072
1072
+
"version": "0.17.0",
1073
1073
+
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
1074
1074
+
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
1075
1075
+
"dev": true,
1076
1076
+
"license": "MIT",
1077
1077
+
"engines": {
1078
1078
+
"node": ">=0.10.0"
1079
1079
+
}
1080
1080
+
},
984
1081
"node_modules/rollup": {
985
1082
"version": "4.61.1",
986
1083
"dev": true,
···
1023
1120
"@rollup/rollup-win32-x64-msvc": "4.61.1",
1024
1121
"fsevents": "~2.3.2"
1025
1122
}
1123
1123
+
},
1124
1124
+
"node_modules/scheduler": {
1125
1125
+
"version": "0.27.0",
1126
1126
+
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
1127
1127
+
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
1128
1128
+
"license": "MIT"
1026
1129
},
1027
1130
"node_modules/semver": {
1028
1131
"version": "6.3.1",
···
1294
1397
"typescript": "^5.5.0"
1295
1398
}
1296
1399
},
1400
1400
+
"packages/react-demo": {
1401
1401
+
"name": "@union-web/react-demo",
1402
1402
+
"version": "0.1.0",
1403
1403
+
"dependencies": {
1404
1404
+
"@union-web/core": "*",
1405
1405
+
"@union-web/react-plugin": "*",
1406
1406
+
"react": "^19.0.0",
1407
1407
+
"react-dom": "^19.0.0"
1408
1408
+
},
1409
1409
+
"devDependencies": {
1410
1410
+
"@types/react": "^19.0.0",
1411
1411
+
"@types/react-dom": "^19.0.0",
1412
1412
+
"@vitejs/plugin-react": "^4.5.0",
1413
1413
+
"typescript": "^5.5.0",
1414
1414
+
"vite": "^6.0.0"
1415
1415
+
}
1416
1416
+
},
1297
1417
"packages/react-plugin": {
1298
1418
"name": "@union-web/react-plugin",
1299
1419
"version": "0.1.0",
···
1302
1422
"react": "^19.0.0"
1303
1423
},
1304
1424
"devDependencies": {
1425
1425
+
"@types/node": "^22.0.0",
1305
1426
"@types/react": "^19.0.0",
1306
1427
"typescript": "^5.5.0",
1307
1428
"vite": "^6.0.0"
···
8
8
"packages/solid-css",
9
9
"packages/solid-plugin",
10
10
"packages/solid-demo",
11
11
-
"packages/react-plugin"
11
11
+
"packages/react-plugin",
12
12
+
"packages/react-demo"
12
13
],
13
14
"scripts": {
14
15
"build": "tsc -b",
15
15
-
"dev": "npm run -w @union-web/solid-demo dev",
16
16
-
"build:demo": "npm run -w @union-web/solid-demo build",
17
17
-
"preview": "npm run -w @union-web/solid-demo preview",
18
18
-
"start": "node packages/core/dist/cli.js"
16
16
+
"dev": "npm run -w @union-web/react-demo dev",
17
17
+
"build:demo": "npm run -w @union-web/react-demo build",
18
18
+
"preview": "npm run -w @union-web/react-demo preview",
19
19
+
"start": "node packages/core/dist/cli.js",
20
20
+
"dev:solid": "npm run -w @union-web/solid-demo dev"
19
21
}
20
22
}
···
452
452
/* Border/outline shorthand */
453
453
case 'border': {
454
454
const parts = raw.split(/\s+/);
455
455
-
if (parts.length === 3) {
455
455
+
if (parts.length >= 3) {
456
456
const w = parseLength(parts[0]);
457
457
if (!target['borderWidth'] && w && !isNaN(w.value)) target['borderWidth'] = w.value;
458
458
if (!target['borderStyle']) target['borderStyle'] = parts[1];
459
459
-
if (!target['borderColor']) target['borderColor'] = parts[2];
459
459
+
if (!target['borderColor']) target['borderColor'] = parts.slice(2).join(' ');
460
460
+
} else if (raw === 'none') {
461
461
+
target['borderWidth'] = 0;
460
462
}
461
463
break;
462
464
}
···
491
493
case 'bottom': {
492
494
if (group === 'border') {
493
495
const parts = raw.split(/\s+/);
494
494
-
if (parts.length === 3) {
496
496
+
if (parts.length >= 3) {
495
497
const w = parseLength(parts[0]);
496
498
target[`${key}Width`] = w && !isNaN(w.value) ? w.value : undefined;
497
499
target[`${key}Style`] = parts[1];
498
498
-
target[`${key}Color`] = parts[2];
500
500
+
target[`${key}Color`] = parts.slice(2).join(' ');
499
501
}
500
502
} else if (group === 'outline') {
501
503
const parts = raw.split(/\s+/);
···
73
73
break;
74
74
}
75
75
case 'ClassSelector': {
76
76
-
parts.push({ type: 'id', name: child.name });
76
76
+
parts.push({ type: 'hint', name: child.name });
77
77
break;
78
78
}
79
79
case 'IdSelector': {
···
1
1
+
<!DOCTYPE html>
2
2
+
<html lang="en">
3
3
+
<head>
4
4
+
<meta charset="UTF-8" />
5
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
+
<title>Union CSS Demo - React</title>
7
7
+
</head>
8
8
+
<body>
9
9
+
<div id="root"></div>
10
10
+
<script type="module" src="/src/main.tsx"></script>
11
11
+
</body>
12
12
+
</html>
···
1
1
+
{
2
2
+
"name": "@union-web/react-demo",
3
3
+
"version": "0.1.0",
4
4
+
"private": true,
5
5
+
"type": "module",
6
6
+
"scripts": {
7
7
+
"dev": "vite",
8
8
+
"build": "vite build",
9
9
+
"preview": "vite preview"
10
10
+
},
11
11
+
"dependencies": {
12
12
+
"@union-web/core": "*",
13
13
+
"@union-web/react-plugin": "*",
14
14
+
"react": "^19.0.0",
15
15
+
"react-dom": "^19.0.0"
16
16
+
},
17
17
+
"devDependencies": {
18
18
+
"@types/react": "^19.0.0",
19
19
+
"@types/react-dom": "^19.0.0",
20
20
+
"@vitejs/plugin-react": "^4.5.0",
21
21
+
"typescript": "^5.5.0",
22
22
+
"vite": "^6.0.0"
23
23
+
}
24
24
+
}
···
1
1
+
/**
2
2
+
* App CSS should never be used for most parity issues!
3
3
+
* This file should only be used for very general and broad browser <-> QML differences
4
4
+
* like the lack of flexboxes. Most parity issues should be fixed by going through the Union
5
5
+
* IR output and tweaking it there, or the output plugin integration of the Union IR output
6
6
+
*/
7
7
+
8
8
+
*, *::before, *::after {
9
9
+
box-sizing: border-box;
10
10
+
}
11
11
+
12
12
+
body {
13
13
+
margin: 0;
14
14
+
padding: 40px;
15
15
+
font-family: "Noto Sans", "Segoe UI", system-ui, sans-serif;
16
16
+
background: #d6d8d9;
17
17
+
color: #232629;
18
18
+
display: flex;
19
19
+
justify-content: center;
20
20
+
}
21
21
+
22
22
+
/* Page fills remaining space in the window */
23
23
+
[data-ctrl="page"] {
24
24
+
flex: 1;
25
25
+
display: flex;
26
26
+
flex-direction: column;
27
27
+
}
28
28
+
29
29
+
a {
30
30
+
color: var(--customcolor-kcolorscheme-active-view-foreground-link);
31
31
+
text-decoration: underline;
32
32
+
}
33
33
+
a:visited {
34
34
+
color: var(--customcolor-kcolorscheme-active-view-foreground-visited);
35
35
+
}
···
1
1
+
import { useState } from "react";
2
2
+
import { ApplicationWindow, ApplicationHeader, Page, ColumnLayout, RowLayout, TextArea, Text, Button } from "./lib/index.js";
3
3
+
import "./App.css";
4
4
+
5
5
+
function mdToHtml(src: string): string {
6
6
+
return src
7
7
+
.split(/\n{2,}/)
8
8
+
.map(block => {
9
9
+
const trimmed = block.trim();
10
10
+
if (!trimmed) return "";
11
11
+
const escaped = trimmed
12
12
+
.replace(/&/g, "&")
13
13
+
.replace(/</g, "<")
14
14
+
.replace(/>/g, ">");
15
15
+
const formatted = escaped
16
16
+
.replace(/```(\w*)\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>")
17
17
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
18
18
+
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
19
19
+
.replace(/\*([^*]+)\*/g, "<i>$1</i>")
20
20
+
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
21
21
+
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
22
22
+
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
23
23
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
24
24
+
.replace(/\n/g, "<br>")
25
25
+
.replace(/<\/(h[123])><br>/g, "</$1>");
26
26
+
return formatted.startsWith("<h") || formatted.startsWith("<pre") ? formatted : `<p>${formatted}</p>`;
27
27
+
})
28
28
+
.join("\n");
29
29
+
}
30
30
+
31
31
+
export function App() {
32
32
+
const [source, setSource] = useState("");
33
33
+
const [output, setOutput] = useState("");
34
34
+
35
35
+
const handleFormat = () => {
36
36
+
const html = mdToHtml(source);
37
37
+
setOutput(html);
38
38
+
};
39
39
+
40
40
+
const handleClear = () => {
41
41
+
setSource("");
42
42
+
setOutput("");
43
43
+
};
44
44
+
45
45
+
return (
46
46
+
<ApplicationWindow>
47
47
+
<ApplicationHeader title="Markdown Viewer React" />
48
48
+
49
49
+
<Page>
50
50
+
<ColumnLayout style={{ padding: "18px", gap: "6px" }}>
51
51
+
<TextArea
52
52
+
value={source}
53
53
+
onChange={setSource}
54
54
+
debug
55
55
+
placeholder="Write some Markdown code here"
56
56
+
/>
57
57
+
58
58
+
<RowLayout style={{ gap: "6px" }}>
59
59
+
<Button onClick={handleFormat} debug>Format</Button>
60
60
+
<Button onClick={handleClear}>Clear</Button>
61
61
+
</RowLayout>
62
62
+
63
63
+
<Text
64
64
+
richText
65
65
+
wrap
66
66
+
text={output || "<p style='color:#898b8c'>Formatted output will appear here</p>"}
67
67
+
/>
68
68
+
</ColumnLayout>
69
69
+
</Page>
70
70
+
</ApplicationWindow>
71
71
+
);
72
72
+
}
···
1
1
+
import type { ReactNode } from 'react';
2
2
+
import { useUnion } from '@union-web/react-plugin';
3
3
+
import { styleGroupToReact } from '../utils.js';
4
4
+
5
5
+
type Props = {
6
6
+
children?: ReactNode;
7
7
+
};
8
8
+
9
9
+
export function ApplicationWindow({ children }: Props) {
10
10
+
const { current, UnionProvider } = useUnion({ role: 'applicationwindow', context: null });
11
11
+
const style = styleGroupToReact(current);
12
12
+
13
13
+
return (
14
14
+
<UnionProvider>
15
15
+
<div
16
16
+
data-ctrl="applicationwindow"
17
17
+
style={{
18
18
+
display: 'flex',
19
19
+
flexDirection: 'column',
20
20
+
width: '900px',
21
21
+
minHeight: '600px',
22
22
+
overflow: 'hidden',
23
23
+
...style,
24
24
+
}}
25
25
+
>
26
26
+
{children}
27
27
+
</div>
28
28
+
</UnionProvider>
29
29
+
);
30
30
+
}
···
1
1
+
import { useState, type ReactNode, type MouseEvent } from 'react';
2
2
+
import { useUnion } from '@union-web/react-plugin';
3
3
+
import { styleGroupToReact } from '../utils.js';
4
4
+
5
5
+
type Props = {
6
6
+
children?: ReactNode;
7
7
+
disabled?: boolean;
8
8
+
debug?: boolean;
9
9
+
onClick?: (e: MouseEvent) => void;
10
10
+
};
11
11
+
12
12
+
export function Button({ children, disabled, onClick, debug }: Props) {
13
13
+
const [hovered, setHovered] = useState(false);
14
14
+
const [pressed, setPressed] = useState(false);
15
15
+
16
16
+
const states: string[] = [];
17
17
+
if (hovered) states.push('hovered');
18
18
+
if (pressed) states.push('pressed');
19
19
+
20
20
+
const { current, UnionProvider } = useUnion({ role: 'button', state: states, context: null, debug });
21
21
+
const style = styleGroupToReact(current);
22
22
+
23
23
+
return (
24
24
+
<UnionProvider>
25
25
+
<div
26
26
+
data-ctrl="button"
27
27
+
data-state={states.length > 0 ? states.join(' ') : undefined}
28
28
+
style={{
29
29
+
display: 'flex',
30
30
+
alignItems: 'center',
31
31
+
justifyContent: 'center',
32
32
+
userSelect: 'none',
33
33
+
...style,
34
34
+
}}
35
35
+
onPointerEnter={() => setHovered(true)}
36
36
+
onPointerLeave={() => { setHovered(false); setPressed(false); }}
37
37
+
onPointerDown={() => setPressed(true)}
38
38
+
onPointerUp={() => setPressed(false)}
39
39
+
onClick={disabled ? undefined : onClick}
40
40
+
>
41
41
+
{children}
42
42
+
</div>
43
43
+
</UnionProvider>
44
44
+
);
45
45
+
}
···
1
1
+
import type { CSSProperties, ReactNode } from 'react';
2
2
+
import { useUnion } from '@union-web/react-plugin';
3
3
+
import { styleGroupToReact } from '../utils.js';
4
4
+
5
5
+
type Props = {
6
6
+
children?: ReactNode;
7
7
+
style?: CSSProperties;
8
8
+
};
9
9
+
10
10
+
export function ColumnLayout({ children, style }: Props) {
11
11
+
const { current, UnionProvider } = useUnion({ role: 'columnlayout', context: null });
12
12
+
const s = styleGroupToReact(current);
13
13
+
14
14
+
return (
15
15
+
<UnionProvider>
16
16
+
<div
17
17
+
style={{
18
18
+
display: 'flex',
19
19
+
flexDirection: 'column',
20
20
+
...s,
21
21
+
...style,
22
22
+
}}
23
23
+
>
24
24
+
{children}
25
25
+
</div>
26
26
+
</UnionProvider>
27
27
+
);
28
28
+
}
···
1
1
+
import type { ReactNode } from 'react';
2
2
+
import { useUnion } from '@union-web/react-plugin';
3
3
+
import { styleGroupToReact } from '../utils.js';
4
4
+
5
5
+
type Props = {
6
6
+
children?: ReactNode;
7
7
+
};
8
8
+
9
9
+
export function Page({ children }: Props) {
10
10
+
const { current, UnionProvider } = useUnion({ role: 'page', context: null });
11
11
+
const style = styleGroupToReact(current);
12
12
+
13
13
+
return (
14
14
+
<UnionProvider>
15
15
+
<div data-ctrl="page" style={style}>
16
16
+
{children}
17
17
+
</div>
18
18
+
</UnionProvider>
19
19
+
);
20
20
+
}
···
1
1
+
import type { CSSProperties, ReactNode } from 'react';
2
2
+
import { useUnion } from '@union-web/react-plugin';
3
3
+
import { styleGroupToReact } from '../utils.js';
4
4
+
5
5
+
type Props = {
6
6
+
children?: ReactNode;
7
7
+
style?: CSSProperties;
8
8
+
};
9
9
+
10
10
+
export function RowLayout({ children, style }: Props) {
11
11
+
const { current, UnionProvider } = useUnion({ role: 'rowlayout', context: null });
12
12
+
const s = styleGroupToReact(current);
13
13
+
14
14
+
return (
15
15
+
<UnionProvider>
16
16
+
<div
17
17
+
style={{
18
18
+
display: 'flex',
19
19
+
flexDirection: 'row',
20
20
+
...s,
21
21
+
...style,
22
22
+
}}
23
23
+
>
24
24
+
{children}
25
25
+
</div>
26
26
+
</UnionProvider>
27
27
+
);
28
28
+
}
···
1
1
+
import type { CSSProperties, ReactNode } from 'react';
2
2
+
import { useUnion } from '@union-web/react-plugin';
3
3
+
import { styleGroupToReact } from '../utils.js';
4
4
+
5
5
+
type Props = {
6
6
+
children?: ReactNode;
7
7
+
text?: string;
8
8
+
richText?: boolean;
9
9
+
wrap?: boolean;
10
10
+
debug?: boolean;
11
11
+
style?: CSSProperties;
12
12
+
};
13
13
+
14
14
+
export function Text({ children, text, richText, wrap, debug, style }: Props) {
15
15
+
const { current } = useUnion({ role: 'text', debug });
16
16
+
const s = styleGroupToReact(current);
17
17
+
18
18
+
if (richText) {
19
19
+
return (
20
20
+
<div
21
21
+
dangerouslySetInnerHTML={{ __html: text ?? '' }}
22
22
+
style={{
23
23
+
whiteSpace: wrap === false ? 'nowrap' : 'pre-wrap',
24
24
+
wordBreak: 'break-word',
25
25
+
...s,
26
26
+
...style,
27
27
+
}}
28
28
+
/>
29
29
+
);
30
30
+
}
31
31
+
32
32
+
return (
33
33
+
<div
34
34
+
style={{
35
35
+
whiteSpace: wrap === false ? 'nowrap' : 'pre-wrap',
36
36
+
wordBreak: 'break-word',
37
37
+
...s,
38
38
+
...style,
39
39
+
}}
40
40
+
>
41
41
+
{text ?? children}
42
42
+
</div>
43
43
+
);
44
44
+
}
···
1
1
+
import { useState, type CSSProperties } from 'react';
2
2
+
import { useUnion } from '@union-web/react-plugin';
3
3
+
import { styleGroupToReact } from '../utils.js';
4
4
+
5
5
+
type Props = {
6
6
+
value?: string;
7
7
+
placeholder?: string;
8
8
+
disabled?: boolean;
9
9
+
debug?: boolean;
10
10
+
onInput?: (value: string) => void;
11
11
+
onChange?: (value: string) => void;
12
12
+
style?: CSSProperties;
13
13
+
};
14
14
+
15
15
+
export function TextArea({ value, placeholder, disabled, debug, onInput, onChange, style }: Props) {
16
16
+
const [hovered, setHovered] = useState(false);
17
17
+
const [focused, setFocused] = useState(false);
18
18
+
19
19
+
const states: string[] = [];
20
20
+
if (hovered) states.push('hovered');
21
21
+
if (focused) states.push('visual-focus');
22
22
+
23
23
+
const { current } = useUnion({ role: 'textarea', state: states, debug });
24
24
+
const s = styleGroupToReact(current);
25
25
+
26
26
+
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
27
27
+
const target = e.target;
28
28
+
target.style.height = 'auto';
29
29
+
target.style.height = `${target.scrollHeight}px`;
30
30
+
const val = target.value;
31
31
+
onChange?.(val);
32
32
+
onInput?.(val);
33
33
+
};
34
34
+
35
35
+
return (
36
36
+
<textarea
37
37
+
data-ctrl="textarea"
38
38
+
data-state={states.length > 0 ? states.join(' ') : undefined}
39
39
+
value={value ?? ''}
40
40
+
placeholder={placeholder}
41
41
+
disabled={disabled}
42
42
+
onPointerEnter={() => setHovered(true)}
43
43
+
onPointerLeave={() => setHovered(false)}
44
44
+
onFocus={() => setFocused(true)}
45
45
+
onBlur={() => setFocused(false)}
46
46
+
onChange={handleChange}
47
47
+
style={{
48
48
+
display: 'block',
49
49
+
fontFamily: 'inherit',
50
50
+
fontSize: 'inherit',
51
51
+
minHeight: '140px',
52
52
+
resize: 'vertical',
53
53
+
...s,
54
54
+
...style,
55
55
+
}}
56
56
+
/>
57
57
+
);
58
58
+
}
···
1
1
+
export { ApplicationHeader } from './components/ApplicationHeader.js';
2
2
+
export { ApplicationWindow } from './components/ApplicationWindow.js';
3
3
+
export { Button } from './components/Button.js';
4
4
+
export { ColumnLayout } from './components/ColumnLayout.js';
5
5
+
export { Page } from './components/Page.js';
6
6
+
export { RowLayout } from './components/RowLayout.js';
7
7
+
export { Text } from './components/Text.js';
8
8
+
export { TextArea } from './components/TextArea.js';
···
1
1
+
import { styleGroupToCss } from '@union-web/core';
2
2
+
import type { StylePropertyGroup } from '@union-web/core';
3
3
+
import type { CSSProperties } from 'react';
4
4
+
5
5
+
const VENDOR_PREFIX_RE = /^(-webkit-|-moz-|-ms-|-o-)/;
6
6
+
7
7
+
function cssPropToReact(prop: string): string {
8
8
+
const match = prop.match(VENDOR_PREFIX_RE);
9
9
+
if (match) {
10
10
+
const prefix = match[1];
11
11
+
const rest = prop.slice(prefix.length);
12
12
+
const camel = rest.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
13
13
+
return prefix[1].toUpperCase() + prefix.slice(2) + camel;
14
14
+
}
15
15
+
return prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
16
16
+
}
17
17
+
18
18
+
export function styleGroupToReact(sgp: StylePropertyGroup): CSSProperties {
19
19
+
const decls = styleGroupToCss(sgp, { useMinSize: true });
20
20
+
const style: Record<string, string> = {};
21
21
+
for (const { property, value } of decls) {
22
22
+
style[cssPropToReact(property)] = value;
23
23
+
}
24
24
+
return style as CSSProperties;
25
25
+
}
···
1
1
+
import { createRoot } from "react-dom/client";
2
2
+
import { UnionIRProvider } from "@union-web/react-plugin";
3
3
+
import { rules } from "virtual:union-ir";
4
4
+
import { App } from "./App";
5
5
+
6
6
+
const root = document.getElementById("root");
7
7
+
if (root) {
8
8
+
createRoot(root).render(
9
9
+
<UnionIRProvider rules={rules}>
10
10
+
<App />
11
11
+
</UnionIRProvider>,
12
12
+
);
13
13
+
}
···
1
1
+
/// <reference types="vite/client" />
2
2
+
3
3
+
declare module 'virtual:union-ir' {
4
4
+
import type { StyleRule } from '@union-web/core';
5
5
+
export const rules: StyleRule[];
6
6
+
export const variables: Record<string, string>;
7
7
+
}
···
1
1
+
{
2
2
+
"compilerOptions": {
3
3
+
"jsx": "react-jsx",
4
4
+
"target": "ES2022",
5
5
+
"module": "ESNext",
6
6
+
"moduleResolution": "bundler",
7
7
+
"strict": true,
8
8
+
"esModuleInterop": true,
9
9
+
"skipLibCheck": true,
10
10
+
"noEmit": true
11
11
+
},
12
12
+
"include": ["./**/*"]
13
13
+
}
···
1
1
+
import { defineConfig } from "vite";
2
2
+
import react from "@vitejs/plugin-react";
3
3
+
import { unionIR } from "@union-web/react-plugin/vite";
4
4
+
import { unionLint } from "@union-web/react-plugin";
5
5
+
import { fileURLToPath } from "url";
6
6
+
import path from "path";
7
7
+
8
8
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
+
10
10
+
export default defineConfig({
11
11
+
optimizeDeps: {
12
12
+
include: [],
13
13
+
},
14
14
+
plugins: [
15
15
+
unionIR({
16
16
+
theme: path.resolve(__dirname, "../../input/themes/breeze/style.css"),
17
17
+
colors: path.resolve(__dirname, "../../input/colors/BreezeDark.colors"),
18
18
+
defaults: path.resolve(__dirname, "../../input/defaults/default.css"),
19
19
+
}),
20
20
+
react(),
21
21
+
],
22
22
+
});
···
2
2
"name": "@union-web/react-plugin",
3
3
"version": "0.1.0",
4
4
"type": "module",
5
5
-
"main": "src/index.ts",
6
6
-
"types": "src/index.ts",
5
5
+
"main": "dist/index.js",
6
6
+
"types": "dist/index.d.ts",
7
7
"exports": {
8
8
".": {
9
9
-
"types": "./src/index.ts",
10
10
-
"default": "./src/index.ts"
9
9
+
"types": "./dist/index.d.ts",
10
10
+
"default": "./dist/index.js"
11
11
+
},
12
12
+
"./vite": {
13
13
+
"types": "./dist/vite-plugin-ir.d.ts",
14
14
+
"default": "./dist/vite-plugin-ir.js"
11
15
}
12
16
},
17
17
+
"scripts": {
18
18
+
"build": "tsc"
19
19
+
},
13
20
"dependencies": {
14
21
"@union-web/core": "*",
15
22
"react": "^19.0.0"
16
23
},
17
24
"devDependencies": {
25
25
+
"@types/node": "^22.0.0",
18
26
"@types/react": "^19.0.0",
19
27
"typescript": "^5.5.0",
20
28
"vite": "^6.0.0"
···
7
7
8
8
export const UnionIRContext = createContext<UnionIR>({ rules: [] });
9
9
10
10
+
export interface AncestorElementData {
11
11
+
role: string;
12
12
+
hints?: string[];
13
13
+
attributes?: Record<string, string>;
14
14
+
ids?: string[];
15
15
+
}
16
16
+
10
17
export interface UnionAncestorData {
11
11
-
roles: string[];
18
18
+
elements: AncestorElementData[];
12
19
}
13
20
14
14
-
export const UnionAncestorContext = createContext<UnionAncestorData>({ roles: [] });
21
21
+
export const UnionAncestorContext = createContext<UnionAncestorData>({ elements: [] });
···
1
1
-
export { useUnion } from './useUnion.jsx';
2
2
-
export type { UseUnionOptions, UseUnionResult } from './useUnion.jsx';
1
1
+
export { useUnion } from './useUnion.js';
2
2
+
export type { UseUnionOptions, UseUnionResult } from './useUnion.js';
3
3
export { UnionIRProvider } from './UnionIRProvider.js';
4
4
export type { UnionIRProviderProps } from './UnionIRProvider.js';
5
5
export { UnionIRContext, UnionAncestorContext } from './context.js';
6
6
-
export type { UnionIR, UnionAncestorData } from './context.js';
6
6
+
export type { UnionIR, UnionAncestorData, AncestorElementData } from './context.js';
7
7
export { unionLint } from './vite-plugin.js';
8
8
+
export { unionIR } from './vite-plugin-ir.js';
9
9
+
export type { UnionIROptions } from './vite-plugin-ir.js';
···
1
1
-
import { useContext, useMemo } from 'react';
2
2
-
import { createElement, resolveElement } from '@union-web/core';
1
1
+
import { useContext, useMemo, useEffect } from 'react';
2
2
+
import { createElement, resolveElement, matchRules } from '@union-web/core';
3
3
import type { StylePropertyGroup } from '@union-web/core';
4
4
import { UnionIRContext, UnionAncestorContext } from './context.js';
5
5
+
import type { AncestorElementData } from './context.js';
5
6
6
7
const DEFAULT_STATES = [
7
8
'hovered',
···
32
33
role: string;
33
34
context?: string[] | null;
34
35
state?: string[];
36
36
+
hints?: string[];
37
37
+
attributes?: Record<string, string>;
38
38
+
ids?: string[];
39
39
+
debug?: boolean;
35
40
}
36
41
37
42
export interface UseUnionResult {
···
40
45
UnionProvider: ({ children }: { children: React.ReactNode }) => React.ReactElement;
41
46
}
42
47
48
48
+
function formatGroup(g: StylePropertyGroup): Record<string, unknown> {
49
49
+
const out: Record<string, unknown> = {};
50
50
+
for (const [key, val] of Object.entries(g)) {
51
51
+
if (val !== undefined) {
52
52
+
const obj = val as Record<string, unknown>;
53
53
+
const defined: Record<string, unknown> = {};
54
54
+
for (const [k, v] of Object.entries(obj)) {
55
55
+
if (v !== undefined) defined[k] = v;
56
56
+
}
57
57
+
if (Object.keys(defined).length > 0) out[key] = defined;
58
58
+
}
59
59
+
}
60
60
+
return out;
61
61
+
}
62
62
+
63
63
+
function logMatchResult(
64
64
+
role: string,
65
65
+
state: string[],
66
66
+
ancestors: string[],
67
67
+
matches: { selector: string; specificity: string; declarations: Record<string, string> }[],
68
68
+
result: StylePropertyGroup,
69
69
+
) {
70
70
+
const stateLabel = state.length === 0 ? 'base' : state.join('+');
71
71
+
console.groupCollapsed(
72
72
+
`%c[useUnion] %c${role}%c (${stateLabel}) %cancestors: [${ancestors.join(', ')}]`,
73
73
+
'color:#888',
74
74
+
'color:#0ae;font-weight:bold',
75
75
+
'color:#888',
76
76
+
'color:#666',
77
77
+
);
78
78
+
if (matches.length === 0) {
79
79
+
console.log('%cno matching rules', 'color:#f80');
80
80
+
}
81
81
+
for (const m of matches) {
82
82
+
console.groupCollapsed(
83
83
+
`%c✓ %c${m.selector} %c(spec: ${m.specificity})`,
84
84
+
'color:#0c0',
85
85
+
'color:#fff;font-weight:bold',
86
86
+
'color:#888',
87
87
+
);
88
88
+
console.table(m.declarations);
89
89
+
console.groupEnd();
90
90
+
}
91
91
+
console.log('%cresult:', 'color:#08f;font-weight:bold', formatGroup(result));
92
92
+
console.groupEnd();
93
93
+
}
94
94
+
43
95
export function useUnion(options: UseUnionOptions): UseUnionResult {
44
96
const ir = useContext(UnionIRContext);
45
97
const ancestorData = useContext(UnionAncestorContext);
46
98
47
47
-
const context = options.context !== undefined ? options.context : ancestorData.roles;
48
48
-
const effectiveContext = context === null ? [] : context;
99
99
+
const effectiveAncestors: AncestorElementData[] = useMemo(() => {
100
100
+
if (options.context !== undefined) {
101
101
+
if (options.context === null) return [];
102
102
+
return options.context.map(role => ({ role }));
103
103
+
}
104
104
+
return ancestorData.elements;
105
105
+
}, [options.context, ancestorData.elements]);
49
106
50
107
const ancestorElements = useMemo(
51
51
-
() => effectiveContext.map(role => createElement(role)),
108
108
+
() =>
109
109
+
effectiveAncestors.map(a =>
110
110
+
createElement(a.role, { hints: a.hints, attributes: a.attributes, ids: a.ids }),
111
111
+
),
52
112
// eslint-disable-next-line react-hooks/exhaustive-deps
53
53
-
[effectiveContext.join(',')],
113
113
+
[JSON.stringify(effectiveAncestors)],
54
114
);
55
115
56
116
const styles = useMemo(() => {
···
58
118
const combos = getStateCombinations(DEFAULT_STATES);
59
119
for (const combo of combos) {
60
120
const key = combo.length === 0 ? 'base' : combo.join('+');
61
61
-
const el = createElement(options.role, { states: combo });
121
121
+
const el = createElement(options.role, {
122
122
+
states: combo,
123
123
+
hints: options.hints,
124
124
+
attributes: options.attributes,
125
125
+
ids: options.ids,
126
126
+
});
62
127
const ancestorEls = ancestorElements.map(a => ({ ...a }));
63
128
resolved[key] = resolveElement(ir.rules, el, ancestorEls);
64
129
}
65
130
return resolved;
66
66
-
}, [options.role, ir.rules, ancestorElements]);
131
131
+
}, [options.role, options.hints, options.attributes, options.ids, ir.rules, ancestorElements]);
67
132
68
133
const currentState = options.state ?? [];
69
134
const currentKey = currentState.length === 0 ? 'base' : currentState.join('+');
···
73
138
if (exactMatch) return exactMatch;
74
139
75
140
/* Unknown state combo — resolve on the fly */
76
76
-
const el = createElement(options.role, { states: currentState });
141
141
+
const el = createElement(options.role, {
142
142
+
states: currentState,
143
143
+
hints: options.hints,
144
144
+
attributes: options.attributes,
145
145
+
ids: options.ids,
146
146
+
});
77
147
const ancestorEls = ancestorElements.map(a => ({ ...a }));
78
148
return resolveElement(ir.rules, el, ancestorEls);
79
79
-
}, [styles, currentKey, options.role, ir.rules, ancestorElements, currentState]);
149
149
+
}, [styles, currentKey, options.role, options.hints, options.attributes, options.ids, ir.rules, ancestorElements, currentState]);
80
150
81
151
const UnionProvider = useMemo(() => {
82
82
-
const nextRoles = context === null ? [options.role] : [...effectiveContext, options.role];
83
83
-
const nextData = { roles: nextRoles };
152
152
+
const currentAncestor: AncestorElementData = {
153
153
+
role: options.role,
154
154
+
hints: options.hints,
155
155
+
attributes: options.attributes,
156
156
+
ids: options.ids,
157
157
+
};
158
158
+
const nextElements =
159
159
+
options.context === null
160
160
+
? [currentAncestor]
161
161
+
: [...effectiveAncestors, currentAncestor];
162
162
+
const nextData = { elements: nextElements };
84
163
function Provider({ children }: { children: React.ReactNode }) {
85
164
return (
86
165
<UnionAncestorContext.Provider value={nextData}>
···
90
169
}
91
170
Provider.displayName = `UnionProvider(${options.role})`;
92
171
return Provider;
93
93
-
}, [options.role, effectiveContext, context]);
172
172
+
}, [options.role, options.hints, options.attributes, options.ids, effectiveAncestors, options.context]);
173
173
+
174
174
+
useEffect(() => {
175
175
+
if (!options.debug) return;
176
176
+
const el = createElement(options.role, {
177
177
+
states: currentState,
178
178
+
hints: options.hints,
179
179
+
attributes: options.attributes,
180
180
+
ids: options.ids,
181
181
+
});
182
182
+
const ancestorEls = ancestorElements.map(a => ({ ...a }));
183
183
+
const matches = matchRules(ir.rules, el, ancestorEls);
184
184
+
const seen = new Set<unknown>();
185
185
+
const uniqueMatches: { rule: typeof matches[number]['rule']; selector: typeof matches[number]['selector'] }[] = [];
186
186
+
for (const m of matches) {
187
187
+
if (!seen.has(m.rule)) {
188
188
+
seen.add(m.rule);
189
189
+
uniqueMatches.push(m);
190
190
+
}
191
191
+
}
192
192
+
193
193
+
function atomToString(a: (typeof matches)[number]['selector']['atoms'][number]): string {
194
194
+
switch (a.type) {
195
195
+
case 'type': return a.name;
196
196
+
case 'id': return `#${a.name}`;
197
197
+
case 'state': return `:${a.name}`;
198
198
+
case 'hint': return `[${a.name}]`;
199
199
+
case 'any': return '*';
200
200
+
case 'attribute': return a.operator ? `[${a.name}${a.operator}${a.value ?? ''}]` : `[${a.name}]`;
201
201
+
case 'pseudo-element': return `::${a.name}`;
202
202
+
case 'child-combinator': return '>';
203
203
+
case 'descendant-combinator': return ' ';
204
204
+
}
205
205
+
}
206
206
+
207
207
+
logMatchResult(
208
208
+
options.role,
209
209
+
currentState,
210
210
+
effectiveAncestors.map(a => a.role),
211
211
+
uniqueMatches.map(m => ({
212
212
+
selector: m.selector.atoms.map(atomToString).join(' '),
213
213
+
specificity: `${m.selector.specificity.a}.${m.selector.specificity.b}.${m.selector.specificity.c}`,
214
214
+
declarations: m.rule.declarations,
215
215
+
})),
216
216
+
resolvedCurrent,
217
217
+
);
218
218
+
});
94
219
95
220
return { styles, current: resolvedCurrent, UnionProvider };
96
221
}
···
1
1
+
import type { Plugin, ResolvedConfig } from 'vite';
2
2
+
import {
3
3
+
parseStylesheet, parseIni, KColorSchemeResolver, VariableResolver,
4
4
+
generateCssVariables,
5
5
+
} from '@union-web/core';
6
6
+
import type { StyleRule } from '@union-web/core';
7
7
+
import * as fs from 'node:fs';
8
8
+
9
9
+
export interface UnionIROptions {
10
10
+
theme: string;
11
11
+
colors: string;
12
12
+
defaults: string;
13
13
+
}
14
14
+
15
15
+
const VIRTUAL_MODULE_ID = 'virtual:union-ir';
16
16
+
const RESOLVED_PREFIX = '\0';
17
17
+
18
18
+
export function unionIR(options: UnionIROptions): Plugin {
19
19
+
let rules: StyleRule[] = [];
20
20
+
let resolvedVars: Record<string, string> = {};
21
21
+
let cssVarsBlock = '';
22
22
+
let config: ResolvedConfig;
23
23
+
24
24
+
function resolveVars(value: string, vars: Record<string, string>): string {
25
25
+
let resolved = value;
26
26
+
let prev: string;
27
27
+
do {
28
28
+
prev = resolved;
29
29
+
resolved = resolved.replace(/var\((--[^)]+)\)/g, (match, name) => vars[name] ?? match);
30
30
+
} while (resolved !== prev);
31
31
+
return resolved;
32
32
+
}
33
33
+
34
34
+
function loadRules() {
35
35
+
const defs = parseStylesheet(options.defaults);
36
36
+
const theme = parseStylesheet(options.theme);
37
37
+
rules = [...defs.rules, ...theme.rules];
38
38
+
39
39
+
const colorsText = fs.readFileSync(options.colors, 'utf-8');
40
40
+
const colorsData = parseIni(colorsText);
41
41
+
const colorResolver = new KColorSchemeResolver({ data: colorsData });
42
42
+
43
43
+
const allVars = { ...defs.variables, ...theme.variables };
44
44
+
const varResolver = new VariableResolver(allVars, colorResolver);
45
45
+
resolvedVars = varResolver.resolveAll();
46
46
+
47
47
+
/* Resolve var() references in declarations so core parsers get concrete values */
48
48
+
for (const rule of rules) {
49
49
+
rule.declarations = Object.fromEntries(
50
50
+
Object.entries(rule.declarations).map(([k, v]) => [k, resolveVars(v, resolvedVars)]),
51
51
+
);
52
52
+
}
53
53
+
54
54
+
const kcsVars = generateCssVariables(colorResolver);
55
55
+
const allCssVars = { ...resolvedVars };
56
56
+
for (const v of kcsVars) {
57
57
+
allCssVars[v.name] = v.value;
58
58
+
}
59
59
+
60
60
+
cssVarsBlock = `:root {\n${Object.entries(allCssVars).map(([k, v]) => ` ${k}: ${v};`).join('\n')}\n}`;
61
61
+
}
62
62
+
63
63
+
return {
64
64
+
name: '@union-web/react-plugin/ir',
65
65
+
enforce: 'pre',
66
66
+
67
67
+
configResolved(resolvedConfig) {
68
68
+
config = resolvedConfig;
69
69
+
},
70
70
+
71
71
+
buildStart() {
72
72
+
loadRules();
73
73
+
},
74
74
+
75
75
+
resolveId(id) {
76
76
+
if (id === VIRTUAL_MODULE_ID) return RESOLVED_PREFIX + VIRTUAL_MODULE_ID;
77
77
+
},
78
78
+
79
79
+
load(id) {
80
80
+
if (id !== RESOLVED_PREFIX + VIRTUAL_MODULE_ID) return;
81
81
+
return `export const rules = ${JSON.stringify(rules)};
82
82
+
export const variables = ${JSON.stringify(resolvedVars)};`;
83
83
+
},
84
84
+
85
85
+
transformIndexHtml() {
86
86
+
return [
87
87
+
{
88
88
+
tag: 'style',
89
89
+
injectTo: 'head-prepend',
90
90
+
children: cssVarsBlock,
91
91
+
},
92
92
+
];
93
93
+
},
94
94
+
95
95
+
configureServer(server) {
96
96
+
const { theme, colors, defaults } = options;
97
97
+
const watched = [theme, colors, defaults];
98
98
+
for (const file of watched) {
99
99
+
if (server.watcher.add) {
100
100
+
server.watcher.add(file);
101
101
+
}
102
102
+
}
103
103
+
104
104
+
server.watcher.on('change', (path: string) => {
105
105
+
if (watched.includes(path) || watched.some(w => path.startsWith(w.substring(0, w.lastIndexOf('/'))))) {
106
106
+
loadRules();
107
107
+
server.ws.send({ type: 'full-reload' });
108
108
+
}
109
109
+
});
110
110
+
},
111
111
+
};
112
112
+
}
···
2
2
"extends": "../../tsconfig.json",
3
3
"compilerOptions": {
4
4
"jsx": "react-jsx",
5
5
-
"noEmit": true
5
5
+
"outDir": "dist",
6
6
+
"declaration": true,
7
7
+
"declarationMap": true,
8
8
+
"sourceMap": true
6
9
},
7
10
"include": ["src/**/*"]
8
11
}