@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.
- package/fesm2022/kouji-ui-core.mjs +26789 -0
- package/fesm2022/kouji-ui-core.mjs.map +1 -0
- package/icon/icon.css +40 -0
- package/package.json +20 -9
- package/types/kouji-ui-core.d.ts +13545 -0
- package/typography/prose.css +312 -0
- package/.turbo/turbo-build.log +0 -27
- package/CHANGELOG.md +0 -21
- package/ng-package.json +0 -7
- package/src/a11y/.gitkeep +0 -0
- package/src/a11y/aria-describedby.spec.ts +0 -55
- package/src/a11y/aria-describedby.ts +0 -33
- package/src/a11y/focus-trap.spec.ts +0 -86
- package/src/a11y/focus-trap.ts +0 -70
- package/src/a11y/index.ts +0 -9
- package/src/a11y/live-region.spec.ts +0 -58
- package/src/a11y/live-region.ts +0 -48
- package/src/a11y/roving-tabindex.spec.ts +0 -62
- package/src/a11y/roving-tabindex.ts +0 -104
- package/src/a11y/visually-hidden.spec.ts +0 -34
- package/src/a11y/visually-hidden.ts +0 -24
- package/src/accordion/accordion.context.ts +0 -22
- package/src/accordion/accordion.spec.ts +0 -32
- package/src/accordion/accordion.ts +0 -129
- package/src/accordion/index.ts +0 -2
- package/src/avatar/avatar.spec.ts +0 -18
- package/src/avatar/avatar.ts +0 -26
- package/src/avatar/index.ts +0 -1
- package/src/badge/badge.spec.ts +0 -20
- package/src/badge/badge.ts +0 -14
- package/src/badge/index.ts +0 -1
- package/src/button/button.example.ts +0 -30
- package/src/button/button.finance.example.ts +0 -37
- package/src/button/button.retro.example.ts +0 -47
- package/src/button/button.sizes.example.ts +0 -25
- package/src/button/button.spec.ts +0 -32
- package/src/button/button.ts +0 -44
- package/src/button/index.ts +0 -1
- package/src/chart/chart.spec.ts +0 -27
- package/src/chart/chart.ts +0 -48
- package/src/chart/index.ts +0 -1
- package/src/checkbox/checkbox.spec.ts +0 -35
- package/src/checkbox/checkbox.ts +0 -82
- package/src/checkbox/index.ts +0 -1
- package/src/dialog/dialog.confirm.example.ts +0 -57
- package/src/dialog/dialog.context.ts +0 -11
- package/src/dialog/dialog.example.ts +0 -40
- package/src/dialog/dialog.finance.example.ts +0 -47
- package/src/dialog/dialog.retro.example.ts +0 -46
- package/src/dialog/dialog.service.ts +0 -176
- package/src/dialog/dialog.spec.ts +0 -122
- package/src/dialog/dialog.ts +0 -201
- package/src/dialog/index.ts +0 -9
- package/src/example-components.ts +0 -33
- package/src/form/form.context.ts +0 -3
- package/src/form/form.spec.ts +0 -25
- package/src/form/form.ts +0 -46
- package/src/form/index.ts +0 -2
- package/src/input/index.ts +0 -1
- package/src/input/input.spec.ts +0 -43
- package/src/input/input.ts +0 -80
- package/src/menu/index.ts +0 -2
- package/src/menu/menu.context.ts +0 -10
- package/src/menu/menu.example.ts +0 -39
- package/src/menu/menu.spec.ts +0 -168
- package/src/menu/menu.ts +0 -176
- package/src/popover/index.ts +0 -12
- package/src/popover/popover.context.ts +0 -18
- package/src/popover/popover.example.ts +0 -50
- package/src/popover/popover.finance.example.ts +0 -52
- package/src/popover/popover.retro.example.ts +0 -53
- package/src/popover/popover.spec.ts +0 -52
- package/src/popover/popover.ts +0 -124
- package/src/primitives/.gitkeep +0 -0
- package/src/primitives/forms/form-control.spec.ts +0 -59
- package/src/primitives/forms/form-control.ts +0 -90
- package/src/primitives/forms/index.ts +0 -1
- package/src/primitives/index.ts +0 -3
- package/src/primitives/interaction/disabled.spec.ts +0 -47
- package/src/primitives/interaction/disabled.ts +0 -24
- package/src/primitives/interaction/focus-ring.spec.ts +0 -62
- package/src/primitives/interaction/focus-ring.ts +0 -57
- package/src/primitives/interaction/index.ts +0 -2
- package/src/primitives/overlay/index.ts +0 -1
- package/src/primitives/overlay/overlay.spec.ts +0 -90
- package/src/primitives/overlay/overlay.ts +0 -106
- package/src/public-api.ts +0 -35
- package/src/radio/index.ts +0 -2
- package/src/radio/radio.context.ts +0 -3
- package/src/radio/radio.spec.ts +0 -36
- package/src/radio/radio.ts +0 -55
- package/src/select/index.ts +0 -2
- package/src/select/select.context.ts +0 -3
- package/src/select/select.spec.ts +0 -145
- package/src/select/select.ts +0 -250
- package/src/styles/docs-themes.css +0 -82
- package/src/table/index.ts +0 -1
- package/src/table/table.spec.ts +0 -113
- package/src/table/table.ts +0 -126
- package/src/tabs/index.ts +0 -2
- package/src/tabs/tabs.context.ts +0 -9
- package/src/tabs/tabs.spec.ts +0 -40
- package/src/tabs/tabs.ts +0 -130
- package/src/test-setup.ts +0 -5
- package/src/toast/index.ts +0 -21
- package/src/toast/toast.example.ts +0 -134
- package/src/toast/toast.finance.example.ts +0 -68
- package/src/toast/toast.retro.example.ts +0 -69
- package/src/toast/toast.service.ts +0 -153
- package/src/toast/toast.spec.ts +0 -95
- package/src/toast/toast.strategy.ts +0 -102
- package/src/toast/toast.ts +0 -307
- package/src/toast/toast.types.ts +0 -5
- package/src/toggle/index.ts +0 -1
- package/src/toggle/toggle.spec.ts +0 -24
- package/src/toggle/toggle.ts +0 -40
- package/src/tooltip/index.ts +0 -5
- package/src/tooltip/tooltip.example.ts +0 -46
- package/src/tooltip/tooltip.finance.example.ts +0 -54
- package/src/tooltip/tooltip.placements.example.ts +0 -56
- package/src/tooltip/tooltip.retro.example.ts +0 -65
- package/src/tooltip/tooltip.spec.ts +0 -104
- package/src/tooltip/tooltip.ts +0 -129
- package/tsconfig.lib.json +0 -13
- package/tsconfig.lib.prod.json +0 -11
- package/tsconfig.spec.json +0 -10
- 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';
|
package/src/radio/index.ts
DELETED
package/src/radio/radio.spec.ts
DELETED
|
@@ -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
|
-
});
|
package/src/radio/radio.ts
DELETED
|
@@ -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
|
-
}
|
package/src/select/index.ts
DELETED
|
@@ -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
|
-
});
|
package/src/select/select.ts
DELETED
|
@@ -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
|
-
}
|