@kouji-ui/core 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +27 -0
- package/CHANGELOG.md +7 -0
- package/README.md +64 -0
- package/ng-package.json +7 -0
- package/package.json +39 -0
- package/src/a11y/.gitkeep +0 -0
- package/src/a11y/aria-describedby.spec.ts +55 -0
- package/src/a11y/aria-describedby.ts +33 -0
- package/src/a11y/focus-trap.spec.ts +86 -0
- package/src/a11y/focus-trap.ts +70 -0
- package/src/a11y/index.ts +9 -0
- package/src/a11y/live-region.spec.ts +58 -0
- package/src/a11y/live-region.ts +48 -0
- package/src/a11y/roving-tabindex.spec.ts +62 -0
- package/src/a11y/roving-tabindex.ts +104 -0
- package/src/a11y/visually-hidden.spec.ts +34 -0
- package/src/a11y/visually-hidden.ts +24 -0
- package/src/accordion/accordion.context.ts +22 -0
- package/src/accordion/accordion.spec.ts +32 -0
- package/src/accordion/accordion.ts +129 -0
- package/src/accordion/index.ts +2 -0
- package/src/avatar/avatar.spec.ts +18 -0
- package/src/avatar/avatar.ts +26 -0
- package/src/avatar/index.ts +1 -0
- package/src/badge/badge.spec.ts +20 -0
- package/src/badge/badge.ts +14 -0
- package/src/badge/index.ts +1 -0
- package/src/button/button.example.ts +30 -0
- package/src/button/button.finance.example.ts +37 -0
- package/src/button/button.retro.example.ts +47 -0
- package/src/button/button.sizes.example.ts +25 -0
- package/src/button/button.spec.ts +32 -0
- package/src/button/button.ts +44 -0
- package/src/button/index.ts +1 -0
- package/src/chart/chart.spec.ts +27 -0
- package/src/chart/chart.ts +48 -0
- package/src/chart/index.ts +1 -0
- package/src/checkbox/checkbox.spec.ts +35 -0
- package/src/checkbox/checkbox.ts +82 -0
- package/src/checkbox/index.ts +1 -0
- package/src/dialog/dialog.confirm.example.ts +57 -0
- package/src/dialog/dialog.context.ts +11 -0
- package/src/dialog/dialog.example.ts +40 -0
- package/src/dialog/dialog.finance.example.ts +47 -0
- package/src/dialog/dialog.retro.example.ts +46 -0
- package/src/dialog/dialog.service.ts +176 -0
- package/src/dialog/dialog.spec.ts +122 -0
- package/src/dialog/dialog.ts +201 -0
- package/src/dialog/index.ts +9 -0
- package/src/example-components.ts +33 -0
- package/src/form/form.context.ts +3 -0
- package/src/form/form.spec.ts +25 -0
- package/src/form/form.ts +46 -0
- package/src/form/index.ts +2 -0
- package/src/input/index.ts +1 -0
- package/src/input/input.spec.ts +43 -0
- package/src/input/input.ts +80 -0
- package/src/menu/index.ts +2 -0
- package/src/menu/menu.context.ts +10 -0
- package/src/menu/menu.example.ts +39 -0
- package/src/menu/menu.spec.ts +168 -0
- package/src/menu/menu.ts +176 -0
- package/src/popover/index.ts +12 -0
- package/src/popover/popover.context.ts +18 -0
- package/src/popover/popover.example.ts +50 -0
- package/src/popover/popover.finance.example.ts +52 -0
- package/src/popover/popover.retro.example.ts +53 -0
- package/src/popover/popover.spec.ts +52 -0
- package/src/popover/popover.ts +124 -0
- package/src/primitives/.gitkeep +0 -0
- package/src/primitives/forms/form-control.spec.ts +59 -0
- package/src/primitives/forms/form-control.ts +90 -0
- package/src/primitives/forms/index.ts +1 -0
- package/src/primitives/index.ts +3 -0
- package/src/primitives/interaction/disabled.spec.ts +47 -0
- package/src/primitives/interaction/disabled.ts +24 -0
- package/src/primitives/interaction/focus-ring.spec.ts +62 -0
- package/src/primitives/interaction/focus-ring.ts +57 -0
- package/src/primitives/interaction/index.ts +2 -0
- package/src/primitives/overlay/index.ts +1 -0
- package/src/primitives/overlay/overlay.spec.ts +90 -0
- package/src/primitives/overlay/overlay.ts +102 -0
- package/src/public-api.ts +35 -0
- package/src/radio/index.ts +2 -0
- package/src/radio/radio.context.ts +3 -0
- package/src/radio/radio.spec.ts +36 -0
- package/src/radio/radio.ts +55 -0
- package/src/select/index.ts +2 -0
- package/src/select/select.context.ts +3 -0
- package/src/select/select.spec.ts +145 -0
- package/src/select/select.ts +250 -0
- package/src/styles/docs-themes.css +82 -0
- package/src/table/index.ts +1 -0
- package/src/table/table.spec.ts +113 -0
- package/src/table/table.ts +126 -0
- package/src/tabs/index.ts +2 -0
- package/src/tabs/tabs.context.ts +9 -0
- package/src/tabs/tabs.spec.ts +40 -0
- package/src/tabs/tabs.ts +130 -0
- package/src/test-setup.ts +5 -0
- package/src/toast/index.ts +21 -0
- package/src/toast/toast.example.ts +134 -0
- package/src/toast/toast.finance.example.ts +68 -0
- package/src/toast/toast.retro.example.ts +69 -0
- package/src/toast/toast.service.ts +153 -0
- package/src/toast/toast.spec.ts +94 -0
- package/src/toast/toast.strategy.ts +102 -0
- package/src/toast/toast.ts +307 -0
- package/src/toast/toast.types.ts +5 -0
- package/src/toggle/index.ts +1 -0
- package/src/toggle/toggle.spec.ts +24 -0
- package/src/toggle/toggle.ts +40 -0
- package/src/tooltip/index.ts +5 -0
- package/src/tooltip/tooltip.example.ts +46 -0
- package/src/tooltip/tooltip.finance.example.ts +54 -0
- package/src/tooltip/tooltip.placements.example.ts +56 -0
- package/src/tooltip/tooltip.retro.example.ts +65 -0
- package/src/tooltip/tooltip.spec.ts +104 -0
- package/src/tooltip/tooltip.ts +129 -0
- package/tsconfig.lib.json +13 -0
- package/tsconfig.lib.prod.json +11 -0
- package/tsconfig.spec.json +10 -0
- package/vite.config.ts +19 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Directive, inject, input, signal } from '@angular/core';
|
|
2
|
+
import { KJ_POPOVER, KjPopoverContext, type KjPopoverSide, type KjPopoverAlign } from './popover.context';
|
|
3
|
+
|
|
4
|
+
let _popoverIdCounter = 0;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Root popover container. Manages open state, ARIA wiring, and click-outside dismissal.
|
|
8
|
+
* Pairs with `[kjPopoverTrigger]` and `[kjPopoverContent]`.
|
|
9
|
+
*
|
|
10
|
+
* Click outside or Escape closes the popover. Clicking the trigger toggles it.
|
|
11
|
+
* `aria-expanded` and `aria-controls` are automatically set on the trigger.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```html
|
|
15
|
+
* <div kjPopover>
|
|
16
|
+
* <button kjPopoverTrigger>Options</button>
|
|
17
|
+
* <div kjPopoverContent>
|
|
18
|
+
* <p>Popover body</p>
|
|
19
|
+
* <button kjPopoverClose>Done</button>
|
|
20
|
+
* </div>
|
|
21
|
+
* </div>
|
|
22
|
+
* ```
|
|
23
|
+
* @doc
|
|
24
|
+
* @doc-example Basic
|
|
25
|
+
* @doc-theme default
|
|
26
|
+
* @doc-file popover.example.ts
|
|
27
|
+
* @doc-theme retro
|
|
28
|
+
* @doc-file popover.retro.example.ts
|
|
29
|
+
* @doc-theme finance
|
|
30
|
+
* @doc-file popover.finance.example.ts
|
|
31
|
+
* @category Core/Overlays
|
|
32
|
+
*/
|
|
33
|
+
@Directive({
|
|
34
|
+
selector: '[kjPopover]',
|
|
35
|
+
standalone: true,
|
|
36
|
+
providers: [{ provide: KJ_POPOVER, useExisting: KjPopover }],
|
|
37
|
+
exportAs: 'kjPopover',
|
|
38
|
+
})
|
|
39
|
+
export class KjPopover implements KjPopoverContext {
|
|
40
|
+
/** Preferred side for the popover panel. Used via `data-side` for CSS. Defaults to `'bottom'`. */
|
|
41
|
+
kjPopoverSide = input<KjPopoverSide>('bottom');
|
|
42
|
+
/** Alignment along the cross-axis. Used via `data-align` for CSS. Defaults to `'start'`. */
|
|
43
|
+
kjPopoverAlign = input<KjPopoverAlign>('start');
|
|
44
|
+
|
|
45
|
+
readonly open = signal(false);
|
|
46
|
+
readonly popoverId = `kj-popover-${++_popoverIdCounter}`;
|
|
47
|
+
|
|
48
|
+
toggle(): void {
|
|
49
|
+
this.open.update(v => !v);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
hide(): void {
|
|
53
|
+
this.open.set(false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Trigger button that toggles the popover. Automatically sets `aria-expanded`
|
|
59
|
+
* and `aria-controls` on the host element.
|
|
60
|
+
*
|
|
61
|
+
* @category Core/Overlays
|
|
62
|
+
*/
|
|
63
|
+
@Directive({
|
|
64
|
+
selector: '[kjPopoverTrigger]',
|
|
65
|
+
standalone: true,
|
|
66
|
+
host: {
|
|
67
|
+
'[attr.aria-expanded]': 'ctx.open().toString()',
|
|
68
|
+
'[attr.aria-haspopup]': '"dialog"',
|
|
69
|
+
'[attr.aria-controls]': 'ctx.popoverId',
|
|
70
|
+
// stopPropagation prevents the document:click on kjPopoverContent from immediately closing
|
|
71
|
+
'(click)': '$event.stopPropagation(); ctx.toggle()',
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
export class KjPopoverTrigger {
|
|
75
|
+
readonly ctx = inject(KJ_POPOVER);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Popover content panel. Hidden when closed. Uses `hidden` attribute for
|
|
80
|
+
* accessibility-compliant visibility. Click-outside and Escape both dismiss it.
|
|
81
|
+
*
|
|
82
|
+
* Clicks inside the panel are stopped so they don't bubble to the document listener.
|
|
83
|
+
*
|
|
84
|
+
* @category Core/Overlays
|
|
85
|
+
*/
|
|
86
|
+
@Directive({
|
|
87
|
+
selector: '[kjPopoverContent]',
|
|
88
|
+
standalone: true,
|
|
89
|
+
host: {
|
|
90
|
+
'[attr.id]': 'ctx.popoverId',
|
|
91
|
+
'[attr.hidden]': '!ctx.open() ? "" : null',
|
|
92
|
+
'[attr.data-side]': 'ctx.kjPopoverSide()',
|
|
93
|
+
'[attr.data-align]': 'ctx.kjPopoverAlign()',
|
|
94
|
+
// Prevent clicks inside from closing the popover
|
|
95
|
+
'(click)': '$event.stopPropagation()',
|
|
96
|
+
// Close on Escape
|
|
97
|
+
'(document:keydown.escape)': 'ctx.open() && ctx.hide()',
|
|
98
|
+
// Close on any click outside (trigger stopPropagation prevents self-close)
|
|
99
|
+
'(document:click)': 'ctx.open() && ctx.hide()',
|
|
100
|
+
},
|
|
101
|
+
})
|
|
102
|
+
export class KjPopoverContent {
|
|
103
|
+
readonly ctx = inject(KJ_POPOVER);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Closes the parent popover when clicked. Place inside `[kjPopoverContent]`.
|
|
108
|
+
*
|
|
109
|
+
* @example
|
|
110
|
+
* ```html
|
|
111
|
+
* <button kjPopoverClose>Done</button>
|
|
112
|
+
* ```
|
|
113
|
+
* @category Core/Overlays
|
|
114
|
+
*/
|
|
115
|
+
@Directive({
|
|
116
|
+
selector: '[kjPopoverClose]',
|
|
117
|
+
standalone: true,
|
|
118
|
+
host: {
|
|
119
|
+
'(click)': 'ctx.hide()',
|
|
120
|
+
},
|
|
121
|
+
})
|
|
122
|
+
export class KjPopoverClose {
|
|
123
|
+
readonly ctx = inject(KJ_POPOVER);
|
|
124
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
|
3
|
+
import { render, fireEvent } from '@testing-library/angular';
|
|
4
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
5
|
+
import { KjFormControl } from './form-control';
|
|
6
|
+
|
|
7
|
+
expect.extend(toHaveNoViolations);
|
|
8
|
+
|
|
9
|
+
@Component({
|
|
10
|
+
standalone: true,
|
|
11
|
+
imports: [KjFormControl, ReactiveFormsModule],
|
|
12
|
+
template: `<input kjFormControl [formControl]="ctrl" (input)="onInput($event)" (blur)="onBlur()" />`,
|
|
13
|
+
})
|
|
14
|
+
class TestHostComponent {
|
|
15
|
+
ctrl = new FormControl('');
|
|
16
|
+
formCtrl!: KjFormControl;
|
|
17
|
+
onInput(e: Event): void {
|
|
18
|
+
// will be wired by the directive consumer pattern
|
|
19
|
+
}
|
|
20
|
+
onBlur(): void {}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('KjFormControl', () => {
|
|
24
|
+
it('registers as NG_VALUE_ACCESSOR so formControl binding works', async () => {
|
|
25
|
+
const { container } = await render(TestHostComponent);
|
|
26
|
+
expect(container.querySelector('input')).toBeInTheDocument();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('writeValue updates the value signal', async () => {
|
|
30
|
+
const { fixture } = await render(TestHostComponent);
|
|
31
|
+
const directive = fixture.debugElement.children[0].injector.get(KjFormControl);
|
|
32
|
+
directive.writeValue('hello');
|
|
33
|
+
expect(directive.value()).toBe('hello');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('setDisabledState updates the disabled signal', async () => {
|
|
37
|
+
const { fixture } = await render(TestHostComponent);
|
|
38
|
+
const directive = fixture.debugElement.children[0].injector.get(KjFormControl);
|
|
39
|
+
directive.setDisabledState(true);
|
|
40
|
+
expect(directive.disabled()).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('notifyChange updates value and calls onChange', async () => {
|
|
44
|
+
const { fixture } = await render(TestHostComponent);
|
|
45
|
+
const directive = fixture.debugElement.children[0].injector.get(KjFormControl);
|
|
46
|
+
let emitted: unknown;
|
|
47
|
+
directive.registerOnChange((v) => { emitted = v; });
|
|
48
|
+
directive.notifyChange('test');
|
|
49
|
+
expect(directive.value()).toBe('test');
|
|
50
|
+
expect(emitted).toBe('test');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('notifyTouched marks touched', async () => {
|
|
54
|
+
const { fixture } = await render(TestHostComponent);
|
|
55
|
+
const directive = fixture.debugElement.children[0].injector.get(KjFormControl);
|
|
56
|
+
directive.notifyTouched();
|
|
57
|
+
expect(directive.touched()).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Directive, forwardRef, signal } from '@angular/core';
|
|
2
|
+
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared ControlValueAccessor primitive. Compose via `hostDirectives` on any
|
|
6
|
+
* form input directive (KjInput, KjCheckbox, etc.) to wire
|
|
7
|
+
* Angular reactive forms and template-driven forms automatically.
|
|
8
|
+
*
|
|
9
|
+
* The host directive calls `notifyChange(value)` on user input and
|
|
10
|
+
* `notifyTouched()` on blur. The directive handles all CVA plumbing.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* @Directive({
|
|
15
|
+
* selector: '[kjInput]',
|
|
16
|
+
* hostDirectives: [KjFormControl],
|
|
17
|
+
* host: {
|
|
18
|
+
* '(input)': 'formCtrl.notifyChange($event.target.value)',
|
|
19
|
+
* '(blur)': 'formCtrl.notifyTouched()',
|
|
20
|
+
* },
|
|
21
|
+
* })
|
|
22
|
+
* export class KjInput {
|
|
23
|
+
* readonly formCtrl = inject(KjFormControl);
|
|
24
|
+
* }
|
|
25
|
+
* ```
|
|
26
|
+
* @category Core/Primitives
|
|
27
|
+
*/
|
|
28
|
+
@Directive({
|
|
29
|
+
selector: '[kjFormControl]',
|
|
30
|
+
standalone: true,
|
|
31
|
+
providers: [
|
|
32
|
+
{
|
|
33
|
+
provide: NG_VALUE_ACCESSOR,
|
|
34
|
+
useExisting: forwardRef(() => KjFormControl),
|
|
35
|
+
multi: true,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
})
|
|
39
|
+
export class KjFormControl implements ControlValueAccessor {
|
|
40
|
+
/** The current form value, kept in sync with the Angular forms model. */
|
|
41
|
+
readonly value = signal<unknown>(undefined);
|
|
42
|
+
|
|
43
|
+
/** Whether the control is disabled by the Angular forms layer. */
|
|
44
|
+
readonly disabled = signal<boolean>(false);
|
|
45
|
+
|
|
46
|
+
/** Whether the control has been touched (blurred at least once). */
|
|
47
|
+
readonly touched = signal<boolean>(false);
|
|
48
|
+
|
|
49
|
+
private _onChange?: (value: unknown) => void;
|
|
50
|
+
private _onTouched?: () => void;
|
|
51
|
+
|
|
52
|
+
/** @internal — called by Angular forms */
|
|
53
|
+
writeValue(val: unknown): void {
|
|
54
|
+
this.value.set(val);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @internal — called by Angular forms */
|
|
58
|
+
registerOnChange(fn: (value: unknown) => void): void {
|
|
59
|
+
this._onChange = fn;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @internal — called by Angular forms */
|
|
63
|
+
registerOnTouched(fn: () => void): void {
|
|
64
|
+
this._onTouched = fn;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @internal — called by Angular forms */
|
|
68
|
+
setDisabledState(isDisabled: boolean): void {
|
|
69
|
+
this.disabled.set(isDisabled);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Call this when the user changes the control's value.
|
|
74
|
+
* Updates the value signal and notifies Angular forms.
|
|
75
|
+
* @param val - The new value.
|
|
76
|
+
*/
|
|
77
|
+
notifyChange(val: unknown): void {
|
|
78
|
+
this.value.set(val);
|
|
79
|
+
this._onChange?.(val);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Call this when the user blurs the control.
|
|
84
|
+
* Marks the control as touched.
|
|
85
|
+
*/
|
|
86
|
+
notifyTouched(): void {
|
|
87
|
+
this.touched.set(true);
|
|
88
|
+
this._onTouched?.();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KjFormControl } from './form-control';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { render } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import { KjDisabled } from './disabled';
|
|
4
|
+
|
|
5
|
+
expect.extend(toHaveNoViolations);
|
|
6
|
+
|
|
7
|
+
describe('KjDisabled', () => {
|
|
8
|
+
it('sets aria-disabled="true" when disabled', async () => {
|
|
9
|
+
const { container } = await render(
|
|
10
|
+
`<button kjDisabled [kjDisabled]="true">Submit</button>`,
|
|
11
|
+
{ imports: [KjDisabled] },
|
|
12
|
+
);
|
|
13
|
+
expect(container.querySelector('button')).toHaveAttribute('aria-disabled', 'true');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('sets data-disabled attribute when disabled', async () => {
|
|
17
|
+
const { container } = await render(
|
|
18
|
+
`<button kjDisabled [kjDisabled]="true">Submit</button>`,
|
|
19
|
+
{ imports: [KjDisabled] },
|
|
20
|
+
);
|
|
21
|
+
expect(container.querySelector('button')).toHaveAttribute('data-disabled', '');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('removes aria-disabled when not disabled', async () => {
|
|
25
|
+
const { container } = await render(
|
|
26
|
+
`<button kjDisabled [kjDisabled]="false">Submit</button>`,
|
|
27
|
+
{ imports: [KjDisabled] },
|
|
28
|
+
);
|
|
29
|
+
expect(container.querySelector('button')).not.toHaveAttribute('aria-disabled');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('defaults to not disabled', async () => {
|
|
33
|
+
const { container } = await render(
|
|
34
|
+
`<button kjDisabled>Submit</button>`,
|
|
35
|
+
{ imports: [KjDisabled] },
|
|
36
|
+
);
|
|
37
|
+
expect(container.querySelector('button')).not.toHaveAttribute('aria-disabled');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('passes axe accessibility audit', async () => {
|
|
41
|
+
const { container } = await render(
|
|
42
|
+
`<button kjDisabled [kjDisabled]="true">Submit</button>`,
|
|
43
|
+
{ imports: [KjDisabled] },
|
|
44
|
+
);
|
|
45
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Directive, input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Applies disabled state to any element via ARIA and data attributes.
|
|
5
|
+
* Compose via `hostDirectives` on other directives to add disabled behavior.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```html
|
|
9
|
+
* <button kjButton [kjDisabled]="isLoading()">Submit</button>
|
|
10
|
+
* ```
|
|
11
|
+
* @category Core/Primitives
|
|
12
|
+
*/
|
|
13
|
+
@Directive({
|
|
14
|
+
selector: '[kjDisabled]',
|
|
15
|
+
standalone: true,
|
|
16
|
+
host: {
|
|
17
|
+
'[attr.aria-disabled]': 'disabled() ? "true" : null',
|
|
18
|
+
'[attr.data-disabled]': 'disabled() ? "" : null',
|
|
19
|
+
},
|
|
20
|
+
})
|
|
21
|
+
export class KjDisabled {
|
|
22
|
+
/** Whether the element is disabled. Reflects via `aria-disabled` and `data-disabled`. */
|
|
23
|
+
readonly disabled = input<boolean>(false, { alias: 'kjDisabled' });
|
|
24
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { render, fireEvent } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import { KjFocusRing } from './focus-ring';
|
|
4
|
+
|
|
5
|
+
expect.extend(toHaveNoViolations);
|
|
6
|
+
|
|
7
|
+
describe('KjFocusRing', () => {
|
|
8
|
+
it('adds data-focus-visible on focus when last interaction was keyboard', async () => {
|
|
9
|
+
const { container } = await render(
|
|
10
|
+
`<button kjFocusRing>Click me</button>`,
|
|
11
|
+
{ imports: [KjFocusRing] },
|
|
12
|
+
);
|
|
13
|
+
const btn = container.querySelector('button')!;
|
|
14
|
+
// No prior pointerdown — _lastWasPointer is false, so focus should be visible.
|
|
15
|
+
fireEvent.focus(btn);
|
|
16
|
+
expect(btn).toHaveAttribute('data-focus-visible', '');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('does not add data-focus-visible on focus after pointer interaction', async () => {
|
|
20
|
+
const { container } = await render(
|
|
21
|
+
`<button kjFocusRing>Click me</button>`,
|
|
22
|
+
{ imports: [KjFocusRing] },
|
|
23
|
+
);
|
|
24
|
+
const btn = container.querySelector('button')!;
|
|
25
|
+
// Simulate pointer interaction, then focus.
|
|
26
|
+
fireEvent.pointerDown(document);
|
|
27
|
+
fireEvent.focus(btn);
|
|
28
|
+
expect(btn).not.toHaveAttribute('data-focus-visible');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('removes data-focus-visible on blur', async () => {
|
|
32
|
+
const { container } = await render(
|
|
33
|
+
`<button kjFocusRing>Click me</button>`,
|
|
34
|
+
{ imports: [KjFocusRing] },
|
|
35
|
+
);
|
|
36
|
+
const btn = container.querySelector('button')!;
|
|
37
|
+
fireEvent.focus(btn);
|
|
38
|
+
fireEvent.blur(btn);
|
|
39
|
+
expect(btn).not.toHaveAttribute('data-focus-visible');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('restores data-focus-visible after keydown followed by focus', async () => {
|
|
43
|
+
const { container } = await render(
|
|
44
|
+
`<button kjFocusRing>Click me</button>`,
|
|
45
|
+
{ imports: [KjFocusRing] },
|
|
46
|
+
);
|
|
47
|
+
const btn = container.querySelector('button')!;
|
|
48
|
+
// First simulate pointer (marks _lastWasPointer = true), then keyboard (resets it).
|
|
49
|
+
fireEvent.pointerDown(document);
|
|
50
|
+
fireEvent.keyDown(document);
|
|
51
|
+
fireEvent.focus(btn);
|
|
52
|
+
expect(btn).toHaveAttribute('data-focus-visible', '');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('passes axe accessibility audit', async () => {
|
|
56
|
+
const { container } = await render(
|
|
57
|
+
`<button kjFocusRing>Click me</button>`,
|
|
58
|
+
{ imports: [KjFocusRing] },
|
|
59
|
+
);
|
|
60
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Directive, DestroyRef, ElementRef, afterNextRender, inject, signal } from '@angular/core';
|
|
2
|
+
import { PLATFORM_ID } from '@angular/core';
|
|
3
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tracks focus-visible state using native PointerEvents and keyboard interaction.
|
|
7
|
+
* Sets `data-focus-visible` only when the element receives focus via keyboard.
|
|
8
|
+
* Compose via `hostDirectives` to add focus-ring behavior to interactive elements.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* ```html
|
|
12
|
+
* <button kjFocusRing>Focusable button</button>
|
|
13
|
+
* ```
|
|
14
|
+
* @category Core/Primitives
|
|
15
|
+
*/
|
|
16
|
+
@Directive({
|
|
17
|
+
selector: '[kjFocusRing]',
|
|
18
|
+
standalone: true,
|
|
19
|
+
host: {
|
|
20
|
+
'[attr.data-focus-visible]': 'focusVisible() ? "" : null',
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
export class KjFocusRing {
|
|
24
|
+
private readonly el = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
25
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
26
|
+
private readonly platformId = inject(PLATFORM_ID);
|
|
27
|
+
|
|
28
|
+
private readonly _focusVisible = signal(false);
|
|
29
|
+
|
|
30
|
+
/** Whether the element currently has visible focus (keyboard-initiated). */
|
|
31
|
+
readonly focusVisible = this._focusVisible.asReadonly();
|
|
32
|
+
|
|
33
|
+
private _lastWasPointer = false;
|
|
34
|
+
|
|
35
|
+
constructor() {
|
|
36
|
+
afterNextRender(() => {
|
|
37
|
+
if (!isPlatformBrowser(this.platformId)) return;
|
|
38
|
+
|
|
39
|
+
const onKeydown = () => { this._lastWasPointer = false; };
|
|
40
|
+
const onPointerdown = () => { this._lastWasPointer = true; };
|
|
41
|
+
const onFocus = () => { this._focusVisible.set(!this._lastWasPointer); };
|
|
42
|
+
const onBlur = () => { this._focusVisible.set(false); };
|
|
43
|
+
|
|
44
|
+
document.addEventListener('keydown', onKeydown, true);
|
|
45
|
+
document.addEventListener('pointerdown', onPointerdown, true);
|
|
46
|
+
this.el.nativeElement.addEventListener('focus', onFocus);
|
|
47
|
+
this.el.nativeElement.addEventListener('blur', onBlur);
|
|
48
|
+
|
|
49
|
+
this.destroyRef.onDestroy(() => {
|
|
50
|
+
document.removeEventListener('keydown', onKeydown, true);
|
|
51
|
+
document.removeEventListener('pointerdown', onPointerdown, true);
|
|
52
|
+
this.el.nativeElement.removeEventListener('focus', onFocus);
|
|
53
|
+
this.el.nativeElement.removeEventListener('blur', onBlur);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KjOverlayService, KjOverlayRef } from './overlay';
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Component, TemplateRef, ViewChild, ViewContainerRef, inject } from '@angular/core';
|
|
2
|
+
import { render } from '@testing-library/angular';
|
|
3
|
+
import { KjOverlayService, KjOverlayRef } from './overlay';
|
|
4
|
+
|
|
5
|
+
describe('KjOverlayService', () => {
|
|
6
|
+
it('can be injected', async () => {
|
|
7
|
+
@Component({ standalone: true, template: '' })
|
|
8
|
+
class TestComponent {
|
|
9
|
+
readonly svc = inject(KjOverlayService);
|
|
10
|
+
}
|
|
11
|
+
const { fixture } = await render(TestComponent);
|
|
12
|
+
expect(fixture.componentInstance.svc).toBeTruthy();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('createFromTemplate returns a KjOverlayRef with isOpen=false', async () => {
|
|
16
|
+
@Component({
|
|
17
|
+
standalone: true,
|
|
18
|
+
template: '<ng-template #tpl><div>overlay content</div></ng-template>',
|
|
19
|
+
})
|
|
20
|
+
class TestComponent {
|
|
21
|
+
@ViewChild('tpl', { static: true }) tpl!: TemplateRef<unknown>;
|
|
22
|
+
readonly svc = inject(KjOverlayService);
|
|
23
|
+
readonly vcr = inject(ViewContainerRef);
|
|
24
|
+
ref!: KjOverlayRef;
|
|
25
|
+
ngOnInit() { this.ref = this.svc.createFromTemplate(this.tpl, this.vcr); }
|
|
26
|
+
}
|
|
27
|
+
const { fixture } = await render(TestComponent);
|
|
28
|
+
expect(fixture.componentInstance.ref.isOpen()).toBe(false);
|
|
29
|
+
fixture.componentInstance.ref.dispose();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('open() sets isOpen to true', async () => {
|
|
33
|
+
@Component({
|
|
34
|
+
standalone: true,
|
|
35
|
+
template: '<ng-template #tpl><div>overlay</div></ng-template>',
|
|
36
|
+
})
|
|
37
|
+
class TestComponent {
|
|
38
|
+
@ViewChild('tpl', { static: true }) tpl!: TemplateRef<unknown>;
|
|
39
|
+
readonly svc = inject(KjOverlayService);
|
|
40
|
+
readonly vcr = inject(ViewContainerRef);
|
|
41
|
+
ref!: KjOverlayRef;
|
|
42
|
+
ngOnInit() { this.ref = this.svc.createFromTemplate(this.tpl, this.vcr); }
|
|
43
|
+
}
|
|
44
|
+
const { fixture } = await render(TestComponent);
|
|
45
|
+
const ref = fixture.componentInstance.ref;
|
|
46
|
+
ref.open();
|
|
47
|
+
expect(ref.isOpen()).toBe(true);
|
|
48
|
+
ref.dispose();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('close() sets isOpen to false', async () => {
|
|
52
|
+
@Component({
|
|
53
|
+
standalone: true,
|
|
54
|
+
template: '<ng-template #tpl><div>overlay</div></ng-template>',
|
|
55
|
+
})
|
|
56
|
+
class TestComponent {
|
|
57
|
+
@ViewChild('tpl', { static: true }) tpl!: TemplateRef<unknown>;
|
|
58
|
+
readonly svc = inject(KjOverlayService);
|
|
59
|
+
readonly vcr = inject(ViewContainerRef);
|
|
60
|
+
ref!: KjOverlayRef;
|
|
61
|
+
ngOnInit() { this.ref = this.svc.createFromTemplate(this.tpl, this.vcr); }
|
|
62
|
+
}
|
|
63
|
+
const { fixture } = await render(TestComponent);
|
|
64
|
+
const ref = fixture.componentInstance.ref;
|
|
65
|
+
ref.open();
|
|
66
|
+
ref.close();
|
|
67
|
+
expect(ref.isOpen()).toBe(false);
|
|
68
|
+
ref.dispose();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('dispose() removes the overlay container from document.body', async () => {
|
|
72
|
+
@Component({
|
|
73
|
+
standalone: true,
|
|
74
|
+
template: '<ng-template #tpl><div>overlay</div></ng-template>',
|
|
75
|
+
})
|
|
76
|
+
class TestComponent {
|
|
77
|
+
@ViewChild('tpl', { static: true }) tpl!: TemplateRef<unknown>;
|
|
78
|
+
readonly svc = inject(KjOverlayService);
|
|
79
|
+
readonly vcr = inject(ViewContainerRef);
|
|
80
|
+
ref!: KjOverlayRef;
|
|
81
|
+
ngOnInit() { this.ref = this.svc.createFromTemplate(this.tpl, this.vcr); }
|
|
82
|
+
}
|
|
83
|
+
const { fixture } = await render(TestComponent);
|
|
84
|
+
const ref = fixture.componentInstance.ref;
|
|
85
|
+
ref.open();
|
|
86
|
+
expect(document.body.querySelector('[data-kj-overlay]')).toBeTruthy();
|
|
87
|
+
ref.dispose();
|
|
88
|
+
expect(document.body.querySelector('[data-kj-overlay]')).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
this.isOpen() ? this.close() : this.open();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Permanently removes the overlay from the DOM and destroys the view.
|
|
49
|
+
* Call inside `DestroyRef.onDestroy()` to prevent memory leaks.
|
|
50
|
+
*/
|
|
51
|
+
dispose(): void {
|
|
52
|
+
this.appRef.detachView(this.viewRef);
|
|
53
|
+
this.viewRef.destroy();
|
|
54
|
+
this.container.remove();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The underlying container `HTMLElement` for advanced usage. */
|
|
58
|
+
get el(): HTMLElement {
|
|
59
|
+
return this.container;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Factory service for creating managed overlay containers.
|
|
65
|
+
* Creates a container appended to `document.body`.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```ts
|
|
69
|
+
* // Dialog — full-screen backdrop overlay
|
|
70
|
+
* const ref = inject(KjOverlayService).createFromTemplate(tpl, vcr);
|
|
71
|
+
* ref.open();
|
|
72
|
+
*
|
|
73
|
+
* // Clean up when done
|
|
74
|
+
* ref.dispose();
|
|
75
|
+
* ```
|
|
76
|
+
*/
|
|
77
|
+
@Injectable({ providedIn: 'root' })
|
|
78
|
+
export class KjOverlayService {
|
|
79
|
+
private readonly appRef = inject(ApplicationRef);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Creates an overlay from an Angular template ref.
|
|
83
|
+
* The container is appended to `document.body` and starts hidden.
|
|
84
|
+
*
|
|
85
|
+
* @param tpl - The `TemplateRef` to render inside the overlay.
|
|
86
|
+
* @param vcr - The `ViewContainerRef` used to create the embedded view.
|
|
87
|
+
* @returns A {@link KjOverlayRef} managing the created overlay.
|
|
88
|
+
*/
|
|
89
|
+
createFromTemplate(tpl: TemplateRef<unknown>, vcr: ViewContainerRef): KjOverlayRef {
|
|
90
|
+
// Use TemplateRef.createEmbeddedView directly so the view is not pre-attached
|
|
91
|
+
// to the VCR's container — ApplicationRef can own the view lifecycle.
|
|
92
|
+
const viewRef = tpl.createEmbeddedView(null as never, vcr.injector);
|
|
93
|
+
this.appRef.attachView(viewRef);
|
|
94
|
+
|
|
95
|
+
const container = document.createElement('div');
|
|
96
|
+
container.setAttribute('data-kj-overlay', '');
|
|
97
|
+
viewRef.rootNodes.forEach(node => container.appendChild(node));
|
|
98
|
+
document.body.appendChild(container);
|
|
99
|
+
|
|
100
|
+
return new KjOverlayRef(container, viewRef, this.appRef);
|
|
101
|
+
}
|
|
102
|
+
}
|