semmet-angular 0.6.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.
- package/package.json +1 -1
- package/src/accordion/files/__name@dasherize__/__name@dasherize__.html.template +29 -10
- package/src/accordion/files/__name@dasherize__/__name@dasherize__.ts.template +69 -15
- package/src/carousel/files/__name@dasherize__/__name@dasherize__.css.template +5 -0
- package/src/carousel/files/__name@dasherize__/__name@dasherize__.html.template +29 -5
- package/src/carousel/files/__name@dasherize__/__name@dasherize__.ts.template +70 -14
- package/src/disclosure/files/__name@dasherize__/__name@dasherize__.html.template +18 -2
- package/src/disclosure/files/__name@dasherize__/__name@dasherize__.ts.template +31 -3
- package/src/tabs/files/__name@dasherize__/__name@dasherize__.html.template +31 -12
- package/src/tabs/files/__name@dasherize__/__name@dasherize__.ts.template +113 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "semmet-angular",
|
|
3
|
-
"version": "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,24 +1,43 @@
|
|
|
1
1
|
<!-- Semmet Angular: Accordion — https://www.w3.org/WAI/ARIA/apg/patterns/accordion/ -->
|
|
2
|
-
|
|
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
|
-
[
|
|
8
|
-
[attr.aria-
|
|
9
|
-
[
|
|
10
|
-
|
|
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
|
-
|
|
39
|
+
@if (shouldRender(item.id())) {
|
|
40
|
+
<ng-container [ngTemplateOutlet]="item.templateRef" />
|
|
41
|
+
}
|
|
23
42
|
</div>
|
|
24
43
|
}
|
|
@@ -1,42 +1,96 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
60
|
+
this.selectIndex(this.activeIndex() - 1);
|
|
30
61
|
}
|
|
31
62
|
|
|
32
63
|
protected next(): void {
|
|
33
|
-
this.activeIndex
|
|
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,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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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,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
|
-
[
|
|
9
|
-
[attr.aria-
|
|
10
|
-
[
|
|
11
|
-
[
|
|
12
|
-
|
|
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
|
-
|
|
44
|
+
@if (shouldRender(item.id())) {
|
|
45
|
+
<ng-container [ngTemplateOutlet]="item.templateRef" />
|
|
46
|
+
}
|
|
28
47
|
</div>
|
|
29
48
|
}
|
|
@@ -1,65 +1,155 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
46
|
+
private readonly renderedIds = signal<ReadonlySet<<%= classify(name) %>ItemId>>(new Set());
|
|
47
|
+
private readonly selectedId = signal<<%= classify(name) %>ItemId | null>(null);
|
|
27
48
|
|
|
28
|
-
|
|
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
|
|
33
|
-
this.
|
|
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
|
|
97
|
+
nextIndex = this.nextEnabledIndex(items, index, 1);
|
|
47
98
|
break;
|
|
48
99
|
case 'ArrowLeft':
|
|
49
|
-
nextIndex = (index -
|
|
100
|
+
nextIndex = this.nextEnabledIndex(items, index, -1);
|
|
50
101
|
break;
|
|
51
102
|
case 'Home':
|
|
52
|
-
nextIndex =
|
|
103
|
+
nextIndex = this.firstEnabledIndex(items);
|
|
53
104
|
break;
|
|
54
105
|
case 'End':
|
|
55
|
-
nextIndex =
|
|
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(
|
|
63
|
-
buttons[nextIndex]
|
|
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
|
}
|