semmet-angular 0.1.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/LICENSE +21 -0
- package/README.md +48 -0
- package/package.json +46 -0
- package/src/accordion/files/__name@dasherize__/__name@dasherize__.css.template +31 -0
- package/src/accordion/files/__name@dasherize__/__name@dasherize__.html.template +24 -0
- package/src/accordion/files/__name@dasherize__/__name@dasherize__.ts.template +70 -0
- package/src/accordion/index.d.ts +1 -0
- package/src/accordion/index.js +6 -0
- package/src/accordion/index.js.map +1 -0
- package/src/accordion/index.ts +3 -0
- package/src/accordion/schema.d.ts +1 -0
- package/src/accordion/schema.js +3 -0
- package/src/accordion/schema.js.map +1 -0
- package/src/accordion/schema.json +29 -0
- package/src/accordion/schema.ts +1 -0
- package/src/collection.json +30 -0
- package/src/dialog/files/__name@dasherize__/__name@dasherize__.css.template +32 -0
- package/src/dialog/files/__name@dasherize__/__name@dasherize__.html.template +18 -0
- package/src/dialog/files/__name@dasherize__/__name@dasherize__.ts.template +74 -0
- package/src/dialog/index.d.ts +1 -0
- package/src/dialog/index.js +6 -0
- package/src/dialog/index.js.map +1 -0
- package/src/dialog/index.ts +3 -0
- package/src/dialog/schema.d.ts +1 -0
- package/src/dialog/schema.js +3 -0
- package/src/dialog/schema.js.map +1 -0
- package/src/dialog/schema.json +29 -0
- package/src/dialog/schema.ts +1 -0
- package/src/disclosure/files/__name@dasherize__/__name@dasherize__.css.template +27 -0
- package/src/disclosure/files/__name@dasherize__/__name@dasherize__.html.template +17 -0
- package/src/disclosure/files/__name@dasherize__/__name@dasherize__.ts.template +18 -0
- package/src/disclosure/index.d.ts +1 -0
- package/src/disclosure/index.js +6 -0
- package/src/disclosure/index.js.map +1 -0
- package/src/disclosure/index.ts +3 -0
- package/src/disclosure/schema.d.ts +1 -0
- package/src/disclosure/schema.js +3 -0
- package/src/disclosure/schema.js.map +1 -0
- package/src/disclosure/schema.json +29 -0
- package/src/disclosure/schema.ts +1 -0
- package/src/tabs/files/__name@dasherize__/__name@dasherize__.css.template +39 -0
- package/src/tabs/files/__name@dasherize__/__name@dasherize__.html.template +29 -0
- package/src/tabs/files/__name@dasherize__/__name@dasherize__.ts.template +65 -0
- package/src/tabs/index.d.ts +1 -0
- package/src/tabs/index.js +6 -0
- package/src/tabs/index.js.map +1 -0
- package/src/tabs/index.ts +3 -0
- package/src/tabs/schema.d.ts +1 -0
- package/src/tabs/schema.js +3 -0
- package/src/tabs/schema.js.map +1 -0
- package/src/tabs/schema.json +29 -0
- package/src/tabs/schema.ts +1 -0
- package/src/tooltip/files/__name@dasherize__/__name@dasherize__.css.template +33 -0
- package/src/tooltip/files/__name@dasherize__/__name@dasherize__.html.template +15 -0
- package/src/tooltip/files/__name@dasherize__/__name@dasherize__.ts.template +28 -0
- package/src/tooltip/index.d.ts +1 -0
- package/src/tooltip/index.js +6 -0
- package/src/tooltip/index.js.map +1 -0
- package/src/tooltip/index.ts +3 -0
- package/src/tooltip/schema.d.ts +1 -0
- package/src/tooltip/schema.js +3 -0
- package/src/tooltip/schema.js.map +1 -0
- package/src/tooltip/schema.json +29 -0
- package/src/tooltip/schema.ts +1 -0
- package/src/utils/generate-component.d.ts +8 -0
- package/src/utils/generate-component.js +30 -0
- package/src/utils/generate-component.js.map +1 -0
- package/src/utils/generate-component.ts +32 -0
- package/src/utils/schema-types.d.ts +5 -0
- package/src/utils/schema-types.js +3 -0
- package/src/utils/schema-types.js.map +1 -0
- package/src/utils/schema-types.ts +5 -0
- package/src/utils/test-helpers.d.ts +3 -0
- package/src/utils/test-helpers.js +23 -0
- package/src/utils/test-helpers.js.map +1 -0
- package/src/utils/test-helpers.ts +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Danilo Silva (daniloagostinho)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# semmet-angular
|
|
2
|
+
|
|
3
|
+
**Real Angular components, same ARIA vocabulary as [Semmet](https://github.com/daniloagostinho/semmet).**
|
|
4
|
+
|
|
5
|
+
`ng generate semmet-angular:accordion my-faq` scaffolds a standalone, signals-based Angular component — `.ts`/`.html`/`.css` — that implements the W3C ARIA Authoring Practices Guide pattern for real: correct roles and `aria-*` wiring, **and** the keyboard/focus behavior the pattern requires (arrow-key navigation, focus traps, focus restoration...). Unlike Semmet (markup only, by design), here markup alone isn't enough — so the generated component is fully self-contained and functional out of the box.
|
|
6
|
+
|
|
7
|
+
## Why a separate project from Semmet
|
|
8
|
+
|
|
9
|
+
Semmet's whole pitch is "just markup, zero CSS/JS, framework-agnostic" — that's the opposite of what's needed here. Generating a real Angular component means real state, real event handlers, real styling. Same ARIA vocabulary as the source of truth, different product.
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install -D semmet-angular
|
|
15
|
+
ng generate semmet-angular:accordion my-faq
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Each generated component is dropped into your project exactly like `ng generate component` would (respecting your `angular.json` project, source root, and selector prefix), and has **zero runtime dependency** on this package — `semmet-angular` is only needed at generation time.
|
|
19
|
+
|
|
20
|
+
## Schematics (5)
|
|
21
|
+
|
|
22
|
+
| Command | Generates | Keyboard/focus behavior implemented |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `ng generate semmet-angular:accordion <name>` | [Accordion](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/) | Click/Enter/Space toggle; Up/Down/Home/End moves focus between headers |
|
|
25
|
+
| `ng generate semmet-angular:tabs <name>` | [Tabs](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) | Automatic activation; Left/Right/Home/End with roving `tabindex` |
|
|
26
|
+
| `ng generate semmet-angular:dialog <name>` | [Dialog (Modal)](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/) | Focus moves in on open; Tab/Shift+Tab focus trap; Escape closes; focus returns to trigger on close |
|
|
27
|
+
| `ng generate semmet-angular:disclosure <name>` | [Disclosure](https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/) | Click/Enter/Space toggle |
|
|
28
|
+
| `ng generate semmet-angular:tooltip <name>` | [Tooltip](https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/) | Shows on hover **and** focus (required for keyboard users); hides on blur/mouseleave/Escape |
|
|
29
|
+
|
|
30
|
+
## What every generated component looks like
|
|
31
|
+
|
|
32
|
+
- Standalone (implicit — no `standalone: true` needed, matches Angular's current default).
|
|
33
|
+
- State via `signal()`/`computed()`, not plain class fields.
|
|
34
|
+
- Templates use the built-in control flow (`@for`, `@if`), not `*ngFor`/`*ngIf`.
|
|
35
|
+
- No `.component` suffix on file names or class names (`accordion.ts`, `export class Accordion`), matching the current `ng generate component` convention.
|
|
36
|
+
- A unique per-instance id (`<name>-0`, `<name>-1`, ...) so multiple instances of the same generated component never collide on `id`/`aria-controls`/`aria-labelledby`.
|
|
37
|
+
|
|
38
|
+
## Options
|
|
39
|
+
|
|
40
|
+
Every schematic accepts the same three options (same as `ng generate component`):
|
|
41
|
+
|
|
42
|
+
- `name` (required, positional) — component name.
|
|
43
|
+
- `project` — target project; defaults to the current/default project.
|
|
44
|
+
- `path` — target directory; defaults to the project's `src/app`.
|
|
45
|
+
|
|
46
|
+
## License
|
|
47
|
+
|
|
48
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "semmet-angular",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Angular schematics that generate real, ARIA-conformant standalone components (Accordion, Tabs, Dialog, Disclosure, Tooltip...) with signals-based state and keyboard interaction built in.",
|
|
5
|
+
"publisher": "danilodevsilva",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"angular",
|
|
9
|
+
"schematics",
|
|
10
|
+
"ng-generate",
|
|
11
|
+
"aria",
|
|
12
|
+
"accessibility",
|
|
13
|
+
"a11y",
|
|
14
|
+
"standalone",
|
|
15
|
+
"signals"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/daniloagostinho/semmet-angular.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/daniloagostinho/semmet-angular/issues"
|
|
23
|
+
},
|
|
24
|
+
"schematics": "./src/collection.json",
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"!src/**/*_spec.ts",
|
|
28
|
+
"!src/**/*_spec.js",
|
|
29
|
+
"!src/**/*_spec.js.map",
|
|
30
|
+
"!src/**/*_spec.d.ts"
|
|
31
|
+
],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc -p tsconfig.json",
|
|
34
|
+
"test": "npm run build && node --test src/**/*_spec.js",
|
|
35
|
+
"prepublishOnly": "npm run build"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@angular-devkit/core": "^22.0.6",
|
|
39
|
+
"@angular-devkit/schematics": "^22.0.6",
|
|
40
|
+
"@schematics/angular": "^22.0.6"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^20.11.0",
|
|
44
|
+
"typescript": "^5.3.3"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
h3 {
|
|
7
|
+
margin: 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
button {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
width: 100%;
|
|
14
|
+
padding: 0.75rem 1rem;
|
|
15
|
+
font: inherit;
|
|
16
|
+
color: inherit;
|
|
17
|
+
background: none;
|
|
18
|
+
border: 1px solid currentColor;
|
|
19
|
+
border-radius: 6px;
|
|
20
|
+
cursor: pointer;
|
|
21
|
+
text-align: left;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
button:focus-visible {
|
|
25
|
+
outline: 2px solid currentColor;
|
|
26
|
+
outline-offset: 2px;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
[role='region'] {
|
|
30
|
+
padding: 0.75rem 1rem;
|
|
31
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
<!-- Semmet Angular: Accordion — https://www.w3.org/WAI/ARIA/apg/patterns/accordion/ -->
|
|
2
|
+
@for (item of items; track item.id; let i = $index) {
|
|
3
|
+
<h3>
|
|
4
|
+
<button
|
|
5
|
+
#headerButton
|
|
6
|
+
type="button"
|
|
7
|
+
[attr.aria-expanded]="isExpanded(item.id)"
|
|
8
|
+
[attr.aria-controls]="instanceId + '-panel-' + item.id"
|
|
9
|
+
[id]="instanceId + '-header-' + item.id"
|
|
10
|
+
(click)="toggle(item.id)"
|
|
11
|
+
(keydown)="onHeaderKeydown($event, i)"
|
|
12
|
+
>
|
|
13
|
+
{{ item.title }}
|
|
14
|
+
</button>
|
|
15
|
+
</h3>
|
|
16
|
+
<div
|
|
17
|
+
[id]="instanceId + '-panel-' + item.id"
|
|
18
|
+
role="region"
|
|
19
|
+
[attr.aria-labelledby]="instanceId + '-header-' + item.id"
|
|
20
|
+
[hidden]="!isExpanded(item.id)"
|
|
21
|
+
>
|
|
22
|
+
{{ item.content }}
|
|
23
|
+
</div>
|
|
24
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Component, ElementRef, signal, viewChildren } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
interface <%= classify(name) %>Item {
|
|
4
|
+
id: number;
|
|
5
|
+
title: string;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let nextId = 0;
|
|
10
|
+
|
|
11
|
+
@Component({
|
|
12
|
+
selector: '<%= selector %>',
|
|
13
|
+
imports: [],
|
|
14
|
+
templateUrl: './<%= dasherize(name) %>.html',
|
|
15
|
+
styleUrl: './<%= dasherize(name) %>.css',
|
|
16
|
+
})
|
|
17
|
+
export class <%= classify(name) %> {
|
|
18
|
+
protected readonly instanceId = `<%= dasherize(name) %>-${nextId++}`;
|
|
19
|
+
|
|
20
|
+
readonly items: <%= classify(name) %>Item[] = [
|
|
21
|
+
{ id: 1, title: 'First section title', content: 'First panel content' },
|
|
22
|
+
{ id: 2, title: 'Second section title', content: 'Second panel content' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
private readonly expandedIds = signal<ReadonlySet<number>>(new Set([1]));
|
|
26
|
+
private readonly headerButtons = viewChildren<ElementRef<HTMLButtonElement>>('headerButton');
|
|
27
|
+
|
|
28
|
+
isExpanded(id: number): boolean {
|
|
29
|
+
return this.expandedIds().has(id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
toggle(id: number): void {
|
|
33
|
+
const next = new Set(this.expandedIds());
|
|
34
|
+
if (next.has(id)) {
|
|
35
|
+
next.delete(id);
|
|
36
|
+
} else {
|
|
37
|
+
next.add(id);
|
|
38
|
+
}
|
|
39
|
+
this.expandedIds.set(next);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Up/Down/Home/End move focus between headers — optional but APG-recommended keyboard support.
|
|
43
|
+
onHeaderKeydown(event: KeyboardEvent, index: number): void {
|
|
44
|
+
const buttons = this.headerButtons();
|
|
45
|
+
if (buttons.length === 0) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let nextIndex: number;
|
|
50
|
+
switch (event.key) {
|
|
51
|
+
case 'ArrowDown':
|
|
52
|
+
nextIndex = (index + 1) % buttons.length;
|
|
53
|
+
break;
|
|
54
|
+
case 'ArrowUp':
|
|
55
|
+
nextIndex = (index - 1 + buttons.length) % buttons.length;
|
|
56
|
+
break;
|
|
57
|
+
case 'Home':
|
|
58
|
+
nextIndex = 0;
|
|
59
|
+
break;
|
|
60
|
+
case 'End':
|
|
61
|
+
nextIndex = buttons.length - 1;
|
|
62
|
+
break;
|
|
63
|
+
default:
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
buttons[nextIndex].nativeElement.focus();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const accordion: (options: import("./schema").Schema) => import("@angular-devkit/schematics").Rule;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.accordion = void 0;
|
|
4
|
+
const generate_component_1 = require("../utils/generate-component");
|
|
5
|
+
exports.accordion = (0, generate_component_1.createAriaComponentSchematic)();
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAA,oEAA2E;AAE9D,QAAA,SAAS,GAAG,IAAA,iDAA4B,GAAE,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ComponentSchematicOptions as Schema } from '../utils/schema-types';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["schema.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema",
|
|
3
|
+
"$id": "SemmetAngularAccordion",
|
|
4
|
+
"title": "Semmet Angular Accordion Options Schema",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"description": "Generates a standalone Angular Accordion component (signals-based state, keyboard support) following the W3C ARIA Authoring Practices Guide.",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"name": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "The name for the new component. Used to derive the class name, selector, and file names.",
|
|
12
|
+
"$default": { "$source": "argv", "index": 0 },
|
|
13
|
+
"x-prompt": "What name would you like to use for the accordion component?"
|
|
14
|
+
},
|
|
15
|
+
"path": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"format": "path",
|
|
18
|
+
"$default": { "$source": "workingDirectory" },
|
|
19
|
+
"description": "The path where the component files should be created, relative to the workspace root. Defaults to the project's source root.",
|
|
20
|
+
"visible": false
|
|
21
|
+
},
|
|
22
|
+
"project": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "The name of the project to add the component to.",
|
|
25
|
+
"$default": { "$source": "projectName" }
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"required": ["name", "project"]
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ComponentSchematicOptions as Schema } from '../utils/schema-types';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../node_modules/@angular-devkit/schematics/collection-schema.json",
|
|
3
|
+
"schematics": {
|
|
4
|
+
"accordion": {
|
|
5
|
+
"description": "Generates a standalone Angular Accordion component following the W3C ARIA Authoring Practices Guide.",
|
|
6
|
+
"factory": "./accordion/index#accordion",
|
|
7
|
+
"schema": "./accordion/schema.json"
|
|
8
|
+
},
|
|
9
|
+
"tabs": {
|
|
10
|
+
"description": "Generates a standalone Angular Tabs component following the W3C ARIA Authoring Practices Guide.",
|
|
11
|
+
"factory": "./tabs/index#tabs",
|
|
12
|
+
"schema": "./tabs/schema.json"
|
|
13
|
+
},
|
|
14
|
+
"dialog": {
|
|
15
|
+
"description": "Generates a standalone Angular Dialog (Modal) component following the W3C ARIA Authoring Practices Guide.",
|
|
16
|
+
"factory": "./dialog/index#dialog",
|
|
17
|
+
"schema": "./dialog/schema.json"
|
|
18
|
+
},
|
|
19
|
+
"disclosure": {
|
|
20
|
+
"description": "Generates a standalone Angular Disclosure component following the W3C ARIA Authoring Practices Guide.",
|
|
21
|
+
"factory": "./disclosure/index#disclosure",
|
|
22
|
+
"schema": "./disclosure/schema.json"
|
|
23
|
+
},
|
|
24
|
+
"tooltip": {
|
|
25
|
+
"description": "Generates a standalone Angular Tooltip component following the W3C ARIA Authoring Practices Guide.",
|
|
26
|
+
"factory": "./tooltip/index#tooltip",
|
|
27
|
+
"schema": "./tooltip/schema.json"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.backdrop {
|
|
6
|
+
position: fixed;
|
|
7
|
+
inset: 0;
|
|
8
|
+
background: rgb(0 0 0 / 50%);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
[role='dialog'] {
|
|
12
|
+
position: fixed;
|
|
13
|
+
top: 50%;
|
|
14
|
+
left: 50%;
|
|
15
|
+
transform: translate(-50%, -50%);
|
|
16
|
+
min-width: 20rem;
|
|
17
|
+
max-width: 90vw;
|
|
18
|
+
padding: 1.5rem;
|
|
19
|
+
border-radius: 8px;
|
|
20
|
+
background: canvas;
|
|
21
|
+
color: canvastext;
|
|
22
|
+
box-shadow: 0 10px 30px rgb(0 0 0 / 25%);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
[role='dialog']:focus-visible {
|
|
26
|
+
outline: 2px solid currentColor;
|
|
27
|
+
outline-offset: 2px;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
button {
|
|
31
|
+
font: inherit;
|
|
32
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!-- Semmet Angular: Dialog (Modal) — https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/ -->
|
|
2
|
+
<button type="button" (click)="open($event)">Open dialog</button>
|
|
3
|
+
|
|
4
|
+
@if (isOpen()) {
|
|
5
|
+
<div class="backdrop" (click)="close()"></div>
|
|
6
|
+
<div
|
|
7
|
+
#dialog
|
|
8
|
+
role="dialog"
|
|
9
|
+
aria-modal="true"
|
|
10
|
+
[attr.aria-labelledby]="instanceId + '-title'"
|
|
11
|
+
tabindex="-1"
|
|
12
|
+
(keydown)="onKeydown($event)"
|
|
13
|
+
>
|
|
14
|
+
<h2 [id]="instanceId + '-title'">Dialog title</h2>
|
|
15
|
+
<div>Dialog content</div>
|
|
16
|
+
<button type="button" (click)="close()">Close</button>
|
|
17
|
+
</div>
|
|
18
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Component, ElementRef, Injector, afterNextRender, inject, signal, viewChild } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
const FOCUSABLE_SELECTOR =
|
|
4
|
+
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
5
|
+
|
|
6
|
+
let nextId = 0;
|
|
7
|
+
|
|
8
|
+
@Component({
|
|
9
|
+
selector: '<%= selector %>',
|
|
10
|
+
imports: [],
|
|
11
|
+
templateUrl: './<%= dasherize(name) %>.html',
|
|
12
|
+
styleUrl: './<%= dasherize(name) %>.css',
|
|
13
|
+
})
|
|
14
|
+
export class <%= classify(name) %> {
|
|
15
|
+
private readonly injector = inject(Injector);
|
|
16
|
+
private readonly dialogRef = viewChild<ElementRef<HTMLElement>>('dialog');
|
|
17
|
+
private triggerElement: HTMLElement | null = null;
|
|
18
|
+
|
|
19
|
+
protected readonly instanceId = `<%= dasherize(name) %>-${nextId++}`;
|
|
20
|
+
protected readonly isOpen = signal(false);
|
|
21
|
+
|
|
22
|
+
open(event: MouseEvent): void {
|
|
23
|
+
this.triggerElement = event.currentTarget as HTMLElement;
|
|
24
|
+
this.isOpen.set(true);
|
|
25
|
+
// Focus moves into the dialog only after Angular flushes the DOM update that un-hides it.
|
|
26
|
+
afterNextRender(() => this.focusFirstElement(), { injector: this.injector });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
close(): void {
|
|
30
|
+
this.isOpen.set(false);
|
|
31
|
+
this.triggerElement?.focus();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
onKeydown(event: KeyboardEvent): void {
|
|
35
|
+
if (event.key === 'Escape') {
|
|
36
|
+
this.close();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (event.key === 'Tab') {
|
|
40
|
+
this.trapFocus(event);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private focusFirstElement(): void {
|
|
45
|
+
const dialog = this.dialogRef()?.nativeElement;
|
|
46
|
+
const firstFocusable = dialog?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
47
|
+
(firstFocusable ?? dialog)?.focus();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Keeps Tab/Shift+Tab cycling within the dialog's focusable elements (APG dialog focus trap).
|
|
51
|
+
private trapFocus(event: KeyboardEvent): void {
|
|
52
|
+
const dialog = this.dialogRef()?.nativeElement;
|
|
53
|
+
if (!dialog) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const focusable = Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
|
|
58
|
+
if (focusable.length === 0) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const first = focusable[0];
|
|
63
|
+
const last = focusable[focusable.length - 1];
|
|
64
|
+
const active = dialog.ownerDocument.activeElement;
|
|
65
|
+
|
|
66
|
+
if (event.shiftKey && active === first) {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
last.focus();
|
|
69
|
+
} else if (!event.shiftKey && active === last) {
|
|
70
|
+
event.preventDefault();
|
|
71
|
+
first.focus();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const dialog: (options: import("./schema").Schema) => import("@angular-devkit/schematics").Rule;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.dialog = void 0;
|
|
4
|
+
const generate_component_1 = require("../utils/generate-component");
|
|
5
|
+
exports.dialog = (0, generate_component_1.createAriaComponentSchematic)();
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAA,oEAA2E;AAE9D,QAAA,MAAM,GAAG,IAAA,iDAA4B,GAAE,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ComponentSchematicOptions as Schema } from '../utils/schema-types';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["schema.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema",
|
|
3
|
+
"$id": "SemmetAngularDialog",
|
|
4
|
+
"title": "Semmet Angular Dialog Options Schema",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"description": "Generates a standalone Angular Dialog (Modal) component with a focus trap and focus restoration, following the W3C ARIA Authoring Practices Guide.",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"name": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "The name for the new component. Used to derive the class name, selector, and file names.",
|
|
12
|
+
"$default": { "$source": "argv", "index": 0 },
|
|
13
|
+
"x-prompt": "What name would you like to use for the dialog component?"
|
|
14
|
+
},
|
|
15
|
+
"path": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"format": "path",
|
|
18
|
+
"$default": { "$source": "workingDirectory" },
|
|
19
|
+
"description": "The path where the component files should be created, relative to the workspace root. Defaults to the project's source root.",
|
|
20
|
+
"visible": false
|
|
21
|
+
},
|
|
22
|
+
"project": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "The name of the project to add the component to.",
|
|
25
|
+
"$default": { "$source": "projectName" }
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"required": ["name", "project"]
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ComponentSchematicOptions as Schema } from '../utils/schema-types';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
button {
|
|
7
|
+
display: flex;
|
|
8
|
+
align-items: center;
|
|
9
|
+
width: 100%;
|
|
10
|
+
padding: 0.75rem 1rem;
|
|
11
|
+
font: inherit;
|
|
12
|
+
color: inherit;
|
|
13
|
+
background: none;
|
|
14
|
+
border: 1px solid currentColor;
|
|
15
|
+
border-radius: 6px;
|
|
16
|
+
cursor: pointer;
|
|
17
|
+
text-align: left;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
button:focus-visible {
|
|
21
|
+
outline: 2px solid currentColor;
|
|
22
|
+
outline-offset: 2px;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
div {
|
|
26
|
+
padding: 0.75rem 1rem;
|
|
27
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!-- Semmet Angular: Disclosure — https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/ -->
|
|
2
|
+
<button
|
|
3
|
+
type="button"
|
|
4
|
+
[attr.aria-expanded]="expanded()"
|
|
5
|
+
[attr.aria-controls]="instanceId + '-panel'"
|
|
6
|
+
[id]="instanceId + '-button'"
|
|
7
|
+
(click)="toggle()"
|
|
8
|
+
>
|
|
9
|
+
Show details
|
|
10
|
+
</button>
|
|
11
|
+
<div
|
|
12
|
+
[id]="instanceId + '-panel'"
|
|
13
|
+
[attr.aria-labelledby]="instanceId + '-button'"
|
|
14
|
+
[hidden]="!expanded()"
|
|
15
|
+
>
|
|
16
|
+
Hidden content
|
|
17
|
+
</div>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
let nextId = 0;
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: '<%= selector %>',
|
|
7
|
+
imports: [],
|
|
8
|
+
templateUrl: './<%= dasherize(name) %>.html',
|
|
9
|
+
styleUrl: './<%= dasherize(name) %>.css',
|
|
10
|
+
})
|
|
11
|
+
export class <%= classify(name) %> {
|
|
12
|
+
protected readonly instanceId = `<%= dasherize(name) %>-${nextId++}`;
|
|
13
|
+
protected readonly expanded = signal(false);
|
|
14
|
+
|
|
15
|
+
toggle(): void {
|
|
16
|
+
this.expanded.update((value) => !value);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const disclosure: (options: import("./schema").Schema) => import("@angular-devkit/schematics").Rule;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.disclosure = void 0;
|
|
4
|
+
const generate_component_1 = require("../utils/generate-component");
|
|
5
|
+
exports.disclosure = (0, generate_component_1.createAriaComponentSchematic)();
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAA,oEAA2E;AAE9D,QAAA,UAAU,GAAG,IAAA,iDAA4B,GAAE,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ComponentSchematicOptions as Schema } from '../utils/schema-types';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["schema.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema",
|
|
3
|
+
"$id": "SemmetAngularDisclosure",
|
|
4
|
+
"title": "Semmet Angular Disclosure Options Schema",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"description": "Generates a standalone Angular Disclosure component (signals-based state) following the W3C ARIA Authoring Practices Guide.",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"name": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "The name for the new component. Used to derive the class name, selector, and file names.",
|
|
12
|
+
"$default": { "$source": "argv", "index": 0 },
|
|
13
|
+
"x-prompt": "What name would you like to use for the disclosure component?"
|
|
14
|
+
},
|
|
15
|
+
"path": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"format": "path",
|
|
18
|
+
"$default": { "$source": "workingDirectory" },
|
|
19
|
+
"description": "The path where the component files should be created, relative to the workspace root. Defaults to the project's source root.",
|
|
20
|
+
"visible": false
|
|
21
|
+
},
|
|
22
|
+
"project": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "The name of the project to add the component to.",
|
|
25
|
+
"$default": { "$source": "projectName" }
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"required": ["name", "project"]
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ComponentSchematicOptions as Schema } from '../utils/schema-types';
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
[role='tablist'] {
|
|
7
|
+
display: flex;
|
|
8
|
+
gap: 0.25rem;
|
|
9
|
+
border-bottom: 1px solid currentColor;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
[role='tab'] {
|
|
13
|
+
font: inherit;
|
|
14
|
+
color: inherit;
|
|
15
|
+
background: none;
|
|
16
|
+
border: none;
|
|
17
|
+
padding: 0.6rem 1rem;
|
|
18
|
+
cursor: pointer;
|
|
19
|
+
border-bottom: 2px solid transparent;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
[role='tab'][aria-selected='true'] {
|
|
23
|
+
border-bottom-color: currentColor;
|
|
24
|
+
font-weight: 600;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
[role='tab']:focus-visible {
|
|
28
|
+
outline: 2px solid currentColor;
|
|
29
|
+
outline-offset: 2px;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
[role='tabpanel'] {
|
|
33
|
+
padding: 1rem 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
[role='tabpanel']:focus-visible {
|
|
37
|
+
outline: 2px solid currentColor;
|
|
38
|
+
outline-offset: 2px;
|
|
39
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!-- Semmet Angular: Tabs — https://www.w3.org/WAI/ARIA/apg/patterns/tabs/ -->
|
|
2
|
+
<div role="tablist" aria-label="Tabs">
|
|
3
|
+
@for (item of items; track item.id; let i = $index) {
|
|
4
|
+
<button
|
|
5
|
+
#tabButton
|
|
6
|
+
type="button"
|
|
7
|
+
role="tab"
|
|
8
|
+
[attr.aria-selected]="isActive(item.id)"
|
|
9
|
+
[attr.aria-controls]="instanceId + '-panel-' + item.id"
|
|
10
|
+
[id]="instanceId + '-tab-' + item.id"
|
|
11
|
+
[tabIndex]="isActive(item.id) ? 0 : -1"
|
|
12
|
+
(click)="select(item.id)"
|
|
13
|
+
(keydown)="onTabKeydown($event, i)"
|
|
14
|
+
>
|
|
15
|
+
{{ item.label }}
|
|
16
|
+
</button>
|
|
17
|
+
}
|
|
18
|
+
</div>
|
|
19
|
+
@for (item of items; track item.id) {
|
|
20
|
+
<div
|
|
21
|
+
[id]="instanceId + '-panel-' + item.id"
|
|
22
|
+
role="tabpanel"
|
|
23
|
+
tabindex="0"
|
|
24
|
+
[attr.aria-labelledby]="instanceId + '-tab-' + item.id"
|
|
25
|
+
[hidden]="!isActive(item.id)"
|
|
26
|
+
>
|
|
27
|
+
{{ item.content }}
|
|
28
|
+
</div>
|
|
29
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { Component, ElementRef, signal, viewChildren } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
interface <%= classify(name) %>Item {
|
|
4
|
+
id: number;
|
|
5
|
+
label: string;
|
|
6
|
+
content: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
let nextId = 0;
|
|
10
|
+
|
|
11
|
+
@Component({
|
|
12
|
+
selector: '<%= selector %>',
|
|
13
|
+
imports: [],
|
|
14
|
+
templateUrl: './<%= dasherize(name) %>.html',
|
|
15
|
+
styleUrl: './<%= dasherize(name) %>.css',
|
|
16
|
+
})
|
|
17
|
+
export class <%= classify(name) %> {
|
|
18
|
+
protected readonly instanceId = `<%= dasherize(name) %>-${nextId++}`;
|
|
19
|
+
|
|
20
|
+
readonly items: <%= classify(name) %>Item[] = [
|
|
21
|
+
{ id: 1, label: 'First tab', content: 'First panel content' },
|
|
22
|
+
{ id: 2, label: 'Second tab', content: 'Second panel content' },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
private readonly activeId = signal(this.items[0].id);
|
|
26
|
+
private readonly tabButtons = viewChildren<ElementRef<HTMLButtonElement>>('tabButton');
|
|
27
|
+
|
|
28
|
+
isActive(id: number): boolean {
|
|
29
|
+
return this.activeId() === id;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
select(id: number): void {
|
|
33
|
+
this.activeId.set(id);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Left/Right/Home/End move focus AND selection (automatic activation model, per APG tabs pattern).
|
|
37
|
+
onTabKeydown(event: KeyboardEvent, index: number): void {
|
|
38
|
+
const buttons = this.tabButtons();
|
|
39
|
+
if (buttons.length === 0) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let nextIndex: number;
|
|
44
|
+
switch (event.key) {
|
|
45
|
+
case 'ArrowRight':
|
|
46
|
+
nextIndex = (index + 1) % buttons.length;
|
|
47
|
+
break;
|
|
48
|
+
case 'ArrowLeft':
|
|
49
|
+
nextIndex = (index - 1 + buttons.length) % buttons.length;
|
|
50
|
+
break;
|
|
51
|
+
case 'Home':
|
|
52
|
+
nextIndex = 0;
|
|
53
|
+
break;
|
|
54
|
+
case 'End':
|
|
55
|
+
nextIndex = buttons.length - 1;
|
|
56
|
+
break;
|
|
57
|
+
default:
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
this.select(this.items[nextIndex].id);
|
|
63
|
+
buttons[nextIndex].nativeElement.focus();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const tabs: (options: import("./schema").Schema) => import("@angular-devkit/schematics").Rule;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.tabs = void 0;
|
|
4
|
+
const generate_component_1 = require("../utils/generate-component");
|
|
5
|
+
exports.tabs = (0, generate_component_1.createAriaComponentSchematic)();
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAA,oEAA2E;AAE9D,QAAA,IAAI,GAAG,IAAA,iDAA4B,GAAE,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ComponentSchematicOptions as Schema } from '../utils/schema-types';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["schema.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema",
|
|
3
|
+
"$id": "SemmetAngularTabs",
|
|
4
|
+
"title": "Semmet Angular Tabs Options Schema",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"description": "Generates a standalone Angular Tabs component (signals-based state, roving tabindex keyboard support) following the W3C ARIA Authoring Practices Guide.",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"name": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "The name for the new component. Used to derive the class name, selector, and file names.",
|
|
12
|
+
"$default": { "$source": "argv", "index": 0 },
|
|
13
|
+
"x-prompt": "What name would you like to use for the tabs component?"
|
|
14
|
+
},
|
|
15
|
+
"path": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"format": "path",
|
|
18
|
+
"$default": { "$source": "workingDirectory" },
|
|
19
|
+
"description": "The path where the component files should be created, relative to the workspace root. Defaults to the project's source root.",
|
|
20
|
+
"visible": false
|
|
21
|
+
},
|
|
22
|
+
"project": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "The name of the project to add the component to.",
|
|
25
|
+
"$default": { "$source": "projectName" }
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"required": ["name", "project"]
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ComponentSchematicOptions as Schema } from '../utils/schema-types';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
position: relative;
|
|
3
|
+
display: inline-block;
|
|
4
|
+
font-family: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
button {
|
|
8
|
+
font: inherit;
|
|
9
|
+
color: inherit;
|
|
10
|
+
background: none;
|
|
11
|
+
border: 1px solid currentColor;
|
|
12
|
+
border-radius: 6px;
|
|
13
|
+
padding: 0.4rem 0.75rem;
|
|
14
|
+
cursor: pointer;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
button:focus-visible {
|
|
18
|
+
outline: 2px solid currentColor;
|
|
19
|
+
outline-offset: 2px;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
[role='tooltip'] {
|
|
23
|
+
position: absolute;
|
|
24
|
+
bottom: 100%;
|
|
25
|
+
left: 0;
|
|
26
|
+
margin-bottom: 0.35rem;
|
|
27
|
+
padding: 0.25rem 0.5rem;
|
|
28
|
+
border-radius: 4px;
|
|
29
|
+
background: #111827;
|
|
30
|
+
color: #fff;
|
|
31
|
+
font-size: 0.85rem;
|
|
32
|
+
white-space: nowrap;
|
|
33
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!-- Semmet Angular: Tooltip — https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/ -->
|
|
2
|
+
<button
|
|
3
|
+
type="button"
|
|
4
|
+
[attr.aria-describedby]="instanceId"
|
|
5
|
+
(mouseenter)="show()"
|
|
6
|
+
(mouseleave)="hide()"
|
|
7
|
+
(focus)="show()"
|
|
8
|
+
(blur)="hide()"
|
|
9
|
+
(keydown)="onTriggerKeydown($event)"
|
|
10
|
+
>
|
|
11
|
+
Hover me
|
|
12
|
+
</button>
|
|
13
|
+
<span role="tooltip" [id]="instanceId" [hidden]="!visible()">
|
|
14
|
+
Tooltip text
|
|
15
|
+
</span>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
let nextId = 0;
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: '<%= selector %>',
|
|
7
|
+
imports: [],
|
|
8
|
+
templateUrl: './<%= dasherize(name) %>.html',
|
|
9
|
+
styleUrl: './<%= dasherize(name) %>.css',
|
|
10
|
+
})
|
|
11
|
+
export class <%= classify(name) %> {
|
|
12
|
+
protected readonly instanceId = `<%= dasherize(name) %>-${nextId++}`;
|
|
13
|
+
protected readonly visible = signal(false);
|
|
14
|
+
|
|
15
|
+
show(): void {
|
|
16
|
+
this.visible.set(true);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
hide(): void {
|
|
20
|
+
this.visible.set(false);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
onTriggerKeydown(event: KeyboardEvent): void {
|
|
24
|
+
if (event.key === 'Escape') {
|
|
25
|
+
this.hide();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const tooltip: (options: import("./schema").Schema) => import("@angular-devkit/schematics").Rule;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.tooltip = void 0;
|
|
4
|
+
const generate_component_1 = require("../utils/generate-component");
|
|
5
|
+
exports.tooltip = (0, generate_component_1.createAriaComponentSchematic)();
|
|
6
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAA,oEAA2E;AAE9D,QAAA,OAAO,GAAG,IAAA,iDAA4B,GAAE,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ComponentSchematicOptions as Schema } from '../utils/schema-types';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema.js","sourceRoot":"","sources":["schema.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema",
|
|
3
|
+
"$id": "SemmetAngularTooltip",
|
|
4
|
+
"title": "Semmet Angular Tooltip Options Schema",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"description": "Generates a standalone Angular Tooltip component (signals-based state, hover + focus support) following the W3C ARIA Authoring Practices Guide.",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"properties": {
|
|
9
|
+
"name": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "The name for the new component. Used to derive the class name, selector, and file names.",
|
|
12
|
+
"$default": { "$source": "argv", "index": 0 },
|
|
13
|
+
"x-prompt": "What name would you like to use for the tooltip component?"
|
|
14
|
+
},
|
|
15
|
+
"path": {
|
|
16
|
+
"type": "string",
|
|
17
|
+
"format": "path",
|
|
18
|
+
"$default": { "$source": "workingDirectory" },
|
|
19
|
+
"description": "The path where the component files should be created, relative to the workspace root. Defaults to the project's source root.",
|
|
20
|
+
"visible": false
|
|
21
|
+
},
|
|
22
|
+
"project": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"description": "The name of the project to add the component to.",
|
|
25
|
+
"$default": { "$source": "projectName" }
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"required": ["name", "project"]
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { ComponentSchematicOptions as Schema } from '../utils/schema-types';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Rule } from '@angular-devkit/schematics';
|
|
2
|
+
import { ComponentSchematicOptions } from './schema-types';
|
|
3
|
+
/**
|
|
4
|
+
* Shared Rule factory for every Semmet Angular schematic. `url('./files')` resolves relative to
|
|
5
|
+
* whichever named schematic is currently executing (per collection.json), not to this module's
|
|
6
|
+
* location, so centralizing this logic here is safe and avoids repeating it in all five schematics.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createAriaComponentSchematic(): (options: ComponentSchematicOptions) => Rule;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAriaComponentSchematic = createAriaComponentSchematic;
|
|
4
|
+
const schematics_1 = require("@angular-devkit/schematics");
|
|
5
|
+
const core_1 = require("@angular-devkit/core");
|
|
6
|
+
const project_1 = require("@schematics/angular/utility/project");
|
|
7
|
+
const workspace_1 = require("@schematics/angular/utility/workspace");
|
|
8
|
+
const parse_name_1 = require("@schematics/angular/utility/parse-name");
|
|
9
|
+
const validation_1 = require("@schematics/angular/utility/validation");
|
|
10
|
+
/**
|
|
11
|
+
* Shared Rule factory for every Semmet Angular schematic. `url('./files')` resolves relative to
|
|
12
|
+
* whichever named schematic is currently executing (per collection.json), not to this module's
|
|
13
|
+
* location, so centralizing this logic here is safe and avoids repeating it in all five schematics.
|
|
14
|
+
*/
|
|
15
|
+
function createAriaComponentSchematic() {
|
|
16
|
+
return (0, project_1.createProjectSchematic)((options, { project }) => {
|
|
17
|
+
options.path ??= (0, workspace_1.buildDefaultPath)(project);
|
|
18
|
+
const parsedPath = (0, parse_name_1.parseName)(options.path, options.name);
|
|
19
|
+
const name = parsedPath.name;
|
|
20
|
+
const selector = `${project.prefix ? `${project.prefix}-` : ''}${core_1.strings.dasherize(name)}`;
|
|
21
|
+
(0, validation_1.validateHtmlSelector)(selector);
|
|
22
|
+
(0, validation_1.validateClassName)(core_1.strings.classify(name));
|
|
23
|
+
const templateSource = (0, schematics_1.apply)((0, schematics_1.url)('./files'), [
|
|
24
|
+
(0, schematics_1.applyTemplates)({ ...core_1.strings, name, selector }),
|
|
25
|
+
(0, schematics_1.move)(parsedPath.path),
|
|
26
|
+
]);
|
|
27
|
+
return (0, schematics_1.chain)([(0, schematics_1.mergeWith)(templateSource)]);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=generate-component.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"generate-component.js","sourceRoot":"","sources":["generate-component.ts"],"names":[],"mappings":";;AAaA,oEAkBC;AA/BD,2DAAsG;AACtG,+CAA+C;AAC/C,iEAA6E;AAC7E,qEAAyE;AACzE,uEAAmE;AACnE,uEAAiG;AAGjG;;;;GAIG;AACH,SAAgB,4BAA4B;IAC1C,OAAO,IAAA,gCAAsB,EAA4B,CAAC,OAAO,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE;QAChF,OAAO,CAAC,IAAI,KAAK,IAAA,4BAAgB,EAAC,OAAO,CAAC,CAAC;QAE3C,MAAM,UAAU,GAAG,IAAA,sBAAS,EAAC,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;QACzD,MAAM,IAAI,GAAG,UAAU,CAAC,IAAI,CAAC;QAE7B,MAAM,QAAQ,GAAG,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,cAAO,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3F,IAAA,iCAAoB,EAAC,QAAQ,CAAC,CAAC;QAC/B,IAAA,8BAAiB,EAAC,cAAO,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;QAE1C,MAAM,cAAc,GAAG,IAAA,kBAAK,EAAC,IAAA,gBAAG,EAAC,SAAS,CAAC,EAAE;YAC3C,IAAA,2BAAc,EAAC,EAAE,GAAG,cAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;YAC9C,IAAA,iBAAI,EAAC,UAAU,CAAC,IAAI,CAAC;SACtB,CAAC,CAAC;QAEH,OAAO,IAAA,kBAAK,EAAC,CAAC,IAAA,sBAAS,EAAC,cAAc,CAAC,CAAC,CAAC,CAAC;IAC5C,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Rule, apply, applyTemplates, chain, mergeWith, move, url } from '@angular-devkit/schematics';
|
|
2
|
+
import { strings } from '@angular-devkit/core';
|
|
3
|
+
import { createProjectSchematic } from '@schematics/angular/utility/project';
|
|
4
|
+
import { buildDefaultPath } from '@schematics/angular/utility/workspace';
|
|
5
|
+
import { parseName } from '@schematics/angular/utility/parse-name';
|
|
6
|
+
import { validateClassName, validateHtmlSelector } from '@schematics/angular/utility/validation';
|
|
7
|
+
import { ComponentSchematicOptions } from './schema-types';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shared Rule factory for every Semmet Angular schematic. `url('./files')` resolves relative to
|
|
11
|
+
* whichever named schematic is currently executing (per collection.json), not to this module's
|
|
12
|
+
* location, so centralizing this logic here is safe and avoids repeating it in all five schematics.
|
|
13
|
+
*/
|
|
14
|
+
export function createAriaComponentSchematic(): (options: ComponentSchematicOptions) => Rule {
|
|
15
|
+
return createProjectSchematic<ComponentSchematicOptions>((options, { project }) => {
|
|
16
|
+
options.path ??= buildDefaultPath(project);
|
|
17
|
+
|
|
18
|
+
const parsedPath = parseName(options.path, options.name);
|
|
19
|
+
const name = parsedPath.name;
|
|
20
|
+
|
|
21
|
+
const selector = `${project.prefix ? `${project.prefix}-` : ''}${strings.dasherize(name)}`;
|
|
22
|
+
validateHtmlSelector(selector);
|
|
23
|
+
validateClassName(strings.classify(name));
|
|
24
|
+
|
|
25
|
+
const templateSource = apply(url('./files'), [
|
|
26
|
+
applyTemplates({ ...strings, name, selector }),
|
|
27
|
+
move(parsedPath.path),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
return chain([mergeWith(templateSource)]);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"schema-types.js","sourceRoot":"","sources":["schema-types.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createWorkspaceTree = createWorkspaceTree;
|
|
4
|
+
const schematics_1 = require("@angular-devkit/schematics");
|
|
5
|
+
/** Minimal fake Angular workspace tree — just enough for getWorkspace()/buildDefaultPath() to work. */
|
|
6
|
+
function createWorkspaceTree() {
|
|
7
|
+
const tree = schematics_1.Tree.empty();
|
|
8
|
+
tree.create('/angular.json', JSON.stringify({
|
|
9
|
+
version: 1,
|
|
10
|
+
projects: {
|
|
11
|
+
demo: {
|
|
12
|
+
root: '',
|
|
13
|
+
sourceRoot: 'src',
|
|
14
|
+
prefix: 'app',
|
|
15
|
+
projectType: 'application',
|
|
16
|
+
architect: {},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
}, null, 2));
|
|
20
|
+
tree.create('/package.json', JSON.stringify({ name: 'demo' }, null, 2));
|
|
21
|
+
return tree;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=test-helpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"test-helpers.js","sourceRoot":"","sources":["test-helpers.ts"],"names":[],"mappings":";;AAGA,kDAyBC;AA5BD,2DAAkD;AAElD,uGAAuG;AACvG,SAAgB,mBAAmB;IACjC,MAAM,IAAI,GAAG,iBAAI,CAAC,KAAK,EAAE,CAAC;IAE1B,IAAI,CAAC,MAAM,CACT,eAAe,EACf,IAAI,CAAC,SAAS,CACZ;QACE,OAAO,EAAE,CAAC;QACV,QAAQ,EAAE;YACR,IAAI,EAAE;gBACJ,IAAI,EAAE,EAAE;gBACR,UAAU,EAAE,KAAK;gBACjB,MAAM,EAAE,KAAK;gBACb,WAAW,EAAE,aAAa;gBAC1B,SAAS,EAAE,EAAE;aACd;SACF;KACF,EACD,IAAI,EACJ,CAAC,CACF,CACF,CAAC;IACF,IAAI,CAAC,MAAM,CAAC,eAAe,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC;IAExE,OAAO,IAAI,CAAC;AACd,CAAC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { Tree } from '@angular-devkit/schematics';
|
|
2
|
+
|
|
3
|
+
/** Minimal fake Angular workspace tree — just enough for getWorkspace()/buildDefaultPath() to work. */
|
|
4
|
+
export function createWorkspaceTree(): Tree {
|
|
5
|
+
const tree = Tree.empty();
|
|
6
|
+
|
|
7
|
+
tree.create(
|
|
8
|
+
'/angular.json',
|
|
9
|
+
JSON.stringify(
|
|
10
|
+
{
|
|
11
|
+
version: 1,
|
|
12
|
+
projects: {
|
|
13
|
+
demo: {
|
|
14
|
+
root: '',
|
|
15
|
+
sourceRoot: 'src',
|
|
16
|
+
prefix: 'app',
|
|
17
|
+
projectType: 'application',
|
|
18
|
+
architect: {},
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
null,
|
|
23
|
+
2
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
tree.create('/package.json', JSON.stringify({ name: 'demo' }, null, 2));
|
|
27
|
+
|
|
28
|
+
return tree;
|
|
29
|
+
}
|