semmet-angular 0.44.0 → 0.46.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/README.md +2 -0
- package/package.json +1 -1
- package/src/accordion/files/__name@dasherize__/__name@dasherize__.css.template +0 -39
- package/src/accordion/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +16 -17
- package/src/accordion/files/__name@dasherize__/__name@dasherize__.html.template +14 -34
- package/src/accordion/files/__name@dasherize__/__name@dasherize__.spec.ts.template +52 -62
- package/src/accordion/files/__name@dasherize__/__name@dasherize__.ts.template +88 -82
- package/src/accordion/schema.json +1 -1
- package/src/block/files/__name@dasherize__/__name@dasherize__.html.template +51 -23
- package/src/block/files/__name@dasherize__/__name@dasherize__.ts.template +16 -10
- package/src/breadcrumb/files/__name@dasherize__/__name@dasherize__.css.template +0 -32
- package/src/breadcrumb/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +5 -2
- package/src/breadcrumb/files/__name@dasherize__/__name@dasherize__.html.template +8 -7
- package/src/breadcrumb/files/__name@dasherize__/__name@dasherize__.spec.ts.template +51 -0
- package/src/breadcrumb/files/__name@dasherize__/__name@dasherize__.ts.template +18 -19
- package/src/breadcrumb/index.js +1 -1
- package/src/breadcrumb/index.js.map +1 -1
- package/src/breadcrumb/index.ts +1 -1
- package/src/breadcrumb/schema.json +1 -1
- package/src/collection.json +9 -9
- package/src/menu-button/files/__name@dasherize__/__name@dasherize__.css.template +0 -48
- package/src/menu-button/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +2 -2
- package/src/menu-button/files/__name@dasherize__/__name@dasherize__.html.template +14 -35
- package/src/menu-button/files/__name@dasherize__/__name@dasherize__.spec.ts.template +53 -11
- package/src/menu-button/files/__name@dasherize__/__name@dasherize__.ts.template +128 -54
- package/src/menu-button/schema.json +1 -1
- package/src/navbar/files/__name@dasherize__/__name@dasherize__.css.template +0 -119
- package/src/navbar/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +10 -7
- package/src/navbar/files/__name@dasherize__/__name@dasherize__.html.template +18 -42
- package/src/navbar/files/__name@dasherize__/__name@dasherize__.spec.ts.template +46 -23
- package/src/navbar/files/__name@dasherize__/__name@dasherize__.ts.template +54 -50
- package/src/navbar/schema.json +1 -1
- package/src/pagination/files/__name@dasherize__/__name@dasherize__.css.template +0 -35
- package/src/pagination/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +2 -2
- package/src/pagination/files/__name@dasherize__/__name@dasherize__.html.template +11 -13
- package/src/pagination/files/__name@dasherize__/__name@dasherize__.spec.ts.template +58 -8
- package/src/pagination/files/__name@dasherize__/__name@dasherize__.ts.template +17 -31
- package/src/pagination/schema.json +1 -1
- package/src/table/files/__name@dasherize__/__name@dasherize__.css.template +0 -17
- package/src/table/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +1 -1
- package/src/table/files/__name@dasherize__/__name@dasherize__.html.template +13 -25
- package/src/table/files/__name@dasherize__/__name@dasherize__.ts.template +3 -35
- package/src/table/schema.json +1 -1
- package/src/tabs/files/__name@dasherize__/__name@dasherize__.css.template +0 -40
- package/src/tabs/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +2 -2
- package/src/tabs/files/__name@dasherize__/__name@dasherize__.html.template +18 -41
- package/src/tabs/files/__name@dasherize__/__name@dasherize__.spec.ts.template +39 -31
- package/src/tabs/files/__name@dasherize__/__name@dasherize__.ts.template +111 -113
- package/src/tabs/schema.json +1 -1
- package/src/toolbar/files/__name@dasherize__/__name@dasherize__.css.template +0 -19
- package/src/toolbar/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +3 -4
- package/src/toolbar/files/__name@dasherize__/__name@dasherize__.html.template +7 -16
- package/src/toolbar/files/__name@dasherize__/__name@dasherize__.spec.ts.template +26 -11
- package/src/toolbar/files/__name@dasherize__/__name@dasherize__.ts.template +59 -37
- package/src/toolbar/schema.json +1 -1
- package/src/tree-view/files/__name@dasherize__/__name@dasherize__.css.template +0 -39
- package/src/tree-view/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +4 -5
- package/src/tree-view/files/__name@dasherize__/__name@dasherize__.html.template +12 -31
- package/src/tree-view/files/__name@dasherize__/__name@dasherize__.spec.ts.template +80 -43
- package/src/tree-view/files/__name@dasherize__/__name@dasherize__.ts.template +105 -88
- package/src/tree-view/schema.json +1 -1
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
**Accessible Angular UI generation for Angular applications.**
|
|
4
4
|
|
|
5
|
+
> **AI agents:** before generating or editing code with this library, read [AI_USAGE_GUIDE.md](./AI_USAGE_GUIDE.md). It has operational rules for Figma-to-code workflows, a Figma-pattern-to-command mapping table, and what is safe to change vs. accessibility-critical.
|
|
6
|
+
|
|
5
7
|
Semmet Angular is a collection of Angular CLI schematics that generate accessible, editable application code: components, starter blocks, tests, and delivery scaffolds.
|
|
6
8
|
|
|
7
9
|
The generated code belongs to the consuming project. There is no Semmet runtime UI package to import at runtime; `semmet-angular` is only needed when generating files.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "semmet-angular",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.46.0",
|
|
4
4
|
"description": "Accessible UI delivery dev kit in Angular: schematics that generate components, compositions, tests, and delivery scaffolds as native editable Angular code.",
|
|
5
5
|
"publisher": "danilodevsilva",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
:host {
|
|
2
|
-
display: block;
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
h3 {
|
|
6
|
-
margin: 0;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
button {
|
|
10
|
-
display: flex;
|
|
11
|
-
align-items: center;
|
|
12
|
-
width: 100%;
|
|
13
|
-
font: inherit;
|
|
14
|
-
color: inherit;
|
|
15
|
-
background: none;
|
|
16
|
-
border: none;
|
|
17
|
-
cursor: pointer;
|
|
18
|
-
text-align: left;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
button:focus-visible {
|
|
22
|
-
outline: 2px solid currentColor;
|
|
23
|
-
outline-offset: 2px;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
button::after {
|
|
27
|
-
content: '';
|
|
28
|
-
width: 0.5rem;
|
|
29
|
-
height: 0.5rem;
|
|
30
|
-
margin-left: auto;
|
|
31
|
-
flex-shrink: 0;
|
|
32
|
-
border-right: 2px solid currentColor;
|
|
33
|
-
border-bottom: 2px solid currentColor;
|
|
34
|
-
transform: rotate(45deg);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
button[aria-expanded='true']::after {
|
|
38
|
-
transform: rotate(-135deg);
|
|
39
|
-
}
|
|
@@ -4,37 +4,36 @@ test.describe('<%= classify(name) %>', () => {
|
|
|
4
4
|
test('toggles mounted accordion panels and keeps disabled headers stable', async ({ page }) => {
|
|
5
5
|
await page.goto('/');
|
|
6
6
|
|
|
7
|
-
const component = page.locator('<%=
|
|
7
|
+
const component = page.locator('[<%= camelize(name) %>]').first();
|
|
8
8
|
test.skip(
|
|
9
9
|
(await component.count()) === 0,
|
|
10
|
-
'Mount
|
|
10
|
+
'Mount an element with [<%= camelize(name) %>] in a route or host component to enable this generated e2e test.'
|
|
11
11
|
);
|
|
12
12
|
|
|
13
13
|
await expect(component).toBeVisible();
|
|
14
14
|
|
|
15
|
-
const
|
|
16
|
-
const enabledButton = buttons.filter({ hasText: /settings|details|show|open/i }).first();
|
|
17
|
-
const targetButton = (await enabledButton.count()) > 0 ? enabledButton : buttons.filter({ hasNotText: /billing/i }).nth(1);
|
|
18
|
-
|
|
15
|
+
const headers = component.locator('[<%= camelize(name) %>Header]');
|
|
19
16
|
test.skip(
|
|
20
|
-
(await
|
|
21
|
-
'Mount at least two
|
|
17
|
+
(await headers.count()) < 2,
|
|
18
|
+
'Mount at least two accordion headers to enable the generated accordion interaction e2e test.'
|
|
22
19
|
);
|
|
23
20
|
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
const targetHeader = headers.nth(1);
|
|
22
|
+
|
|
23
|
+
await expect(targetHeader).toHaveAttribute('aria-controls', /.+/);
|
|
24
|
+
const controls = await targetHeader.getAttribute('aria-controls');
|
|
26
25
|
const panel = page.locator(`#${controls}`);
|
|
27
26
|
|
|
28
|
-
await
|
|
29
|
-
await expect(
|
|
27
|
+
await targetHeader.click();
|
|
28
|
+
await expect(targetHeader).toHaveAttribute('aria-expanded', 'true');
|
|
30
29
|
await expect(panel).toBeVisible();
|
|
31
30
|
|
|
32
|
-
await
|
|
33
|
-
await expect(
|
|
31
|
+
await targetHeader.click();
|
|
32
|
+
await expect(targetHeader).toHaveAttribute('aria-expanded', 'false');
|
|
34
33
|
|
|
35
|
-
const
|
|
36
|
-
if ((await
|
|
37
|
-
await expect(
|
|
34
|
+
const disabledHeader = component.locator('[<%= camelize(name) %>Header][aria-disabled="true"]').first();
|
|
35
|
+
if ((await disabledHeader.count()) > 0) {
|
|
36
|
+
await expect(disabledHeader).toHaveAttribute('aria-expanded', 'false');
|
|
38
37
|
}
|
|
39
38
|
});
|
|
40
39
|
});
|
|
@@ -1,43 +1,23 @@
|
|
|
1
1
|
<!-- Semmet Angular: Accordion — https://www.w3.org/WAI/ARIA/apg/patterns/accordion/ -->
|
|
2
2
|
<!--
|
|
3
3
|
Usage from a parent component template:
|
|
4
|
-
Import <%= classify(name)
|
|
4
|
+
Import <%= classify(name) %>, <%= classify(name) %>Header and <%= classify(name) %>Panel
|
|
5
|
+
in that parent component. This directive set never owns markup: bring your own wrapper,
|
|
6
|
+
heading level and panel content, in whatever structure the layout needs.
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
<
|
|
8
|
+
<div <%= camelize(name) %>>
|
|
9
|
+
<h3>
|
|
10
|
+
<button type="button" <%= camelize(name) %>Header value="profile" [expanded]="true">Profile</button>
|
|
11
|
+
</h3>
|
|
12
|
+
<div <%= camelize(name) %>Panel value="profile">
|
|
8
13
|
<app-profile-panel />
|
|
9
|
-
</
|
|
10
|
-
|
|
11
|
-
<ng-template <%= camelize(name) %>Item id="settings" title="Settings">
|
|
12
|
-
<app-settings-panel [userId]="userId()" (saved)="reload()" />
|
|
13
|
-
</ng-template>
|
|
14
|
-
</<%= selector %>>
|
|
14
|
+
</div>
|
|
15
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) {
|
|
19
16
|
<h3>
|
|
20
|
-
<button
|
|
21
|
-
#headerButton
|
|
22
|
-
type="button"
|
|
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)"
|
|
28
|
-
(keydown)="navigateHeaders($event)"
|
|
29
|
-
>
|
|
30
|
-
{{ item.title() }}
|
|
31
|
-
</button>
|
|
17
|
+
<button type="button" <%= camelize(name) %>Header value="settings">Settings</button>
|
|
32
18
|
</h3>
|
|
33
|
-
<div
|
|
34
|
-
[
|
|
35
|
-
role="region"
|
|
36
|
-
[attr.aria-labelledby]="instanceId + '-header-' + item.id()"
|
|
37
|
-
[hidden]="!isExpanded(item.id())"
|
|
38
|
-
>
|
|
39
|
-
@if (shouldRender(item.id())) {
|
|
40
|
-
<ng-container [ngTemplateOutlet]="item.templateRef" />
|
|
41
|
-
}
|
|
19
|
+
<div <%= camelize(name) %>Panel value="settings">
|
|
20
|
+
<app-settings-panel [userId]="userId()" (saved)="reload()" />
|
|
42
21
|
</div>
|
|
43
|
-
|
|
22
|
+
</div>
|
|
23
|
+
-->
|
|
@@ -1,31 +1,38 @@
|
|
|
1
1
|
import { Component } from '@angular/core';
|
|
2
2
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
3
|
-
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
<%= classify(name) %>,
|
|
6
|
+
<%= classify(name) %>Header,
|
|
7
|
+
<%= classify(name) %>Panel,
|
|
8
|
+
} from './<%= dasherize(name) %>';
|
|
4
9
|
|
|
5
10
|
@Component({
|
|
6
|
-
imports: [<%= classify(name) %>, <%= classify(name) %>
|
|
11
|
+
imports: [<%= classify(name) %>, <%= classify(name) %>Header, <%= classify(name) %>Panel],
|
|
7
12
|
template: `
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
<p>Billing content</p>
|
|
19
|
-
</ng-template>
|
|
20
|
-
</<%= selector %>>
|
|
13
|
+
<div <%= camelize(name) %>>
|
|
14
|
+
@for (item of items; track item.id) {
|
|
15
|
+
<h3>
|
|
16
|
+
<button type="button" <%= camelize(name) %>Header [value]="item.id" [expanded]="item.expanded" [disabled]="item.disabled">
|
|
17
|
+
{{ item.title }}
|
|
18
|
+
</button>
|
|
19
|
+
</h3>
|
|
20
|
+
<div <%= camelize(name) %>Panel [value]="item.id">{{ item.title }} content</div>
|
|
21
|
+
}
|
|
22
|
+
</div>
|
|
21
23
|
`,
|
|
22
24
|
})
|
|
23
25
|
class HostComponent {
|
|
24
|
-
|
|
26
|
+
readonly items = [
|
|
27
|
+
{ id: 'profile', title: 'Profile', expanded: true, disabled: false },
|
|
28
|
+
{ id: 'settings', title: 'Settings', expanded: false, disabled: false },
|
|
29
|
+
{ id: 'billing', title: 'Billing', expanded: false, disabled: true },
|
|
30
|
+
];
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
describe('<%= classify(name) %>', () => {
|
|
28
34
|
let fixture: ComponentFixture<HostComponent>;
|
|
35
|
+
let host: HTMLElement;
|
|
29
36
|
|
|
30
37
|
beforeEach(async () => {
|
|
31
38
|
await TestBed.configureTestingModule({
|
|
@@ -34,91 +41,74 @@ describe('<%= classify(name) %>', () => {
|
|
|
34
41
|
|
|
35
42
|
fixture = TestBed.createComponent(HostComponent);
|
|
36
43
|
fixture.detectChanges();
|
|
44
|
+
host = fixture.nativeElement as HTMLElement;
|
|
37
45
|
});
|
|
38
46
|
|
|
39
|
-
function
|
|
40
|
-
return Array.from(
|
|
47
|
+
function headers(): HTMLButtonElement[] {
|
|
48
|
+
return Array.from(host.querySelectorAll('button'));
|
|
41
49
|
}
|
|
42
50
|
|
|
43
51
|
function panels(): HTMLElement[] {
|
|
44
|
-
return Array.from(
|
|
52
|
+
return Array.from(host.querySelectorAll('[role="region"]'));
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
it('
|
|
48
|
-
const [
|
|
55
|
+
it('decorates consumer-owned markup with header/region ARIA relationships', () => {
|
|
56
|
+
const [profileHeader, settingsHeader] = headers();
|
|
49
57
|
const [profilePanel, settingsPanel] = panels();
|
|
50
58
|
|
|
51
|
-
expect(
|
|
52
|
-
expect(
|
|
53
|
-
expect(
|
|
54
|
-
expect(profilePanel.getAttribute('aria-labelledby')).toBe(profileButton.id);
|
|
59
|
+
expect(profileHeader.getAttribute('aria-expanded')).toBe('true');
|
|
60
|
+
expect(profileHeader.getAttribute('aria-controls')).toBe(profilePanel.id);
|
|
61
|
+
expect(profilePanel.getAttribute('aria-labelledby')).toBe(profileHeader.id);
|
|
55
62
|
expect(profilePanel.hidden).toBe(false);
|
|
56
63
|
|
|
57
|
-
expect(
|
|
58
|
-
expect(settingsButton.getAttribute('aria-expanded')).toBe('false');
|
|
59
|
-
expect(settingsButton.getAttribute('aria-controls')).toBe(settingsPanel.id);
|
|
64
|
+
expect(settingsHeader.getAttribute('aria-expanded')).toBe('false');
|
|
60
65
|
expect(settingsPanel.hidden).toBe(true);
|
|
61
66
|
});
|
|
62
67
|
|
|
63
|
-
it('toggles an enabled
|
|
64
|
-
const [,
|
|
68
|
+
it('toggles an enabled header with click and keeps panel content mounted', () => {
|
|
69
|
+
const [, settingsHeader] = headers();
|
|
65
70
|
const [, settingsPanel] = panels();
|
|
66
71
|
|
|
67
|
-
|
|
72
|
+
settingsHeader.click();
|
|
68
73
|
fixture.detectChanges();
|
|
69
74
|
|
|
70
|
-
expect(
|
|
75
|
+
expect(settingsHeader.getAttribute('aria-expanded')).toBe('true');
|
|
71
76
|
expect(settingsPanel.hidden).toBe(false);
|
|
72
77
|
expect(settingsPanel.textContent).toContain('Settings content');
|
|
73
78
|
|
|
74
|
-
|
|
79
|
+
settingsHeader.click();
|
|
75
80
|
fixture.detectChanges();
|
|
76
81
|
|
|
77
|
-
expect(
|
|
82
|
+
expect(settingsHeader.getAttribute('aria-expanded')).toBe('false');
|
|
78
83
|
expect(settingsPanel.hidden).toBe(true);
|
|
84
|
+
expect(settingsPanel.textContent).toContain('Settings content');
|
|
79
85
|
});
|
|
80
86
|
|
|
81
|
-
it('does not toggle disabled
|
|
82
|
-
const
|
|
87
|
+
it('does not toggle disabled headers', () => {
|
|
88
|
+
const disabledHeader = headers()[2];
|
|
83
89
|
const disabledPanel = panels()[2];
|
|
84
90
|
|
|
85
|
-
|
|
86
|
-
expect(disabledButton.getAttribute('aria-expanded')).toBe('false');
|
|
87
|
-
|
|
88
|
-
disabledButton.click();
|
|
91
|
+
disabledHeader.click();
|
|
89
92
|
fixture.detectChanges();
|
|
90
93
|
|
|
91
|
-
expect(
|
|
94
|
+
expect(disabledHeader.getAttribute('aria-disabled')).toBe('true');
|
|
95
|
+
expect(disabledHeader.getAttribute('aria-expanded')).toBe('false');
|
|
92
96
|
expect(disabledPanel.hidden).toBe(true);
|
|
93
97
|
});
|
|
94
98
|
|
|
95
|
-
it('keeps closed content after first render when preserveContent is enabled', () => {
|
|
96
|
-
const [, settingsButton] = buttons();
|
|
97
|
-
const [, settingsPanel] = panels();
|
|
98
|
-
|
|
99
|
-
settingsButton.click();
|
|
100
|
-
fixture.detectChanges();
|
|
101
|
-
settingsButton.click();
|
|
102
|
-
fixture.detectChanges();
|
|
103
|
-
|
|
104
|
-
expect(settingsPanel.hidden).toBe(true);
|
|
105
|
-
expect(settingsPanel.textContent).toContain('Settings content');
|
|
106
|
-
});
|
|
107
|
-
|
|
108
99
|
it('moves focus between headers with arrow, Home, and End keys', () => {
|
|
109
|
-
const [
|
|
100
|
+
const [profileHeader, settingsHeader] = headers();
|
|
110
101
|
|
|
111
|
-
|
|
112
|
-
profileButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
102
|
+
profileHeader.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
113
103
|
fixture.detectChanges();
|
|
114
|
-
expect(document.activeElement).toBe(
|
|
104
|
+
expect(document.activeElement).toBe(settingsHeader);
|
|
115
105
|
|
|
116
|
-
|
|
106
|
+
settingsHeader.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
|
|
117
107
|
fixture.detectChanges();
|
|
118
|
-
expect(document.activeElement).toBe(
|
|
108
|
+
expect(document.activeElement).toBe(settingsHeader);
|
|
119
109
|
|
|
120
|
-
|
|
110
|
+
settingsHeader.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
|
|
121
111
|
fixture.detectChanges();
|
|
122
|
-
expect(document.activeElement).toBe(
|
|
112
|
+
expect(document.activeElement).toBe(profileHeader);
|
|
123
113
|
});
|
|
124
114
|
});
|
|
@@ -1,106 +1,56 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
Directive,
|
|
5
|
-
ElementRef,
|
|
6
|
-
TemplateRef,
|
|
7
|
-
booleanAttribute,
|
|
8
|
-
computed,
|
|
9
|
-
contentChildren,
|
|
10
|
-
inject,
|
|
11
|
-
input,
|
|
12
|
-
signal,
|
|
13
|
-
viewChildren,
|
|
14
|
-
} from '@angular/core';
|
|
15
|
-
|
|
16
|
-
type <%= classify(name) %>ItemId = string | number;
|
|
1
|
+
import { Directive, ElementRef, booleanAttribute, computed, contentChildren, inject, input, model } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
type <%= classify(name) %>Id = string;
|
|
17
4
|
|
|
18
5
|
let nextId = 0;
|
|
19
|
-
let nextItemId = 0;
|
|
20
6
|
|
|
21
7
|
@Directive({
|
|
22
|
-
selector: '
|
|
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
|
-
}
|
|
32
|
-
|
|
33
|
-
@Component({
|
|
34
|
-
selector: '<%= selector %>',
|
|
35
|
-
imports: [NgTemplateOutlet],
|
|
36
|
-
templateUrl: './<%= dasherize(name) %>.html',
|
|
37
|
-
styleUrl: './<%= dasherize(name) %>.css',
|
|
8
|
+
selector: '[<%= camelize(name) %>]',
|
|
9
|
+
exportAs: '<%= camelize(name) %>',
|
|
38
10
|
})
|
|
39
11
|
export class <%= classify(name) %> {
|
|
40
|
-
private readonly
|
|
41
|
-
|
|
42
|
-
protected readonly items = contentChildren(<%= classify(name) %>Item);
|
|
43
|
-
protected readonly instanceId = `<%= dasherize(name) %>-${nextId++}`;
|
|
44
|
-
readonly preserveContent = input(true, { transform: booleanAttribute });
|
|
12
|
+
private readonly instanceId = `<%= dasherize(name) %>-${nextId++}`;
|
|
13
|
+
private readonly headers = contentChildren(<%= classify(name) %>Header, { descendants: true });
|
|
45
14
|
|
|
46
|
-
|
|
47
|
-
private readonly userExpandedIds = signal<ReadonlySet<<%= classify(name) %>ItemId> | null>(null);
|
|
15
|
+
readonly expandedIds = model<ReadonlySet<<%= classify(name) %>Id> | null>(null);
|
|
48
16
|
|
|
49
|
-
private readonly
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
52
|
-
return
|
|
17
|
+
private readonly resolvedExpandedIds = computed<ReadonlySet<<%= classify(name) %>Id>>(() => {
|
|
18
|
+
const expandedIds = this.expandedIds();
|
|
19
|
+
if (expandedIds) {
|
|
20
|
+
return expandedIds;
|
|
53
21
|
}
|
|
54
22
|
|
|
55
23
|
return new Set(
|
|
56
|
-
this.
|
|
57
|
-
.filter((
|
|
58
|
-
.map((
|
|
24
|
+
this.headers()
|
|
25
|
+
.filter((header) => header.expanded())
|
|
26
|
+
.map((header) => header.value()),
|
|
59
27
|
);
|
|
60
28
|
});
|
|
61
29
|
|
|
62
|
-
|
|
63
|
-
return this.
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
protected shouldRender(id: <%= classify(name) %>ItemId): boolean {
|
|
67
|
-
return this.isExpanded(id) || (this.preserveContent() && this.renderedIds().has(id));
|
|
30
|
+
isExpanded(value: <%= classify(name) %>Id): boolean {
|
|
31
|
+
return this.resolvedExpandedIds().has(value);
|
|
68
32
|
}
|
|
69
33
|
|
|
70
|
-
|
|
71
|
-
if (
|
|
34
|
+
toggle(header: <%= classify(name) %>Header): void {
|
|
35
|
+
if (header.disabled()) {
|
|
72
36
|
return;
|
|
73
37
|
}
|
|
74
38
|
|
|
75
|
-
const
|
|
76
|
-
const next = new Set(this.
|
|
77
|
-
if (next.has(
|
|
78
|
-
|
|
79
|
-
next.delete(id);
|
|
39
|
+
const value = header.value();
|
|
40
|
+
const next = new Set(this.resolvedExpandedIds());
|
|
41
|
+
if (next.has(value)) {
|
|
42
|
+
next.delete(value);
|
|
80
43
|
} else {
|
|
81
|
-
next.add(
|
|
82
|
-
this.markRendered(id);
|
|
44
|
+
next.add(value);
|
|
83
45
|
}
|
|
84
|
-
this.userExpandedIds.set(next);
|
|
85
|
-
}
|
|
86
46
|
|
|
87
|
-
|
|
88
|
-
const renderedIds = this.renderedIds();
|
|
89
|
-
if (renderedIds.has(id)) {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
this.renderedIds.set(new Set(renderedIds).add(id));
|
|
47
|
+
this.expandedIds.set(next);
|
|
94
48
|
}
|
|
95
49
|
|
|
96
50
|
// Up/Down/Home/End move focus between headers — optional but APG-recommended keyboard support.
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const index = buttons.findIndex((button) => button.nativeElement === event.currentTarget);
|
|
51
|
+
navigate(current: <%= classify(name) %>Header, event: KeyboardEvent): void {
|
|
52
|
+
const headers = this.headers().filter((header) => !header.disabled());
|
|
53
|
+
const index = headers.indexOf(current);
|
|
104
54
|
if (index === -1) {
|
|
105
55
|
return;
|
|
106
56
|
}
|
|
@@ -108,22 +58,78 @@ export class <%= classify(name) %> {
|
|
|
108
58
|
let nextIndex: number;
|
|
109
59
|
switch (event.key) {
|
|
110
60
|
case 'ArrowDown':
|
|
111
|
-
nextIndex = (index + 1) %
|
|
61
|
+
nextIndex = (index + 1) % headers.length;
|
|
112
62
|
break;
|
|
113
63
|
case 'ArrowUp':
|
|
114
|
-
nextIndex = (index - 1 +
|
|
64
|
+
nextIndex = (index - 1 + headers.length) % headers.length;
|
|
115
65
|
break;
|
|
116
66
|
case 'Home':
|
|
117
67
|
nextIndex = 0;
|
|
118
68
|
break;
|
|
119
69
|
case 'End':
|
|
120
|
-
nextIndex =
|
|
70
|
+
nextIndex = headers.length - 1;
|
|
121
71
|
break;
|
|
122
72
|
default:
|
|
123
73
|
return;
|
|
124
74
|
}
|
|
125
75
|
|
|
126
76
|
event.preventDefault();
|
|
127
|
-
|
|
77
|
+
headers[nextIndex]?.focus();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
headerDomId(value: <%= classify(name) %>Id): string {
|
|
81
|
+
return `${this.instanceId}-header-${value}`;
|
|
128
82
|
}
|
|
83
|
+
|
|
84
|
+
panelDomId(value: <%= classify(name) %>Id): string {
|
|
85
|
+
return `${this.instanceId}-panel-${value}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@Directive({
|
|
90
|
+
selector: '[<%= camelize(name) %>Header]',
|
|
91
|
+
exportAs: '<%= camelize(name) %>Header',
|
|
92
|
+
host: {
|
|
93
|
+
'[id]': 'root.headerDomId(value())',
|
|
94
|
+
'[attr.aria-expanded]': 'open()',
|
|
95
|
+
'[attr.aria-controls]': 'root.panelDomId(value())',
|
|
96
|
+
'[attr.aria-disabled]': 'disabled() ? "true" : null',
|
|
97
|
+
'[attr.tabindex]': 'disabled() ? -1 : null',
|
|
98
|
+
'(click)': 'toggle()',
|
|
99
|
+
'(keydown)': 'root.navigate(this, $event)',
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
export class <%= classify(name) %>Header {
|
|
103
|
+
protected readonly root = inject(<%= classify(name) %>);
|
|
104
|
+
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
105
|
+
|
|
106
|
+
readonly value = input.required<<%= classify(name) %>Id>();
|
|
107
|
+
readonly expanded = input(false, { transform: booleanAttribute });
|
|
108
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
109
|
+
readonly open = computed(() => this.root.isExpanded(this.value()));
|
|
110
|
+
|
|
111
|
+
toggle(): void {
|
|
112
|
+
this.root.toggle(this);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
focus(): void {
|
|
116
|
+
this.elementRef.nativeElement.focus();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@Directive({
|
|
121
|
+
selector: '[<%= camelize(name) %>Panel]',
|
|
122
|
+
exportAs: '<%= camelize(name) %>Panel',
|
|
123
|
+
host: {
|
|
124
|
+
role: 'region',
|
|
125
|
+
'[id]': 'root.panelDomId(value())',
|
|
126
|
+
'[attr.aria-labelledby]': 'root.headerDomId(value())',
|
|
127
|
+
'[hidden]': '!open()',
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
export class <%= classify(name) %>Panel {
|
|
131
|
+
protected readonly root = inject(<%= classify(name) %>);
|
|
132
|
+
|
|
133
|
+
readonly value = input.required<<%= classify(name) %>Id>();
|
|
134
|
+
readonly open = computed(() => this.root.isExpanded(this.value()));
|
|
129
135
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"$id": "SemmetAngularAccordion",
|
|
4
4
|
"title": "Semmet Angular Accordion Options Schema",
|
|
5
5
|
"type": "object",
|
|
6
|
-
"description": "Generates
|
|
6
|
+
"description": "Generates headless Angular Accordion directives (root, header, panel) with signals-based state and keyboard support, coordinated over consumer-owned markup, following the W3C ARIA Authoring Practices Guide.",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"properties": {
|
|
9
9
|
"name": {
|