@kouji-ui/core 0.0.4 → 0.1.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 (127) hide show
  1. package/fesm2022/kouji-ui-core.mjs +26789 -0
  2. package/fesm2022/kouji-ui-core.mjs.map +1 -0
  3. package/icon/icon.css +40 -0
  4. package/package.json +20 -9
  5. package/types/kouji-ui-core.d.ts +13545 -0
  6. package/typography/prose.css +312 -0
  7. package/.turbo/turbo-build.log +0 -27
  8. package/CHANGELOG.md +0 -21
  9. package/ng-package.json +0 -7
  10. package/src/a11y/.gitkeep +0 -0
  11. package/src/a11y/aria-describedby.spec.ts +0 -55
  12. package/src/a11y/aria-describedby.ts +0 -33
  13. package/src/a11y/focus-trap.spec.ts +0 -86
  14. package/src/a11y/focus-trap.ts +0 -70
  15. package/src/a11y/index.ts +0 -9
  16. package/src/a11y/live-region.spec.ts +0 -58
  17. package/src/a11y/live-region.ts +0 -48
  18. package/src/a11y/roving-tabindex.spec.ts +0 -62
  19. package/src/a11y/roving-tabindex.ts +0 -104
  20. package/src/a11y/visually-hidden.spec.ts +0 -34
  21. package/src/a11y/visually-hidden.ts +0 -24
  22. package/src/accordion/accordion.context.ts +0 -22
  23. package/src/accordion/accordion.spec.ts +0 -32
  24. package/src/accordion/accordion.ts +0 -129
  25. package/src/accordion/index.ts +0 -2
  26. package/src/avatar/avatar.spec.ts +0 -18
  27. package/src/avatar/avatar.ts +0 -26
  28. package/src/avatar/index.ts +0 -1
  29. package/src/badge/badge.spec.ts +0 -20
  30. package/src/badge/badge.ts +0 -14
  31. package/src/badge/index.ts +0 -1
  32. package/src/button/button.example.ts +0 -30
  33. package/src/button/button.finance.example.ts +0 -37
  34. package/src/button/button.retro.example.ts +0 -47
  35. package/src/button/button.sizes.example.ts +0 -25
  36. package/src/button/button.spec.ts +0 -32
  37. package/src/button/button.ts +0 -44
  38. package/src/button/index.ts +0 -1
  39. package/src/chart/chart.spec.ts +0 -27
  40. package/src/chart/chart.ts +0 -48
  41. package/src/chart/index.ts +0 -1
  42. package/src/checkbox/checkbox.spec.ts +0 -35
  43. package/src/checkbox/checkbox.ts +0 -82
  44. package/src/checkbox/index.ts +0 -1
  45. package/src/dialog/dialog.confirm.example.ts +0 -57
  46. package/src/dialog/dialog.context.ts +0 -11
  47. package/src/dialog/dialog.example.ts +0 -40
  48. package/src/dialog/dialog.finance.example.ts +0 -47
  49. package/src/dialog/dialog.retro.example.ts +0 -46
  50. package/src/dialog/dialog.service.ts +0 -176
  51. package/src/dialog/dialog.spec.ts +0 -122
  52. package/src/dialog/dialog.ts +0 -201
  53. package/src/dialog/index.ts +0 -9
  54. package/src/example-components.ts +0 -33
  55. package/src/form/form.context.ts +0 -3
  56. package/src/form/form.spec.ts +0 -25
  57. package/src/form/form.ts +0 -46
  58. package/src/form/index.ts +0 -2
  59. package/src/input/index.ts +0 -1
  60. package/src/input/input.spec.ts +0 -43
  61. package/src/input/input.ts +0 -80
  62. package/src/menu/index.ts +0 -2
  63. package/src/menu/menu.context.ts +0 -10
  64. package/src/menu/menu.example.ts +0 -39
  65. package/src/menu/menu.spec.ts +0 -168
  66. package/src/menu/menu.ts +0 -176
  67. package/src/popover/index.ts +0 -12
  68. package/src/popover/popover.context.ts +0 -18
  69. package/src/popover/popover.example.ts +0 -50
  70. package/src/popover/popover.finance.example.ts +0 -52
  71. package/src/popover/popover.retro.example.ts +0 -53
  72. package/src/popover/popover.spec.ts +0 -52
  73. package/src/popover/popover.ts +0 -124
  74. package/src/primitives/.gitkeep +0 -0
  75. package/src/primitives/forms/form-control.spec.ts +0 -59
  76. package/src/primitives/forms/form-control.ts +0 -90
  77. package/src/primitives/forms/index.ts +0 -1
  78. package/src/primitives/index.ts +0 -3
  79. package/src/primitives/interaction/disabled.spec.ts +0 -47
  80. package/src/primitives/interaction/disabled.ts +0 -24
  81. package/src/primitives/interaction/focus-ring.spec.ts +0 -62
  82. package/src/primitives/interaction/focus-ring.ts +0 -57
  83. package/src/primitives/interaction/index.ts +0 -2
  84. package/src/primitives/overlay/index.ts +0 -1
  85. package/src/primitives/overlay/overlay.spec.ts +0 -90
  86. package/src/primitives/overlay/overlay.ts +0 -106
  87. package/src/public-api.ts +0 -35
  88. package/src/radio/index.ts +0 -2
  89. package/src/radio/radio.context.ts +0 -3
  90. package/src/radio/radio.spec.ts +0 -36
  91. package/src/radio/radio.ts +0 -55
  92. package/src/select/index.ts +0 -2
  93. package/src/select/select.context.ts +0 -3
  94. package/src/select/select.spec.ts +0 -145
  95. package/src/select/select.ts +0 -250
  96. package/src/styles/docs-themes.css +0 -82
  97. package/src/table/index.ts +0 -1
  98. package/src/table/table.spec.ts +0 -113
  99. package/src/table/table.ts +0 -126
  100. package/src/tabs/index.ts +0 -2
  101. package/src/tabs/tabs.context.ts +0 -9
  102. package/src/tabs/tabs.spec.ts +0 -40
  103. package/src/tabs/tabs.ts +0 -130
  104. package/src/test-setup.ts +0 -5
  105. package/src/toast/index.ts +0 -21
  106. package/src/toast/toast.example.ts +0 -134
  107. package/src/toast/toast.finance.example.ts +0 -68
  108. package/src/toast/toast.retro.example.ts +0 -69
  109. package/src/toast/toast.service.ts +0 -153
  110. package/src/toast/toast.spec.ts +0 -95
  111. package/src/toast/toast.strategy.ts +0 -102
  112. package/src/toast/toast.ts +0 -307
  113. package/src/toast/toast.types.ts +0 -5
  114. package/src/toggle/index.ts +0 -1
  115. package/src/toggle/toggle.spec.ts +0 -24
  116. package/src/toggle/toggle.ts +0 -40
  117. package/src/tooltip/index.ts +0 -5
  118. package/src/tooltip/tooltip.example.ts +0 -46
  119. package/src/tooltip/tooltip.finance.example.ts +0 -54
  120. package/src/tooltip/tooltip.placements.example.ts +0 -56
  121. package/src/tooltip/tooltip.retro.example.ts +0 -65
  122. package/src/tooltip/tooltip.spec.ts +0 -104
  123. package/src/tooltip/tooltip.ts +0 -129
  124. package/tsconfig.lib.json +0 -13
  125. package/tsconfig.lib.prod.json +0 -11
  126. package/tsconfig.spec.json +0 -10
  127. package/vite.config.ts +0 -19
@@ -1,106 +0,0 @@
1
- import {
2
- Injectable, inject, signal,
3
- EmbeddedViewRef, ApplicationRef, TemplateRef, ViewContainerRef,
4
- } from '@angular/core';
5
-
6
- /**
7
- * Managed wrapper around a DOM overlay container.
8
- * Provides `isOpen` as a signal and lifecycle methods.
9
- *
10
- * Create instances via {@link KjOverlayService} — do not construct directly.
11
- *
12
- * @example
13
- * ```ts
14
- * const ref = inject(KjOverlayService).createFromTemplate(tpl, vcr);
15
- * ref.open();
16
- * ```
17
- */
18
- export class KjOverlayRef {
19
- private readonly _isOpen = signal(false);
20
-
21
- /** Whether the overlay is currently open. */
22
- readonly isOpen = this._isOpen.asReadonly();
23
-
24
- constructor(
25
- private readonly container: HTMLElement,
26
- private readonly viewRef: EmbeddedViewRef<unknown>,
27
- private readonly appRef: ApplicationRef,
28
- ) {}
29
-
30
- /** Shows the overlay container. */
31
- open(): void {
32
- this._isOpen.set(true);
33
- this.container.removeAttribute('hidden');
34
- }
35
-
36
- /** Hides the overlay container. */
37
- close(): void {
38
- this._isOpen.set(false);
39
- this.container.setAttribute('hidden', '');
40
- }
41
-
42
- /** Toggles the overlay between open and closed states. */
43
- toggle(): void {
44
- if (this.isOpen()) {
45
- this.close();
46
- } else {
47
- this.open();
48
- }
49
- }
50
-
51
- /**
52
- * Permanently removes the overlay from the DOM and destroys the view.
53
- * Call inside `DestroyRef.onDestroy()` to prevent memory leaks.
54
- */
55
- dispose(): void {
56
- this.appRef.detachView(this.viewRef);
57
- this.viewRef.destroy();
58
- this.container.remove();
59
- }
60
-
61
- /** The underlying container `HTMLElement` for advanced usage. */
62
- get el(): HTMLElement {
63
- return this.container;
64
- }
65
- }
66
-
67
- /**
68
- * Factory service for creating managed overlay containers.
69
- * Creates a container appended to `document.body`.
70
- *
71
- * @example
72
- * ```ts
73
- * // Dialog — full-screen backdrop overlay
74
- * const ref = inject(KjOverlayService).createFromTemplate(tpl, vcr);
75
- * ref.open();
76
- *
77
- * // Clean up when done
78
- * ref.dispose();
79
- * ```
80
- */
81
- @Injectable({ providedIn: 'root' })
82
- export class KjOverlayService {
83
- private readonly appRef = inject(ApplicationRef);
84
-
85
- /**
86
- * Creates an overlay from an Angular template ref.
87
- * The container is appended to `document.body` and starts hidden.
88
- *
89
- * @param tpl - The `TemplateRef` to render inside the overlay.
90
- * @param vcr - The `ViewContainerRef` used to create the embedded view.
91
- * @returns A {@link KjOverlayRef} managing the created overlay.
92
- */
93
- createFromTemplate(tpl: TemplateRef<unknown>, vcr: ViewContainerRef): KjOverlayRef {
94
- // Use TemplateRef.createEmbeddedView directly so the view is not pre-attached
95
- // to the VCR's container — ApplicationRef can own the view lifecycle.
96
- const viewRef = tpl.createEmbeddedView(null as never, vcr.injector);
97
- this.appRef.attachView(viewRef);
98
-
99
- const container = document.createElement('div');
100
- container.setAttribute('data-kj-overlay', '');
101
- viewRef.rootNodes.forEach(node => container.appendChild(node));
102
- document.body.appendChild(container);
103
-
104
- return new KjOverlayRef(container, viewRef, this.appRef);
105
- }
106
- }
package/src/public-api.ts DELETED
@@ -1,35 +0,0 @@
1
- // Public API for @kouji-ui/core
2
-
3
- export const KJ_CORE_VERSION = '0.0.1';
4
-
5
- // ── Primitives ──────────────────────────────────────────────
6
- export * from './primitives/index';
7
-
8
- // ── Accessibility utilities ──────────────────────────────────
9
- export * from './a11y/index';
10
-
11
- // ── Foundation components ────────────────────────────────────
12
- export * from './button/index';
13
- export * from './input/index';
14
- export * from './checkbox/index';
15
- export * from './radio/index';
16
- export * from './toggle/index';
17
- export * from './badge/index';
18
- export * from './avatar/index';
19
-
20
- // ── Overlay components ───────────────────────────────────────
21
- export * from './dialog/index';
22
- export * from './tooltip/index';
23
- export * from './popover/index';
24
- export * from './menu/index';
25
- export * from './toast/index';
26
-
27
- // ── Data components ──────────────────────────────────────────
28
- export * from './table/index';
29
- export * from './form/index';
30
- export * from './tabs/index';
31
- export * from './accordion/index';
32
- export * from './select/index';
33
-
34
- // ── Charts ───────────────────────────────────────────────────
35
- export * from './chart/index';
@@ -1,2 +0,0 @@
1
- export { KjRadioGroup, KjRadio } from './radio';
2
- export { KJ_RADIO_GROUP, type KjRadioContext } from './radio.context';
@@ -1,3 +0,0 @@
1
- import { InjectionToken, Signal } from '@angular/core';
2
- export interface KjRadioContext { value: Signal<unknown>; select: (value: unknown) => void; }
3
- export const KJ_RADIO_GROUP = new InjectionToken<KjRadioContext>('KjRadioGroup');
@@ -1,36 +0,0 @@
1
- import { render, fireEvent } from '@testing-library/angular';
2
- import { axe, toHaveNoViolations } from 'jest-axe';
3
- import { KjRadioGroup, KjRadio } from './radio';
4
-
5
- expect.extend(toHaveNoViolations);
6
-
7
- const template = `<div kjRadioGroup [(kjValue)]="selected" aria-label="Opts"><div kjRadio [kjRadioValue]="'a'" tabindex="0">A</div><div kjRadio [kjRadioValue]="'b'" tabindex="-1">B</div></div>`;
8
- const imports = [KjRadioGroup, KjRadio];
9
-
10
- describe('KjRadioGroup', () => {
11
- it('has radiogroup role', async () => {
12
- const { getByRole } = await render(template, { imports, componentProperties: { selected: 'a' } });
13
- expect(getByRole('radiogroup')).toBeInTheDocument();
14
- });
15
-
16
- it('radio items have role=radio', async () => {
17
- const { getAllByRole } = await render(template, { imports, componentProperties: { selected: 'a' } });
18
- expect(getAllByRole('radio')).toHaveLength(2);
19
- });
20
-
21
- it('selected radio has aria-checked=true', async () => {
22
- const { getAllByRole } = await render(template, { imports, componentProperties: { selected: 'a' } });
23
- expect(getAllByRole('radio')[0]).toHaveAttribute('aria-checked', 'true');
24
- });
25
-
26
- it('clicking radio selects it', async () => {
27
- const { getAllByRole } = await render(template, { imports, componentProperties: { selected: 'a' } });
28
- fireEvent.click(getAllByRole('radio')[1]);
29
- expect(getAllByRole('radio')[1]).toHaveAttribute('aria-checked', 'true');
30
- });
31
-
32
- it('passes axe audit', async () => {
33
- const { container } = await render(template, { imports, componentProperties: { selected: 'a' } });
34
- expect(await axe(container)).toHaveNoViolations();
35
- });
36
- });
@@ -1,55 +0,0 @@
1
- import { Directive, computed, inject, input, model } from '@angular/core';
2
- import { KJ_RADIO_GROUP, KjRadioContext } from './radio.context';
3
- import { KjDisabled, KjFocusRing, KjFormControl } from '../primitives';
4
-
5
- /**
6
- * Container for a group of radio buttons. Manages the selected value.
7
- * @example
8
- * ```html
9
- * <div kjRadioGroup [(kjValue)]="size" aria-label="Size">
10
- * <div kjRadio [kjRadioValue]="'s'" tabindex="0">Small</div>
11
- * </div>
12
- * ```
13
- * @category Core/Inputs
14
- */
15
- @Directive({
16
- selector: '[kjRadioGroup]', standalone: true,
17
- providers: [{ provide: KJ_RADIO_GROUP, useExisting: KjRadioGroup }],
18
- host: { role: 'radiogroup' },
19
- })
20
- export class KjRadioGroup implements KjRadioContext {
21
- kjValue = model<unknown>(undefined);
22
- readonly value = this.kjValue.asReadonly();
23
- select(val: unknown): void { this.kjValue.set(val); }
24
- }
25
-
26
- /**
27
- * Individual radio button within a `[kjRadioGroup]`. Includes CVA for forms integration.
28
- * @example `<div kjRadio [kjRadioValue]="'a'" tabindex="0">Option A</div>`
29
- * @category Core/Inputs
30
- */
31
- @Directive({
32
- selector: '[kjRadio]', standalone: true,
33
- hostDirectives: [
34
- { directive: KjDisabled, inputs: ['kjDisabled'] },
35
- KjFocusRing,
36
- KjFormControl,
37
- ],
38
- host: {
39
- role: 'radio',
40
- '[attr.aria-checked]': 'checked().toString()',
41
- '[attr.data-checked]': 'checked() ? "" : null',
42
- '(click)': 'select()',
43
- '(keydown.space)': 'onSpace($event)',
44
- '(keydown.enter)': 'select()',
45
- '(blur)': 'formCtrl.notifyTouched()',
46
- },
47
- })
48
- export class KjRadio {
49
- private readonly group = inject(KJ_RADIO_GROUP);
50
- readonly formCtrl = inject(KjFormControl);
51
- kjRadioValue = input.required<unknown>();
52
- readonly checked = computed(() => this.group.value() === this.kjRadioValue());
53
- select(): void { this.group.select(this.kjRadioValue()); this.formCtrl.notifyChange(this.kjRadioValue()); }
54
- onSpace(e: Event): void { e.preventDefault(); this.select(); }
55
- }
@@ -1,2 +0,0 @@
1
- export { KjSelect, KjSelectTrigger, KjSelectContent, KjOption } from './select';
2
- export { KJ_SELECT, type KjSelectContext } from './select.context';
@@ -1,3 +0,0 @@
1
- import { InjectionToken, Signal } from '@angular/core';
2
- export interface KjSelectContext { value: Signal<unknown>; open: Signal<boolean>; select: (value: unknown) => void; toggle: () => void; hide: () => void; }
3
- export const KJ_SELECT = new InjectionToken<KjSelectContext>('KjSelect');
@@ -1,145 +0,0 @@
1
- import { render, fireEvent } from '@testing-library/angular';
2
- import { axe, toHaveNoViolations } from 'jest-axe';
3
- import { KjSelect, KjSelectTrigger, KjSelectContent, KjOption } from './select';
4
-
5
- expect.extend(toHaveNoViolations);
6
-
7
- const imports = [KjSelect, KjSelectTrigger, KjSelectContent, KjOption];
8
- const template = `
9
- <div kjSelect [(kjSelectValue)]="val">
10
- <button kjSelectTrigger aria-haspopup="listbox">Select fruit</button>
11
- <div kjSelectContent>
12
- <div kjOption [kjOptionValue]="'apple'">Apple</div>
13
- <div kjOption [kjOptionValue]="'banana'">Banana</div>
14
- <div kjOption [kjOptionValue]="'cherry'">Cherry</div>
15
- </div>
16
- </div>`;
17
-
18
- describe('KjSelect', () => {
19
- it('listbox hidden by default', async () => {
20
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
21
- expect(container.querySelector('[kjSelectContent]')).toHaveAttribute('hidden', '');
22
- });
23
-
24
- it('trigger opens listbox', async () => {
25
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
26
- fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
27
- expect(container.querySelector('[kjSelectContent]')).not.toHaveAttribute('hidden');
28
- });
29
-
30
- it('trigger sets aria-expanded to false by default', async () => {
31
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
32
- expect(container.querySelector('[kjSelectTrigger]')).toHaveAttribute('aria-expanded', 'false');
33
- });
34
-
35
- it('trigger sets aria-expanded to true when open', async () => {
36
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
37
- fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
38
- expect(container.querySelector('[kjSelectTrigger]')).toHaveAttribute('aria-expanded', 'true');
39
- });
40
-
41
- it('clicking option selects and closes', async () => {
42
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
43
- fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
44
- fireEvent.click(container.querySelector('[kjOption]')!);
45
- expect(container.querySelector('[kjSelectContent]')).toHaveAttribute('hidden', '');
46
- });
47
-
48
- it('selected option has aria-selected="true"', async () => {
49
- const { container } = await render(template, { imports, componentProperties: { val: 'apple' } });
50
- const options = container.querySelectorAll('[kjOption]');
51
- expect(options[0]).toHaveAttribute('aria-selected', 'true');
52
- expect(options[1]).toHaveAttribute('aria-selected', 'false');
53
- });
54
-
55
- it('listbox has role="listbox"', async () => {
56
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
57
- expect(container.querySelector('[kjSelectContent]')).toHaveAttribute('role', 'listbox');
58
- });
59
-
60
- it('options have role="option"', async () => {
61
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
62
- const options = container.querySelectorAll('[kjOption]');
63
- options.forEach(opt => expect(opt).toHaveAttribute('role', 'option'));
64
- });
65
-
66
- it('ArrowDown moves focus to next option', async () => {
67
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
68
- fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
69
- const content = container.querySelector('[kjSelectContent]')!;
70
- fireEvent.keyDown(content, { key: 'ArrowDown' });
71
- const options = container.querySelectorAll('[kjOption]');
72
- expect(document.activeElement).toBe(options[0]);
73
- });
74
-
75
- it('ArrowDown then ArrowDown moves to second option', async () => {
76
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
77
- fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
78
- const content = container.querySelector('[kjSelectContent]')!;
79
- fireEvent.keyDown(content, { key: 'ArrowDown' });
80
- fireEvent.keyDown(content, { key: 'ArrowDown' });
81
- const options = container.querySelectorAll('[kjOption]');
82
- expect(document.activeElement).toBe(options[1]);
83
- });
84
-
85
- it('Home key moves focus to first option', async () => {
86
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
87
- fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
88
- const content = container.querySelector('[kjSelectContent]')!;
89
- // Move down first
90
- fireEvent.keyDown(content, { key: 'ArrowDown' });
91
- fireEvent.keyDown(content, { key: 'ArrowDown' });
92
- // Then Home
93
- fireEvent.keyDown(content, { key: 'Home' });
94
- const options = container.querySelectorAll('[kjOption]');
95
- expect(document.activeElement).toBe(options[0]);
96
- });
97
-
98
- it('End key moves focus to last option', async () => {
99
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
100
- fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
101
- const content = container.querySelector('[kjSelectContent]')!;
102
- fireEvent.keyDown(content, { key: 'End' });
103
- const options = container.querySelectorAll('[kjOption]');
104
- expect(document.activeElement).toBe(options[2]);
105
- });
106
-
107
- it('Enter selects focused option', async () => {
108
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
109
- fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
110
- const content = container.querySelector('[kjSelectContent]')!;
111
- fireEvent.keyDown(content, { key: 'ArrowDown' });
112
- fireEvent.keyDown(content, { key: 'Enter' });
113
- expect(container.querySelector('[kjSelectContent]')).toHaveAttribute('hidden', '');
114
- });
115
-
116
- it('Space selects focused option', async () => {
117
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
118
- fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
119
- const content = container.querySelector('[kjSelectContent]')!;
120
- fireEvent.keyDown(content, { key: 'ArrowDown' });
121
- fireEvent.keyDown(content, { key: ' ' });
122
- expect(container.querySelector('[kjSelectContent]')).toHaveAttribute('hidden', '');
123
- });
124
-
125
- it('type-ahead jumps to matching option', async () => {
126
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
127
- fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
128
- const content = container.querySelector('[kjSelectContent]')!;
129
- fireEvent.keyDown(content, { key: 'b' });
130
- const options = container.querySelectorAll('[kjOption]');
131
- expect(document.activeElement).toBe(options[1]);
132
- });
133
-
134
- it('Escape closes the listbox', async () => {
135
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
136
- fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
137
- fireEvent.keyDown(document, { key: 'Escape' });
138
- expect(container.querySelector('[kjSelectContent]')).toHaveAttribute('hidden', '');
139
- });
140
-
141
- it('passes axe audit', async () => {
142
- const { container } = await render(template, { imports, componentProperties: { val: '' } });
143
- expect(await axe(container)).toHaveNoViolations();
144
- });
145
- });
@@ -1,250 +0,0 @@
1
- import {
2
- afterNextRender,
3
- computed,
4
- Directive,
5
- effect,
6
- ElementRef,
7
- inject,
8
- input,
9
- model,
10
- signal,
11
- } from '@angular/core';
12
- import { KjDisabled, KjFocusRing } from '../primitives';
13
- import { KJ_SELECT } from './select.context';
14
-
15
- /**
16
- * Root select container. Manages open/close state via `kjSelectValue` model.
17
- *
18
- * @example
19
- * ```html
20
- * <div kjSelect [(kjSelectValue)]="selected">
21
- * <button kjSelectTrigger aria-haspopup="listbox">Choose fruit</button>
22
- * <div kjSelectContent>
23
- * <div kjOption [kjOptionValue]="'apple'">Apple</div>
24
- * </div>
25
- * </div>
26
- * ```
27
- * @category Core/Inputs
28
- */
29
- @Directive({
30
- selector: '[kjSelect]',
31
- standalone: true,
32
- providers: [{ provide: KJ_SELECT, useExisting: KjSelect }],
33
- })
34
- export class KjSelect {
35
- /** The currently selected value. Supports two-way binding. */
36
- readonly kjSelectValue = model<unknown>(undefined);
37
-
38
- /** Read-only signal for the current value. */
39
- readonly value = this.kjSelectValue.asReadonly();
40
-
41
- private readonly _open = signal(false);
42
-
43
- /** Whether the listbox is currently open. */
44
- readonly open = this._open.asReadonly();
45
-
46
- /**
47
- * Selects a value and closes the listbox.
48
- * @param val The value to select.
49
- */
50
- select(val: unknown): void {
51
- this.kjSelectValue.set(val);
52
- this._open.set(false);
53
- }
54
-
55
- /** Toggles the open state of the listbox. */
56
- toggle(): void {
57
- this._open.update(v => !v);
58
- }
59
-
60
- /** Closes the listbox. */
61
- hide(): void {
62
- this._open.set(false);
63
- }
64
- }
65
-
66
- /**
67
- * Trigger button that opens/closes the select listbox.
68
- * Automatically binds `aria-expanded` to reflect open state.
69
- *
70
- * @example
71
- * ```html
72
- * <button kjSelectTrigger aria-haspopup="listbox">Choose fruit</button>
73
- * ```
74
- * @category Core/Inputs
75
- */
76
- @Directive({
77
- selector: '[kjSelectTrigger]',
78
- standalone: true,
79
- hostDirectives: [KjFocusRing],
80
- host: {
81
- '[attr.aria-expanded]': 'ctx.open().toString()',
82
- '(click)': '$event.stopPropagation(); ctx.toggle()',
83
- },
84
- })
85
- export class KjSelectTrigger {
86
- /** @internal */
87
- readonly ctx = inject(KJ_SELECT);
88
- }
89
-
90
- /**
91
- * Listbox container. Provides keyboard navigation (Arrow keys, Home, End,
92
- * Enter, Space, type-ahead) and ARIA listbox semantics. Hidden when closed.
93
- *
94
- * @example
95
- * ```html
96
- * <div kjSelectContent>
97
- * <div kjOption [kjOptionValue]="'apple'">Apple</div>
98
- * </div>
99
- * ```
100
- * @category Core/Inputs
101
- */
102
- @Directive({
103
- selector: '[kjSelectContent]',
104
- standalone: true,
105
- host: {
106
- 'role': 'listbox',
107
- '[attr.hidden]': '!ctx.open() ? "" : null',
108
- '[attr.aria-activedescendant]': 'activeId()',
109
- '(keydown)': 'onKeydown($event)',
110
- '(document:keydown.escape)': 'ctx.hide()',
111
- '(document:click)': 'onDocClick()',
112
- '(click)': '$event.stopPropagation()',
113
- },
114
- })
115
- export class KjSelectContent {
116
- private readonly el = inject(ElementRef<HTMLElement>);
117
- /** @internal */
118
- readonly ctx = inject(KJ_SELECT);
119
-
120
- private readonly _activeIndex = signal(-1);
121
-
122
- /** @internal */
123
- readonly activeId = computed(() => {
124
- const items = this.getOptions();
125
- const idx = this._activeIndex();
126
- return items[idx]?.id ?? null;
127
- });
128
-
129
- /** @internal */
130
- onDocClick(): void {
131
- if (this.ctx.open()) this.ctx.hide();
132
- }
133
-
134
- /** @internal */
135
- onKeydown(e: KeyboardEvent): void {
136
- const items = this.getOptions();
137
- if (!items.length) return;
138
- let idx = this._activeIndex();
139
-
140
- switch (e.key) {
141
- case 'ArrowDown':
142
- e.preventDefault();
143
- idx = Math.min(idx + 1, items.length - 1);
144
- this._activeIndex.set(idx);
145
- items[idx]?.el.focus();
146
- break;
147
- case 'ArrowUp':
148
- e.preventDefault();
149
- idx = Math.max(idx - 1, 0);
150
- this._activeIndex.set(idx);
151
- items[idx]?.el.focus();
152
- break;
153
- case 'Home':
154
- e.preventDefault();
155
- this._activeIndex.set(0);
156
- items[0]?.el.focus();
157
- break;
158
- case 'End':
159
- e.preventDefault();
160
- this._activeIndex.set(items.length - 1);
161
- items[items.length - 1]?.el.focus();
162
- break;
163
- case 'Enter':
164
- case ' ':
165
- e.preventDefault();
166
- if (idx >= 0) {
167
- const val = items[idx]?.value;
168
- if (val !== undefined) this.ctx.select(val);
169
- }
170
- break;
171
- default: {
172
- const char = e.key.length === 1 ? e.key.toLowerCase() : null;
173
- if (char) {
174
- const match = items.findIndex(item =>
175
- item.el.textContent?.trim().toLowerCase().startsWith(char)
176
- );
177
- if (match >= 0) {
178
- this._activeIndex.set(match);
179
- items[match].el.focus();
180
- }
181
- }
182
- }
183
- }
184
- }
185
-
186
- private getOptions(): Array<{ el: HTMLElement; value: unknown; id: string }> {
187
- return (Array.from(
188
- this.el.nativeElement.querySelectorAll('[kjOption]')
189
- ) as HTMLElement[]).map((el, i) => ({
190
- el,
191
- value: (el as HTMLElement & { __kjOptionValue?: unknown }).__kjOptionValue,
192
- id: el.id || `kj-option-${i}`,
193
- }));
194
- }
195
- }
196
-
197
- /**
198
- * Individual option inside a `kjSelectContent`. Clicking or pressing Enter/Space
199
- * calls `select()` on the parent context.
200
- *
201
- * @example
202
- * ```html
203
- * <div kjOption [kjOptionValue]="'apple'">Apple</div>
204
- * ```
205
- * @category Core/Inputs
206
- */
207
- @Directive({
208
- selector: '[kjOption]',
209
- standalone: true,
210
- hostDirectives: [
211
- { directive: KjDisabled, inputs: ['kjDisabled'] },
212
- ],
213
- host: {
214
- 'role': 'option',
215
- '[attr.tabindex]': '"0"',
216
- '[attr.aria-selected]': 'selected().toString()',
217
- '[attr.aria-disabled]': 'disabled() ? "true" : null',
218
- '(click)': 'handleClick()',
219
- '(keydown.enter)': '$event.preventDefault(); handleClick()',
220
- '(keydown.space)': '$event.preventDefault(); handleClick()',
221
- },
222
- })
223
- export class KjOption {
224
- private readonly ctx = inject(KJ_SELECT);
225
- private readonly el = inject(ElementRef<HTMLElement>);
226
- /** @internal */
227
- readonly disabled = inject(KjDisabled).disabled;
228
-
229
- /** The value this option represents. */
230
- readonly kjOptionValue = input.required<unknown>();
231
-
232
- /** @internal */
233
- readonly selected = computed(() => this.ctx.value() === this.kjOptionValue());
234
-
235
- constructor() {
236
- effect(() => {
237
- (this.el.nativeElement as HTMLElement & { __kjOptionValue?: unknown }).__kjOptionValue = this.kjOptionValue();
238
- });
239
- afterNextRender(() => {
240
- if (!this.el.nativeElement.id) {
241
- this.el.nativeElement.id = `kj-option-${Math.random().toString(36).slice(2, 7)}`;
242
- }
243
- });
244
- }
245
-
246
- /** @internal */
247
- handleClick(): void {
248
- if (!this.disabled()) this.ctx.select(this.kjOptionValue());
249
- }
250
- }