semmet-angular 0.5.0 → 0.7.0

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.
Files changed (37) hide show
  1. package/package.json +1 -1
  2. package/src/accordion/files/__name@dasherize__/__name@dasherize__.css.template +4 -4
  3. package/src/accordion/files/__name@dasherize__/__name@dasherize__.html.template +29 -10
  4. package/src/accordion/files/__name@dasherize__/__name@dasherize__.ts.template +69 -15
  5. package/src/alert/files/__name@dasherize__/__name@dasherize__.css.template +1 -1
  6. package/src/alert-dialog/files/__name@dasherize__/__name@dasherize__.css.template +3 -3
  7. package/src/breadcrumb/files/__name@dasherize__/__name@dasherize__.css.template +3 -3
  8. package/src/card/files/__name@dasherize__/__name@dasherize__.css.template +4 -4
  9. package/src/carousel/files/__name@dasherize__/__name@dasherize__.css.template +8 -3
  10. package/src/carousel/files/__name@dasherize__/__name@dasherize__.html.template +29 -5
  11. package/src/carousel/files/__name@dasherize__/__name@dasherize__.ts.template +70 -14
  12. package/src/checkbox/files/__name@dasherize__/__name@dasherize__.css.template +3 -3
  13. package/src/combobox/files/__name@dasherize__/__name@dasherize__.css.template +4 -4
  14. package/src/dialog/files/__name@dasherize__/__name@dasherize__.css.template +3 -3
  15. package/src/disclosure/files/__name@dasherize__/__name@dasherize__.css.template +4 -4
  16. package/src/disclosure/files/__name@dasherize__/__name@dasherize__.html.template +18 -2
  17. package/src/disclosure/files/__name@dasherize__/__name@dasherize__.ts.template +31 -3
  18. package/src/landmarks/files/__name@dasherize__/__name@dasherize__.css.template +3 -3
  19. package/src/listbox/files/__name@dasherize__/__name@dasherize__.css.template +3 -3
  20. package/src/menu-button/files/__name@dasherize__/__name@dasherize__.css.template +4 -4
  21. package/src/meter/files/__name@dasherize__/__name@dasherize__.css.template +2 -2
  22. package/src/navigation-menu/files/__name@dasherize__/__name@dasherize__.css.template +2 -2
  23. package/src/pagination/files/__name@dasherize__/__name@dasherize__.css.template +3 -3
  24. package/src/progress-bar/files/__name@dasherize__/__name@dasherize__.css.template +3 -3
  25. package/src/radio-group/files/__name@dasherize__/__name@dasherize__.css.template +4 -4
  26. package/src/skip-link/files/__name@dasherize__/__name@dasherize__.css.template +1 -1
  27. package/src/slider/files/__name@dasherize__/__name@dasherize__.css.template +3 -3
  28. package/src/spinbutton/files/__name@dasherize__/__name@dasherize__.css.template +3 -3
  29. package/src/switch/files/__name@dasherize__/__name@dasherize__.css.template +3 -3
  30. package/src/table/files/__name@dasherize__/__name@dasherize__.css.template +4 -4
  31. package/src/tabs/files/__name@dasherize__/__name@dasherize__.css.template +4 -4
  32. package/src/tabs/files/__name@dasherize__/__name@dasherize__.html.template +31 -12
  33. package/src/tabs/files/__name@dasherize__/__name@dasherize__.ts.template +113 -23
  34. package/src/toast/files/__name@dasherize__/__name@dasherize__.css.template +2 -2
  35. package/src/toolbar/files/__name@dasherize__/__name@dasherize__.css.template +3 -3
  36. package/src/tooltip/files/__name@dasherize__/__name@dasherize__.css.template +2 -2
  37. package/src/tree-view/files/__name@dasherize__/__name@dasherize__.css.template +3 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "semmet-angular",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Angular schematics that generate real, ARIA-conformant standalone components (Accordion, Tabs, Dialog, Disclosure, Tooltip...) with signals-based state and keyboard interaction built in.",
5
5
  "publisher": "danilodevsilva",
6
6
  "license": "MIT",
@@ -1,9 +1,9 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-surface: #ffffff;
4
- --semmet-color-outline: #d7dedd;
5
- --semmet-color-on-surface: #1b201f;
6
- --semmet-color-on-surface-variant: #576260;
4
+ --semmet-color-outline: #d4d4d8;
5
+ --semmet-color-on-surface: #18181b;
6
+ --semmet-color-on-surface-variant: #52525b;
7
7
  --semmet-radius-md: 12px;
8
8
  --semmet-elevation-1: 0 1px 2px rgba(20, 24, 23, 0.1), 0 1px 1px rgba(20, 24, 23, 0.06);
9
9
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
@@ -1,24 +1,43 @@
1
1
  <!-- Semmet Angular: Accordion — https://www.w3.org/WAI/ARIA/apg/patterns/accordion/ -->
2
- @for (item of items; track item.id; let i = $index) {
2
+ <!--
3
+ Usage from a parent component template:
4
+ Import <%= classify(name) %> and <%= classify(name) %>Item in that parent component.
5
+
6
+ <<%= selector %>>
7
+ <ng-template <%= camelize(name) %>Item id="profile" title="Profile" [expanded]="true">
8
+ <app-profile-panel />
9
+ </ng-template>
10
+
11
+ <ng-template <%= camelize(name) %>Item id="settings" title="Settings">
12
+ <app-settings-panel [userId]="userId()" (saved)="reload()" />
13
+ </ng-template>
14
+ </<%= selector %>>
15
+
16
+ Set [preserveContent]="false" on <<%= selector %>> to destroy panel content when closed.
17
+ -->
18
+ @for (item of items(); track item.id(); let i = $index) {
3
19
  <h3>
4
20
  <button
5
21
  #headerButton
6
22
  type="button"
7
- [attr.aria-expanded]="isExpanded(item.id)"
8
- [attr.aria-controls]="instanceId + '-panel-' + item.id"
9
- [id]="instanceId + '-header-' + item.id"
10
- (click)="toggle(item.id)"
23
+ [disabled]="item.disabled()"
24
+ [attr.aria-expanded]="isExpanded(item.id())"
25
+ [attr.aria-controls]="instanceId + '-panel-' + item.id()"
26
+ [id]="instanceId + '-header-' + item.id()"
27
+ (click)="toggle(item)"
11
28
  (keydown)="navigateHeaders($event, i)"
12
29
  >
13
- {{ item.title }}
30
+ {{ item.title() }}
14
31
  </button>
15
32
  </h3>
16
33
  <div
17
- [id]="instanceId + '-panel-' + item.id"
34
+ [id]="instanceId + '-panel-' + item.id()"
18
35
  role="region"
19
- [attr.aria-labelledby]="instanceId + '-header-' + item.id"
20
- [hidden]="!isExpanded(item.id)"
36
+ [attr.aria-labelledby]="instanceId + '-header-' + item.id()"
37
+ [hidden]="!isExpanded(item.id())"
21
38
  >
22
- {{ item.content }}
39
+ @if (shouldRender(item.id())) {
40
+ <ng-container [ngTemplateOutlet]="item.templateRef" />
41
+ }
23
42
  </div>
24
43
  }
@@ -1,42 +1,96 @@
1
- import { Component, ElementRef, signal, viewChildren } from '@angular/core';
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import {
3
+ Component,
4
+ Directive,
5
+ ElementRef,
6
+ TemplateRef,
7
+ booleanAttribute,
8
+ computed,
9
+ contentChildren,
10
+ inject,
11
+ input,
12
+ signal,
13
+ viewChildren,
14
+ } from '@angular/core';
2
15
 
3
- interface <%= classify(name) %>Item {
4
- id: number;
5
- title: string;
6
- content: string;
7
- }
16
+ type <%= classify(name) %>ItemId = string | number;
8
17
 
9
18
  let nextId = 0;
19
+ let nextItemId = 0;
20
+
21
+ @Directive({
22
+ selector: 'ng-template[<%= camelize(name) %>Item]',
23
+ })
24
+ export class <%= classify(name) %>Item {
25
+ readonly templateRef = inject<TemplateRef<unknown>>(TemplateRef);
26
+
27
+ readonly id = input<<%= classify(name) %>ItemId>(`<%= dasherize(name) %>-item-${nextItemId++}`);
28
+ readonly title = input.required<string>();
29
+ readonly expanded = input(false, { transform: booleanAttribute });
30
+ readonly disabled = input(false, { transform: booleanAttribute });
31
+ }
10
32
 
11
33
  @Component({
12
34
  selector: '<%= selector %>',
13
- imports: [],
35
+ imports: [NgTemplateOutlet],
14
36
  templateUrl: './<%= dasherize(name) %>.html',
15
37
  styleUrl: './<%= dasherize(name) %>.css',
16
38
  })
17
39
  export class <%= classify(name) %> {
18
40
  private readonly headerButtons = viewChildren<ElementRef<HTMLButtonElement>>('headerButton');
19
41
 
42
+ protected readonly items = contentChildren(<%= classify(name) %>Item);
20
43
  protected readonly instanceId = `<%= dasherize(name) %>-${nextId++}`;
21
- protected readonly items: <%= classify(name) %>Item[] = [
22
- { id: 1, title: 'First section title', content: 'First panel content' },
23
- { id: 2, title: 'Second section title', content: 'Second panel content' },
24
- ];
44
+ readonly preserveContent = input(true, { transform: booleanAttribute });
25
45
 
26
- private readonly expandedIds = signal<ReadonlySet<number>>(new Set([1]));
46
+ private readonly renderedIds = signal<ReadonlySet<<%= classify(name) %>ItemId>>(new Set());
47
+ private readonly userExpandedIds = signal<ReadonlySet<<%= classify(name) %>ItemId> | null>(null);
27
48
 
28
- protected isExpanded(id: number): boolean {
49
+ private readonly expandedIds = computed<ReadonlySet<<%= classify(name) %>ItemId>>(() => {
50
+ const userExpandedIds = this.userExpandedIds();
51
+ if (userExpandedIds) {
52
+ return userExpandedIds;
53
+ }
54
+
55
+ return new Set(
56
+ this.items()
57
+ .filter((item) => item.expanded())
58
+ .map((item) => item.id()),
59
+ );
60
+ });
61
+
62
+ protected isExpanded(id: <%= classify(name) %>ItemId): boolean {
29
63
  return this.expandedIds().has(id);
30
64
  }
31
65
 
32
- protected toggle(id: number): void {
66
+ protected shouldRender(id: <%= classify(name) %>ItemId): boolean {
67
+ return this.isExpanded(id) || (this.preserveContent() && this.renderedIds().has(id));
68
+ }
69
+
70
+ protected toggle(item: <%= classify(name) %>Item): void {
71
+ if (item.disabled()) {
72
+ return;
73
+ }
74
+
75
+ const id = item.id();
33
76
  const next = new Set(this.expandedIds());
34
77
  if (next.has(id)) {
78
+ this.markRendered(id);
35
79
  next.delete(id);
36
80
  } else {
37
81
  next.add(id);
82
+ this.markRendered(id);
38
83
  }
39
- this.expandedIds.set(next);
84
+ this.userExpandedIds.set(next);
85
+ }
86
+
87
+ private markRendered(id: <%= classify(name) %>ItemId): void {
88
+ const renderedIds = this.renderedIds();
89
+ if (renderedIds.has(id)) {
90
+ return;
91
+ }
92
+
93
+ this.renderedIds.set(new Set(renderedIds).add(id));
40
94
  }
41
95
 
42
96
  // Up/Down/Home/End move focus between headers — optional but APG-recommended keyboard support.
@@ -1,5 +1,5 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-on-primary: #ffffff;
4
4
  --semmet-radius-sm: 8px;
5
5
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
@@ -1,9 +1,9 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-on-primary: #ffffff;
4
4
  --semmet-color-surface: #ffffff;
5
- --semmet-color-on-surface: #1b201f;
6
- --semmet-color-on-surface-variant: #576260;
5
+ --semmet-color-on-surface: #18181b;
6
+ --semmet-color-on-surface-variant: #52525b;
7
7
  --semmet-color-scrim: rgba(20, 24, 23, 0.45);
8
8
  --semmet-radius-lg: 20px;
9
9
  --semmet-elevation-3: 0 8px 24px rgba(20, 24, 23, 0.18), 0 2px 8px rgba(20, 24, 23, 0.1);
@@ -1,7 +1,7 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
3
- --semmet-color-on-surface: #1b201f;
4
- --semmet-color-on-surface-variant: #576260;
2
+ --semmet-color-primary: #18181b;
3
+ --semmet-color-on-surface: #18181b;
4
+ --semmet-color-on-surface-variant: #52525b;
5
5
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
6
6
 
7
7
  display: block;
@@ -1,9 +1,9 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-surface: #ffffff;
4
- --semmet-color-outline: #d7dedd;
5
- --semmet-color-on-surface: #1b201f;
6
- --semmet-color-on-surface-variant: #576260;
4
+ --semmet-color-outline: #d4d4d8;
5
+ --semmet-color-on-surface: #18181b;
6
+ --semmet-color-on-surface-variant: #52525b;
7
7
  --semmet-radius-md: 12px;
8
8
  --semmet-elevation-1: 0 1px 2px rgba(20, 24, 23, 0.1), 0 1px 1px rgba(20, 24, 23, 0.06);
9
9
 
@@ -1,9 +1,9 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-on-primary: #ffffff;
4
4
  --semmet-color-surface: #ffffff;
5
- --semmet-color-outline: #d7dedd;
6
- --semmet-color-on-surface: #1b201f;
5
+ --semmet-color-outline: #d4d4d8;
6
+ --semmet-color-on-surface: #18181b;
7
7
  --semmet-radius-md: 12px;
8
8
  --semmet-elevation-1: 0 1px 2px rgba(20, 24, 23, 0.1), 0 1px 1px rgba(20, 24, 23, 0.06);
9
9
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
@@ -52,6 +52,11 @@ button:hover {
52
52
  background: color-mix(in srgb, var(--semmet-color-primary) 88%, black);
53
53
  }
54
54
 
55
+ button:disabled {
56
+ opacity: 0.45;
57
+ cursor: not-allowed;
58
+ }
59
+
55
60
  button:focus-visible {
56
61
  outline: 2px solid var(--semmet-color-primary);
57
62
  outline-offset: 2px;
@@ -1,10 +1,34 @@
1
1
  <!-- Semmet Angular: Carousel — https://www.w3.org/WAI/ARIA/apg/patterns/carousel/ -->
2
+ <!--
3
+ Usage from a parent component template:
4
+ Import <%= classify(name) %> and <%= classify(name) %>Slide in that parent component.
5
+
6
+ <<%= selector %>>
7
+ <ng-template <%= camelize(name) %>Slide id="intro" label="Introduction">
8
+ <app-intro-slide />
9
+ </ng-template>
10
+
11
+ <ng-template <%= camelize(name) %>Slide id="summary" label="Summary">
12
+ <app-summary-slide [userId]="userId()" (selected)="selectSummary()" />
13
+ </ng-template>
14
+ </<%= selector %>>
15
+
16
+ Set [preserveContent]="false" on <<%= selector %>> to destroy inactive slide content.
17
+ -->
2
18
  <section aria-roledescription="carousel" aria-label="Featured items">
3
19
  <div aria-live="polite">
4
- <div aria-roledescription="slide" [attr.aria-label]="slideLabel()">
5
- {{ activeSlide().content }}
6
- </div>
20
+ @for (slide of slides(); track slide.id(); let i = $index) {
21
+ <div
22
+ aria-roledescription="slide"
23
+ [attr.aria-label]="slideLabel(i, slide)"
24
+ [hidden]="!isActive(i)"
25
+ >
26
+ @if (shouldRender(slide)) {
27
+ <ng-container [ngTemplateOutlet]="slide.templateRef" />
28
+ }
29
+ </div>
30
+ }
7
31
  </div>
8
- <button type="button" (click)="previous()" aria-label="Previous slide">‹</button>
9
- <button type="button" (click)="next()" aria-label="Next slide">›</button>
32
+ <button type="button" [disabled]="slides().length <= 1" (click)="previous()" aria-label="Previous slide">‹</button>
33
+ <button type="button" [disabled]="slides().length <= 1" (click)="next()" aria-label="Next slide">›</button>
10
34
  </section>
@@ -1,35 +1,91 @@
1
- import { Component, computed, signal } from '@angular/core';
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import {
3
+ Component,
4
+ Directive,
5
+ TemplateRef,
6
+ booleanAttribute,
7
+ computed,
8
+ contentChildren,
9
+ inject,
10
+ input,
11
+ signal,
12
+ } from '@angular/core';
2
13
 
3
- interface <%= classify(name) %>Slide {
4
- id: number;
5
- content: string;
6
- }
14
+ type <%= classify(name) %>SlideId = string | number;
7
15
 
8
16
  let nextId = 0;
17
+ let nextSlideId = 0;
18
+
19
+ @Directive({
20
+ selector: 'ng-template[<%= camelize(name) %>Slide]',
21
+ })
22
+ export class <%= classify(name) %>Slide {
23
+ readonly templateRef = inject<TemplateRef<unknown>>(TemplateRef);
24
+
25
+ readonly id = input<<%= classify(name) %>SlideId>(`<%= dasherize(name) %>-slide-${nextSlideId++}`);
26
+ readonly label = input('');
27
+ }
9
28
 
10
29
  @Component({
11
30
  selector: '<%= selector %>',
12
- imports: [],
31
+ imports: [NgTemplateOutlet],
13
32
  templateUrl: './<%= dasherize(name) %>.html',
14
33
  styleUrl: './<%= dasherize(name) %>.css',
15
34
  })
16
35
  export class <%= classify(name) %> {
36
+ protected readonly slides = contentChildren(<%= classify(name) %>Slide);
17
37
  protected readonly instanceId = `<%= dasherize(name) %>-${nextId++}`;
18
- protected readonly slides: <%= classify(name) %>Slide[] = [
19
- { id: 1, content: 'First slide content' },
20
- { id: 2, content: 'Second slide content' },
21
- ];
38
+ readonly preserveContent = input(true, { transform: booleanAttribute });
22
39
 
23
40
  protected readonly activeIndex = signal(0);
24
41
 
25
- protected readonly activeSlide = computed(() => this.slides[this.activeIndex()]);
26
- protected readonly slideLabel = computed(() => `${this.activeIndex() + 1} of ${this.slides.length}`);
42
+ private readonly renderedIds = signal<ReadonlySet<<%= classify(name) %>SlideId>>(new Set());
43
+ private readonly slideCount = computed(() => this.slides().length);
44
+ private readonly activeSlide = computed(() => this.slides()[this.activeIndex()] ?? null);
45
+
46
+ protected isActive(index: number): boolean {
47
+ return this.activeIndex() === index;
48
+ }
49
+
50
+ protected shouldRender(slide: <%= classify(name) %>Slide): boolean {
51
+ return this.activeSlide()?.id() === slide.id() || (this.preserveContent() && this.renderedIds().has(slide.id()));
52
+ }
53
+
54
+ protected slideLabel(index: number, slide: <%= classify(name) %>Slide): string {
55
+ const position = `${index + 1} of ${this.slideCount()}`;
56
+ return slide.label() ? `${position}: ${slide.label()}` : position;
57
+ }
27
58
 
28
59
  protected previous(): void {
29
- this.activeIndex.update((index) => (index - 1 + this.slides.length) % this.slides.length);
60
+ this.selectIndex(this.activeIndex() - 1);
30
61
  }
31
62
 
32
63
  protected next(): void {
33
- this.activeIndex.update((index) => (index + 1) % this.slides.length);
64
+ this.selectIndex(this.activeIndex() + 1);
65
+ }
66
+
67
+ private selectIndex(index: number): void {
68
+ const slides = this.slides();
69
+ if (slides.length === 0) {
70
+ return;
71
+ }
72
+
73
+ const activeSlide = this.activeSlide();
74
+ if (activeSlide) {
75
+ this.markRendered(activeSlide.id());
76
+ }
77
+
78
+ const nextIndex = (index + slides.length) % slides.length;
79
+ this.markRendered(slides[nextIndex].id());
80
+ this.activeIndex.set(nextIndex);
81
+ }
82
+
83
+ private markRendered(id: <%= classify(name) %>SlideId): void {
84
+ const renderedIds = this.renderedIds();
85
+ if (renderedIds.has(id)) {
86
+ return;
87
+ }
88
+
89
+ this.renderedIds.set(new Set(renderedIds).add(id));
34
90
  }
35
91
  }
@@ -1,8 +1,8 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-on-primary: #ffffff;
4
- --semmet-color-outline: #8a9793;
5
- --semmet-color-on-surface: #1b201f;
4
+ --semmet-color-outline: #71717a;
5
+ --semmet-color-on-surface: #18181b;
6
6
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
7
7
 
8
8
  display: inline-block;
@@ -1,9 +1,9 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-surface: #ffffff;
4
- --semmet-color-outline: #d7dedd;
5
- --semmet-color-on-surface: #1b201f;
6
- --semmet-color-on-surface-variant: #576260;
4
+ --semmet-color-outline: #d4d4d8;
5
+ --semmet-color-on-surface: #18181b;
6
+ --semmet-color-on-surface-variant: #52525b;
7
7
  --semmet-radius-sm: 8px;
8
8
  --semmet-radius-md: 12px;
9
9
  --semmet-elevation-3: 0 8px 24px rgba(20, 24, 23, 0.18), 0 2px 8px rgba(20, 24, 23, 0.1);
@@ -1,8 +1,8 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-surface: #ffffff;
4
- --semmet-color-on-surface: #1b201f;
5
- --semmet-color-on-surface-variant: #576260;
4
+ --semmet-color-on-surface: #18181b;
5
+ --semmet-color-on-surface-variant: #52525b;
6
6
  --semmet-color-scrim: rgba(20, 24, 23, 0.45);
7
7
  --semmet-radius-lg: 20px;
8
8
  --semmet-elevation-3: 0 8px 24px rgba(20, 24, 23, 0.18), 0 2px 8px rgba(20, 24, 23, 0.1);
@@ -1,9 +1,9 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-surface: #ffffff;
4
- --semmet-color-outline: #d7dedd;
5
- --semmet-color-on-surface: #1b201f;
6
- --semmet-color-on-surface-variant: #576260;
4
+ --semmet-color-outline: #d4d4d8;
5
+ --semmet-color-on-surface: #18181b;
6
+ --semmet-color-on-surface-variant: #52525b;
7
7
  --semmet-radius-md: 12px;
8
8
  --semmet-elevation-1: 0 1px 2px rgba(20, 24, 23, 0.1), 0 1px 1px rgba(20, 24, 23, 0.06);
9
9
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
@@ -1,4 +1,16 @@
1
1
  <!-- Semmet Angular: Disclosure — https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/ -->
2
+ <!--
3
+ Usage from a parent component template:
4
+ Import <%= classify(name) %> and <%= classify(name) %>Content in that parent component.
5
+
6
+ <<%= selector %> label="Show profile details" [(expanded)]="profileDetailsOpen">
7
+ <ng-template <%= camelize(name) %>Content>
8
+ <app-profile-details [userId]="userId()" (saved)="reload()" />
9
+ </ng-template>
10
+ </<%= selector %>>
11
+
12
+ Set [preserveContent]="false" on <<%= selector %>> to destroy hidden content.
13
+ -->
2
14
  <button
3
15
  type="button"
4
16
  [attr.aria-expanded]="expanded()"
@@ -6,12 +18,16 @@
6
18
  [id]="instanceId + '-button'"
7
19
  (click)="toggle()"
8
20
  >
9
- Show details
21
+ {{ label() }}
10
22
  </button>
11
23
  <div
12
24
  [id]="instanceId + '-panel'"
13
25
  [attr.aria-labelledby]="instanceId + '-button'"
14
26
  [hidden]="!expanded()"
15
27
  >
16
- Hidden content
28
+ @if (content(); as panel) {
29
+ @if (shouldRender()) {
30
+ <ng-container [ngTemplateOutlet]="panel.templateRef" />
31
+ }
32
+ }
17
33
  </div>
@@ -1,18 +1,46 @@
1
- import { Component, signal } from '@angular/core';
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import {
3
+ Component,
4
+ Directive,
5
+ TemplateRef,
6
+ booleanAttribute,
7
+ contentChild,
8
+ inject,
9
+ input,
10
+ model,
11
+ signal,
12
+ } from '@angular/core';
2
13
 
3
14
  let nextId = 0;
4
15
 
16
+ @Directive({
17
+ selector: 'ng-template[<%= camelize(name) %>Content]',
18
+ })
19
+ export class <%= classify(name) %>Content {
20
+ readonly templateRef = inject<TemplateRef<unknown>>(TemplateRef);
21
+ }
22
+
5
23
  @Component({
6
24
  selector: '<%= selector %>',
7
- imports: [],
25
+ imports: [NgTemplateOutlet],
8
26
  templateUrl: './<%= dasherize(name) %>.html',
9
27
  styleUrl: './<%= dasherize(name) %>.css',
10
28
  })
11
29
  export class <%= classify(name) %> {
30
+ protected readonly content = contentChild(<%= classify(name) %>Content);
12
31
  protected readonly instanceId = `<%= dasherize(name) %>-${nextId++}`;
13
- protected readonly expanded = signal(false);
32
+ readonly label = input('Show details');
33
+ readonly preserveContent = input(true, { transform: booleanAttribute });
34
+ readonly expanded = model(false);
35
+
36
+ private readonly rendered = signal(false);
37
+
38
+ protected shouldRender(): boolean {
39
+ return this.expanded() || (this.preserveContent() && this.rendered());
40
+ }
14
41
 
15
42
  protected toggle(): void {
43
+ this.rendered.set(true);
16
44
  this.expanded.update((value) => !value);
17
45
  }
18
46
  }
@@ -1,7 +1,7 @@
1
1
  :host {
2
- --semmet-color-outline: #d7dedd;
3
- --semmet-color-on-surface: #1b201f;
4
- --semmet-color-on-surface-variant: #576260;
2
+ --semmet-color-outline: #d4d4d8;
3
+ --semmet-color-on-surface: #18181b;
4
+ --semmet-color-on-surface-variant: #52525b;
5
5
 
6
6
  display: grid;
7
7
  grid-template-columns: 1fr;
@@ -1,8 +1,8 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-surface: #ffffff;
4
- --semmet-color-outline: #d7dedd;
5
- --semmet-color-on-surface: #1b201f;
4
+ --semmet-color-outline: #d4d4d8;
5
+ --semmet-color-on-surface: #18181b;
6
6
  --semmet-radius-sm: 8px;
7
7
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
8
8
 
@@ -1,9 +1,9 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-surface: #ffffff;
4
- --semmet-color-outline: #d7dedd;
5
- --semmet-color-on-surface: #1b201f;
6
- --semmet-color-on-surface-variant: #576260;
4
+ --semmet-color-outline: #d4d4d8;
5
+ --semmet-color-on-surface: #18181b;
6
+ --semmet-color-on-surface-variant: #52525b;
7
7
  --semmet-radius-sm: 8px;
8
8
  --semmet-radius-md: 12px;
9
9
  --semmet-elevation-3: 0 8px 24px rgba(20, 24, 23, 0.18), 0 2px 8px rgba(20, 24, 23, 0.1);
@@ -1,6 +1,6 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
3
- --semmet-color-on-surface: #1b201f;
2
+ --semmet-color-primary: #18181b;
3
+ --semmet-color-on-surface: #18181b;
4
4
 
5
5
  display: block;
6
6
  font-family: Roboto, system-ui, -apple-system, 'Segoe UI', sans-serif;
@@ -1,7 +1,7 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-surface: #ffffff;
4
- --semmet-color-on-surface: #1b201f;
4
+ --semmet-color-on-surface: #18181b;
5
5
  --semmet-radius-sm: 8px;
6
6
  --semmet-radius-md: 12px;
7
7
  --semmet-elevation-3: 0 8px 24px rgba(20, 24, 23, 0.18), 0 2px 8px rgba(20, 24, 23, 0.1);
@@ -1,8 +1,8 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-on-primary: #ffffff;
4
- --semmet-color-on-surface: #1b201f;
5
- --semmet-color-on-surface-variant: #576260;
4
+ --semmet-color-on-surface: #18181b;
5
+ --semmet-color-on-surface-variant: #52525b;
6
6
  --semmet-radius-sm: 8px;
7
7
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
8
8
 
@@ -1,7 +1,7 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
3
- --semmet-color-on-surface: #1b201f;
4
- --semmet-color-on-surface-variant: #576260;
2
+ --semmet-color-primary: #18181b;
3
+ --semmet-color-on-surface: #18181b;
4
+ --semmet-color-on-surface-variant: #52525b;
5
5
  --semmet-radius-sm: 8px;
6
6
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
7
7
 
@@ -1,8 +1,8 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
3
- --semmet-color-outline: #8a9793;
4
- --semmet-color-on-surface: #1b201f;
5
- --semmet-color-on-surface-variant: #576260;
2
+ --semmet-color-primary: #18181b;
3
+ --semmet-color-outline: #71717a;
4
+ --semmet-color-on-surface: #18181b;
5
+ --semmet-color-on-surface-variant: #52525b;
6
6
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
7
7
 
8
8
  display: block;
@@ -1,5 +1,5 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-on-primary: #ffffff;
4
4
  --semmet-radius-sm: 8px;
5
5
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
@@ -1,7 +1,7 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
3
- --semmet-color-outline: #d7dedd;
4
- --semmet-color-on-surface: #1b201f;
2
+ --semmet-color-primary: #18181b;
3
+ --semmet-color-outline: #d4d4d8;
4
+ --semmet-color-on-surface: #18181b;
5
5
  --semmet-motion: 120ms cubic-bezier(0.2, 0, 0, 1);
6
6
 
7
7
  display: block;
@@ -1,7 +1,7 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
3
- --semmet-color-outline: #d7dedd;
4
- --semmet-color-on-surface: #1b201f;
2
+ --semmet-color-primary: #18181b;
3
+ --semmet-color-outline: #d4d4d8;
4
+ --semmet-color-on-surface: #18181b;
5
5
  --semmet-radius-sm: 8px;
6
6
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
7
7
 
@@ -1,8 +1,8 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
3
- --semmet-color-outline: #8a9793;
2
+ --semmet-color-primary: #18181b;
3
+ --semmet-color-outline: #71717a;
4
4
  --semmet-color-surface: #ffffff;
5
- --semmet-color-on-surface: #1b201f;
5
+ --semmet-color-on-surface: #18181b;
6
6
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
7
7
 
8
8
  display: inline-block;
@@ -1,8 +1,8 @@
1
1
  :host {
2
- --semmet-color-outline: #d7dedd;
3
- --semmet-color-on-surface: #1b201f;
4
- --semmet-color-on-surface-variant: #576260;
5
- --semmet-color-surface-variant: #f2f6f5;
2
+ --semmet-color-outline: #d4d4d8;
3
+ --semmet-color-on-surface: #18181b;
4
+ --semmet-color-on-surface-variant: #52525b;
5
+ --semmet-color-surface-variant: #f4f4f5;
6
6
 
7
7
  display: block;
8
8
  font-family: Roboto, system-ui, -apple-system, 'Segoe UI', sans-serif;
@@ -1,8 +1,8 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
3
- --semmet-color-outline: #d7dedd;
4
- --semmet-color-on-surface: #1b201f;
5
- --semmet-color-on-surface-variant: #576260;
2
+ --semmet-color-primary: #18181b;
3
+ --semmet-color-outline: #d4d4d8;
4
+ --semmet-color-on-surface: #18181b;
5
+ --semmet-color-on-surface-variant: #52525b;
6
6
  --semmet-radius-sm: 8px;
7
7
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
8
8
 
@@ -1,29 +1,48 @@
1
1
  <!-- Semmet Angular: Tabs — https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ -->
2
+ <!--
3
+ Usage from a parent component template:
4
+ Import <%= classify(name) %> and <%= classify(name) %>Item in that parent component.
5
+
6
+ <<%= selector %>>
7
+ <ng-template <%= camelize(name) %>Item id="profile" label="Profile" [selected]="true">
8
+ <app-profile-panel />
9
+ </ng-template>
10
+
11
+ <ng-template <%= camelize(name) %>Item id="settings" label="Settings">
12
+ <app-settings-panel [userId]="userId()" (saved)="reload()" />
13
+ </ng-template>
14
+ </<%= selector %>>
15
+
16
+ Set [preserveContent]="false" on <<%= selector %>> to destroy inactive tab content.
17
+ -->
2
18
  <div role="tablist" aria-label="Tabs">
3
- @for (item of items; track item.id; let i = $index) {
19
+ @for (item of items(); track item.id(); let i = $index) {
4
20
  <button
5
21
  #tabButton
6
22
  type="button"
7
23
  role="tab"
8
- [attr.aria-selected]="isActive(item.id)"
9
- [attr.aria-controls]="instanceId + '-panel-' + item.id"
10
- [id]="instanceId + '-tab-' + item.id"
11
- [tabIndex]="isActive(item.id) ? 0 : -1"
12
- (click)="select(item.id)"
24
+ [disabled]="item.disabled()"
25
+ [attr.aria-selected]="isActive(item.id())"
26
+ [attr.aria-controls]="instanceId + '-panel-' + item.id()"
27
+ [id]="instanceId + '-tab-' + item.id()"
28
+ [tabIndex]="isActive(item.id()) ? 0 : -1"
29
+ (click)="select(item)"
13
30
  (keydown)="navigateTabs($event, i)"
14
31
  >
15
- {{ item.label }}
32
+ {{ item.label() }}
16
33
  </button>
17
34
  }
18
35
  </div>
19
- @for (item of items; track item.id) {
36
+ @for (item of items(); track item.id()) {
20
37
  <div
21
- [id]="instanceId + '-panel-' + item.id"
38
+ [id]="instanceId + '-panel-' + item.id()"
22
39
  role="tabpanel"
23
40
  tabindex="0"
24
- [attr.aria-labelledby]="instanceId + '-tab-' + item.id"
25
- [hidden]="!isActive(item.id)"
41
+ [attr.aria-labelledby]="instanceId + '-tab-' + item.id()"
42
+ [hidden]="!isActive(item.id())"
26
43
  >
27
- {{ item.content }}
44
+ @if (shouldRender(item.id())) {
45
+ <ng-container [ngTemplateOutlet]="item.templateRef" />
46
+ }
28
47
  </div>
29
48
  }
@@ -1,65 +1,155 @@
1
- import { Component, ElementRef, signal, viewChildren } from '@angular/core';
1
+ import { NgTemplateOutlet } from '@angular/common';
2
+ import {
3
+ Component,
4
+ Directive,
5
+ ElementRef,
6
+ TemplateRef,
7
+ booleanAttribute,
8
+ computed,
9
+ contentChildren,
10
+ inject,
11
+ input,
12
+ signal,
13
+ viewChildren,
14
+ } from '@angular/core';
2
15
 
3
- interface <%= classify(name) %>Item {
4
- id: number;
5
- label: string;
6
- content: string;
7
- }
16
+ type <%= classify(name) %>ItemId = string | number;
8
17
 
9
18
  let nextId = 0;
19
+ let nextItemId = 0;
20
+
21
+ @Directive({
22
+ selector: 'ng-template[<%= camelize(name) %>Item]',
23
+ })
24
+ export class <%= classify(name) %>Item {
25
+ readonly templateRef = inject<TemplateRef<unknown>>(TemplateRef);
26
+
27
+ readonly id = input<<%= classify(name) %>ItemId>(`<%= dasherize(name) %>-item-${nextItemId++}`);
28
+ readonly label = input.required<string>();
29
+ readonly selected = input(false, { transform: booleanAttribute });
30
+ readonly disabled = input(false, { transform: booleanAttribute });
31
+ }
10
32
 
11
33
  @Component({
12
34
  selector: '<%= selector %>',
13
- imports: [],
35
+ imports: [NgTemplateOutlet],
14
36
  templateUrl: './<%= dasherize(name) %>.html',
15
37
  styleUrl: './<%= dasherize(name) %>.css',
16
38
  })
17
39
  export class <%= classify(name) %> {
18
40
  private readonly tabButtons = viewChildren<ElementRef<HTMLButtonElement>>('tabButton');
19
41
 
42
+ protected readonly items = contentChildren(<%= classify(name) %>Item);
20
43
  protected readonly instanceId = `<%= dasherize(name) %>-${nextId++}`;
21
- protected readonly items: <%= classify(name) %>Item[] = [
22
- { id: 1, label: 'First tab', content: 'First panel content' },
23
- { id: 2, label: 'Second tab', content: 'Second panel content' },
24
- ];
44
+ readonly preserveContent = input(true, { transform: booleanAttribute });
25
45
 
26
- private readonly activeId = signal(this.items[0].id);
46
+ private readonly renderedIds = signal<ReadonlySet<<%= classify(name) %>ItemId>>(new Set());
47
+ private readonly selectedId = signal<<%= classify(name) %>ItemId | null>(null);
27
48
 
28
- protected isActive(id: number): boolean {
49
+ private readonly activeId = computed<<%= classify(name) %>ItemId | null>(() => {
50
+ const selectedId = this.selectedId();
51
+ const items = this.items();
52
+ if (selectedId !== null && items.some((item) => item.id() === selectedId && !item.disabled())) {
53
+ return selectedId;
54
+ }
55
+
56
+ return (
57
+ items.find((item) => item.selected() && !item.disabled()) ??
58
+ items.find((item) => !item.disabled()) ??
59
+ items[0]
60
+ )?.id() ?? null;
61
+ });
62
+
63
+ protected isActive(id: <%= classify(name) %>ItemId): boolean {
29
64
  return this.activeId() === id;
30
65
  }
31
66
 
32
- protected select(id: number): void {
33
- this.activeId.set(id);
67
+ protected shouldRender(id: <%= classify(name) %>ItemId): boolean {
68
+ return this.isActive(id) || (this.preserveContent() && this.renderedIds().has(id));
69
+ }
70
+
71
+ protected select(item: <%= classify(name) %>Item): void {
72
+ if (item.disabled()) {
73
+ return;
74
+ }
75
+
76
+ const activeId = this.activeId();
77
+ if (activeId !== null) {
78
+ this.markRendered(activeId);
79
+ }
80
+
81
+ const id = item.id();
82
+ this.markRendered(id);
83
+ this.selectedId.set(id);
34
84
  }
35
85
 
36
86
  // Left/Right/Home/End move focus AND selection (automatic activation model, per APG tabs pattern).
37
87
  protected navigateTabs(event: KeyboardEvent, index: number): void {
88
+ const items = this.items();
38
89
  const buttons = this.tabButtons();
39
- if (buttons.length === 0) {
90
+ if (buttons.length === 0 || items.length === 0) {
40
91
  return;
41
92
  }
42
93
 
43
- let nextIndex: number;
94
+ let nextIndex: number | null;
44
95
  switch (event.key) {
45
96
  case 'ArrowRight':
46
- nextIndex = (index + 1) % buttons.length;
97
+ nextIndex = this.nextEnabledIndex(items, index, 1);
47
98
  break;
48
99
  case 'ArrowLeft':
49
- nextIndex = (index - 1 + buttons.length) % buttons.length;
100
+ nextIndex = this.nextEnabledIndex(items, index, -1);
50
101
  break;
51
102
  case 'Home':
52
- nextIndex = 0;
103
+ nextIndex = this.firstEnabledIndex(items);
53
104
  break;
54
105
  case 'End':
55
- nextIndex = buttons.length - 1;
106
+ nextIndex = this.lastEnabledIndex(items);
56
107
  break;
57
108
  default:
58
109
  return;
59
110
  }
60
111
 
112
+ if (nextIndex === null) {
113
+ return;
114
+ }
115
+
61
116
  event.preventDefault();
62
- this.select(this.items[nextIndex].id);
63
- buttons[nextIndex].nativeElement.focus();
117
+ this.select(items[nextIndex]);
118
+ buttons[nextIndex]?.nativeElement.focus();
119
+ }
120
+
121
+ private markRendered(id: <%= classify(name) %>ItemId): void {
122
+ const renderedIds = this.renderedIds();
123
+ if (renderedIds.has(id)) {
124
+ return;
125
+ }
126
+
127
+ this.renderedIds.set(new Set(renderedIds).add(id));
128
+ }
129
+
130
+ private nextEnabledIndex(items: readonly <%= classify(name) %>Item[], index: number, direction: 1 | -1): number | null {
131
+ for (let step = 1; step <= items.length; step++) {
132
+ const nextIndex = (index + step * direction + items.length) % items.length;
133
+ if (!items[nextIndex].disabled()) {
134
+ return nextIndex;
135
+ }
136
+ }
137
+
138
+ return null;
139
+ }
140
+
141
+ private firstEnabledIndex(items: readonly <%= classify(name) %>Item[]): number | null {
142
+ const index = items.findIndex((item) => !item.disabled());
143
+ return index === -1 ? null : index;
144
+ }
145
+
146
+ private lastEnabledIndex(items: readonly <%= classify(name) %>Item[]): number | null {
147
+ for (let index = items.length - 1; index >= 0; index--) {
148
+ if (!items[index].disabled()) {
149
+ return index;
150
+ }
151
+ }
152
+
153
+ return null;
64
154
  }
65
155
  }
@@ -1,6 +1,6 @@
1
1
  :host {
2
- --semmet-color-on-surface: #1b201f;
3
- --semmet-color-inverse-surface: #2f3736;
2
+ --semmet-color-on-surface: #18181b;
3
+ --semmet-color-inverse-surface: #27272a;
4
4
  --semmet-color-inverse-on-surface: #ffffff;
5
5
  --semmet-radius-sm: 8px;
6
6
  --semmet-elevation-3: 0 8px 24px rgba(20, 24, 23, 0.18), 0 2px 8px rgba(20, 24, 23, 0.1);
@@ -1,9 +1,9 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
2
+ --semmet-color-primary: #18181b;
3
3
  --semmet-color-on-primary: #ffffff;
4
4
  --semmet-color-surface: #ffffff;
5
- --semmet-color-outline: #d7dedd;
6
- --semmet-color-on-surface: #1b201f;
5
+ --semmet-color-outline: #d4d4d8;
6
+ --semmet-color-on-surface: #18181b;
7
7
  --semmet-radius-sm: 8px;
8
8
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
9
9
 
@@ -1,6 +1,6 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
3
- --semmet-color-inverse-surface: #2f3736;
2
+ --semmet-color-primary: #18181b;
3
+ --semmet-color-inverse-surface: #27272a;
4
4
  --semmet-color-inverse-on-surface: #ffffff;
5
5
  --semmet-radius-sm: 6px;
6
6
  --semmet-elevation-1: 0 1px 2px rgba(20, 24, 23, 0.1), 0 1px 1px rgba(20, 24, 23, 0.06);
@@ -1,7 +1,7 @@
1
1
  :host {
2
- --semmet-color-primary: #0d7a72;
3
- --semmet-color-on-surface: #1b201f;
4
- --semmet-color-on-surface-variant: #576260;
2
+ --semmet-color-primary: #18181b;
3
+ --semmet-color-on-surface: #18181b;
4
+ --semmet-color-on-surface-variant: #52525b;
5
5
  --semmet-radius-sm: 6px;
6
6
  --semmet-motion: 160ms cubic-bezier(0.2, 0, 0, 1);
7
7