ripple 0.3.125 → 0.3.126
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +45 -0
- package/package.json +3 -3
- package/src/runtime/internal/client/render.js +3 -2
- package/src/runtime/internal/server/index.js +6 -1
- package/tests/client/basic/basic.styling.test.tsrx +3 -3
- package/tests/client/css/scoped-styles.test.tsrx +652 -0
- package/tests/client/dynamic-elements.test.tsrx +5 -2
- package/tests/fixtures/scoped-styles/theme.tsrx +20 -0
- package/tests/server/dynamic-elements.test.tsrx +5 -2
- package/tests/server/scoped-styles.test.tsrx +299 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
1
|
# ripple
|
|
2
2
|
|
|
3
|
+
## 0.3.126
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#1441](https://github.com/Ripple-TS/ripple/pull/1441)
|
|
8
|
+
[`2404ad4`](https://github.com/Ripple-TS/ripple/commit/2404ad4a4fcca21a1887bab25bb94671698f0c9d)
|
|
9
|
+
Thanks [@leonidaz](https://github.com/leonidaz)! - Implement sibling-scoped
|
|
10
|
+
`<style>` blocks, `$class`, and `apply`
|
|
11
|
+
([TSRX RFC #1](https://github.com/tsrx-org/RFCs/discussions/1)) for the Ripple
|
|
12
|
+
target.
|
|
13
|
+
|
|
14
|
+
- A `<style>` block is scoped to its siblings: it styles the items beside it and
|
|
15
|
+
everything below them, never the element that contains it. Blocks among the
|
|
16
|
+
same children share one hash and one stylesheet; nested children lists that
|
|
17
|
+
hold blocks are nested scopes, and every element carries the hash of each
|
|
18
|
+
enclosing scope, outer first. Control-flow branches and templates assigned to
|
|
19
|
+
variables host scopes too.
|
|
20
|
+
- Assigning a block to a variable exposes `$class`. Exported, applied, or
|
|
21
|
+
`$class`-read blocks are themes that keep every selector; other assigned
|
|
22
|
+
blocks stay class maps. `<style apply={theme} />` attaches a theme to a whole
|
|
23
|
+
scope, `<style apply={theme}>…</style>` applies and declares in one tag,
|
|
24
|
+
arrays apply several themes, and a theme may apply other themes. Same-module
|
|
25
|
+
themes inline as literals; imported themes are read through `theme.$class` at
|
|
26
|
+
runtime.
|
|
27
|
+
- CSS is emitted in lexical order (an applied theme before the block that
|
|
28
|
+
applies it, a scope's blocks together, nested scopes after their parent). On
|
|
29
|
+
the server a render registers the sheets it needs in that order, class-map
|
|
30
|
+
reads register their own sheet, and reading `theme.$class` outside a render no
|
|
31
|
+
longer throws.
|
|
32
|
+
- The style diagnostics of the RFC (`tsrx-style-*` codes) are reported, and
|
|
33
|
+
type-only output verifies `apply` targets through a `$class` read.
|
|
34
|
+
- Fixed: a `@{ … }` block rendered as the child of a DOM element on the client
|
|
35
|
+
was dropped; it now renders in place. The `#class` spread attribute accepts
|
|
36
|
+
several class tokens.
|
|
37
|
+
|
|
38
|
+
Raw CSS in a `<style>` inside a plain function that returns JSX is now an error
|
|
39
|
+
(`tsrx-style-standalone-outside-template`): use a `@{ … }` body, or
|
|
40
|
+
`<style>{css}</style>`. Elements are stamped with the scope class after their
|
|
41
|
+
authored classes, and every element of a scope carries it, not only those a
|
|
42
|
+
selector matches.
|
|
43
|
+
|
|
44
|
+
- Updated dependencies
|
|
45
|
+
[[`2404ad4`](https://github.com/Ripple-TS/ripple/commit/2404ad4a4fcca21a1887bab25bb94671698f0c9d)]:
|
|
46
|
+
- @tsrx/ripple@0.1.63
|
|
47
|
+
|
|
3
48
|
## 0.3.125
|
|
4
49
|
|
|
5
50
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"description": "Ripple is an elegant TypeScript UI framework",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Dominic Gannaway",
|
|
6
|
-
"version": "0.3.
|
|
6
|
+
"version": "0.3.126",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"module": "src/runtime/index-client.js",
|
|
9
9
|
"main": "src/runtime/index-client.js",
|
|
@@ -73,8 +73,8 @@
|
|
|
73
73
|
"clsx": "^2.1.1",
|
|
74
74
|
"devalue": "^5.8.1",
|
|
75
75
|
"esm-env": "^1.2.2",
|
|
76
|
-
"@tsrx/core": "^0.1.
|
|
77
|
-
"@tsrx/ripple": "0.1.
|
|
76
|
+
"@tsrx/core": "^0.1.67",
|
|
77
|
+
"@tsrx/ripple": "0.1.63"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@types/estree": "^1.0.8",
|
|
@@ -140,8 +140,9 @@ function set_attribute_helper(element, key, value, remove_listeners, prev) {
|
|
|
140
140
|
} else if (key === 'style') {
|
|
141
141
|
set_style(element, value, prev.style);
|
|
142
142
|
} else if (key === '#class') {
|
|
143
|
-
// Special case for
|
|
144
|
-
|
|
143
|
+
// Special case for the scope classes of an element that spreads props:
|
|
144
|
+
// one token per scope hash and applied theme.
|
|
145
|
+
element.classList.add(...String(value).split(' ').filter(Boolean));
|
|
145
146
|
} else if (typeof key === 'string' && is_event_attribute(key)) {
|
|
146
147
|
// Handle event handlers in spread props
|
|
147
148
|
if (remove_listeners[key]) {
|
|
@@ -1252,7 +1252,12 @@ export function set_output_target(target) {
|
|
|
1252
1252
|
* @returns {void}
|
|
1253
1253
|
*/
|
|
1254
1254
|
export function output_register_css(hash) {
|
|
1255
|
-
|
|
1255
|
+
// A style class map is read wherever its value is used, including outside
|
|
1256
|
+
// a render (a module-level `theme.$class` read); only a render has a
|
|
1257
|
+
// request to register the CSS with.
|
|
1258
|
+
if (active_block !== null) {
|
|
1259
|
+
active_block.o.register_css(hash);
|
|
1260
|
+
}
|
|
1256
1261
|
}
|
|
1257
1262
|
|
|
1258
1263
|
/**
|
|
@@ -2,8 +2,8 @@ import { compile } from '@tsrx/ripple';
|
|
|
2
2
|
|
|
3
3
|
describe('basic client > styling', () => {
|
|
4
4
|
it('renders with styling scoped to component', () => {
|
|
5
|
-
function Basic() {
|
|
6
|
-
|
|
5
|
+
function Basic() @{
|
|
6
|
+
<>
|
|
7
7
|
<div class="styled-container">
|
|
8
8
|
<h1>{'Styled heading'}</h1>
|
|
9
9
|
<p class="text">{'Styled paragraph'}</p>
|
|
@@ -24,7 +24,7 @@ describe('basic client > styling', () => {
|
|
|
24
24
|
font-size: 14px;
|
|
25
25
|
}
|
|
26
26
|
</style>
|
|
27
|
-
|
|
27
|
+
</>
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
render(Basic);
|
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
import { track, flushSync } from 'ripple';
|
|
2
|
+
import { compile } from '@tsrx/ripple';
|
|
3
|
+
import {
|
|
4
|
+
base as importedBase,
|
|
5
|
+
theme as importedTheme,
|
|
6
|
+
} from '../../fixtures/scoped-styles/theme.tsrx';
|
|
7
|
+
|
|
8
|
+
// RFC tsrx-org/RFCs#1: a `<style>` block is scoped to its siblings — it styles
|
|
9
|
+
// the items beside it and everything below them, never the element that
|
|
10
|
+
// contains it. Sibling blocks share one hash, nested children lists that hold
|
|
11
|
+
// blocks are nested scopes, assigned blocks expose `$class`, and `apply`
|
|
12
|
+
// attaches a theme to a whole scope.
|
|
13
|
+
|
|
14
|
+
function hashes(element: Element | null): string[] {
|
|
15
|
+
return Array.from(element?.classList ?? []).filter((cls) => cls.startsWith('tsrx-'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function tokens(value: string): string[] {
|
|
19
|
+
return value.split(' ').filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const one = <style>
|
|
23
|
+
div {
|
|
24
|
+
color: red;
|
|
25
|
+
}
|
|
26
|
+
.one {
|
|
27
|
+
margin: 0;
|
|
28
|
+
}
|
|
29
|
+
</style>;
|
|
30
|
+
|
|
31
|
+
const two = <style>
|
|
32
|
+
div {
|
|
33
|
+
color: blue;
|
|
34
|
+
}
|
|
35
|
+
</style>;
|
|
36
|
+
|
|
37
|
+
const localBase = <style>
|
|
38
|
+
p {
|
|
39
|
+
margin: 0;
|
|
40
|
+
}
|
|
41
|
+
</style>;
|
|
42
|
+
|
|
43
|
+
const accent = <style apply={localBase}>
|
|
44
|
+
p {
|
|
45
|
+
color: purple;
|
|
46
|
+
}
|
|
47
|
+
</style>;
|
|
48
|
+
|
|
49
|
+
const bundle = <style apply={[two, accent]} />;
|
|
50
|
+
|
|
51
|
+
describe('sibling-scoped style blocks', () => {
|
|
52
|
+
it('styles the items beside a block and everything below them, never the container', () => {
|
|
53
|
+
function Status() @{
|
|
54
|
+
let &[ready] = track(false);
|
|
55
|
+
<>
|
|
56
|
+
<button onClick={() => (ready = !ready)}>{'toggle'}</button>
|
|
57
|
+
<style>
|
|
58
|
+
.status {
|
|
59
|
+
padding: 0.5rem;
|
|
60
|
+
}
|
|
61
|
+
</style>
|
|
62
|
+
<section id="status" class="status">
|
|
63
|
+
<style>
|
|
64
|
+
.title {
|
|
65
|
+
font-weight: 700;
|
|
66
|
+
}
|
|
67
|
+
.status {
|
|
68
|
+
color: rgb(9, 9, 9);
|
|
69
|
+
}
|
|
70
|
+
</style>
|
|
71
|
+
<h2 id="title" class="title">{'Status'}</h2>
|
|
72
|
+
@if (ready) {
|
|
73
|
+
<>
|
|
74
|
+
<style>
|
|
75
|
+
.ok {
|
|
76
|
+
color: green;
|
|
77
|
+
}
|
|
78
|
+
</style>
|
|
79
|
+
<p id="ok" class="ok">{'Ready'}</p>
|
|
80
|
+
</>
|
|
81
|
+
} @else {
|
|
82
|
+
<>
|
|
83
|
+
<style>
|
|
84
|
+
.wait {
|
|
85
|
+
color: gray;
|
|
86
|
+
}
|
|
87
|
+
</style>
|
|
88
|
+
<p id="wait" class="wait">{'Waiting'}</p>
|
|
89
|
+
</>
|
|
90
|
+
}
|
|
91
|
+
</section>
|
|
92
|
+
</>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
render(Status);
|
|
96
|
+
|
|
97
|
+
const section = container.querySelector('#status');
|
|
98
|
+
const title = container.querySelector('#title');
|
|
99
|
+
const wait = container.querySelector('#wait');
|
|
100
|
+
|
|
101
|
+
const [A] = hashes(section);
|
|
102
|
+
expect(A).toBeTruthy();
|
|
103
|
+
// The section carries only the fragment's scope: the block written
|
|
104
|
+
// inside it styles its children, not the section. The button is a
|
|
105
|
+
// sibling of the block too.
|
|
106
|
+
expect(hashes(section)).toEqual([A]);
|
|
107
|
+
expect(hashes(container.querySelector('button'))).toEqual([A]);
|
|
108
|
+
const B = hashes(title)[1];
|
|
109
|
+
expect(hashes(title)).toEqual([A, B]);
|
|
110
|
+
const D = hashes(wait)[2];
|
|
111
|
+
expect(hashes(wait)).toEqual([A, B, D]);
|
|
112
|
+
expect(new Set([A, B, D]).size).toBe(3);
|
|
113
|
+
expect(container.querySelector('#ok')).toBeNull();
|
|
114
|
+
|
|
115
|
+
container.querySelector('button')?.click();
|
|
116
|
+
flushSync();
|
|
117
|
+
|
|
118
|
+
const ok = container.querySelector('#ok');
|
|
119
|
+
const C = hashes(ok)[2];
|
|
120
|
+
expect(hashes(ok)).toEqual([A, B, C]);
|
|
121
|
+
expect([A, B, D]).not.toContain(C);
|
|
122
|
+
expect(container.querySelector('#wait')).toBeNull();
|
|
123
|
+
expect(hashes(container.querySelector('#status'))).toEqual([A]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('prunes a rule that only matches the container and emits the scopes in source order', () => {
|
|
127
|
+
const source = `
|
|
128
|
+
export function Status({ ready }: { ready: boolean }) @{
|
|
129
|
+
<>
|
|
130
|
+
<style>.status { padding: 0.5rem; }</style>
|
|
131
|
+
<section class="status">
|
|
132
|
+
<style>
|
|
133
|
+
.title { font-weight: 700; }
|
|
134
|
+
.status { color: rgb(9, 9, 9); }
|
|
135
|
+
</style>
|
|
136
|
+
<h2 class="title">{'Status'}</h2>
|
|
137
|
+
@if (ready) {
|
|
138
|
+
<>
|
|
139
|
+
<style>.ok { color: green; }</style>
|
|
140
|
+
<p class="ok">{'Ready'}</p>
|
|
141
|
+
</>
|
|
142
|
+
} @else {
|
|
143
|
+
<>
|
|
144
|
+
<style>.wait { color: gray; }</style>
|
|
145
|
+
<p class="wait">{'Waiting'}</p>
|
|
146
|
+
</>
|
|
147
|
+
}
|
|
148
|
+
</section>
|
|
149
|
+
</>
|
|
150
|
+
}`;
|
|
151
|
+
const { css, cssHash } = compile(source, 'test.tsrx');
|
|
152
|
+
const [A, B, C, D] = cssHash!.split(' ');
|
|
153
|
+
expect(new Set([A, B, C, D]).size).toBe(4);
|
|
154
|
+
expect(css).toContain(`.status.${A} { padding: 0.5rem; }`);
|
|
155
|
+
expect(css).toContain(`.title.${B} { font-weight: 700; }`);
|
|
156
|
+
expect(css).toContain('/* (unused) .status { color: rgb(9, 9, 9); }*/');
|
|
157
|
+
expect(css).not.toContain(`.status.${B}`);
|
|
158
|
+
expect(css).toContain(`.ok.${C}`);
|
|
159
|
+
expect(css).toContain(`.wait.${D}`);
|
|
160
|
+
expect(css.indexOf(`.status.${A}`)).toBeLessThan(css.indexOf(`.title.${B}`));
|
|
161
|
+
expect(css.indexOf(`.title.${B}`)).toBeLessThan(css.indexOf(`.ok.${C}`));
|
|
162
|
+
expect(css.indexOf(`.ok.${C}`)).toBeLessThan(css.indexOf(`.wait.${D}`));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it(
|
|
166
|
+
'shares one hash between the blocks of one children list, and none with the enclosing list',
|
|
167
|
+
() => {
|
|
168
|
+
function Two() @{
|
|
169
|
+
<>
|
|
170
|
+
<style>
|
|
171
|
+
.host {
|
|
172
|
+
margin: 0;
|
|
173
|
+
}
|
|
174
|
+
</style>
|
|
175
|
+
<div id="host" class="host">
|
|
176
|
+
<style>
|
|
177
|
+
.a {
|
|
178
|
+
color: red;
|
|
179
|
+
}
|
|
180
|
+
</style>
|
|
181
|
+
<i class="a">{'a'}</i>
|
|
182
|
+
<style>
|
|
183
|
+
.b {
|
|
184
|
+
color: blue;
|
|
185
|
+
}
|
|
186
|
+
</style>
|
|
187
|
+
<b class="b">{'b'}</b>
|
|
188
|
+
</div>
|
|
189
|
+
</>
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
render(Two);
|
|
193
|
+
|
|
194
|
+
const host = container.querySelector('#host');
|
|
195
|
+
const i = container.querySelector('i');
|
|
196
|
+
const b = container.querySelector('b');
|
|
197
|
+
const [H] = hashes(host);
|
|
198
|
+
expect(hashes(host)).toEqual([H]);
|
|
199
|
+
const [, C] = hashes(i);
|
|
200
|
+
expect(C).not.toBe(H);
|
|
201
|
+
expect(hashes(i)).toEqual([H, C]);
|
|
202
|
+
expect(hashes(b)).toEqual([H, C]);
|
|
203
|
+
|
|
204
|
+
const { css, cssHash } = compile(
|
|
205
|
+
`export function Two() @{
|
|
206
|
+
<>
|
|
207
|
+
<style>.host { margin: 0; }</style>
|
|
208
|
+
<div class="host">
|
|
209
|
+
<style>.a { color: red; }</style>
|
|
210
|
+
<i class="a">{'a'}</i>
|
|
211
|
+
<style>.b { color: blue; }</style>
|
|
212
|
+
<b class="b">{'b'}</b>
|
|
213
|
+
</div>
|
|
214
|
+
</>
|
|
215
|
+
}`,
|
|
216
|
+
'test.tsrx',
|
|
217
|
+
);
|
|
218
|
+
const [outer, inner] = cssHash!.split(' ');
|
|
219
|
+
expect(cssHash!.split(' ')).toHaveLength(2);
|
|
220
|
+
expect(css).toContain(`.host.${outer}`);
|
|
221
|
+
expect(css).toContain(`.a.${inner}`);
|
|
222
|
+
expect(css).toContain(`.b.${inner}`);
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
it('reaches into a nested code block scope, and the nested block never reaches out', () => {
|
|
227
|
+
function Panel() @{
|
|
228
|
+
<>
|
|
229
|
+
<style>
|
|
230
|
+
div {
|
|
231
|
+
color: black;
|
|
232
|
+
}
|
|
233
|
+
</style>
|
|
234
|
+
<div id="outer">{'Black'}</div>
|
|
235
|
+
@{
|
|
236
|
+
<>
|
|
237
|
+
<style>
|
|
238
|
+
div {
|
|
239
|
+
font-weight: bold;
|
|
240
|
+
}
|
|
241
|
+
</style>
|
|
242
|
+
<div id="inner">{'Black and bold'}</div>
|
|
243
|
+
</>
|
|
244
|
+
}
|
|
245
|
+
</>
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
render(Panel);
|
|
249
|
+
|
|
250
|
+
const [A] = hashes(container.querySelector('#outer'));
|
|
251
|
+
expect(hashes(container.querySelector('#outer'))).toEqual([A]);
|
|
252
|
+
const inner = hashes(container.querySelector('#inner'));
|
|
253
|
+
expect(inner).toHaveLength(2);
|
|
254
|
+
expect(inner[0]).toBe(A);
|
|
255
|
+
expect(inner[1]).not.toBe(A);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('scopes a block inside a @for branch to the items that branch renders', () => {
|
|
259
|
+
function List() @{
|
|
260
|
+
let items = track(['a', 'b']);
|
|
261
|
+
<>
|
|
262
|
+
<button onClick={() => (items.value = [])}>{'clear'}</button>
|
|
263
|
+
<style>
|
|
264
|
+
li {
|
|
265
|
+
list-style: none;
|
|
266
|
+
}
|
|
267
|
+
</style>
|
|
268
|
+
<ul>
|
|
269
|
+
@for (const item of items.value) {
|
|
270
|
+
<>
|
|
271
|
+
<style>
|
|
272
|
+
li {
|
|
273
|
+
padding: 0.75rem 0;
|
|
274
|
+
}
|
|
275
|
+
</style>
|
|
276
|
+
<li class="item">{item}</li>
|
|
277
|
+
</>
|
|
278
|
+
} @empty {
|
|
279
|
+
<li class="empty">{'none'}</li>
|
|
280
|
+
}
|
|
281
|
+
</ul>
|
|
282
|
+
</>
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
render(List);
|
|
286
|
+
|
|
287
|
+
const [first, second] = Array.from(container.querySelectorAll('li.item'));
|
|
288
|
+
const [A, B] = hashes(first);
|
|
289
|
+
expect(A).toBeTruthy();
|
|
290
|
+
expect(B).toBeTruthy();
|
|
291
|
+
expect(hashes(second)).toEqual([A, B]);
|
|
292
|
+
expect(hashes(container.querySelector('ul'))).toEqual([A]);
|
|
293
|
+
|
|
294
|
+
container.querySelector('button')?.click();
|
|
295
|
+
flushSync();
|
|
296
|
+
|
|
297
|
+
// The `@empty` branch holds no block of its own: only the outer scope.
|
|
298
|
+
expect(hashes(container.querySelector('li.empty'))).toEqual([A]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it(
|
|
302
|
+
'keeps the block of an element assigned to a variable, styling its children and not its root',
|
|
303
|
+
() => {
|
|
304
|
+
function Templates() @{
|
|
305
|
+
const card = <div id="root" class="card-root">
|
|
306
|
+
<style>
|
|
307
|
+
.text {
|
|
308
|
+
margin: 0;
|
|
309
|
+
}
|
|
310
|
+
</style>
|
|
311
|
+
<p id="text" class="text">{'card'}</p>
|
|
312
|
+
</div>;
|
|
313
|
+
<>
|
|
314
|
+
{card}
|
|
315
|
+
<p id="outside">{'outside'}</p>
|
|
316
|
+
</>
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
render(Templates);
|
|
320
|
+
|
|
321
|
+
expect(hashes(container.querySelector('#root'))).toEqual([]);
|
|
322
|
+
expect(hashes(container.querySelector('#text'))).toHaveLength(1);
|
|
323
|
+
expect(hashes(container.querySelector('#outside'))).toEqual([]);
|
|
324
|
+
},
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
it('adds the scope classes of an element that spreads its props, one token per scope', () => {
|
|
328
|
+
function Button(&{ ...rest }: { id: string; class?: string }) @{
|
|
329
|
+
<>
|
|
330
|
+
<style>
|
|
331
|
+
button {
|
|
332
|
+
padding: 0;
|
|
333
|
+
}
|
|
334
|
+
</style>
|
|
335
|
+
<div>
|
|
336
|
+
<style>
|
|
337
|
+
button {
|
|
338
|
+
margin: 0;
|
|
339
|
+
}
|
|
340
|
+
</style>
|
|
341
|
+
<button {...rest}>{'b'}</button>
|
|
342
|
+
</div>
|
|
343
|
+
</>
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function App() @{
|
|
347
|
+
<Button id="btn" class="authored" />
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
render(App);
|
|
351
|
+
|
|
352
|
+
const button = container.querySelector('#btn');
|
|
353
|
+
expect(button?.classList.contains('authored')).toBe(true);
|
|
354
|
+
expect(hashes(button)).toHaveLength(2);
|
|
355
|
+
expect(hashes(container.querySelector('div'))).toHaveLength(1);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('keeps the scope classes of a static class when a spread brings its own class', () => {
|
|
359
|
+
function Card(&{ ...rest }: { class?: string }) @{
|
|
360
|
+
<>
|
|
361
|
+
<style apply={importedTheme}>
|
|
362
|
+
.box {
|
|
363
|
+
color: red;
|
|
364
|
+
}
|
|
365
|
+
</style>
|
|
366
|
+
<div id="box" class="box" {...rest}>{'x'}</div>
|
|
367
|
+
<style>
|
|
368
|
+
.plain {
|
|
369
|
+
margin: 0;
|
|
370
|
+
}
|
|
371
|
+
</style>
|
|
372
|
+
</>
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function App() @{
|
|
376
|
+
<Card class="from-spread" />
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
render(App);
|
|
380
|
+
|
|
381
|
+
const box = container.querySelector('#box');
|
|
382
|
+
const [local] = hashes(box);
|
|
383
|
+
expect(local).toBeTruthy();
|
|
384
|
+
expect(hashes(box)).toEqual([local, ...tokens(importedTheme.$class)]);
|
|
385
|
+
expect(box?.classList.contains('box')).toBe(true);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('keeps the scope classes while a dynamic class value changes', () => {
|
|
389
|
+
function App() @{
|
|
390
|
+
let &[active] = track(false);
|
|
391
|
+
<>
|
|
392
|
+
<style>
|
|
393
|
+
.on {
|
|
394
|
+
color: green;
|
|
395
|
+
}
|
|
396
|
+
</style>
|
|
397
|
+
<button id="toggle" onClick={() => (active = !active)}>{'toggle'}</button>
|
|
398
|
+
<p id="p" class={active ? 'on' : 'off'}>{'p'}</p>
|
|
399
|
+
</>
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
render(App);
|
|
403
|
+
|
|
404
|
+
const p = container.querySelector('#p');
|
|
405
|
+
const [A] = hashes(p);
|
|
406
|
+
expect(A).toBeTruthy();
|
|
407
|
+
expect(p?.className).toBe(`off ${A}`);
|
|
408
|
+
|
|
409
|
+
container.querySelector('#toggle')?.click();
|
|
410
|
+
flushSync();
|
|
411
|
+
|
|
412
|
+
expect(p?.className).toBe(`on ${A}`);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe('$class and apply', () => {
|
|
417
|
+
it('exposes $class on an assigned block and applies it to a whole scope', () => {
|
|
418
|
+
expect(tokens(one.$class)).toHaveLength(1);
|
|
419
|
+
expect(one.one).toBe(`${one.$class} one`);
|
|
420
|
+
|
|
421
|
+
function SelfClosed() @{
|
|
422
|
+
<>
|
|
423
|
+
<style apply={one} />
|
|
424
|
+
<div id="a1">{'a'}</div>
|
|
425
|
+
<p id="a2">
|
|
426
|
+
<span id="a3">{'c'}</span>
|
|
427
|
+
</p>
|
|
428
|
+
</>
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
render(SelfClosed);
|
|
432
|
+
|
|
433
|
+
for (const id of ['a1', 'a2', 'a3']) {
|
|
434
|
+
expect(hashes(container.querySelector(`#${id}`))).toEqual(tokens(one.$class));
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('applies a theme and declares the scope block in one tag', () => {
|
|
439
|
+
function WithBody() @{
|
|
440
|
+
<>
|
|
441
|
+
<style apply={one}>
|
|
442
|
+
.c {
|
|
443
|
+
margin: 0;
|
|
444
|
+
}
|
|
445
|
+
</style>
|
|
446
|
+
<div id="c1" class="c">{'c'}</div>
|
|
447
|
+
</>
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
render(WithBody);
|
|
451
|
+
|
|
452
|
+
const div = container.querySelector('#c1');
|
|
453
|
+
const classes = hashes(div);
|
|
454
|
+
expect(classes).toHaveLength(2);
|
|
455
|
+
// The scope's own hash comes first, then the applied theme.
|
|
456
|
+
expect(classes[1]).toBe(one.$class);
|
|
457
|
+
expect(classes[0]).not.toBe(one.$class);
|
|
458
|
+
expect(div?.className).toBe(`c ${classes[0]} ${one.$class}`);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('applies several themes with an array and with several blocks', () => {
|
|
462
|
+
function ArrayForm() @{
|
|
463
|
+
<>
|
|
464
|
+
<style apply={[one, two]} />
|
|
465
|
+
<div id="b1">{'b'}</div>
|
|
466
|
+
</>
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function TwoApplies() @{
|
|
470
|
+
<>
|
|
471
|
+
<style apply={one} />
|
|
472
|
+
<style apply={two} />
|
|
473
|
+
<div id="d1">{'d'}</div>
|
|
474
|
+
</>
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
render(ArrayForm);
|
|
478
|
+
expect(hashes(container.querySelector('#b1'))).toEqual([one.$class, two.$class]);
|
|
479
|
+
|
|
480
|
+
render(TwoApplies);
|
|
481
|
+
expect(hashes(container.querySelector('#d1'))).toEqual([one.$class, two.$class]);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('composes themes at their definition and bundles them without CSS of their own', () => {
|
|
485
|
+
expect(accent.$class).toBe(`${localBase.$class} ${tokens(accent.$class)[1]}`);
|
|
486
|
+
expect(tokens(accent.$class)).toHaveLength(2);
|
|
487
|
+
expect(bundle.$class).toBe(`${two.$class} ${accent.$class}`);
|
|
488
|
+
|
|
489
|
+
function Composed() @{
|
|
490
|
+
<>
|
|
491
|
+
<style apply={accent} />
|
|
492
|
+
<p id="p">{'composed'}</p>
|
|
493
|
+
</>
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
render(Composed);
|
|
497
|
+
|
|
498
|
+
expect(hashes(container.querySelector('#p'))).toEqual(tokens(accent.$class));
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('applies an imported theme through its $class at runtime', () => {
|
|
502
|
+
expect(importedTheme.$class).toBe(`${importedBase.$class} ${tokens(importedTheme.$class)[1]}`);
|
|
503
|
+
|
|
504
|
+
function Card() @{
|
|
505
|
+
<>
|
|
506
|
+
<style apply={importedTheme}>
|
|
507
|
+
h2 {
|
|
508
|
+
margin: 0;
|
|
509
|
+
}
|
|
510
|
+
</style>
|
|
511
|
+
<article id="card">
|
|
512
|
+
<h2 id="h2" class={importedTheme.dark}>{'Green, system-ui'}</h2>
|
|
513
|
+
</article>
|
|
514
|
+
</>
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
render(Card);
|
|
518
|
+
|
|
519
|
+
const card = container.querySelector('#card');
|
|
520
|
+
const [local] = hashes(card);
|
|
521
|
+
expect(hashes(card)).toEqual([local, ...tokens(importedTheme.$class)]);
|
|
522
|
+
expect(container.querySelector('#h2')?.className).toBe(
|
|
523
|
+
`${importedTheme.dark} ${local} ${importedTheme.$class}`,
|
|
524
|
+
);
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
it('opts single elements into a theme with $class, including a child through a prop', () => {
|
|
528
|
+
function Card({ parentClass }: { parentClass: string }) @{
|
|
529
|
+
<>
|
|
530
|
+
<style>
|
|
531
|
+
.local {
|
|
532
|
+
padding: 0;
|
|
533
|
+
}
|
|
534
|
+
</style>
|
|
535
|
+
<article id="article" class={['local', parentClass]}>
|
|
536
|
+
<h2 id="h2" class={parentClass}>{'title'}</h2>
|
|
537
|
+
</article>
|
|
538
|
+
</>
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function App() @{
|
|
542
|
+
const theme = <style>
|
|
543
|
+
div {
|
|
544
|
+
color: blue;
|
|
545
|
+
}
|
|
546
|
+
.card {
|
|
547
|
+
color: red;
|
|
548
|
+
}
|
|
549
|
+
</style>;
|
|
550
|
+
<>
|
|
551
|
+
<Card parentClass={theme.$class} />
|
|
552
|
+
<div id="opted" class={theme.$class}>{'opted in'}</div>
|
|
553
|
+
<div id="card" class={theme.card}>{'card'}</div>
|
|
554
|
+
<p id="untouched">{'untouched'}</p>
|
|
555
|
+
</>
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
render(App);
|
|
559
|
+
|
|
560
|
+
const opted = container.querySelector('#opted');
|
|
561
|
+
const theme_hash = opted?.className;
|
|
562
|
+
expect(tokens(theme_hash!)).toHaveLength(1);
|
|
563
|
+
expect(container.querySelector('#card')?.className).toBe(`${theme_hash} card`);
|
|
564
|
+
expect(container.querySelector('#untouched')?.className).toBe('');
|
|
565
|
+
|
|
566
|
+
const article = container.querySelector('#article');
|
|
567
|
+
const local = hashes(article).find((cls) => cls !== theme_hash);
|
|
568
|
+
expect(article?.className).toBe(`local ${theme_hash} ${local}`);
|
|
569
|
+
expect(container.querySelector('#h2')?.className).toBe(`${theme_hash} ${local}`);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('keeps every selector of a theme and only class selectors of a plain class map', () => {
|
|
573
|
+
const source = `
|
|
574
|
+
export const theme = <style>
|
|
575
|
+
div { color: green; }
|
|
576
|
+
.dark { color: purple; }
|
|
577
|
+
</style>;
|
|
578
|
+
|
|
579
|
+
const plain = <style>
|
|
580
|
+
div { color: red; }
|
|
581
|
+
.card { color: blue; }
|
|
582
|
+
</style>;
|
|
583
|
+
|
|
584
|
+
const read = <style>
|
|
585
|
+
span { color: red; }
|
|
586
|
+
</style>;
|
|
587
|
+
|
|
588
|
+
export function App() @{
|
|
589
|
+
<>
|
|
590
|
+
<div class={plain.card}>{'card'}</div>
|
|
591
|
+
<span class={read.$class}>{'read'}</span>
|
|
592
|
+
</>
|
|
593
|
+
}`;
|
|
594
|
+
const { css, code } = compile(source, 'test.tsrx');
|
|
595
|
+
const [theme_hash, plain_hash, read_hash] = [...css.matchAll(/\.(tsrx-[0-9a-f]+)/g)].map(
|
|
596
|
+
(match) => match[1],
|
|
597
|
+
).filter((hash, index, all) => all.indexOf(hash) === index);
|
|
598
|
+
expect(css).toContain(`div.${theme_hash} { color: green; }`);
|
|
599
|
+
expect(css).toContain(`.dark.${theme_hash} { color: purple; }`);
|
|
600
|
+
expect(css).toContain('/* (unused) div { color: red; }*/');
|
|
601
|
+
expect(css).toContain(`.card.${plain_hash} { color: blue; }`);
|
|
602
|
+
// Reading `read.$class` makes the block a theme.
|
|
603
|
+
expect(css).toContain(`span.${read_hash} { color: red; }`);
|
|
604
|
+
expect(code).toContain(`'$class': '${theme_hash}'`);
|
|
605
|
+
expect(code).toContain(`'dark': '${theme_hash} dark'`);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it(
|
|
609
|
+
'emits an applied theme before the scope that applies it, and a scope before the scopes nested in it',
|
|
610
|
+
() => {
|
|
611
|
+
const source = `
|
|
612
|
+
const theme = <style>
|
|
613
|
+
div { color: green; }
|
|
614
|
+
</style>;
|
|
615
|
+
|
|
616
|
+
export function Precedence() @{
|
|
617
|
+
<>
|
|
618
|
+
<style apply={theme}>
|
|
619
|
+
div { color: black; }
|
|
620
|
+
</style>
|
|
621
|
+
<div class="outer">{'outer'}</div>
|
|
622
|
+
@{
|
|
623
|
+
<>
|
|
624
|
+
<style>
|
|
625
|
+
div { color: blue; }
|
|
626
|
+
</style>
|
|
627
|
+
<div class="inner">{'inner'}</div>
|
|
628
|
+
</>
|
|
629
|
+
}
|
|
630
|
+
<style>
|
|
631
|
+
div { font-style: italic; }
|
|
632
|
+
</style>
|
|
633
|
+
</>
|
|
634
|
+
}`;
|
|
635
|
+
const { css, cssHash, code } = compile(source, 'test.tsrx');
|
|
636
|
+
const [theme_hash, outer, inner] = cssHash!.split(' ');
|
|
637
|
+
expect(cssHash!.split(' ')).toHaveLength(3);
|
|
638
|
+
const green = css.indexOf(`div.${theme_hash} { color: green; }`);
|
|
639
|
+
const black = css.indexOf(`div.${outer} { color: black; }`);
|
|
640
|
+
const italic = css.indexOf(`div.${outer} { font-style: italic; }`);
|
|
641
|
+
const blue = css.indexOf(`div.${inner} { color: blue; }`);
|
|
642
|
+
expect(green).toBeGreaterThanOrEqual(0);
|
|
643
|
+
expect(green).toBeLessThan(black);
|
|
644
|
+
// The outer scope's blocks stay one group, even though the second
|
|
645
|
+
// block is written after the nested scope.
|
|
646
|
+
expect(black).toBeLessThan(italic);
|
|
647
|
+
expect(italic).toBeLessThan(blue);
|
|
648
|
+
expect(code).toContain(`class="outer ${outer} ${theme_hash}"`);
|
|
649
|
+
expect(code).toContain(`class="inner ${outer} ${inner} ${theme_hash}"`);
|
|
650
|
+
},
|
|
651
|
+
);
|
|
652
|
+
});
|
|
@@ -671,7 +671,9 @@ describe('dynamic DOM elements', () => {
|
|
|
671
671
|
expect(innerScopes).toHaveLength(1);
|
|
672
672
|
expect(innerScopes[0]).not.toBe(outerScope);
|
|
673
673
|
|
|
674
|
-
|
|
674
|
+
// Every element of the child's scope carries the child's hash, and none
|
|
675
|
+
// of the parent's.
|
|
676
|
+
expect(innerInnerScopes).toEqual(innerScopes);
|
|
675
677
|
});
|
|
676
678
|
|
|
677
679
|
it('doesn\'t add scoping class to dynamically rendered component', () => {
|
|
@@ -708,7 +710,8 @@ describe('dynamic DOM elements', () => {
|
|
|
708
710
|
const innerScopes = Array.from(p.classList).filter((c) => c.startsWith('tsrx-'));
|
|
709
711
|
|
|
710
712
|
expect(outerScopes).toHaveLength(1);
|
|
711
|
-
|
|
713
|
+
// The child's own scope reaches its <p>; the parent's never does.
|
|
714
|
+
expect(innerScopes).toEqual(outerScopes);
|
|
712
715
|
});
|
|
713
716
|
|
|
714
717
|
it('handles ref on dynamic element passed through component with reactive props', () => {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// A theme module shared by the scoped-style tests: `base` is applied by
|
|
2
|
+
// `theme`, so `theme.$class` carries both hashes and `theme` keeps every
|
|
3
|
+
// selector (it is exported).
|
|
4
|
+
export const base = <style>
|
|
5
|
+
article {
|
|
6
|
+
font-family: system-ui;
|
|
7
|
+
}
|
|
8
|
+
.muted {
|
|
9
|
+
color: gray;
|
|
10
|
+
}
|
|
11
|
+
</style>;
|
|
12
|
+
|
|
13
|
+
export const theme = <style apply={base}>
|
|
14
|
+
article {
|
|
15
|
+
color: green;
|
|
16
|
+
}
|
|
17
|
+
.dark {
|
|
18
|
+
color: purple;
|
|
19
|
+
}
|
|
20
|
+
</style>;
|
|
@@ -398,7 +398,9 @@ describe('server dynamic DOM elements', () => {
|
|
|
398
398
|
expect(innerScopes).toHaveLength(1);
|
|
399
399
|
expect(innerScopes[0]).not.toBe(outerScope);
|
|
400
400
|
|
|
401
|
-
|
|
401
|
+
// Every element of the child's scope carries the child's hash, and none
|
|
402
|
+
// of the parent's.
|
|
403
|
+
expect(innerInnerScopes).toEqual(innerScopes);
|
|
402
404
|
});
|
|
403
405
|
|
|
404
406
|
it('doesn\'t add scoping class to dynamically rendered component', async () => {
|
|
@@ -437,6 +439,7 @@ describe('server dynamic DOM elements', () => {
|
|
|
437
439
|
const innerScopes = Array.from(p.classList).filter((c) => c.startsWith('tsrx-'));
|
|
438
440
|
|
|
439
441
|
expect(outerScopes).toHaveLength(1);
|
|
440
|
-
|
|
442
|
+
// The child's own scope reaches its <p>; the parent's never does.
|
|
443
|
+
expect(innerScopes).toEqual(outerScopes);
|
|
441
444
|
});
|
|
442
445
|
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { getCss } from 'ripple/server';
|
|
2
|
+
import { base as importedBase, theme as importedTheme } from '../fixtures/scoped-styles/theme.tsrx';
|
|
3
|
+
|
|
4
|
+
// RFC tsrx-org/RFCs#1 on the server: the class table matches the client, and
|
|
5
|
+
// a request collects the sheets of the scopes it rendered — an applied theme
|
|
6
|
+
// before the scope that applies it, whichever module the theme lives in.
|
|
7
|
+
|
|
8
|
+
function hashes(element: Element | null): string[] {
|
|
9
|
+
return Array.from(element?.classList ?? []).filter((cls) => cls.startsWith('tsrx-'));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function tokens(value: string): string[] {
|
|
13
|
+
return value.split(' ').filter(Boolean);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const one = <style>
|
|
17
|
+
div {
|
|
18
|
+
color: red;
|
|
19
|
+
}
|
|
20
|
+
</style>;
|
|
21
|
+
|
|
22
|
+
describe('sibling-scoped style blocks (server)', () => {
|
|
23
|
+
function Status({ ready }: { ready: boolean }) @{
|
|
24
|
+
<>
|
|
25
|
+
<style>
|
|
26
|
+
.status {
|
|
27
|
+
padding: 0.5rem;
|
|
28
|
+
}
|
|
29
|
+
</style>
|
|
30
|
+
<section id="status" class="status">
|
|
31
|
+
<style>
|
|
32
|
+
.title {
|
|
33
|
+
font-weight: 700;
|
|
34
|
+
}
|
|
35
|
+
.status {
|
|
36
|
+
color: rgb(9, 9, 9);
|
|
37
|
+
}
|
|
38
|
+
</style>
|
|
39
|
+
<h2 id="title" class="title">{'Status'}</h2>
|
|
40
|
+
@if (ready) {
|
|
41
|
+
<>
|
|
42
|
+
<style>
|
|
43
|
+
.ok {
|
|
44
|
+
color: green;
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
47
|
+
<p id="ok" class="ok">{'Ready'}</p>
|
|
48
|
+
</>
|
|
49
|
+
} @else {
|
|
50
|
+
<>
|
|
51
|
+
<style>
|
|
52
|
+
.wait {
|
|
53
|
+
color: gray;
|
|
54
|
+
}
|
|
55
|
+
</style>
|
|
56
|
+
<p id="wait" class="wait">{'Waiting'}</p>
|
|
57
|
+
</>
|
|
58
|
+
}
|
|
59
|
+
</section>
|
|
60
|
+
</>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
it('renders the class table and collects every scope sheet in source order', async () => {
|
|
64
|
+
function Ready() @{
|
|
65
|
+
<Status ready={true} />
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { body, css } = await render(Ready);
|
|
69
|
+
const { document } = parseHtml(body);
|
|
70
|
+
|
|
71
|
+
const [A] = hashes(document.querySelector('#status'));
|
|
72
|
+
expect(hashes(document.querySelector('#status'))).toEqual([A]);
|
|
73
|
+
const B = hashes(document.querySelector('#title'))[1];
|
|
74
|
+
expect(hashes(document.querySelector('#title'))).toEqual([A, B]);
|
|
75
|
+
const C = hashes(document.querySelector('#ok'))[2];
|
|
76
|
+
expect(hashes(document.querySelector('#ok'))).toEqual([A, B, C]);
|
|
77
|
+
expect(document.querySelector('#wait')).toBeNull();
|
|
78
|
+
|
|
79
|
+
// Every scope's CSS ships, the branch never rendered included, and the
|
|
80
|
+
// sheets follow source order.
|
|
81
|
+
const collected = [...css];
|
|
82
|
+
expect(collected).toHaveLength(4);
|
|
83
|
+
expect(collected.slice(0, 3)).toEqual([A, B, C]);
|
|
84
|
+
const text = getCss(css);
|
|
85
|
+
expect(text).toContain(`.status.${A}`);
|
|
86
|
+
expect(text).toContain(`.title.${B}`);
|
|
87
|
+
expect(text).toContain('/* (unused) .status {');
|
|
88
|
+
expect(text).toContain(`.ok.${C}`);
|
|
89
|
+
expect(text).toContain(`.wait.${collected[3]}`);
|
|
90
|
+
expect(text.indexOf(`.status.${A}`)).toBeLessThan(text.indexOf(`.title.${B}`));
|
|
91
|
+
expect(text.indexOf(`.title.${B}`)).toBeLessThan(text.indexOf(`.ok.${C}`));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('stamps the other branch with its own hash', async () => {
|
|
95
|
+
function Waiting() @{
|
|
96
|
+
<Status ready={false} />
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { body } = await render(Waiting);
|
|
100
|
+
const { document } = parseHtml(body);
|
|
101
|
+
|
|
102
|
+
const wait = hashes(document.querySelector('#wait'));
|
|
103
|
+
expect(wait).toHaveLength(3);
|
|
104
|
+
expect(wait.slice(0, 2)).toEqual(hashes(document.querySelector('#title')));
|
|
105
|
+
expect(document.querySelector('#ok')).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('registers the blocks of one children list as one sheet', async () => {
|
|
109
|
+
function Two() @{
|
|
110
|
+
<>
|
|
111
|
+
<style>
|
|
112
|
+
.host {
|
|
113
|
+
margin: 0;
|
|
114
|
+
}
|
|
115
|
+
</style>
|
|
116
|
+
<div id="host" class="host">
|
|
117
|
+
<style>
|
|
118
|
+
.a {
|
|
119
|
+
color: red;
|
|
120
|
+
}
|
|
121
|
+
</style>
|
|
122
|
+
<i class="a">{'a'}</i>
|
|
123
|
+
<style>
|
|
124
|
+
.b {
|
|
125
|
+
color: blue;
|
|
126
|
+
}
|
|
127
|
+
</style>
|
|
128
|
+
<b class="b">{'b'}</b>
|
|
129
|
+
</div>
|
|
130
|
+
</>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const { body, css } = await render(Two);
|
|
134
|
+
const { document } = parseHtml(body);
|
|
135
|
+
|
|
136
|
+
const [H] = hashes(document.querySelector('#host'));
|
|
137
|
+
const [, C] = hashes(document.querySelector('i'));
|
|
138
|
+
expect(hashes(document.querySelector('b'))).toEqual([H, C]);
|
|
139
|
+
expect([...css]).toEqual([H, C]);
|
|
140
|
+
const text = getCss(css);
|
|
141
|
+
expect(text).toContain(`.a.${C}`);
|
|
142
|
+
expect(text).toContain(`.b.${C}`);
|
|
143
|
+
expect(text.match(new RegExp(`\\.a\\.${C}`, 'g'))).toHaveLength(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('merges the scope classes into a spread', async () => {
|
|
147
|
+
function Button(&{ ...rest }: { id: string; class?: string }) @{
|
|
148
|
+
<>
|
|
149
|
+
<style>
|
|
150
|
+
button {
|
|
151
|
+
padding: 0;
|
|
152
|
+
}
|
|
153
|
+
</style>
|
|
154
|
+
<div>
|
|
155
|
+
<style>
|
|
156
|
+
button {
|
|
157
|
+
margin: 0;
|
|
158
|
+
}
|
|
159
|
+
</style>
|
|
160
|
+
<button {...rest}>{'b'}</button>
|
|
161
|
+
</div>
|
|
162
|
+
</>
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function App() @{
|
|
166
|
+
<Button id="btn" class="authored" />
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { body } = await render(App);
|
|
170
|
+
const { document } = parseHtml(body);
|
|
171
|
+
|
|
172
|
+
const button = document.querySelector('#btn');
|
|
173
|
+
expect(button?.classList.contains('authored')).toBe(true);
|
|
174
|
+
expect(hashes(button)).toHaveLength(2);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('spreads with an imported theme (server)', () => {
|
|
179
|
+
it('merges the scope classes of a static class into the spread once', async () => {
|
|
180
|
+
function Card(&{ ...rest }: { class?: string }) @{
|
|
181
|
+
<>
|
|
182
|
+
<style apply={importedTheme}>
|
|
183
|
+
.box {
|
|
184
|
+
color: red;
|
|
185
|
+
}
|
|
186
|
+
</style>
|
|
187
|
+
<div id="box" class="box" {...rest}>{'x'}</div>
|
|
188
|
+
</>
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function App() @{
|
|
192
|
+
<Card class="from-spread" />
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const { body } = await render(App);
|
|
196
|
+
expect(body.match(/class=/g)).toHaveLength(1);
|
|
197
|
+
const { document } = parseHtml(body);
|
|
198
|
+
|
|
199
|
+
const box = document.querySelector('#box');
|
|
200
|
+
const [local] = hashes(box);
|
|
201
|
+
expect(local).toBeTruthy();
|
|
202
|
+
expect(hashes(box)).toEqual([local, ...tokens(importedTheme.$class)]);
|
|
203
|
+
expect(box?.classList.contains('box')).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('$class and apply (server)', () => {
|
|
208
|
+
it(
|
|
209
|
+
'applies a same-module theme and registers its sheet before the scope that applies it',
|
|
210
|
+
async () => {
|
|
211
|
+
function WithBody() @{
|
|
212
|
+
<>
|
|
213
|
+
<style apply={one}>
|
|
214
|
+
.c {
|
|
215
|
+
margin: 0;
|
|
216
|
+
}
|
|
217
|
+
</style>
|
|
218
|
+
<div id="c1" class="c">{'c'}</div>
|
|
219
|
+
</>
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { body, css } = await render(WithBody);
|
|
223
|
+
const { document } = parseHtml(body);
|
|
224
|
+
|
|
225
|
+
const div = document.querySelector('#c1');
|
|
226
|
+
const [local, applied] = hashes(div);
|
|
227
|
+
expect(div?.className).toBe(`c ${local} ${one.$class}`);
|
|
228
|
+
expect(applied).toBe(one.$class);
|
|
229
|
+
expect([...css]).toEqual([one.$class, local]);
|
|
230
|
+
const text = getCss(css);
|
|
231
|
+
expect(text.indexOf(`div.${one.$class}`)).toBeLessThan(text.indexOf(`.c.${local}`));
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
it('applies an imported theme through its $class and collects its sheets first', async () => {
|
|
236
|
+
function Card() @{
|
|
237
|
+
<>
|
|
238
|
+
<style apply={importedTheme}>
|
|
239
|
+
h2 {
|
|
240
|
+
margin: 0;
|
|
241
|
+
}
|
|
242
|
+
</style>
|
|
243
|
+
<article id="card">
|
|
244
|
+
<h2 id="h2" class={importedTheme.dark}>{'Green, system-ui'}</h2>
|
|
245
|
+
</article>
|
|
246
|
+
</>
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const { body, css } = await render(Card);
|
|
250
|
+
const { document } = parseHtml(body);
|
|
251
|
+
|
|
252
|
+
const card = document.querySelector('#card');
|
|
253
|
+
const [local] = hashes(card);
|
|
254
|
+
expect(hashes(card)).toEqual([local, ...tokens(importedTheme.$class)]);
|
|
255
|
+
// The class list is normalized on the server, so the theme hash that
|
|
256
|
+
// `theme.dark` already carries appears once.
|
|
257
|
+
const h2 = Array.from(document.querySelector('#h2')?.classList ?? []);
|
|
258
|
+
expect(h2.slice(0, 2)).toEqual(tokens(importedTheme.dark));
|
|
259
|
+
expect(h2).toContain(local);
|
|
260
|
+
for (const token of tokens(importedTheme.$class)) expect(h2).toContain(token);
|
|
261
|
+
// base (applied by theme), theme, then the applying scope.
|
|
262
|
+
expect([...css]).toEqual([...tokens(importedTheme.$class), local]);
|
|
263
|
+
const text = getCss(css);
|
|
264
|
+
expect(text.indexOf(`article.${importedBase.$class}`)).toBeLessThan(
|
|
265
|
+
text.indexOf(`article.${tokens(importedTheme.$class)[1]}`),
|
|
266
|
+
);
|
|
267
|
+
expect(text.indexOf(`.dark.${tokens(importedTheme.$class)[1]}`)).toBeLessThan(
|
|
268
|
+
text.indexOf(`h2.${local}`),
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('registers a theme only when something reads it', async () => {
|
|
273
|
+
function App() @{
|
|
274
|
+
const unused = <style>
|
|
275
|
+
.u {
|
|
276
|
+
color: red;
|
|
277
|
+
}
|
|
278
|
+
</style>;
|
|
279
|
+
const theme = <style>
|
|
280
|
+
div {
|
|
281
|
+
color: blue;
|
|
282
|
+
}
|
|
283
|
+
</style>;
|
|
284
|
+
<>
|
|
285
|
+
<div id="opted" class={theme.$class}>{'opted in'}</div>
|
|
286
|
+
<p id="untouched">{'untouched'}</p>
|
|
287
|
+
</>
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const { body, css } = await render(App);
|
|
291
|
+
const { document } = parseHtml(body);
|
|
292
|
+
|
|
293
|
+
const theme_hash = document.querySelector('#opted')?.className;
|
|
294
|
+
expect(tokens(theme_hash!)).toHaveLength(1);
|
|
295
|
+
expect(document.querySelector('#untouched')?.className).toBe('');
|
|
296
|
+
expect([...css]).toEqual([theme_hash]);
|
|
297
|
+
expect(getCss(css)).toContain(`div.${theme_hash}`);
|
|
298
|
+
});
|
|
299
|
+
});
|