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.
Files changed (61) hide show
  1. package/README.md +2 -0
  2. package/package.json +1 -1
  3. package/src/accordion/files/__name@dasherize__/__name@dasherize__.css.template +0 -39
  4. package/src/accordion/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +16 -17
  5. package/src/accordion/files/__name@dasherize__/__name@dasherize__.html.template +14 -34
  6. package/src/accordion/files/__name@dasherize__/__name@dasherize__.spec.ts.template +52 -62
  7. package/src/accordion/files/__name@dasherize__/__name@dasherize__.ts.template +88 -82
  8. package/src/accordion/schema.json +1 -1
  9. package/src/block/files/__name@dasherize__/__name@dasherize__.html.template +51 -23
  10. package/src/block/files/__name@dasherize__/__name@dasherize__.ts.template +16 -10
  11. package/src/breadcrumb/files/__name@dasherize__/__name@dasherize__.css.template +0 -32
  12. package/src/breadcrumb/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +5 -2
  13. package/src/breadcrumb/files/__name@dasherize__/__name@dasherize__.html.template +8 -7
  14. package/src/breadcrumb/files/__name@dasherize__/__name@dasherize__.spec.ts.template +51 -0
  15. package/src/breadcrumb/files/__name@dasherize__/__name@dasherize__.ts.template +18 -19
  16. package/src/breadcrumb/index.js +1 -1
  17. package/src/breadcrumb/index.js.map +1 -1
  18. package/src/breadcrumb/index.ts +1 -1
  19. package/src/breadcrumb/schema.json +1 -1
  20. package/src/collection.json +9 -9
  21. package/src/menu-button/files/__name@dasherize__/__name@dasherize__.css.template +0 -48
  22. package/src/menu-button/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +2 -2
  23. package/src/menu-button/files/__name@dasherize__/__name@dasherize__.html.template +14 -35
  24. package/src/menu-button/files/__name@dasherize__/__name@dasherize__.spec.ts.template +53 -11
  25. package/src/menu-button/files/__name@dasherize__/__name@dasherize__.ts.template +128 -54
  26. package/src/menu-button/schema.json +1 -1
  27. package/src/navbar/files/__name@dasherize__/__name@dasherize__.css.template +0 -119
  28. package/src/navbar/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +10 -7
  29. package/src/navbar/files/__name@dasherize__/__name@dasherize__.html.template +18 -42
  30. package/src/navbar/files/__name@dasherize__/__name@dasherize__.spec.ts.template +46 -23
  31. package/src/navbar/files/__name@dasherize__/__name@dasherize__.ts.template +54 -50
  32. package/src/navbar/schema.json +1 -1
  33. package/src/pagination/files/__name@dasherize__/__name@dasherize__.css.template +0 -35
  34. package/src/pagination/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +2 -2
  35. package/src/pagination/files/__name@dasherize__/__name@dasherize__.html.template +11 -13
  36. package/src/pagination/files/__name@dasherize__/__name@dasherize__.spec.ts.template +58 -8
  37. package/src/pagination/files/__name@dasherize__/__name@dasherize__.ts.template +17 -31
  38. package/src/pagination/schema.json +1 -1
  39. package/src/table/files/__name@dasherize__/__name@dasherize__.css.template +0 -17
  40. package/src/table/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +1 -1
  41. package/src/table/files/__name@dasherize__/__name@dasherize__.html.template +13 -25
  42. package/src/table/files/__name@dasherize__/__name@dasherize__.ts.template +3 -35
  43. package/src/table/schema.json +1 -1
  44. package/src/tabs/files/__name@dasherize__/__name@dasherize__.css.template +0 -40
  45. package/src/tabs/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +2 -2
  46. package/src/tabs/files/__name@dasherize__/__name@dasherize__.html.template +18 -41
  47. package/src/tabs/files/__name@dasherize__/__name@dasherize__.spec.ts.template +39 -31
  48. package/src/tabs/files/__name@dasherize__/__name@dasherize__.ts.template +111 -113
  49. package/src/tabs/schema.json +1 -1
  50. package/src/toolbar/files/__name@dasherize__/__name@dasherize__.css.template +0 -19
  51. package/src/toolbar/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +3 -4
  52. package/src/toolbar/files/__name@dasherize__/__name@dasherize__.html.template +7 -16
  53. package/src/toolbar/files/__name@dasherize__/__name@dasherize__.spec.ts.template +26 -11
  54. package/src/toolbar/files/__name@dasherize__/__name@dasherize__.ts.template +59 -37
  55. package/src/toolbar/schema.json +1 -1
  56. package/src/tree-view/files/__name@dasherize__/__name@dasherize__.css.template +0 -39
  57. package/src/tree-view/files/__name@dasherize__/__name@dasherize__.e2e-spec.ts.template +4 -5
  58. package/src/tree-view/files/__name@dasherize__/__name@dasherize__.html.template +12 -31
  59. package/src/tree-view/files/__name@dasherize__/__name@dasherize__.spec.ts.template +80 -43
  60. package/src/tree-view/files/__name@dasherize__/__name@dasherize__.ts.template +105 -88
  61. package/src/tree-view/schema.json +1 -1
@@ -1,51 +1,44 @@
1
- import { Component, ElementRef, input, signal, viewChildren } from '@angular/core';
1
+ import { Directive, ElementRef, computed, contentChildren, inject, input, model } from '@angular/core';
2
2
 
3
- interface <%= classify(name) %>Action {
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
- @Component({
15
- selector: '<%= selector %>',
16
- imports: [],
17
- templateUrl: './<%= dasherize(name) %>.html',
18
- styleUrl: './<%= dasherize(name) %>.css',
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 buttons = viewChildren<ElementRef<HTMLButtonElement>>('toolbarButton');
14
+ private readonly items = contentChildren(<%= classify(name) %>Item, { descendants: true });
22
15
 
23
- readonly label = input('Text formatting');
24
- readonly actions = input<readonly <%= classify(name) %>Action[]>(DEFAULT_ACTIONS);
16
+ readonly label = input('');
17
+ readonly activeId = model<<%= classify(name) %>Id | null>(null);
25
18
 
26
- protected readonly activeId = signal(DEFAULT_ACTIONS[0].id);
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
- private readonly pressedIds = signal<ReadonlySet<number>>(new Set());
26
+ return items[0]?.value() ?? null;
27
+ });
29
28
 
30
- protected isPressed(id: number): boolean {
31
- return this.pressedIds().has(id);
29
+ isActive(value: <%= classify(name) %>Id): boolean {
30
+ return this.resolvedActiveId() === value;
32
31
  }
33
32
 
34
- protected toggle(id: number): void {
35
- this.activeId.set(id);
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
- protected navigateToolbar(event: KeyboardEvent, index: number): void {
47
- const items = this.buttons();
48
- if (items.length === 0) {
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
- this.activeId.set(this.actions()[nextIndex].id);
72
- items[nextIndex].nativeElement.focus();
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
  }
@@ -3,7 +3,7 @@
3
3
  "$id": "SemmetAngularToolbar",
4
4
  "title": "Semmet Angular Toolbar Options Schema",
5
5
  "type": "object",
6
- "description": "Generates a standalone Angular Toolbar component with roving-tabindex keyboard navigation, following the W3C ARIA Authoring Practices Guide.",
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 component = page.locator('<%= selector %>').first();
7
+ const tree = page.locator('[<%= camelize(name) %>]').first();
8
8
  test.skip(
9
- (await component.count()) === 0,
10
- 'Mount <<%= selector %> /> in a route or host component to enable this generated e2e test.'
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 tree = component.getByRole('tree').first();
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 parent component.
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
- <<%= selector %> label="Files" [tree]="fileTree" />
7
- -->
8
- <ul role="tree" [attr.aria-label]="label()">
9
- @for (root of tree(); track root.id) {
10
- <li
11
- #treeItem
12
- role="treeitem"
13
- [attr.aria-expanded]="hasChildren(root) ? isExpanded(root.id) : null"
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 { <%= classify(name) %> } from './<%= dasherize(name) %>';
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: `<<%= selector %> />`,
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
- it('renders tree semantics with expanded root and roving tabindex', () => {
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 items = Array.from(host.querySelectorAll<HTMLElement>('[role="treeitem"]'));
56
+ const [documents, work, resume, coverLetter, personal] = items();
30
57
 
31
58
  expect(tree?.getAttribute('aria-label')).toBe('Files');
32
- expect(items.map((item) => item.textContent?.trim())).toEqual([
33
- 'Documents Resume.pdf Cover Letter.docx',
34
- 'Resume.pdf',
35
- 'Cover Letter.docx',
36
- ]);
37
- expect(items[0].getAttribute('aria-expanded')).toBe('true');
38
- expect(items[0].getAttribute('tabindex')).toBe('0');
39
- expect(items[1].getAttribute('tabindex')).toBe('-1');
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('moves focus with arrow keys and collapses/expands branches', () => {
43
- const getItems = () => Array.from(host.querySelectorAll<HTMLElement>('[role="treeitem"]'));
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
- fixture.debugElement.queryAll(By.css('[role="treeitem"]'))[0].triggerEventHandler(
47
- 'keydown',
48
- new KeyboardEvent('keydown', { key: 'ArrowDown' }),
49
- );
82
+ work.click();
50
83
  fixture.detectChanges();
51
84
 
52
- items = getItems();
53
- expect(items[0].getAttribute('tabindex')).toBe('-1');
54
- expect(items[1].getAttribute('tabindex')).toBe('0');
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
- fixture.debugElement.queryAll(By.css('[role="treeitem"]'))[1].triggerEventHandler(
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
- items = getItems();
63
- expect(items[0].getAttribute('tabindex')).toBe('0');
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
- fixture.debugElement.queryAll(By.css('[role="treeitem"]'))[0].triggerEventHandler(
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
- items = getItems();
72
- expect(items).toHaveLength(1);
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
- fixture.debugElement.query(By.css('[role="treeitem"]')).triggerEventHandler(
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(getItems()).toHaveLength(3);
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 { Component, ElementRef, computed, input, signal, viewChildren } from '@angular/core';
1
+ import { Directive, ElementRef, computed, contentChildren, inject, input, model } from '@angular/core';
2
2
 
3
- interface <%= classify(name) %>Node {
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
- const DEFAULT_TREE: readonly <%= classify(name) %>Node[] = [
15
- {
16
- id: 1,
17
- label: 'Documents',
18
- children: [
19
- { id: 2, label: 'Resume.pdf' },
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 treeItems = viewChildren<ElementRef<HTMLElement>>('treeItem');
14
+ private readonly items = contentChildren(<%= classify(name) %>Item, { descendants: true });
33
15
 
34
- readonly label = input('Files');
35
- readonly tree = input<readonly <%= classify(name) %>Node[]>(DEFAULT_TREE);
16
+ readonly label = input('');
17
+ readonly activeId = model<<%= classify(name) %>Id | null>(null);
36
18
 
37
- private readonly expandedIds = signal<ReadonlySet<number>>(new Set([1]));
38
- protected readonly activeId = signal(1);
19
+ private readonly visibleItems = computed(() => this.items().filter((item) => item.visible()));
39
20
 
40
- protected readonly visibleNodes = computed<VisibleNode[]>(() => {
41
- const result: VisibleNode[] = [];
42
- const walk = (nodes: readonly <%= classify(name) %>Node[], parentId: number | null): void => {
43
- for (const node of nodes) {
44
- result.push({ node, parentId });
45
- if (node.children && this.expandedIds().has(node.id)) {
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
- protected hasChildren(node: <%= classify(name) %>Node): boolean {
59
- return !!node.children && node.children.length > 0;
60
- }
28
+ return visible[0]?.value() ?? null;
29
+ });
61
30
 
62
- protected toggle(id: number): void {
63
- const next = new Set(this.expandedIds());
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
- protected activateItem(node: <%= classify(name) %>Node): void {
73
- this.focusItem(node.id);
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 nodes; Right expands or moves into children; Left collapses
80
- // or moves to the parent; Home/End jump to the first/last visible node (APG tree keyboard support).
81
- protected handleKeydown(event: KeyboardEvent, id: number): void {
82
- const visible = this.visibleNodes();
83
- const index = visible.findIndex((entry) => entry.node.id === id);
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].node.id);
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].node.id);
58
+ this.focusItem(visible[index - 1]);
100
59
  }
101
60
  break;
102
61
  case 'ArrowRight':
103
62
  event.preventDefault();
104
- if (this.hasChildren(current.node)) {
105
- if (!this.isExpanded(current.node.id)) {
106
- this.toggle(current.node.id);
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].node.id);
67
+ this.focusItem(visible[index + 1]);
109
68
  }
110
69
  }
111
70
  break;
112
71
  case 'ArrowLeft':
113
72
  event.preventDefault();
114
- if (this.hasChildren(current.node) && this.isExpanded(current.node.id)) {
115
- this.toggle(current.node.id);
116
- } else if (current.parentId !== null) {
117
- this.focusItem(current.parentId);
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
- this.focusItem(visible[0].node.id);
81
+ if (visible.length > 0) {
82
+ this.focusItem(visible[0]);
83
+ }
123
84
  break;
124
85
  case 'End':
125
86
  event.preventDefault();
126
- this.focusItem(visible[visible.length - 1].node.id);
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 (this.hasChildren(current.node)) {
132
- this.toggle(current.node.id);
94
+ if (current.hasChildren()) {
95
+ current.expanded.update((value) => !value);
133
96
  }
134
97
  break;
135
98
  }
136
99
  }
137
100
 
138
- private focusItem(id: number): void {
139
- this.activeId.set(id);
140
- const index = this.visibleNodes().findIndex((entry) => entry.node.id === id);
141
- this.treeItems()[index]?.nativeElement.focus();
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 a standalone Angular Tree View component with roving tabindex and arrow-key navigation, following the W3C ARIA Authoring Practices Guide.",
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": {