semmet-angular 0.45.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/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
|
@@ -1,51 +1,44 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Directive, ElementRef, computed, contentChildren, inject, input, model } from '@angular/core';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
id: number;
|
|
5
|
-
label: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
const DEFAULT_ACTIONS: readonly <%= classify(name) %>Action[] = [
|
|
9
|
-
{ id: 1, label: 'Bold' },
|
|
10
|
-
{ id: 2, label: 'Italic' },
|
|
11
|
-
{ id: 3, label: 'Underline' },
|
|
12
|
-
];
|
|
3
|
+
type <%= classify(name) %>Id = string;
|
|
13
4
|
|
|
14
|
-
@
|
|
15
|
-
selector: '<%=
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
5
|
+
@Directive({
|
|
6
|
+
selector: '[<%= camelize(name) %>]',
|
|
7
|
+
exportAs: '<%= camelize(name) %>',
|
|
8
|
+
host: {
|
|
9
|
+
role: 'toolbar',
|
|
10
|
+
'[attr.aria-label]': 'label() || null',
|
|
11
|
+
},
|
|
19
12
|
})
|
|
20
13
|
export class <%= classify(name) %> {
|
|
21
|
-
private readonly
|
|
14
|
+
private readonly items = contentChildren(<%= classify(name) %>Item, { descendants: true });
|
|
22
15
|
|
|
23
|
-
readonly label = input('
|
|
24
|
-
readonly
|
|
16
|
+
readonly label = input('');
|
|
17
|
+
readonly activeId = model<<%= classify(name) %>Id | null>(null);
|
|
25
18
|
|
|
26
|
-
|
|
19
|
+
private readonly resolvedActiveId = computed<<%= classify(name) %>Id | null>(() => {
|
|
20
|
+
const activeId = this.activeId();
|
|
21
|
+
const items = this.items();
|
|
22
|
+
if (activeId !== null && items.some((item) => item.value() === activeId)) {
|
|
23
|
+
return activeId;
|
|
24
|
+
}
|
|
27
25
|
|
|
28
|
-
|
|
26
|
+
return items[0]?.value() ?? null;
|
|
27
|
+
});
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
return this.
|
|
29
|
+
isActive(value: <%= classify(name) %>Id): boolean {
|
|
30
|
+
return this.resolvedActiveId() === value;
|
|
32
31
|
}
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
this.activeId.set(
|
|
36
|
-
const next = new Set(this.pressedIds());
|
|
37
|
-
if (next.has(id)) {
|
|
38
|
-
next.delete(id);
|
|
39
|
-
} else {
|
|
40
|
-
next.add(id);
|
|
41
|
-
}
|
|
42
|
-
this.pressedIds.set(next);
|
|
33
|
+
setActive(item: <%= classify(name) %>Item): void {
|
|
34
|
+
this.activeId.set(item.value());
|
|
43
35
|
}
|
|
44
36
|
|
|
45
37
|
// Left/Right/Home/End move focus across toolbar buttons with roving tabindex (APG toolbar pattern).
|
|
46
|
-
|
|
47
|
-
const items = this.
|
|
48
|
-
|
|
38
|
+
navigate(current: <%= classify(name) %>Item, event: KeyboardEvent): void {
|
|
39
|
+
const items = this.items();
|
|
40
|
+
const index = items.indexOf(current);
|
|
41
|
+
if (index === -1) {
|
|
49
42
|
return;
|
|
50
43
|
}
|
|
51
44
|
|
|
@@ -68,7 +61,36 @@ export class <%= classify(name) %> {
|
|
|
68
61
|
}
|
|
69
62
|
|
|
70
63
|
event.preventDefault();
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
const next = items[nextIndex];
|
|
65
|
+
this.activeId.set(next.value());
|
|
66
|
+
next.focus();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
@Directive({
|
|
71
|
+
selector: '[<%= camelize(name) %>Item]',
|
|
72
|
+
exportAs: '<%= camelize(name) %>Item',
|
|
73
|
+
host: {
|
|
74
|
+
'[attr.aria-pressed]': 'pressed()',
|
|
75
|
+
'[attr.tabindex]': 'active() ? 0 : -1',
|
|
76
|
+
'(click)': 'toggle()',
|
|
77
|
+
'(keydown)': 'root.navigate(this, $event)',
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
export class <%= classify(name) %>Item {
|
|
81
|
+
protected readonly root = inject(<%= classify(name) %>);
|
|
82
|
+
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
83
|
+
|
|
84
|
+
readonly value = input.required<<%= classify(name) %>Id>();
|
|
85
|
+
readonly pressed = model(false);
|
|
86
|
+
readonly active = computed(() => this.root.isActive(this.value()));
|
|
87
|
+
|
|
88
|
+
toggle(): void {
|
|
89
|
+
this.root.setActive(this);
|
|
90
|
+
this.pressed.update((value) => !value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
focus(): void {
|
|
94
|
+
this.elementRef.nativeElement.focus();
|
|
73
95
|
}
|
|
74
96
|
}
|
package/src/toolbar/schema.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"$id": "SemmetAngularToolbar",
|
|
4
4
|
"title": "Semmet Angular Toolbar Options Schema",
|
|
5
5
|
"type": "object",
|
|
6
|
-
"description": "Generates
|
|
6
|
+
"description": "Generates headless Angular Toolbar directives (root, item) with roving-tabindex keyboard navigation, coordinated over consumer-owned markup, following the W3C ARIA Authoring Practices Guide.",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"properties": {
|
|
9
9
|
"name": {
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
:host {
|
|
2
|
-
display: block;
|
|
3
|
-
}
|
|
4
|
-
|
|
5
|
-
[role='tree'],
|
|
6
|
-
[role='group'] {
|
|
7
|
-
margin: 0;
|
|
8
|
-
padding: 0;
|
|
9
|
-
list-style: none;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
[role='group'] {
|
|
13
|
-
padding-left: 1rem;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
[role='treeitem'] {
|
|
17
|
-
display: flex;
|
|
18
|
-
align-items: center;
|
|
19
|
-
cursor: pointer;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
[role='treeitem']:focus-visible {
|
|
23
|
-
outline: 2px solid currentColor;
|
|
24
|
-
outline-offset: 2px;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
[role='treeitem'][aria-expanded]::before {
|
|
28
|
-
content: '';
|
|
29
|
-
width: 0.4rem;
|
|
30
|
-
height: 0.4rem;
|
|
31
|
-
flex-shrink: 0;
|
|
32
|
-
border-right: 2px solid currentColor;
|
|
33
|
-
border-bottom: 2px solid currentColor;
|
|
34
|
-
transform: rotate(-45deg);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
[role='treeitem'][aria-expanded='true']::before {
|
|
38
|
-
transform: rotate(45deg);
|
|
39
|
-
}
|
|
@@ -4,14 +4,13 @@ test.describe('<%= classify(name) %>', () => {
|
|
|
4
4
|
test('navigates and collapses mounted tree view with keyboard', async ({ page }) => {
|
|
5
5
|
await page.goto('/');
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const tree = page.locator('[<%= camelize(name) %>]').first();
|
|
8
8
|
test.skip(
|
|
9
|
-
(await
|
|
10
|
-
'Mount
|
|
9
|
+
(await tree.count()) === 0,
|
|
10
|
+
'Mount an element with [<%= camelize(name) %>] in a route or host component to enable this generated e2e test.'
|
|
11
11
|
);
|
|
12
12
|
|
|
13
|
-
const
|
|
14
|
-
const items = component.getByRole('treeitem');
|
|
13
|
+
const items = tree.getByRole('treeitem');
|
|
15
14
|
await expect(tree).toBeVisible();
|
|
16
15
|
await expect(items.first()).toHaveAttribute('aria-expanded', 'true');
|
|
17
16
|
|
|
@@ -1,36 +1,17 @@
|
|
|
1
1
|
<!-- Semmet Angular: Tree View — https://www.w3.org/WAI/ARIA/apg/patterns/treeview/ -->
|
|
2
2
|
<!--
|
|
3
3
|
Usage from a parent component template:
|
|
4
|
-
Import <%= classify(name) %> in that
|
|
4
|
+
Import <%= classify(name) %>, <%= classify(name) %>Item and <%= classify(name) %>Group in that
|
|
5
|
+
parent component. This directive set never owns markup: nest <ul>/<li> as deep as the tree needs —
|
|
6
|
+
each level's group is always rendered, `[<%= camelize(name) %>Group]` hides it declaratively while collapsed.
|
|
5
7
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
[tabIndex]="activeId() === root.id ? 0 : -1"
|
|
15
|
-
(click)="activateItem(root)"
|
|
16
|
-
(keydown)="handleKeydown($event, root.id)"
|
|
17
|
-
>
|
|
18
|
-
{{ root.label }}
|
|
19
|
-
@if (hasChildren(root) && isExpanded(root.id)) {
|
|
20
|
-
<ul role="group">
|
|
21
|
-
@for (child of root.children; track child.id) {
|
|
22
|
-
<li
|
|
23
|
-
#treeItem
|
|
24
|
-
role="treeitem"
|
|
25
|
-
[tabIndex]="activeId() === child.id ? 0 : -1"
|
|
26
|
-
(click)="activateItem(child)"
|
|
27
|
-
(keydown)="handleKeydown($event, child.id)"
|
|
28
|
-
>
|
|
29
|
-
{{ child.label }}
|
|
30
|
-
</li>
|
|
31
|
-
}
|
|
32
|
-
</ul>
|
|
33
|
-
}
|
|
34
|
-
</li>
|
|
35
|
-
}
|
|
8
|
+
<ul <%= camelize(name) %> label="Files">
|
|
9
|
+
<li <%= camelize(name) %>Item value="documents" [expanded]="true">
|
|
10
|
+
Documents
|
|
11
|
+
<ul <%= camelize(name) %>Group>
|
|
12
|
+
<li <%= camelize(name) %>Item value="resume">Resume.pdf</li>
|
|
13
|
+
<li <%= camelize(name) %>Item value="cover-letter">Cover Letter.docx</li>
|
|
14
|
+
</ul>
|
|
15
|
+
</li>
|
|
36
16
|
</ul>
|
|
17
|
+
-->
|
|
@@ -1,12 +1,31 @@
|
|
|
1
1
|
import { Component } from '@angular/core';
|
|
2
2
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
3
|
-
import { By } from '@angular/platform-browser';
|
|
4
3
|
|
|
5
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
<%= classify(name) %>,
|
|
6
|
+
<%= classify(name) %>Group,
|
|
7
|
+
<%= classify(name) %>Item,
|
|
8
|
+
} from './<%= dasherize(name) %>';
|
|
6
9
|
|
|
7
10
|
@Component({
|
|
8
|
-
imports: [<%= classify(name) %>],
|
|
9
|
-
template:
|
|
11
|
+
imports: [<%= classify(name) %>, <%= classify(name) %>Group, <%= classify(name) %>Item],
|
|
12
|
+
template: `
|
|
13
|
+
<ul <%= camelize(name) %> label="Files">
|
|
14
|
+
<li <%= camelize(name) %>Item value="documents" [expanded]="true">
|
|
15
|
+
Documents
|
|
16
|
+
<ul <%= camelize(name) %>Group>
|
|
17
|
+
<li <%= camelize(name) %>Item value="work" [expanded]="true">
|
|
18
|
+
Work
|
|
19
|
+
<ul <%= camelize(name) %>Group>
|
|
20
|
+
<li <%= camelize(name) %>Item value="resume">Resume.pdf</li>
|
|
21
|
+
<li <%= camelize(name) %>Item value="cover-letter">Cover Letter.docx</li>
|
|
22
|
+
</ul>
|
|
23
|
+
</li>
|
|
24
|
+
<li <%= camelize(name) %>Item value="personal">Personal.txt</li>
|
|
25
|
+
</ul>
|
|
26
|
+
</li>
|
|
27
|
+
</ul>
|
|
28
|
+
`,
|
|
10
29
|
})
|
|
11
30
|
class HostComponent {}
|
|
12
31
|
|
|
@@ -24,61 +43,79 @@ describe('<%= classify(name) %>', () => {
|
|
|
24
43
|
host = fixture.nativeElement as HTMLElement;
|
|
25
44
|
});
|
|
26
45
|
|
|
27
|
-
|
|
46
|
+
function items(): HTMLElement[] {
|
|
47
|
+
return Array.from(host.querySelectorAll<HTMLElement>('[role="treeitem"]'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function groups(): HTMLElement[] {
|
|
51
|
+
return Array.from(host.querySelectorAll<HTMLElement>('[role="group"]'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
it('decorates consumer-owned markup with tree semantics and roving tabindex, 3 levels deep', () => {
|
|
28
55
|
const tree = host.querySelector<HTMLElement>('[role="tree"]');
|
|
29
|
-
const
|
|
56
|
+
const [documents, work, resume, coverLetter, personal] = items();
|
|
30
57
|
|
|
31
58
|
expect(tree?.getAttribute('aria-label')).toBe('Files');
|
|
32
|
-
expect(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
expect(
|
|
38
|
-
expect(
|
|
39
|
-
expect(
|
|
59
|
+
expect(documents.getAttribute('aria-expanded')).toBe('true');
|
|
60
|
+
expect(work.getAttribute('aria-expanded')).toBe('true');
|
|
61
|
+
expect(documents.getAttribute('tabindex')).toBe('0');
|
|
62
|
+
expect(work.getAttribute('tabindex')).toBe('-1');
|
|
63
|
+
expect(resume.getAttribute('tabindex')).toBe('-1');
|
|
64
|
+
expect(coverLetter.getAttribute('tabindex')).toBe('-1');
|
|
65
|
+
expect(personal.getAttribute('tabindex')).toBe('-1');
|
|
66
|
+
expect(groups().every((group) => !group.hidden)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('clicking a nested item does not also collapse its ancestors (no event-bubbling leakage)', () => {
|
|
70
|
+
const [documents, work, resume] = items();
|
|
71
|
+
|
|
72
|
+
resume.click();
|
|
73
|
+
fixture.detectChanges();
|
|
74
|
+
|
|
75
|
+
expect(documents.getAttribute('aria-expanded')).toBe('true');
|
|
76
|
+
expect(work.getAttribute('aria-expanded')).toBe('true');
|
|
40
77
|
});
|
|
41
78
|
|
|
42
|
-
it('
|
|
43
|
-
const
|
|
44
|
-
let items = getItems();
|
|
79
|
+
it('collapsing a nested branch does not affect a sibling branch or the ancestor', () => {
|
|
80
|
+
const [documents, work] = items();
|
|
45
81
|
|
|
46
|
-
|
|
47
|
-
'keydown',
|
|
48
|
-
new KeyboardEvent('keydown', { key: 'ArrowDown' }),
|
|
49
|
-
);
|
|
82
|
+
work.click();
|
|
50
83
|
fixture.detectChanges();
|
|
51
84
|
|
|
52
|
-
|
|
53
|
-
expect(
|
|
54
|
-
|
|
85
|
+
expect(work.getAttribute('aria-expanded')).toBe('false');
|
|
86
|
+
expect(documents.getAttribute('aria-expanded')).toBe('true');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('moves focus with ArrowDown/ArrowLeft between parent and child without leaking to grandparents', () => {
|
|
90
|
+
const [documents, work] = items();
|
|
55
91
|
|
|
56
|
-
|
|
57
|
-
'keydown',
|
|
58
|
-
new KeyboardEvent('keydown', { key: 'ArrowLeft' }),
|
|
59
|
-
);
|
|
92
|
+
work.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
|
60
93
|
fixture.detectChanges();
|
|
61
94
|
|
|
62
|
-
|
|
63
|
-
expect(
|
|
95
|
+
const [, , resume] = items();
|
|
96
|
+
expect(document.activeElement).toBe(resume);
|
|
97
|
+
expect(resume.getAttribute('tabindex')).toBe('0');
|
|
98
|
+
|
|
99
|
+
resume.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
|
100
|
+
fixture.detectChanges();
|
|
101
|
+
|
|
102
|
+
expect(document.activeElement).toBe(work);
|
|
103
|
+
expect(documents.getAttribute('aria-expanded')).toBe('true');
|
|
104
|
+
expect(work.getAttribute('aria-expanded')).toBe('true');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('collapses and re-expands a branch with ArrowLeft/ArrowRight, keeping child items mounted', () => {
|
|
108
|
+
const [documents] = items();
|
|
64
109
|
|
|
65
|
-
|
|
66
|
-
'keydown',
|
|
67
|
-
new KeyboardEvent('keydown', { key: 'ArrowLeft' }),
|
|
68
|
-
);
|
|
110
|
+
documents.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }));
|
|
69
111
|
fixture.detectChanges();
|
|
70
112
|
|
|
71
|
-
|
|
72
|
-
expect(items).
|
|
73
|
-
expect(items[0].getAttribute('aria-expanded')).toBe('false');
|
|
113
|
+
expect(documents.getAttribute('aria-expanded')).toBe('false');
|
|
114
|
+
expect(items().length).toBe(5);
|
|
74
115
|
|
|
75
|
-
|
|
76
|
-
'keydown',
|
|
77
|
-
new KeyboardEvent('keydown', { key: 'ArrowRight' }),
|
|
78
|
-
);
|
|
116
|
+
documents.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
|
|
79
117
|
fixture.detectChanges();
|
|
80
118
|
|
|
81
|
-
expect(
|
|
82
|
-
expect(getItems()[0].getAttribute('aria-expanded')).toBe('true');
|
|
119
|
+
expect(documents.getAttribute('aria-expanded')).toBe('true');
|
|
83
120
|
});
|
|
84
121
|
});
|
|
@@ -1,143 +1,160 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Directive, ElementRef, computed, contentChildren, inject, input, model } from '@angular/core';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
id: number;
|
|
5
|
-
label: string;
|
|
6
|
-
children?: <%= classify(name) %>Node[];
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
interface VisibleNode {
|
|
10
|
-
node: <%= classify(name) %>Node;
|
|
11
|
-
parentId: number | null;
|
|
12
|
-
}
|
|
3
|
+
type <%= classify(name) %>Id = string;
|
|
13
4
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
{ id: 3, label: 'Cover Letter.docx' },
|
|
21
|
-
],
|
|
5
|
+
@Directive({
|
|
6
|
+
selector: '[<%= camelize(name) %>]',
|
|
7
|
+
exportAs: '<%= camelize(name) %>',
|
|
8
|
+
host: {
|
|
9
|
+
role: 'tree',
|
|
10
|
+
'[attr.aria-label]': 'label() || null',
|
|
22
11
|
},
|
|
23
|
-
];
|
|
24
|
-
|
|
25
|
-
@Component({
|
|
26
|
-
selector: '<%= selector %>',
|
|
27
|
-
imports: [],
|
|
28
|
-
templateUrl: './<%= dasherize(name) %>.html',
|
|
29
|
-
styleUrl: './<%= dasherize(name) %>.css',
|
|
30
12
|
})
|
|
31
13
|
export class <%= classify(name) %> {
|
|
32
|
-
private readonly
|
|
14
|
+
private readonly items = contentChildren(<%= classify(name) %>Item, { descendants: true });
|
|
33
15
|
|
|
34
|
-
readonly label = input('
|
|
35
|
-
readonly
|
|
16
|
+
readonly label = input('');
|
|
17
|
+
readonly activeId = model<<%= classify(name) %>Id | null>(null);
|
|
36
18
|
|
|
37
|
-
private readonly
|
|
38
|
-
protected readonly activeId = signal(1);
|
|
19
|
+
private readonly visibleItems = computed(() => this.items().filter((item) => item.visible()));
|
|
39
20
|
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
walk(node.children, node.id);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
walk(this.tree(), null);
|
|
51
|
-
return result;
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
protected isExpanded(id: number): boolean {
|
|
55
|
-
return this.expandedIds().has(id);
|
|
56
|
-
}
|
|
21
|
+
private readonly resolvedActiveId = computed<<%= classify(name) %>Id | null>(() => {
|
|
22
|
+
const activeId = this.activeId();
|
|
23
|
+
const visible = this.visibleItems();
|
|
24
|
+
if (activeId !== null && visible.some((item) => item.value() === activeId)) {
|
|
25
|
+
return activeId;
|
|
26
|
+
}
|
|
57
27
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
28
|
+
return visible[0]?.value() ?? null;
|
|
29
|
+
});
|
|
61
30
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
if (next.has(id)) {
|
|
65
|
-
next.delete(id);
|
|
66
|
-
} else {
|
|
67
|
-
next.add(id);
|
|
68
|
-
}
|
|
69
|
-
this.expandedIds.set(next);
|
|
31
|
+
isActive(value: <%= classify(name) %>Id): boolean {
|
|
32
|
+
return this.resolvedActiveId() === value;
|
|
70
33
|
}
|
|
71
34
|
|
|
72
|
-
|
|
73
|
-
this.
|
|
74
|
-
if (this.hasChildren(node)) {
|
|
75
|
-
this.toggle(node.id);
|
|
76
|
-
}
|
|
35
|
+
setActive(item: <%= classify(name) %>Item): void {
|
|
36
|
+
this.activeId.set(item.value());
|
|
77
37
|
}
|
|
78
38
|
|
|
79
|
-
// Up/Down move between visible
|
|
80
|
-
// or moves to the parent; Home/End jump to the first/last visible
|
|
81
|
-
|
|
82
|
-
const visible = this.
|
|
83
|
-
const index = visible.
|
|
39
|
+
// Up/Down move between visible items; Right expands or moves into children; Left collapses
|
|
40
|
+
// or moves to the parent; Home/End jump to the first/last visible item (APG tree pattern).
|
|
41
|
+
navigate(current: <%= classify(name) %>Item, event: KeyboardEvent): void {
|
|
42
|
+
const visible = this.visibleItems();
|
|
43
|
+
const index = visible.indexOf(current);
|
|
84
44
|
if (index === -1) {
|
|
85
45
|
return;
|
|
86
46
|
}
|
|
87
|
-
const current = visible[index];
|
|
88
47
|
|
|
89
48
|
switch (event.key) {
|
|
90
49
|
case 'ArrowDown':
|
|
91
50
|
event.preventDefault();
|
|
92
51
|
if (index < visible.length - 1) {
|
|
93
|
-
this.focusItem(visible[index + 1]
|
|
52
|
+
this.focusItem(visible[index + 1]);
|
|
94
53
|
}
|
|
95
54
|
break;
|
|
96
55
|
case 'ArrowUp':
|
|
97
56
|
event.preventDefault();
|
|
98
57
|
if (index > 0) {
|
|
99
|
-
this.focusItem(visible[index - 1]
|
|
58
|
+
this.focusItem(visible[index - 1]);
|
|
100
59
|
}
|
|
101
60
|
break;
|
|
102
61
|
case 'ArrowRight':
|
|
103
62
|
event.preventDefault();
|
|
104
|
-
if (
|
|
105
|
-
if (!
|
|
106
|
-
|
|
63
|
+
if (current.hasChildren()) {
|
|
64
|
+
if (!current.expanded()) {
|
|
65
|
+
current.expanded.set(true);
|
|
107
66
|
} else if (index < visible.length - 1) {
|
|
108
|
-
this.focusItem(visible[index + 1]
|
|
67
|
+
this.focusItem(visible[index + 1]);
|
|
109
68
|
}
|
|
110
69
|
}
|
|
111
70
|
break;
|
|
112
71
|
case 'ArrowLeft':
|
|
113
72
|
event.preventDefault();
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
} else if (current.
|
|
117
|
-
this.focusItem(current.
|
|
73
|
+
if (current.hasChildren() && current.expanded()) {
|
|
74
|
+
current.expanded.set(false);
|
|
75
|
+
} else if (current.parent) {
|
|
76
|
+
this.focusItem(current.parent);
|
|
118
77
|
}
|
|
119
78
|
break;
|
|
120
79
|
case 'Home':
|
|
121
80
|
event.preventDefault();
|
|
122
|
-
|
|
81
|
+
if (visible.length > 0) {
|
|
82
|
+
this.focusItem(visible[0]);
|
|
83
|
+
}
|
|
123
84
|
break;
|
|
124
85
|
case 'End':
|
|
125
86
|
event.preventDefault();
|
|
126
|
-
|
|
87
|
+
if (visible.length > 0) {
|
|
88
|
+
this.focusItem(visible[visible.length - 1]);
|
|
89
|
+
}
|
|
127
90
|
break;
|
|
128
91
|
case 'Enter':
|
|
129
92
|
case ' ':
|
|
130
93
|
event.preventDefault();
|
|
131
|
-
if (
|
|
132
|
-
|
|
94
|
+
if (current.hasChildren()) {
|
|
95
|
+
current.expanded.update((value) => !value);
|
|
133
96
|
}
|
|
134
97
|
break;
|
|
135
98
|
}
|
|
136
99
|
}
|
|
137
100
|
|
|
138
|
-
private focusItem(
|
|
139
|
-
this.activeId.set(
|
|
140
|
-
|
|
141
|
-
|
|
101
|
+
private focusItem(item: <%= classify(name) %>Item): void {
|
|
102
|
+
this.activeId.set(item.value());
|
|
103
|
+
item.focus();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@Directive({
|
|
108
|
+
selector: '[<%= camelize(name) %>Item]',
|
|
109
|
+
exportAs: '<%= camelize(name) %>Item',
|
|
110
|
+
host: {
|
|
111
|
+
role: 'treeitem',
|
|
112
|
+
'[attr.aria-expanded]': 'hasChildren() ? expanded() : null',
|
|
113
|
+
'[attr.tabindex]': 'root.isActive(value()) ? 0 : -1',
|
|
114
|
+
'(click)': 'activate($event)',
|
|
115
|
+
'(keydown)': 'handleKeydown($event)',
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
export class <%= classify(name) %>Item {
|
|
119
|
+
readonly root = inject(<%= classify(name) %>);
|
|
120
|
+
readonly parent = inject(<%= classify(name) %>Item, { skipSelf: true, optional: true });
|
|
121
|
+
private readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
122
|
+
private readonly childItems = contentChildren(<%= classify(name) %>Item, { descendants: true });
|
|
123
|
+
|
|
124
|
+
readonly value = input.required<<%= classify(name) %>Id>();
|
|
125
|
+
readonly expanded = model(false);
|
|
126
|
+
|
|
127
|
+
readonly hasChildren = computed(() => this.childItems().length > 0);
|
|
128
|
+
readonly visible = computed((): boolean => !this.parent || (this.parent.expanded() && this.parent.visible()));
|
|
129
|
+
|
|
130
|
+
// Nested items live inside their ancestors' DOM subtree, so click/keydown must not
|
|
131
|
+
// bubble up and also re-trigger the parent item's own handlers.
|
|
132
|
+
activate(event: MouseEvent): void {
|
|
133
|
+
event.stopPropagation();
|
|
134
|
+
this.root.setActive(this);
|
|
135
|
+
if (this.hasChildren()) {
|
|
136
|
+
this.expanded.update((value) => !value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
handleKeydown(event: KeyboardEvent): void {
|
|
141
|
+
event.stopPropagation();
|
|
142
|
+
this.root.navigate(this, event);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
focus(): void {
|
|
146
|
+
this.elementRef.nativeElement.focus();
|
|
142
147
|
}
|
|
143
148
|
}
|
|
149
|
+
|
|
150
|
+
@Directive({
|
|
151
|
+
selector: '[<%= camelize(name) %>Group]',
|
|
152
|
+
exportAs: '<%= camelize(name) %>Group',
|
|
153
|
+
host: {
|
|
154
|
+
role: 'group',
|
|
155
|
+
'[hidden]': '!parent?.expanded()',
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
export class <%= classify(name) %>Group {
|
|
159
|
+
protected readonly parent = inject(<%= classify(name) %>Item, { optional: true });
|
|
160
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"$id": "SemmetAngularTreeView",
|
|
4
4
|
"title": "Semmet Angular Tree View Options Schema",
|
|
5
5
|
"type": "object",
|
|
6
|
-
"description": "Generates
|
|
6
|
+
"description": "Generates headless Angular Tree View directives (root, item, group) with roving tabindex and arrow-key navigation over consumer-owned, arbitrarily nested markup, following the W3C ARIA Authoring Practices Guide.",
|
|
7
7
|
"additionalProperties": false,
|
|
8
8
|
"properties": {
|
|
9
9
|
"name": {
|