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
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.44.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('<%= selector %>').first();
7
+ const component = page.locator('[<%= camelize(name) %>]').first();
8
8
  test.skip(
9
9
  (await component.count()) === 0,
10
- 'Mount <<%= selector %> /> in a route or host component to enable this generated e2e test.'
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 buttons = component.getByRole('button');
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 targetButton.count()) === 0,
21
- 'Mount at least two enabled accordion headers to enable the generated accordion interaction e2e test.'
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
- await expect(targetButton).toHaveAttribute('aria-controls', /.+/);
25
- const controls = await targetButton.getAttribute('aria-controls');
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 targetButton.click();
29
- await expect(targetButton).toHaveAttribute('aria-expanded', 'true');
27
+ await targetHeader.click();
28
+ await expect(targetHeader).toHaveAttribute('aria-expanded', 'true');
30
29
  await expect(panel).toBeVisible();
31
30
 
32
- await targetButton.click();
33
- await expect(targetButton).toHaveAttribute('aria-expanded', 'false');
31
+ await targetHeader.click();
32
+ await expect(targetHeader).toHaveAttribute('aria-expanded', 'false');
34
33
 
35
- const disabledButton = component.getByRole('button', { disabled: true }).first();
36
- if ((await disabledButton.count()) > 0) {
37
- await expect(disabledButton).toHaveAttribute('aria-expanded', 'false');
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) %> and <%= classify(name) %>Item in that parent component.
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
- <<%= selector %>>
7
- <ng-template <%= camelize(name) %>Item id="profile" title="Profile" [expanded]="true">
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
- </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 %>>
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
- [id]="instanceId + '-panel-' + item.id()"
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
- import { <%= classify(name) %>, <%= classify(name) %>Item } from './<%= dasherize(name) %>';
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) %>Item],
11
+ imports: [<%= classify(name) %>, <%= classify(name) %>Header, <%= classify(name) %>Panel],
7
12
  template: `
8
- <<%= selector %> [preserveContent]="preserveContent">
9
- <ng-template <%= camelize(name) %>Item id="profile" title="Profile" [expanded]="true">
10
- <p>Profile content</p>
11
- </ng-template>
12
-
13
- <ng-template <%= camelize(name) %>Item id="settings" title="Settings">
14
- <p>Settings content</p>
15
- </ng-template>
16
-
17
- <ng-template <%= camelize(name) %>Item id="billing" title="Billing" disabled>
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
- preserveContent = true;
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 buttons(): HTMLButtonElement[] {
40
- return Array.from(fixture.nativeElement.querySelectorAll('button'));
47
+ function headers(): HTMLButtonElement[] {
48
+ return Array.from(host.querySelectorAll('button'));
41
49
  }
42
50
 
43
51
  function panels(): HTMLElement[] {
44
- return Array.from(fixture.nativeElement.querySelectorAll('[role="region"]'));
52
+ return Array.from(host.querySelectorAll('[role="region"]'));
45
53
  }
46
54
 
47
- it('renders projected accordion items with ARIA relationships', () => {
48
- const [profileButton, settingsButton] = buttons();
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(profileButton.textContent?.trim()).toBe('Profile');
52
- expect(profileButton.getAttribute('aria-expanded')).toBe('true');
53
- expect(profileButton.getAttribute('aria-controls')).toBe(profilePanel.id);
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(settingsButton.textContent?.trim()).toBe('Settings');
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 item with click', () => {
64
- const [, settingsButton] = buttons();
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
- settingsButton.click();
72
+ settingsHeader.click();
68
73
  fixture.detectChanges();
69
74
 
70
- expect(settingsButton.getAttribute('aria-expanded')).toBe('true');
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
- settingsButton.click();
79
+ settingsHeader.click();
75
80
  fixture.detectChanges();
76
81
 
77
- expect(settingsButton.getAttribute('aria-expanded')).toBe('false');
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 items', () => {
82
- const disabledButton = buttons()[2];
87
+ it('does not toggle disabled headers', () => {
88
+ const disabledHeader = headers()[2];
83
89
  const disabledPanel = panels()[2];
84
90
 
85
- expect(disabledButton.disabled).toBe(true);
86
- expect(disabledButton.getAttribute('aria-expanded')).toBe('false');
87
-
88
- disabledButton.click();
91
+ disabledHeader.click();
89
92
  fixture.detectChanges();
90
93
 
91
- expect(disabledButton.getAttribute('aria-expanded')).toBe('false');
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 [profileButton, settingsButton] = buttons();
100
+ const [profileHeader, settingsHeader] = headers();
110
101
 
111
- profileButton.focus();
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(settingsButton);
104
+ expect(document.activeElement).toBe(settingsHeader);
115
105
 
116
- settingsButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
106
+ settingsHeader.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
117
107
  fixture.detectChanges();
118
- expect(document.activeElement).toBe(settingsButton);
108
+ expect(document.activeElement).toBe(settingsHeader);
119
109
 
120
- settingsButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
110
+ settingsHeader.dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
121
111
  fixture.detectChanges();
122
- expect(document.activeElement).toBe(profileButton);
112
+ expect(document.activeElement).toBe(profileHeader);
123
113
  });
124
114
  });
@@ -1,106 +1,56 @@
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';
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: '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
- }
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 headerButtons = viewChildren<ElementRef<HTMLButtonElement>>('headerButton');
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
- private readonly renderedIds = signal<ReadonlySet<<%= classify(name) %>ItemId>>(new Set());
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 expandedIds = computed<ReadonlySet<<%= classify(name) %>ItemId>>(() => {
50
- const userExpandedIds = this.userExpandedIds();
51
- if (userExpandedIds) {
52
- return userExpandedIds;
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.items()
57
- .filter((item) => item.expanded())
58
- .map((item) => item.id()),
24
+ this.headers()
25
+ .filter((header) => header.expanded())
26
+ .map((header) => header.value()),
59
27
  );
60
28
  });
61
29
 
62
- protected isExpanded(id: <%= classify(name) %>ItemId): boolean {
63
- return this.expandedIds().has(id);
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
- protected toggle(item: <%= classify(name) %>Item): void {
71
- if (item.disabled()) {
34
+ toggle(header: <%= classify(name) %>Header): void {
35
+ if (header.disabled()) {
72
36
  return;
73
37
  }
74
38
 
75
- const id = item.id();
76
- const next = new Set(this.expandedIds());
77
- if (next.has(id)) {
78
- this.markRendered(id);
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(id);
82
- this.markRendered(id);
44
+ next.add(value);
83
45
  }
84
- this.userExpandedIds.set(next);
85
- }
86
46
 
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));
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
- protected navigateHeaders(event: KeyboardEvent): void {
98
- const buttons = this.headerButtons().filter((button) => !button.nativeElement.disabled);
99
- if (buttons.length === 0) {
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) % buttons.length;
61
+ nextIndex = (index + 1) % headers.length;
112
62
  break;
113
63
  case 'ArrowUp':
114
- nextIndex = (index - 1 + buttons.length) % buttons.length;
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 = buttons.length - 1;
70
+ nextIndex = headers.length - 1;
121
71
  break;
122
72
  default:
123
73
  return;
124
74
  }
125
75
 
126
76
  event.preventDefault();
127
- buttons[nextIndex].nativeElement.focus();
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 a standalone Angular Accordion component (signals-based state, keyboard support) following the W3C ARIA Authoring Practices Guide.",
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": {