ripple 0.3.125 → 0.3.127

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.
@@ -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 static class when spreading props
144
- element.classList.add(value);
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
- /** @type {Block} */ (active_block).o.register_css(hash);
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
- return <>
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
+ });