@kouji-ui/core 0.0.2
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/.turbo/turbo-build.log +27 -0
- package/CHANGELOG.md +7 -0
- package/README.md +64 -0
- package/ng-package.json +7 -0
- package/package.json +39 -0
- package/src/a11y/.gitkeep +0 -0
- package/src/a11y/aria-describedby.spec.ts +55 -0
- package/src/a11y/aria-describedby.ts +33 -0
- package/src/a11y/focus-trap.spec.ts +86 -0
- package/src/a11y/focus-trap.ts +70 -0
- package/src/a11y/index.ts +9 -0
- package/src/a11y/live-region.spec.ts +58 -0
- package/src/a11y/live-region.ts +48 -0
- package/src/a11y/roving-tabindex.spec.ts +62 -0
- package/src/a11y/roving-tabindex.ts +104 -0
- package/src/a11y/visually-hidden.spec.ts +34 -0
- package/src/a11y/visually-hidden.ts +24 -0
- package/src/accordion/accordion.context.ts +22 -0
- package/src/accordion/accordion.spec.ts +32 -0
- package/src/accordion/accordion.ts +129 -0
- package/src/accordion/index.ts +2 -0
- package/src/avatar/avatar.spec.ts +18 -0
- package/src/avatar/avatar.ts +26 -0
- package/src/avatar/index.ts +1 -0
- package/src/badge/badge.spec.ts +20 -0
- package/src/badge/badge.ts +14 -0
- package/src/badge/index.ts +1 -0
- package/src/button/button.example.ts +30 -0
- package/src/button/button.finance.example.ts +37 -0
- package/src/button/button.retro.example.ts +47 -0
- package/src/button/button.sizes.example.ts +25 -0
- package/src/button/button.spec.ts +32 -0
- package/src/button/button.ts +44 -0
- package/src/button/index.ts +1 -0
- package/src/chart/chart.spec.ts +27 -0
- package/src/chart/chart.ts +48 -0
- package/src/chart/index.ts +1 -0
- package/src/checkbox/checkbox.spec.ts +35 -0
- package/src/checkbox/checkbox.ts +82 -0
- package/src/checkbox/index.ts +1 -0
- package/src/dialog/dialog.confirm.example.ts +57 -0
- package/src/dialog/dialog.context.ts +11 -0
- package/src/dialog/dialog.example.ts +40 -0
- package/src/dialog/dialog.finance.example.ts +47 -0
- package/src/dialog/dialog.retro.example.ts +46 -0
- package/src/dialog/dialog.service.ts +176 -0
- package/src/dialog/dialog.spec.ts +122 -0
- package/src/dialog/dialog.ts +201 -0
- package/src/dialog/index.ts +9 -0
- package/src/example-components.ts +33 -0
- package/src/form/form.context.ts +3 -0
- package/src/form/form.spec.ts +25 -0
- package/src/form/form.ts +46 -0
- package/src/form/index.ts +2 -0
- package/src/input/index.ts +1 -0
- package/src/input/input.spec.ts +43 -0
- package/src/input/input.ts +80 -0
- package/src/menu/index.ts +2 -0
- package/src/menu/menu.context.ts +10 -0
- package/src/menu/menu.example.ts +39 -0
- package/src/menu/menu.spec.ts +168 -0
- package/src/menu/menu.ts +176 -0
- package/src/popover/index.ts +12 -0
- package/src/popover/popover.context.ts +18 -0
- package/src/popover/popover.example.ts +50 -0
- package/src/popover/popover.finance.example.ts +52 -0
- package/src/popover/popover.retro.example.ts +53 -0
- package/src/popover/popover.spec.ts +52 -0
- package/src/popover/popover.ts +124 -0
- package/src/primitives/.gitkeep +0 -0
- package/src/primitives/forms/form-control.spec.ts +59 -0
- package/src/primitives/forms/form-control.ts +90 -0
- package/src/primitives/forms/index.ts +1 -0
- package/src/primitives/index.ts +3 -0
- package/src/primitives/interaction/disabled.spec.ts +47 -0
- package/src/primitives/interaction/disabled.ts +24 -0
- package/src/primitives/interaction/focus-ring.spec.ts +62 -0
- package/src/primitives/interaction/focus-ring.ts +57 -0
- package/src/primitives/interaction/index.ts +2 -0
- package/src/primitives/overlay/index.ts +1 -0
- package/src/primitives/overlay/overlay.spec.ts +90 -0
- package/src/primitives/overlay/overlay.ts +102 -0
- package/src/public-api.ts +35 -0
- package/src/radio/index.ts +2 -0
- package/src/radio/radio.context.ts +3 -0
- package/src/radio/radio.spec.ts +36 -0
- package/src/radio/radio.ts +55 -0
- package/src/select/index.ts +2 -0
- package/src/select/select.context.ts +3 -0
- package/src/select/select.spec.ts +145 -0
- package/src/select/select.ts +250 -0
- package/src/styles/docs-themes.css +82 -0
- package/src/table/index.ts +1 -0
- package/src/table/table.spec.ts +113 -0
- package/src/table/table.ts +126 -0
- package/src/tabs/index.ts +2 -0
- package/src/tabs/tabs.context.ts +9 -0
- package/src/tabs/tabs.spec.ts +40 -0
- package/src/tabs/tabs.ts +130 -0
- package/src/test-setup.ts +5 -0
- package/src/toast/index.ts +21 -0
- package/src/toast/toast.example.ts +134 -0
- package/src/toast/toast.finance.example.ts +68 -0
- package/src/toast/toast.retro.example.ts +69 -0
- package/src/toast/toast.service.ts +153 -0
- package/src/toast/toast.spec.ts +94 -0
- package/src/toast/toast.strategy.ts +102 -0
- package/src/toast/toast.ts +307 -0
- package/src/toast/toast.types.ts +5 -0
- package/src/toggle/index.ts +1 -0
- package/src/toggle/toggle.spec.ts +24 -0
- package/src/toggle/toggle.ts +40 -0
- package/src/tooltip/index.ts +5 -0
- package/src/tooltip/tooltip.example.ts +46 -0
- package/src/tooltip/tooltip.finance.example.ts +54 -0
- package/src/tooltip/tooltip.placements.example.ts +56 -0
- package/src/tooltip/tooltip.retro.example.ts +65 -0
- package/src/tooltip/tooltip.spec.ts +104 -0
- package/src/tooltip/tooltip.ts +129 -0
- package/tsconfig.lib.json +13 -0
- package/tsconfig.lib.prod.json +11 -0
- package/tsconfig.spec.json +10 -0
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
|
|
2
|
+
> @kouji-ui/core@0.0.2 build /home/runner/work/kouji-ui/kouji-ui/packages/core
|
|
3
|
+
> ng-packagr -p ng-package.json
|
|
4
|
+
|
|
5
|
+
Building Angular Package
|
|
6
|
+
|
|
7
|
+
------------------------------------------------------------------------------
|
|
8
|
+
Building entry point '@kouji-ui/core'
|
|
9
|
+
------------------------------------------------------------------------------
|
|
10
|
+
- Compiling with Angular sources in partial compilation mode.
|
|
11
|
+
[32m✔[39m Compiling with Angular sources in partial compilation mode.
|
|
12
|
+
[32m✔[39m Generating FESM and DTS bundles
|
|
13
|
+
- Copying assets
|
|
14
|
+
[32m✔[39m Copying assets
|
|
15
|
+
- Writing package manifest
|
|
16
|
+
[34mℹ[39m Removing scripts section in package.json as it's considered a potential security vulnerability.
|
|
17
|
+
[32m✔[39m Writing package manifest
|
|
18
|
+
[32m✔[39m Built @kouji-ui/core
|
|
19
|
+
|
|
20
|
+
------------------------------------------------------------------------------
|
|
21
|
+
Built Angular Package
|
|
22
|
+
- from: /home/runner/work/kouji-ui/kouji-ui/packages/core
|
|
23
|
+
- to: /home/runner/work/kouji-ui/kouji-ui/dist/kj-core
|
|
24
|
+
------------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
Build at: 2026-05-04T20:37:30.262Z - Time: 4059ms
|
|
27
|
+
|
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# KjCore
|
|
2
|
+
|
|
3
|
+
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.0.
|
|
4
|
+
|
|
5
|
+
## Code scaffolding
|
|
6
|
+
|
|
7
|
+
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
ng generate component component-name
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
ng generate --help
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Building
|
|
20
|
+
|
|
21
|
+
To build the library, run:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
ng build kj-core
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
This command will compile your project, and the build artifacts will be placed in the `dist/` directory.
|
|
28
|
+
|
|
29
|
+
### Publishing the Library
|
|
30
|
+
|
|
31
|
+
Once the project is built, you can publish your library by following these steps:
|
|
32
|
+
|
|
33
|
+
1. Navigate to the `dist` directory:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cd dist/kj-core
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
2. Run the `npm publish` command to publish your library to the npm registry:
|
|
40
|
+
```bash
|
|
41
|
+
npm publish
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Running unit tests
|
|
45
|
+
|
|
46
|
+
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
ng test
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Running end-to-end tests
|
|
53
|
+
|
|
54
|
+
For end-to-end (e2e) testing, run:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
ng e2e
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
|
61
|
+
|
|
62
|
+
## Additional Resources
|
|
63
|
+
|
|
64
|
+
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
package/ng-package.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kouji-ui/core",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "Headless Angular 21 UI primitives — directives over CDK with WCAG 2.1 AAA semantics and zero CSS.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/kouji-dev/kouji-ui.git",
|
|
9
|
+
"directory": "packages/core"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://kouji-ui.onrender.com",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/kouji-dev/kouji-ui/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"angular",
|
|
17
|
+
"ui",
|
|
18
|
+
"headless",
|
|
19
|
+
"directives",
|
|
20
|
+
"cdk",
|
|
21
|
+
"a11y",
|
|
22
|
+
"wcag"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public",
|
|
26
|
+
"provenance": true
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@angular/common": "^21.0.0",
|
|
30
|
+
"@angular/core": "^21.0.0",
|
|
31
|
+
"@angular/cdk": "^21.0.0"
|
|
32
|
+
},
|
|
33
|
+
"sideEffects": false,
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "ng-packagr -p ng-package.json",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"lint": "ng lint kj-core"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { render } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import { KjAriaDescribedBy } from './aria-describedby';
|
|
4
|
+
|
|
5
|
+
expect.extend(toHaveNoViolations);
|
|
6
|
+
|
|
7
|
+
describe('KjAriaDescribedBy', () => {
|
|
8
|
+
it('sets aria-describedby from a single id string', async () => {
|
|
9
|
+
const { container } = await render(
|
|
10
|
+
`<input kjAriaDescribedBy [kjDescribedBy]="'hint-1'" />
|
|
11
|
+
<span id="hint-1">This is a hint</span>`,
|
|
12
|
+
{ imports: [KjAriaDescribedBy] },
|
|
13
|
+
);
|
|
14
|
+
expect(container.querySelector('input')).toHaveAttribute('aria-describedby', 'hint-1');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('sets aria-describedby from multiple ids', async () => {
|
|
18
|
+
const { container } = await render(
|
|
19
|
+
`<input kjAriaDescribedBy [kjDescribedBy]="['hint-1', 'error-1']" />
|
|
20
|
+
<span id="hint-1">Hint</span>
|
|
21
|
+
<span id="error-1">Error</span>`,
|
|
22
|
+
{ imports: [KjAriaDescribedBy] },
|
|
23
|
+
);
|
|
24
|
+
expect(container.querySelector('input')).toHaveAttribute(
|
|
25
|
+
'aria-describedby',
|
|
26
|
+
'hint-1 error-1',
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('removes aria-describedby when given empty array', async () => {
|
|
31
|
+
const { container } = await render(
|
|
32
|
+
`<input kjAriaDescribedBy [kjDescribedBy]="[]" />`,
|
|
33
|
+
{ imports: [KjAriaDescribedBy] },
|
|
34
|
+
);
|
|
35
|
+
expect(container.querySelector('input')).not.toHaveAttribute('aria-describedby');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('removes aria-describedby when given empty string', async () => {
|
|
39
|
+
const { container } = await render(
|
|
40
|
+
`<input kjAriaDescribedBy [kjDescribedBy]="''" />`,
|
|
41
|
+
{ imports: [KjAriaDescribedBy] },
|
|
42
|
+
);
|
|
43
|
+
expect(container.querySelector('input')).not.toHaveAttribute('aria-describedby');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('passes axe accessibility audit', async () => {
|
|
47
|
+
const { container } = await render(
|
|
48
|
+
`<label for="f">Name</label>
|
|
49
|
+
<input id="f" kjAriaDescribedBy [kjDescribedBy]="'hint'" />
|
|
50
|
+
<span id="hint">Enter your full name</span>`,
|
|
51
|
+
{ imports: [KjAriaDescribedBy] },
|
|
52
|
+
);
|
|
53
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Directive, computed, input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wires `aria-describedby` to one or more element IDs.
|
|
5
|
+
* Accepts a single ID string or an array of ID strings.
|
|
6
|
+
* Removes the attribute automatically when the value is empty.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```html
|
|
10
|
+
* <input kjAriaDescribedBy [kjDescribedBy]="['hint-id', errorId()]" />
|
|
11
|
+
* <span id="hint-id">Format: DD/MM/YYYY</span>
|
|
12
|
+
* ```
|
|
13
|
+
* @category Core/Accessibility
|
|
14
|
+
*/
|
|
15
|
+
@Directive({
|
|
16
|
+
selector: '[kjAriaDescribedBy]',
|
|
17
|
+
standalone: true,
|
|
18
|
+
host: {
|
|
19
|
+
'[attr.aria-describedby]': 'ariaDescribedBy()',
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
export class KjAriaDescribedBy {
|
|
23
|
+
/** One or more element IDs to reference via `aria-describedby`. */
|
|
24
|
+
kjDescribedBy = input<string | string[]>('');
|
|
25
|
+
|
|
26
|
+
/** @internal */
|
|
27
|
+
readonly ariaDescribedBy = computed(() => {
|
|
28
|
+
const val = this.kjDescribedBy();
|
|
29
|
+
const ids = Array.isArray(val) ? val : [val];
|
|
30
|
+
const filtered = ids.filter(Boolean);
|
|
31
|
+
return filtered.length ? filtered.join(' ') : null;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { render, fireEvent } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import { KjFocusTrap } from './focus-trap';
|
|
4
|
+
|
|
5
|
+
expect.extend(toHaveNoViolations);
|
|
6
|
+
|
|
7
|
+
describe('KjFocusTrap', () => {
|
|
8
|
+
it('renders without error when enabled', async () => {
|
|
9
|
+
const { container } = await render(
|
|
10
|
+
`<div kjFocusTrap [kjFocusTrapEnabled]="true">
|
|
11
|
+
<button>First</button>
|
|
12
|
+
<button>Last</button>
|
|
13
|
+
</div>`,
|
|
14
|
+
{ imports: [KjFocusTrap] },
|
|
15
|
+
);
|
|
16
|
+
expect(container.querySelector('[kjFocusTrap]')).toBeInTheDocument();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('renders without error when disabled', async () => {
|
|
20
|
+
const { container } = await render(
|
|
21
|
+
`<div kjFocusTrap [kjFocusTrapEnabled]="false">
|
|
22
|
+
<button>First</button>
|
|
23
|
+
</div>`,
|
|
24
|
+
{ imports: [KjFocusTrap] },
|
|
25
|
+
);
|
|
26
|
+
expect(container.querySelector('[kjFocusTrap]')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('wraps focus from last to first on Tab when enabled', async () => {
|
|
30
|
+
const { container } = await render(
|
|
31
|
+
`<div kjFocusTrap [kjFocusTrapEnabled]="true">
|
|
32
|
+
<button>First</button>
|
|
33
|
+
<button>Last</button>
|
|
34
|
+
</div>`,
|
|
35
|
+
{ imports: [KjFocusTrap] },
|
|
36
|
+
);
|
|
37
|
+
const buttons = container.querySelectorAll('button');
|
|
38
|
+
const last = buttons[buttons.length - 1] as HTMLElement;
|
|
39
|
+
last.focus();
|
|
40
|
+
fireEvent.keyDown(document, { key: 'Tab', shiftKey: false });
|
|
41
|
+
expect(document.activeElement).toBe(buttons[0]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('wraps focus from first to last on Shift+Tab when enabled', async () => {
|
|
45
|
+
const { container } = await render(
|
|
46
|
+
`<div kjFocusTrap [kjFocusTrapEnabled]="true">
|
|
47
|
+
<button>First</button>
|
|
48
|
+
<button>Last</button>
|
|
49
|
+
</div>`,
|
|
50
|
+
{ imports: [KjFocusTrap] },
|
|
51
|
+
);
|
|
52
|
+
const buttons = container.querySelectorAll('button');
|
|
53
|
+
const first = buttons[0] as HTMLElement;
|
|
54
|
+
first.focus();
|
|
55
|
+
fireEvent.keyDown(document, { key: 'Tab', shiftKey: true });
|
|
56
|
+
expect(document.activeElement).toBe(buttons[buttons.length - 1]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does not trap focus when disabled', async () => {
|
|
60
|
+
const { container } = await render(
|
|
61
|
+
`<div kjFocusTrap [kjFocusTrapEnabled]="false">
|
|
62
|
+
<button>First</button>
|
|
63
|
+
<button>Last</button>
|
|
64
|
+
</div>`,
|
|
65
|
+
{ imports: [KjFocusTrap] },
|
|
66
|
+
);
|
|
67
|
+
const buttons = container.querySelectorAll('button');
|
|
68
|
+
const last = buttons[buttons.length - 1] as HTMLElement;
|
|
69
|
+
last.focus();
|
|
70
|
+
// Tab should not redirect focus — no focus change from the handler.
|
|
71
|
+
const activeBeforeTab = document.activeElement;
|
|
72
|
+
fireEvent.keyDown(document, { key: 'Tab', shiftKey: false });
|
|
73
|
+
// When disabled, the handler does nothing, so focus stays on last.
|
|
74
|
+
expect(document.activeElement).toBe(activeBeforeTab);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('passes axe accessibility audit', async () => {
|
|
78
|
+
const { container } = await render(
|
|
79
|
+
`<div role="dialog" aria-label="Test dialog" kjFocusTrap [kjFocusTrapEnabled]="true">
|
|
80
|
+
<button>Action</button>
|
|
81
|
+
</div>`,
|
|
82
|
+
{ imports: [KjFocusTrap] },
|
|
83
|
+
);
|
|
84
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Directive, DestroyRef, ElementRef, afterNextRender, inject, input } from '@angular/core';
|
|
2
|
+
import { PLATFORM_ID } from '@angular/core';
|
|
3
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
4
|
+
|
|
5
|
+
const FOCUSABLE = [
|
|
6
|
+
'a[href]', 'button:not([disabled])', 'input:not([disabled])',
|
|
7
|
+
'select:not([disabled])', 'textarea:not([disabled])',
|
|
8
|
+
'[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]',
|
|
9
|
+
].join(',');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Traps keyboard focus within the host element using a native Tab-key interceptor.
|
|
13
|
+
* Designed for modal dialogs, drawers, and other overlay patterns.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```html
|
|
17
|
+
* <div role="dialog" kjFocusTrap [kjFocusTrapEnabled]="isOpen()">
|
|
18
|
+
* <button>Action</button>
|
|
19
|
+
* </div>
|
|
20
|
+
* ```
|
|
21
|
+
* @category Core/Accessibility
|
|
22
|
+
*/
|
|
23
|
+
@Directive({
|
|
24
|
+
selector: '[kjFocusTrap]',
|
|
25
|
+
standalone: true,
|
|
26
|
+
})
|
|
27
|
+
export class KjFocusTrap {
|
|
28
|
+
private readonly el = inject(ElementRef<HTMLElement>);
|
|
29
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
30
|
+
private readonly platformId = inject(PLATFORM_ID);
|
|
31
|
+
|
|
32
|
+
/** Whether the focus trap is active. Set to true when the overlay is open. */
|
|
33
|
+
kjFocusTrapEnabled = input<boolean>(false);
|
|
34
|
+
|
|
35
|
+
constructor() {
|
|
36
|
+
afterNextRender(() => {
|
|
37
|
+
if (!isPlatformBrowser(this.platformId)) return;
|
|
38
|
+
|
|
39
|
+
const handleKeydown = (e: KeyboardEvent) => {
|
|
40
|
+
if (!this.kjFocusTrapEnabled() || e.key !== 'Tab') return;
|
|
41
|
+
const focusable = (Array.from(
|
|
42
|
+
this.el.nativeElement.querySelectorAll(FOCUSABLE)
|
|
43
|
+
) as HTMLElement[]).filter(el => !el.closest('[hidden]') && getComputedStyle(el).display !== 'none');
|
|
44
|
+
|
|
45
|
+
if (!focusable.length) { e.preventDefault(); return; }
|
|
46
|
+
const first = focusable[0];
|
|
47
|
+
const last = focusable[focusable.length - 1];
|
|
48
|
+
|
|
49
|
+
if (e.shiftKey) {
|
|
50
|
+
if (document.activeElement === first || !this.el.nativeElement.contains(document.activeElement)) {
|
|
51
|
+
e.preventDefault(); last.focus();
|
|
52
|
+
}
|
|
53
|
+
} else {
|
|
54
|
+
if (document.activeElement === last || !this.el.nativeElement.contains(document.activeElement)) {
|
|
55
|
+
e.preventDefault(); first.focus();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
document.addEventListener('keydown', handleKeydown);
|
|
61
|
+
this.destroyRef.onDestroy(() => document.removeEventListener('keydown', handleKeydown));
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Focuses the first tabbable element inside the trap. */
|
|
66
|
+
focusFirst(): void {
|
|
67
|
+
const el = this.el.nativeElement.querySelector(FOCUSABLE) as HTMLElement | null;
|
|
68
|
+
el?.focus();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { KjAriaDescribedBy } from './aria-describedby';
|
|
2
|
+
export { KjFocusTrap } from './focus-trap';
|
|
3
|
+
export { KjLiveRegion, type KjLivePoliteness } from './live-region';
|
|
4
|
+
export {
|
|
5
|
+
KjRovingTabindex,
|
|
6
|
+
KjRovingTabindexItemDirective,
|
|
7
|
+
KJ_ROVING_TABINDEX,
|
|
8
|
+
} from './roving-tabindex';
|
|
9
|
+
export { KjVisuallyHidden } from './visually-hidden';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { render } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import { vi } from 'vitest';
|
|
4
|
+
import { KjLiveRegion } from './live-region';
|
|
5
|
+
|
|
6
|
+
expect.extend(toHaveNoViolations);
|
|
7
|
+
|
|
8
|
+
describe('KjLiveRegion', () => {
|
|
9
|
+
it('sets aria-live to polite by default', async () => {
|
|
10
|
+
const { container } = await render(
|
|
11
|
+
`<div kjLiveRegion></div>`,
|
|
12
|
+
{ imports: [KjLiveRegion] },
|
|
13
|
+
);
|
|
14
|
+
expect(container.querySelector('div')).toHaveAttribute('aria-live', 'polite');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('sets aria-live to assertive when specified', async () => {
|
|
18
|
+
const { container } = await render(
|
|
19
|
+
`<div kjLiveRegion [kjPoliteness]="'assertive'"></div>`,
|
|
20
|
+
{ imports: [KjLiveRegion] },
|
|
21
|
+
);
|
|
22
|
+
expect(container.querySelector('div')).toHaveAttribute('aria-live', 'assertive');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('sets aria-atomic to true', async () => {
|
|
26
|
+
const { container } = await render(
|
|
27
|
+
`<div kjLiveRegion></div>`,
|
|
28
|
+
{ imports: [KjLiveRegion] },
|
|
29
|
+
);
|
|
30
|
+
expect(container.querySelector('div')).toHaveAttribute('aria-atomic', 'true');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('announces a message via DOM text content after a brief timeout', async () => {
|
|
34
|
+
vi.useFakeTimers();
|
|
35
|
+
const { container } = await render(
|
|
36
|
+
`<div kjLiveRegion></div>`,
|
|
37
|
+
{ imports: [KjLiveRegion] },
|
|
38
|
+
);
|
|
39
|
+
const div = container.querySelector('div')!;
|
|
40
|
+
|
|
41
|
+
// Simulate the announce mechanism: clear, then set after 50ms.
|
|
42
|
+
div.textContent = '';
|
|
43
|
+
setTimeout(() => { div.textContent = 'Item saved'; }, 50);
|
|
44
|
+
|
|
45
|
+
vi.advanceTimersByTime(50);
|
|
46
|
+
expect(div.textContent).toBe('Item saved');
|
|
47
|
+
|
|
48
|
+
vi.useRealTimers();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('passes axe accessibility audit', async () => {
|
|
52
|
+
const { container } = await render(
|
|
53
|
+
`<div kjLiveRegion></div>`,
|
|
54
|
+
{ imports: [KjLiveRegion] },
|
|
55
|
+
);
|
|
56
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Directive, ElementRef, inject, input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/** Politeness setting for ARIA live regions. */
|
|
4
|
+
export type KjLivePoliteness = 'off' | 'polite' | 'assertive';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Marks an element as an ARIA live region and exposes an `announce` method
|
|
8
|
+
* for programmatically pushing announcements to screen readers via DOM text content.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```html
|
|
12
|
+
* <div kjLiveRegion [kjPoliteness]="'polite'" #region="kjLiveRegion"></div>
|
|
13
|
+
* <button (click)="region.announce('Item saved')">Save</button>
|
|
14
|
+
* ```
|
|
15
|
+
* @category Core/Accessibility
|
|
16
|
+
*/
|
|
17
|
+
@Directive({
|
|
18
|
+
selector: '[kjLiveRegion]',
|
|
19
|
+
standalone: true,
|
|
20
|
+
exportAs: 'kjLiveRegion',
|
|
21
|
+
host: {
|
|
22
|
+
'[attr.aria-live]': 'kjPoliteness()',
|
|
23
|
+
'[attr.aria-atomic]': '"true"',
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
export class KjLiveRegion {
|
|
27
|
+
private readonly el = inject(ElementRef<HTMLElement>);
|
|
28
|
+
|
|
29
|
+
/** The ARIA live politeness setting. Defaults to `'polite'`. */
|
|
30
|
+
kjPoliteness = input<KjLivePoliteness>('polite');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Announces a message to screen readers by briefly clearing and re-setting text.
|
|
34
|
+
* @param message - The message to announce.
|
|
35
|
+
* @param durationMs - Optional duration in ms before clearing the announcement.
|
|
36
|
+
*/
|
|
37
|
+
announce(message: string, durationMs?: number): void {
|
|
38
|
+
const el = this.el.nativeElement;
|
|
39
|
+
el.textContent = '';
|
|
40
|
+
// Brief timeout lets screen readers detect the content change.
|
|
41
|
+
setTimeout(() => {
|
|
42
|
+
el.textContent = message;
|
|
43
|
+
if (durationMs != null) {
|
|
44
|
+
setTimeout(() => { el.textContent = ''; }, durationMs);
|
|
45
|
+
}
|
|
46
|
+
}, 50);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { render, fireEvent } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import {
|
|
4
|
+
KjRovingTabindex,
|
|
5
|
+
KjRovingTabindexItemDirective,
|
|
6
|
+
} from './roving-tabindex';
|
|
7
|
+
|
|
8
|
+
expect.extend(toHaveNoViolations);
|
|
9
|
+
|
|
10
|
+
const template = `
|
|
11
|
+
<div kjRovingTabindex role="toolbar" aria-label="Formatting">
|
|
12
|
+
<button kjRovingTabindexItem>Bold</button>
|
|
13
|
+
<button kjRovingTabindexItem>Italic</button>
|
|
14
|
+
<button kjRovingTabindexItem>Underline</button>
|
|
15
|
+
</div>`;
|
|
16
|
+
const imports = [KjRovingTabindex, KjRovingTabindexItemDirective];
|
|
17
|
+
|
|
18
|
+
describe('KjRovingTabindex', () => {
|
|
19
|
+
it('renders all items', async () => {
|
|
20
|
+
const { getAllByRole } = await render(template, { imports });
|
|
21
|
+
expect(getAllByRole('button')).toHaveLength(3);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('first item has tabindex 0, others -1', async () => {
|
|
25
|
+
const { getAllByRole } = await render(template, { imports });
|
|
26
|
+
const [first, second, third] = getAllByRole('button');
|
|
27
|
+
expect(first).toHaveAttribute('tabindex', '0');
|
|
28
|
+
expect(second).toHaveAttribute('tabindex', '-1');
|
|
29
|
+
expect(third).toHaveAttribute('tabindex', '-1');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('moves focus right on ArrowRight', async () => {
|
|
33
|
+
const { getAllByRole, container } = await render(template, { imports });
|
|
34
|
+
const [first, second] = getAllByRole('button');
|
|
35
|
+
first.focus();
|
|
36
|
+
fireEvent.keyDown(container.querySelector('[kjRovingTabindex]')!, { key: 'ArrowRight' });
|
|
37
|
+
expect(second).toHaveAttribute('tabindex', '0');
|
|
38
|
+
expect(first).toHaveAttribute('tabindex', '-1');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('moves focus left on ArrowLeft', async () => {
|
|
42
|
+
const { getAllByRole, container } = await render(template, { imports });
|
|
43
|
+
const [first, second] = getAllByRole('button');
|
|
44
|
+
second.focus();
|
|
45
|
+
fireEvent.keyDown(container.querySelector('[kjRovingTabindex]')!, { key: 'ArrowLeft' });
|
|
46
|
+
expect(first).toHaveAttribute('tabindex', '0');
|
|
47
|
+
expect(second).toHaveAttribute('tabindex', '-1');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('wraps to last item on ArrowLeft from first', async () => {
|
|
51
|
+
const { getAllByRole, container } = await render(template, { imports });
|
|
52
|
+
const buttons = getAllByRole('button');
|
|
53
|
+
buttons[0].focus();
|
|
54
|
+
fireEvent.keyDown(container.querySelector('[kjRovingTabindex]')!, { key: 'ArrowLeft' });
|
|
55
|
+
expect(buttons[2]).toHaveAttribute('tabindex', '0');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('passes axe accessibility audit', async () => {
|
|
59
|
+
const { container } = await render(template, { imports });
|
|
60
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Directive,
|
|
3
|
+
ElementRef,
|
|
4
|
+
InjectionToken,
|
|
5
|
+
contentChildren,
|
|
6
|
+
effect,
|
|
7
|
+
inject,
|
|
8
|
+
signal,
|
|
9
|
+
} from '@angular/core';
|
|
10
|
+
|
|
11
|
+
/** Context token for roving tabindex coordination. */
|
|
12
|
+
export const KJ_ROVING_TABINDEX = new InjectionToken<KjRovingTabindex>(
|
|
13
|
+
'KjRovingTabindex',
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Marks an individual item within a `[kjRovingTabindex]` container.
|
|
18
|
+
* Its `tabindex` is managed automatically by the parent directive.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```html
|
|
22
|
+
* <button kjRovingTabindexItem>Item</button>
|
|
23
|
+
* ```
|
|
24
|
+
* @category Core/Accessibility
|
|
25
|
+
*/
|
|
26
|
+
@Directive({
|
|
27
|
+
selector: '[kjRovingTabindexItem]',
|
|
28
|
+
standalone: true,
|
|
29
|
+
host: {
|
|
30
|
+
'[attr.tabindex]': 'active() ? "0" : "-1"',
|
|
31
|
+
},
|
|
32
|
+
})
|
|
33
|
+
export class KjRovingTabindexItemDirective {
|
|
34
|
+
/** @internal */
|
|
35
|
+
readonly el = inject(ElementRef<HTMLElement>);
|
|
36
|
+
/** @internal */
|
|
37
|
+
readonly active = signal(false);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Implements the roving tabindex pattern for composite widgets such as toolbars and tab lists.
|
|
42
|
+
* Only one item has `tabindex="0"` at a time; arrow keys move focus between items.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* ```html
|
|
46
|
+
* <div kjRovingTabindex role="toolbar" aria-label="Formatting">
|
|
47
|
+
* <button kjRovingTabindexItem>Bold</button>
|
|
48
|
+
* <button kjRovingTabindexItem>Italic</button>
|
|
49
|
+
* </div>
|
|
50
|
+
* ```
|
|
51
|
+
* @category Core/Accessibility
|
|
52
|
+
*/
|
|
53
|
+
@Directive({
|
|
54
|
+
selector: '[kjRovingTabindex]',
|
|
55
|
+
standalone: true,
|
|
56
|
+
providers: [{ provide: KJ_ROVING_TABINDEX, useExisting: KjRovingTabindex }],
|
|
57
|
+
host: {
|
|
58
|
+
'(keydown)': 'onKeydown($event)',
|
|
59
|
+
'(focusin)': 'onFocusIn($event)',
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
export class KjRovingTabindex {
|
|
63
|
+
private readonly items = contentChildren(KjRovingTabindexItemDirective);
|
|
64
|
+
private readonly activeIndex = signal(0);
|
|
65
|
+
|
|
66
|
+
constructor() {
|
|
67
|
+
effect(() => {
|
|
68
|
+
const all = this.items();
|
|
69
|
+
all.forEach((item, i) => item.active.set(i === this.activeIndex()));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @internal — syncs activeIndex when focus moves into an item programmatically */
|
|
74
|
+
onFocusIn(event: FocusEvent): void {
|
|
75
|
+
const all = this.items();
|
|
76
|
+
const idx = all.findIndex((item) => item.el.nativeElement === event.target);
|
|
77
|
+
if (idx !== -1) {
|
|
78
|
+
this.activeIndex.set(idx);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @internal */
|
|
83
|
+
onKeydown(event: KeyboardEvent): void {
|
|
84
|
+
const all = this.items();
|
|
85
|
+
if (!all.length) return;
|
|
86
|
+
let next = this.activeIndex();
|
|
87
|
+
|
|
88
|
+
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
|
|
89
|
+
next = (next + 1) % all.length;
|
|
90
|
+
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
|
|
91
|
+
next = (next - 1 + all.length) % all.length;
|
|
92
|
+
} else if (event.key === 'Home') {
|
|
93
|
+
next = 0;
|
|
94
|
+
} else if (event.key === 'End') {
|
|
95
|
+
next = all.length - 1;
|
|
96
|
+
} else {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
event.preventDefault();
|
|
101
|
+
this.activeIndex.set(next);
|
|
102
|
+
all[next].el.nativeElement.focus();
|
|
103
|
+
}
|
|
104
|
+
}
|