@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.
Files changed (123) hide show
  1. package/.turbo/turbo-build.log +27 -0
  2. package/CHANGELOG.md +7 -0
  3. package/README.md +64 -0
  4. package/ng-package.json +7 -0
  5. package/package.json +39 -0
  6. package/src/a11y/.gitkeep +0 -0
  7. package/src/a11y/aria-describedby.spec.ts +55 -0
  8. package/src/a11y/aria-describedby.ts +33 -0
  9. package/src/a11y/focus-trap.spec.ts +86 -0
  10. package/src/a11y/focus-trap.ts +70 -0
  11. package/src/a11y/index.ts +9 -0
  12. package/src/a11y/live-region.spec.ts +58 -0
  13. package/src/a11y/live-region.ts +48 -0
  14. package/src/a11y/roving-tabindex.spec.ts +62 -0
  15. package/src/a11y/roving-tabindex.ts +104 -0
  16. package/src/a11y/visually-hidden.spec.ts +34 -0
  17. package/src/a11y/visually-hidden.ts +24 -0
  18. package/src/accordion/accordion.context.ts +22 -0
  19. package/src/accordion/accordion.spec.ts +32 -0
  20. package/src/accordion/accordion.ts +129 -0
  21. package/src/accordion/index.ts +2 -0
  22. package/src/avatar/avatar.spec.ts +18 -0
  23. package/src/avatar/avatar.ts +26 -0
  24. package/src/avatar/index.ts +1 -0
  25. package/src/badge/badge.spec.ts +20 -0
  26. package/src/badge/badge.ts +14 -0
  27. package/src/badge/index.ts +1 -0
  28. package/src/button/button.example.ts +30 -0
  29. package/src/button/button.finance.example.ts +37 -0
  30. package/src/button/button.retro.example.ts +47 -0
  31. package/src/button/button.sizes.example.ts +25 -0
  32. package/src/button/button.spec.ts +32 -0
  33. package/src/button/button.ts +44 -0
  34. package/src/button/index.ts +1 -0
  35. package/src/chart/chart.spec.ts +27 -0
  36. package/src/chart/chart.ts +48 -0
  37. package/src/chart/index.ts +1 -0
  38. package/src/checkbox/checkbox.spec.ts +35 -0
  39. package/src/checkbox/checkbox.ts +82 -0
  40. package/src/checkbox/index.ts +1 -0
  41. package/src/dialog/dialog.confirm.example.ts +57 -0
  42. package/src/dialog/dialog.context.ts +11 -0
  43. package/src/dialog/dialog.example.ts +40 -0
  44. package/src/dialog/dialog.finance.example.ts +47 -0
  45. package/src/dialog/dialog.retro.example.ts +46 -0
  46. package/src/dialog/dialog.service.ts +176 -0
  47. package/src/dialog/dialog.spec.ts +122 -0
  48. package/src/dialog/dialog.ts +201 -0
  49. package/src/dialog/index.ts +9 -0
  50. package/src/example-components.ts +33 -0
  51. package/src/form/form.context.ts +3 -0
  52. package/src/form/form.spec.ts +25 -0
  53. package/src/form/form.ts +46 -0
  54. package/src/form/index.ts +2 -0
  55. package/src/input/index.ts +1 -0
  56. package/src/input/input.spec.ts +43 -0
  57. package/src/input/input.ts +80 -0
  58. package/src/menu/index.ts +2 -0
  59. package/src/menu/menu.context.ts +10 -0
  60. package/src/menu/menu.example.ts +39 -0
  61. package/src/menu/menu.spec.ts +168 -0
  62. package/src/menu/menu.ts +176 -0
  63. package/src/popover/index.ts +12 -0
  64. package/src/popover/popover.context.ts +18 -0
  65. package/src/popover/popover.example.ts +50 -0
  66. package/src/popover/popover.finance.example.ts +52 -0
  67. package/src/popover/popover.retro.example.ts +53 -0
  68. package/src/popover/popover.spec.ts +52 -0
  69. package/src/popover/popover.ts +124 -0
  70. package/src/primitives/.gitkeep +0 -0
  71. package/src/primitives/forms/form-control.spec.ts +59 -0
  72. package/src/primitives/forms/form-control.ts +90 -0
  73. package/src/primitives/forms/index.ts +1 -0
  74. package/src/primitives/index.ts +3 -0
  75. package/src/primitives/interaction/disabled.spec.ts +47 -0
  76. package/src/primitives/interaction/disabled.ts +24 -0
  77. package/src/primitives/interaction/focus-ring.spec.ts +62 -0
  78. package/src/primitives/interaction/focus-ring.ts +57 -0
  79. package/src/primitives/interaction/index.ts +2 -0
  80. package/src/primitives/overlay/index.ts +1 -0
  81. package/src/primitives/overlay/overlay.spec.ts +90 -0
  82. package/src/primitives/overlay/overlay.ts +102 -0
  83. package/src/public-api.ts +35 -0
  84. package/src/radio/index.ts +2 -0
  85. package/src/radio/radio.context.ts +3 -0
  86. package/src/radio/radio.spec.ts +36 -0
  87. package/src/radio/radio.ts +55 -0
  88. package/src/select/index.ts +2 -0
  89. package/src/select/select.context.ts +3 -0
  90. package/src/select/select.spec.ts +145 -0
  91. package/src/select/select.ts +250 -0
  92. package/src/styles/docs-themes.css +82 -0
  93. package/src/table/index.ts +1 -0
  94. package/src/table/table.spec.ts +113 -0
  95. package/src/table/table.ts +126 -0
  96. package/src/tabs/index.ts +2 -0
  97. package/src/tabs/tabs.context.ts +9 -0
  98. package/src/tabs/tabs.spec.ts +40 -0
  99. package/src/tabs/tabs.ts +130 -0
  100. package/src/test-setup.ts +5 -0
  101. package/src/toast/index.ts +21 -0
  102. package/src/toast/toast.example.ts +134 -0
  103. package/src/toast/toast.finance.example.ts +68 -0
  104. package/src/toast/toast.retro.example.ts +69 -0
  105. package/src/toast/toast.service.ts +153 -0
  106. package/src/toast/toast.spec.ts +94 -0
  107. package/src/toast/toast.strategy.ts +102 -0
  108. package/src/toast/toast.ts +307 -0
  109. package/src/toast/toast.types.ts +5 -0
  110. package/src/toggle/index.ts +1 -0
  111. package/src/toggle/toggle.spec.ts +24 -0
  112. package/src/toggle/toggle.ts +40 -0
  113. package/src/tooltip/index.ts +5 -0
  114. package/src/tooltip/tooltip.example.ts +46 -0
  115. package/src/tooltip/tooltip.finance.example.ts +54 -0
  116. package/src/tooltip/tooltip.placements.example.ts +56 -0
  117. package/src/tooltip/tooltip.retro.example.ts +65 -0
  118. package/src/tooltip/tooltip.spec.ts +104 -0
  119. package/src/tooltip/tooltip.ts +129 -0
  120. package/tsconfig.lib.json +13 -0
  121. package/tsconfig.lib.prod.json +11 -0
  122. package/tsconfig.spec.json +10 -0
  123. 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
+ ✔ Compiling with Angular sources in partial compilation mode.
12
+ ✔ Generating FESM and DTS bundles
13
+ - Copying assets
14
+ ✔ Copying assets
15
+ - Writing package manifest
16
+ ℹ Removing scripts section in package.json as it's considered a potential security vulnerability.
17
+ ✔ Writing package manifest
18
+ ✔ 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
@@ -0,0 +1,7 @@
1
+ # @kouji-ui/core
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 1813d21: Initial publish — headless Angular 21 UI primitives over CDK with WCAG 2.1 AAA semantics and zero CSS.
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.
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "../../dist/kj-core",
4
+ "lib": {
5
+ "entryFile": "src/public-api.ts"
6
+ }
7
+ }
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
+ }