@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,35 @@
|
|
|
1
|
+
import { render, fireEvent } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import { KjCheckbox } from './checkbox';
|
|
4
|
+
|
|
5
|
+
expect.extend(toHaveNoViolations);
|
|
6
|
+
|
|
7
|
+
describe('KjCheckbox', () => {
|
|
8
|
+
it('sets role=checkbox', async () => {
|
|
9
|
+
const { getByRole } = await render(`<div kjCheckbox tabindex="0">Check</div>`, { imports: [KjCheckbox] });
|
|
10
|
+
expect(getByRole('checkbox')).toBeInTheDocument();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('has aria-checked=false by default', async () => {
|
|
14
|
+
const { getByRole } = await render(`<div kjCheckbox tabindex="0">Check</div>`, { imports: [KjCheckbox] });
|
|
15
|
+
expect(getByRole('checkbox')).toHaveAttribute('aria-checked', 'false');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('toggles checked on click', async () => {
|
|
19
|
+
const { getByRole } = await render(`<div kjCheckbox tabindex="0">Check</div>`, { imports: [KjCheckbox] });
|
|
20
|
+
fireEvent.click(getByRole('checkbox'));
|
|
21
|
+
expect(getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
|
|
22
|
+
expect(getByRole('checkbox')).toHaveAttribute('data-checked', '');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('toggles on Space key', async () => {
|
|
26
|
+
const { getByRole } = await render(`<div kjCheckbox tabindex="0">Check</div>`, { imports: [KjCheckbox] });
|
|
27
|
+
fireEvent.keyDown(getByRole('checkbox'), { key: ' ' });
|
|
28
|
+
expect(getByRole('checkbox')).toHaveAttribute('aria-checked', 'true');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('passes axe audit', async () => {
|
|
32
|
+
const { container } = await render(`<div kjCheckbox tabindex="0" aria-label="Accept terms">Accept</div>`, { imports: [KjCheckbox] });
|
|
33
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { Directive, inject, model } from '@angular/core';
|
|
2
|
+
import { KjDisabled, KjFocusRing, KjFormControl } from '../primitives';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Adds checkbox semantics with Angular forms integration.
|
|
6
|
+
* Supports `formControl`, `formControlName`, and `ngModel` bindings.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```html
|
|
10
|
+
* <div kjCheckbox tabindex="0" [(kjChecked)]="accepted" aria-label="Accept terms">Accept</div>
|
|
11
|
+
* ```
|
|
12
|
+
* @doc
|
|
13
|
+
* @doc-file checkbox.example.ts
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { Component, signal } from '@angular/core';
|
|
16
|
+
* import { KjCheckbox } from '@kouji-ui/core';
|
|
17
|
+
*
|
|
18
|
+
* @Component({
|
|
19
|
+
* standalone: true,
|
|
20
|
+
* imports: [KjCheckbox],
|
|
21
|
+
* styles: [`
|
|
22
|
+
* .row { display: flex; flex-direction: column; gap: 1rem; padding: 2rem; background: #0c0c0c; }
|
|
23
|
+
* .item { display: flex; align-items: center; gap: 0.75rem; }
|
|
24
|
+
* [kjCheckbox] { width: 18px; height: 18px; border: 2px solid #333; background: #111; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
|
25
|
+
* [data-checked] { background: #b8f500; border-color: #b8f500; }
|
|
26
|
+
* [data-checked]::after { content: '✓'; color: #0c0c0c; font-size: 0.75rem; font-weight: bold; }
|
|
27
|
+
* span { color: #888; font-family: 'JetBrains Mono', monospace; font-size: 0.875rem; }
|
|
28
|
+
* `],
|
|
29
|
+
* template: `
|
|
30
|
+
* <div class="row">
|
|
31
|
+
* <div class="item">
|
|
32
|
+
* <div kjCheckbox tabindex="0" [(kjChecked)]="terms" aria-label="Accept terms"></div>
|
|
33
|
+
* <span>Accept terms — {{ terms() ? 'checked' : 'unchecked' }}</span>
|
|
34
|
+
* </div>
|
|
35
|
+
* <div class="item">
|
|
36
|
+
* <div kjCheckbox tabindex="0" [(kjChecked)]="newsletter" aria-label="Newsletter"></div>
|
|
37
|
+
* <span>Subscribe to newsletter</span>
|
|
38
|
+
* </div>
|
|
39
|
+
* </div>
|
|
40
|
+
* `,
|
|
41
|
+
* })
|
|
42
|
+
* export class CheckboxExampleComponent {
|
|
43
|
+
* terms = signal(false);
|
|
44
|
+
* newsletter = signal(true);
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
* @category Core/Inputs
|
|
48
|
+
*/
|
|
49
|
+
@Directive({
|
|
50
|
+
selector: '[kjCheckbox]',
|
|
51
|
+
standalone: true,
|
|
52
|
+
hostDirectives: [
|
|
53
|
+
{ directive: KjDisabled, inputs: ['kjDisabled'] },
|
|
54
|
+
KjFocusRing,
|
|
55
|
+
KjFormControl,
|
|
56
|
+
],
|
|
57
|
+
host: {
|
|
58
|
+
role: 'checkbox',
|
|
59
|
+
'[attr.aria-checked]': 'kjChecked().toString()',
|
|
60
|
+
'[attr.data-checked]': 'kjChecked() ? "" : null',
|
|
61
|
+
'[attr.disabled]': 'formCtrl.disabled() ? "" : null',
|
|
62
|
+
'(click)': 'toggle()',
|
|
63
|
+
'(keydown.space)': 'onSpace($event)',
|
|
64
|
+
'(blur)': 'formCtrl.notifyTouched()',
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
export class KjCheckbox {
|
|
68
|
+
readonly formCtrl = inject(KjFormControl);
|
|
69
|
+
|
|
70
|
+
/** Whether the checkbox is checked. Supports two-way binding. */
|
|
71
|
+
kjChecked = model<boolean>(false);
|
|
72
|
+
|
|
73
|
+
/** @internal */
|
|
74
|
+
toggle(): void {
|
|
75
|
+
const next = !this.kjChecked();
|
|
76
|
+
this.kjChecked.set(next);
|
|
77
|
+
this.formCtrl.notifyChange(next);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @internal */
|
|
81
|
+
onSpace(e: Event): void { e.preventDefault(); this.toggle(); }
|
|
82
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KjCheckbox } from './checkbox';
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Component, signal } from '@angular/core';
|
|
2
|
+
import { KjDialogTrigger, KjDialog, KjDialogOverlay, KjDialogTitle } from './dialog';
|
|
3
|
+
import { KjButton } from '../button/button';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'kj-example-dialog-confirm',
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [KjDialogTrigger, KjDialog, KjDialogOverlay, KjDialogTitle, KjButton],
|
|
9
|
+
styleUrls: ['../styles/docs-themes.css'],
|
|
10
|
+
styles: [`
|
|
11
|
+
:host { display: flex; align-items: center; justify-content: center; gap: 1rem; padding: 3rem 2rem; background: var(--kj-bg); font-family: var(--kj-font); min-height: 160px; flex-direction: column; color: var(--kj-text); }
|
|
12
|
+
.status { font-size: 0.8125rem; color: var(--kj-text-muted); min-height: 1.25rem; }
|
|
13
|
+
.status.confirmed { color: var(--kj-accent); }
|
|
14
|
+
.status.cancelled { color: var(--kj-destructive); }
|
|
15
|
+
button[kjButton] { padding: 0.5rem 1.5rem; border: var(--kj-btn-border); cursor: pointer; font-family: var(--kj-font); font-size: 0.875rem; }
|
|
16
|
+
[data-variant="destructive"] { background: var(--kj-destructive); color: #fff; }
|
|
17
|
+
[data-variant="outline"] { background: transparent; color: var(--kj-text); border: 1px solid var(--kj-border); }
|
|
18
|
+
::ng-deep .kj-dialog-backdrop { background: var(--kj-backdrop); }
|
|
19
|
+
::ng-deep .kj-dialog-panel { background: transparent; box-shadow: none; }
|
|
20
|
+
::ng-deep [kjDialogOverlay] { display: flex; align-items: center; justify-content: center; position: fixed; inset: 0; }
|
|
21
|
+
::ng-deep [kjDialog] { background: var(--kj-surface); border: 1px solid var(--kj-border); border-radius: var(--kj-radius-lg); padding: 1.5rem; min-width: 22rem; color: var(--kj-text); font-family: var(--kj-font); box-shadow: var(--kj-shadow-hard); }
|
|
22
|
+
::ng-deep [kjDialogTitle] { margin: 0 0 0.5rem; font-size: 1.125rem; color: var(--kj-text); }
|
|
23
|
+
::ng-deep .dialog-body { margin: 0 0 1.5rem; font-size: 0.875rem; color: var(--kj-text-muted); line-height: 1.6; }
|
|
24
|
+
::ng-deep .dialog-actions { display: flex; gap: 0.75rem; justify-content: flex-end; }
|
|
25
|
+
::ng-deep .dialog-actions button[kjButton] { padding: 0.4rem 1rem; font-size: 0.8125rem; }
|
|
26
|
+
`],
|
|
27
|
+
template: `
|
|
28
|
+
<button kjButton [kjVariant]="'destructive'"
|
|
29
|
+
[kjDialogTrigger]="confirmDialog"
|
|
30
|
+
(kjDialogClosed)="onResult($event)">
|
|
31
|
+
Delete Account
|
|
32
|
+
</button>
|
|
33
|
+
<ng-template #confirmDialog>
|
|
34
|
+
<div kjDialogOverlay>
|
|
35
|
+
<div kjDialog #dlg="kjDialog">
|
|
36
|
+
<h2 kjDialogTitle>Are you absolutely sure?</h2>
|
|
37
|
+
<p class="dialog-body">This action cannot be undone. All your data will be permanently removed.</p>
|
|
38
|
+
<div class="dialog-actions">
|
|
39
|
+
<button kjButton [kjVariant]="'outline'" (click)="dlg.close('cancelled')">Cancel</button>
|
|
40
|
+
<button kjButton [kjVariant]="'destructive'" (click)="dlg.close('confirmed')">Yes, delete</button>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</ng-template>
|
|
45
|
+
<span class="status"
|
|
46
|
+
[class.confirmed]="result() === 'confirmed'"
|
|
47
|
+
[class.cancelled]="result() === 'cancelled'">
|
|
48
|
+
{{ result() === 'confirmed' ? '✓ Account deleted' : result() === 'cancelled' ? '✕ Cancelled' : '' }}
|
|
49
|
+
</span>
|
|
50
|
+
`,
|
|
51
|
+
})
|
|
52
|
+
export class DialogConfirmExample {
|
|
53
|
+
readonly result = signal<'confirmed' | 'cancelled' | null>(null);
|
|
54
|
+
onResult(value: unknown): void {
|
|
55
|
+
if (value === 'confirmed' || value === 'cancelled') this.result.set(value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { InjectionToken, Signal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
export interface KjDialogContext {
|
|
4
|
+
readonly open: Signal<boolean>;
|
|
5
|
+
readonly dialogId: string;
|
|
6
|
+
readonly closeOnEscape: Signal<boolean>;
|
|
7
|
+
readonly closeOnBackdrop: Signal<boolean>;
|
|
8
|
+
close(result?: unknown): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const KJ_DIALOG = new InjectionToken<KjDialogContext>('KjDialog');
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { KjDialogTrigger, KjDialog, KjDialogOverlay, KjDialogTitle, KjDialogClose } from './dialog';
|
|
3
|
+
import { KjButton } from '../button/button';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'kj-example-dialog-basic',
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [KjDialogTrigger, KjDialog, KjDialogOverlay, KjDialogTitle, KjDialogClose, KjButton],
|
|
9
|
+
styleUrls: ['../styles/docs-themes.css'],
|
|
10
|
+
styles: [`
|
|
11
|
+
:host { display: flex; align-items: center; justify-content: center; padding: 3rem 2rem; background: var(--kj-bg); font-family: var(--kj-font); min-height: 160px; }
|
|
12
|
+
button[kjButton] { padding: 0.5rem 1.5rem; border: var(--kj-btn-border); cursor: pointer; font-family: var(--kj-font); font-size: 0.875rem; transition: var(--kj-transition); }
|
|
13
|
+
[data-variant="default"] { background: var(--kj-accent); color: var(--kj-accent-on); }
|
|
14
|
+
[data-variant="outline"] { background: transparent; color: var(--kj-text); border: 1px solid var(--kj-border); }
|
|
15
|
+
::ng-deep .kj-dialog-backdrop { background: var(--kj-backdrop); }
|
|
16
|
+
::ng-deep .kj-dialog-panel { background: transparent; box-shadow: none; }
|
|
17
|
+
::ng-deep [kjDialogOverlay] { display: flex; align-items: center; justify-content: center; position: fixed; inset: 0; }
|
|
18
|
+
::ng-deep [kjDialog] { background: var(--kj-surface); border: 1px solid var(--kj-border); border-radius: var(--kj-radius-lg); padding: 1.5rem; min-width: 20rem; color: var(--kj-text); font-family: var(--kj-font); box-shadow: var(--kj-shadow-hard); }
|
|
19
|
+
::ng-deep [kjDialogTitle] { margin: 0 0 0.5rem; font-size: 1.125rem; color: var(--kj-text); }
|
|
20
|
+
::ng-deep .dialog-body { margin: 0 0 1.5rem; font-size: 0.875rem; color: var(--kj-text-muted); line-height: 1.6; }
|
|
21
|
+
::ng-deep .dialog-actions { display: flex; gap: 0.75rem; justify-content: flex-end; }
|
|
22
|
+
::ng-deep .dialog-actions button[kjButton] { padding: 0.4rem 1rem; font-size: 0.8125rem; }
|
|
23
|
+
`],
|
|
24
|
+
template: `
|
|
25
|
+
<button kjButton [kjVariant]="'default'" [kjDialogTrigger]="myDialog">Open Dialog</button>
|
|
26
|
+
<ng-template #myDialog>
|
|
27
|
+
<div kjDialogOverlay>
|
|
28
|
+
<div kjDialog>
|
|
29
|
+
<h2 kjDialogTitle>Edit Profile</h2>
|
|
30
|
+
<p class="dialog-body">Make changes to your profile settings here.</p>
|
|
31
|
+
<div class="dialog-actions">
|
|
32
|
+
<button kjButton [kjVariant]="'outline'" kjDialogClose>Cancel</button>
|
|
33
|
+
<button kjButton [kjVariant]="'default'" kjDialogClose>Save Changes</button>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</ng-template>
|
|
38
|
+
`,
|
|
39
|
+
})
|
|
40
|
+
export class DialogBasicExample {}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { KjDialogTrigger, KjDialog, KjDialogOverlay, KjDialogTitle, KjDialogClose } from './dialog';
|
|
3
|
+
|
|
4
|
+
@Component({
|
|
5
|
+
selector: 'kj-example-dialog-finance',
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [KjDialogTrigger, KjDialog, KjDialogOverlay, KjDialogTitle, KjDialogClose],
|
|
8
|
+
styleUrls: ['../styles/docs-themes.css'],
|
|
9
|
+
styles: [`
|
|
10
|
+
:host { display: flex; align-items: center; justify-content: center; padding: 3rem 2rem; background: var(--kj-bg); font-family: var(--kj-font); min-height: 160px; }
|
|
11
|
+
button {
|
|
12
|
+
padding: 0.45rem 1.125rem; background: var(--kj-accent); color: var(--kj-accent-on);
|
|
13
|
+
border: var(--kj-btn-border); border-color: var(--kj-accent);
|
|
14
|
+
border-radius: var(--kj-radius-md); cursor: pointer; font-family: var(--kj-font); font-size: 0.875rem; font-weight: 500;
|
|
15
|
+
}
|
|
16
|
+
button:hover { background: #2563eb; }
|
|
17
|
+
::ng-deep .kj-dialog-backdrop { background: var(--kj-backdrop); }
|
|
18
|
+
::ng-deep .kj-dialog-panel { background: transparent; box-shadow: none; }
|
|
19
|
+
::ng-deep [kjDialogOverlay] { display: flex; align-items: center; justify-content: center; position: fixed; inset: 0; }
|
|
20
|
+
::ng-deep [kjDialog] { background: var(--kj-surface); border: 1px solid var(--kj-border); border-radius: var(--kj-radius-lg); padding: 1.5rem; min-width: 22rem; box-shadow: var(--kj-shadow-hard); font-family: var(--kj-font); color: var(--kj-text); }
|
|
21
|
+
::ng-deep [kjDialogTitle] { margin: 0 0 0.375rem; font-size: 1.0625rem; font-weight: 600; color: var(--kj-text); }
|
|
22
|
+
::ng-deep .dialog-body { margin: 0 0 1.5rem; font-size: 0.875rem; color: var(--kj-text-muted); line-height: 1.6; }
|
|
23
|
+
::ng-deep .dialog-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
|
|
24
|
+
::ng-deep [kjDialogOverlay] button { padding: 0.4rem 1rem; font-size: 0.8125rem; font-weight: 500; border-radius: var(--kj-radius-md); }
|
|
25
|
+
::ng-deep .btn-cancel { background: var(--kj-surface); color: var(--kj-text); border: 1px solid var(--kj-border); }
|
|
26
|
+
::ng-deep .btn-cancel:hover { background: var(--kj-bg); }
|
|
27
|
+
::ng-deep .btn-primary { background: var(--kj-accent); color: var(--kj-accent-on); border-color: var(--kj-accent); }
|
|
28
|
+
::ng-deep .btn-primary:hover { background: #2563eb; }
|
|
29
|
+
`],
|
|
30
|
+
host: { class: 'kj-theme-finance' },
|
|
31
|
+
template: `
|
|
32
|
+
<button [kjDialogTrigger]="myDialog">Open Dialog</button>
|
|
33
|
+
<ng-template #myDialog>
|
|
34
|
+
<div kjDialogOverlay>
|
|
35
|
+
<div kjDialog>
|
|
36
|
+
<h2 kjDialogTitle>Edit Profile</h2>
|
|
37
|
+
<p class="dialog-body">Make changes to your profile settings here.</p>
|
|
38
|
+
<div class="dialog-actions">
|
|
39
|
+
<button class="btn-cancel" kjDialogClose>Cancel</button>
|
|
40
|
+
<button class="btn-primary" kjDialogClose>Save Changes</button>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</ng-template>
|
|
45
|
+
`,
|
|
46
|
+
})
|
|
47
|
+
export class DialogFinanceExample {}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { KjDialogTrigger, KjDialog, KjDialogOverlay, KjDialogTitle, KjDialogClose } from './dialog';
|
|
3
|
+
|
|
4
|
+
@Component({
|
|
5
|
+
selector: 'kj-example-dialog-retro',
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [KjDialogTrigger, KjDialog, KjDialogOverlay, KjDialogTitle, KjDialogClose],
|
|
8
|
+
styleUrls: ['../styles/docs-themes.css'],
|
|
9
|
+
styles: [`
|
|
10
|
+
:host { display: flex; align-items: center; justify-content: center; padding: 3rem 2rem; background: var(--kj-bg); font-family: var(--kj-font); min-height: 160px; color: var(--kj-text); }
|
|
11
|
+
button {
|
|
12
|
+
padding: 0.4rem 1rem; font-family: var(--kj-font); font-size: 0.8rem; font-weight: 700;
|
|
13
|
+
letter-spacing: 0.06em; text-transform: uppercase; background: var(--kj-surface); color: var(--kj-text);
|
|
14
|
+
border: var(--kj-btn-border); cursor: pointer;
|
|
15
|
+
box-shadow: var(--kj-shadow-sm); transition: var(--kj-transition);
|
|
16
|
+
}
|
|
17
|
+
button:hover { transform: translate(-1px, -1px); box-shadow: var(--kj-shadow-md); }
|
|
18
|
+
::ng-deep .kj-dialog-backdrop { background: var(--kj-backdrop); }
|
|
19
|
+
::ng-deep .kj-dialog-panel { background: transparent; box-shadow: none; }
|
|
20
|
+
::ng-deep [kjDialogOverlay] { display: flex; align-items: center; justify-content: center; position: fixed; inset: 0; }
|
|
21
|
+
::ng-deep [kjDialog] { background: var(--kj-surface); border: var(--kj-btn-border); padding: 1.5rem; min-width: 20rem; box-shadow: var(--kj-shadow-hard); font-family: var(--kj-font); color: var(--kj-text); }
|
|
22
|
+
::ng-deep [kjDialogTitle] { margin: 0 0 0.5rem; font-size: 1rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 2px solid var(--kj-border); padding-bottom: 0.5rem; color: var(--kj-text); }
|
|
23
|
+
::ng-deep .dialog-body { margin: 0.75rem 0 1.5rem; font-size: 0.8rem; color: var(--kj-text-muted); line-height: 1.6; }
|
|
24
|
+
::ng-deep .dialog-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
|
|
25
|
+
::ng-deep [kjDialogOverlay] button { font-size: 0.75rem; padding: 0.3rem 0.875rem; box-shadow: var(--kj-shadow-sm); }
|
|
26
|
+
::ng-deep [kjDialogOverlay] button:hover { box-shadow: var(--kj-shadow-md); }
|
|
27
|
+
::ng-deep .btn-primary { background: var(--kj-accent); color: var(--kj-accent-on); }
|
|
28
|
+
`],
|
|
29
|
+
host: { class: 'kj-theme-retro' },
|
|
30
|
+
template: `
|
|
31
|
+
<button [kjDialogTrigger]="myDialog">Open Dialog</button>
|
|
32
|
+
<ng-template #myDialog>
|
|
33
|
+
<div kjDialogOverlay>
|
|
34
|
+
<div kjDialog>
|
|
35
|
+
<h2 kjDialogTitle>Edit Profile</h2>
|
|
36
|
+
<p class="dialog-body">Make changes to your profile settings here.</p>
|
|
37
|
+
<div class="dialog-actions">
|
|
38
|
+
<button kjDialogClose>Cancel</button>
|
|
39
|
+
<button class="btn-primary" kjDialogClose>Save Changes</button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</ng-template>
|
|
44
|
+
`,
|
|
45
|
+
})
|
|
46
|
+
export class DialogRetroExample {}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ApplicationRef, ComponentRef, EmbeddedViewRef,
|
|
3
|
+
Injectable, InjectionToken, Injector,
|
|
4
|
+
TemplateRef, Type, ViewContainerRef,
|
|
5
|
+
createComponent, inject, signal,
|
|
6
|
+
} from '@angular/core';
|
|
7
|
+
|
|
8
|
+
/** Token for passing data to a programmatically opened dialog component. */
|
|
9
|
+
export const DIALOG_DATA = new InjectionToken<unknown>('KjDialogData');
|
|
10
|
+
|
|
11
|
+
/** Configuration for programmatically opened dialogs. */
|
|
12
|
+
export interface KjDialogOpenConfig<D = unknown> {
|
|
13
|
+
/** Data to inject into the dialog component via `DIALOG_DATA`. */
|
|
14
|
+
data?: D;
|
|
15
|
+
/** Additional CSS classes for the dialog panel element. */
|
|
16
|
+
panelClass?: string | string[];
|
|
17
|
+
/** Where to focus when the dialog opens. Defaults to the first focusable element. */
|
|
18
|
+
autoFocus?: boolean | string;
|
|
19
|
+
/** Whether to restore focus to the trigger after the dialog closes. Defaults to `true`. */
|
|
20
|
+
restoreFocus?: boolean | string;
|
|
21
|
+
/** Panel width. */
|
|
22
|
+
width?: string;
|
|
23
|
+
/** Maximum panel width. */
|
|
24
|
+
maxWidth?: string;
|
|
25
|
+
/** Panel height. */
|
|
26
|
+
height?: string;
|
|
27
|
+
/** When `true`, clicking the backdrop or pressing Escape won't close the dialog. */
|
|
28
|
+
disableClose?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reference returned by `KjDialogService.open()`.
|
|
33
|
+
* Use `close(result?)` to dismiss the dialog and emit the result.
|
|
34
|
+
*/
|
|
35
|
+
export class KjDialogRef<R = unknown> {
|
|
36
|
+
private readonly _closed = signal<R | undefined>(undefined);
|
|
37
|
+
private _closedCallbacks: Array<(result: R | undefined) => void> = [];
|
|
38
|
+
|
|
39
|
+
/** @internal */
|
|
40
|
+
constructor(
|
|
41
|
+
private readonly container: HTMLElement,
|
|
42
|
+
private readonly viewRef: EmbeddedViewRef<unknown> | ComponentRef<unknown>,
|
|
43
|
+
private readonly appRef: ApplicationRef,
|
|
44
|
+
private readonly previouslyFocused: Element | null,
|
|
45
|
+
private readonly restoreFocus: boolean,
|
|
46
|
+
) {}
|
|
47
|
+
|
|
48
|
+
/** Closes the dialog and emits `result` to subscribers. */
|
|
49
|
+
close(result?: R): void {
|
|
50
|
+
this.appRef.detachView(
|
|
51
|
+
this.viewRef instanceof ComponentRef
|
|
52
|
+
? this.viewRef.hostView as EmbeddedViewRef<unknown>
|
|
53
|
+
: this.viewRef,
|
|
54
|
+
);
|
|
55
|
+
this.viewRef.destroy();
|
|
56
|
+
this.container.remove();
|
|
57
|
+
|
|
58
|
+
if (this.restoreFocus && this.previouslyFocused instanceof HTMLElement) {
|
|
59
|
+
this.previouslyFocused.focus();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this._closedCallbacks.forEach(cb => cb(result));
|
|
63
|
+
this._closedCallbacks = [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Registers a callback invoked when the dialog closes.
|
|
68
|
+
* @param cb - Callback receiving the close result.
|
|
69
|
+
*/
|
|
70
|
+
afterClosed(cb: (result: R | undefined) => void): this {
|
|
71
|
+
this._closedCallbacks.push(cb);
|
|
72
|
+
return this;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Programmatic service for opening dialogs with components or templates.
|
|
78
|
+
* The opened component can inject `DIALOG_DATA` for typed data and `KjDialogRef` to close itself.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```ts
|
|
82
|
+
* // Open a component as a dialog
|
|
83
|
+
* private readonly dialog = inject(KjDialogService);
|
|
84
|
+
*
|
|
85
|
+
* openConfirm() {
|
|
86
|
+
* const ref = this.dialog.open(ConfirmDialogComponent, {
|
|
87
|
+
* data: { message: 'Delete this item?' },
|
|
88
|
+
* });
|
|
89
|
+
* ref.afterClosed(result => {
|
|
90
|
+
* if (result) this.doDelete();
|
|
91
|
+
* });
|
|
92
|
+
* }
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* ```ts
|
|
96
|
+
* // Inside ConfirmDialogComponent
|
|
97
|
+
* readonly data = inject<{ message: string }>(DIALOG_DATA);
|
|
98
|
+
* readonly ref = inject(KjDialogRef);
|
|
99
|
+
* confirm() { this.ref.close(true); }
|
|
100
|
+
* cancel() { this.ref.close(false); }
|
|
101
|
+
* ```
|
|
102
|
+
* @category Core/Overlays
|
|
103
|
+
*/
|
|
104
|
+
@Injectable({ providedIn: 'root' })
|
|
105
|
+
export class KjDialogService {
|
|
106
|
+
private readonly appRef = inject(ApplicationRef);
|
|
107
|
+
private readonly injector = inject(Injector);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Opens a component as a modal dialog.
|
|
111
|
+
* @param content - Angular component type to render inside the dialog.
|
|
112
|
+
* @param config - Optional configuration: data, panelClass, width, autoFocus, etc.
|
|
113
|
+
* @returns A `KjDialogRef` whose `afterClosed()` fires when the dialog closes.
|
|
114
|
+
*/
|
|
115
|
+
open<R = unknown, D = unknown, C = unknown>(
|
|
116
|
+
content: Type<C> | TemplateRef<C>,
|
|
117
|
+
config?: KjDialogOpenConfig<D>,
|
|
118
|
+
): KjDialogRef<R> {
|
|
119
|
+
const container = document.createElement('div');
|
|
120
|
+
container.setAttribute('data-kj-dialog-container', '');
|
|
121
|
+
|
|
122
|
+
const previouslyFocused = document.activeElement;
|
|
123
|
+
const restoreFocus = config?.restoreFocus !== false;
|
|
124
|
+
|
|
125
|
+
const dialogRef = new KjDialogRef<R>(
|
|
126
|
+
container,
|
|
127
|
+
null as never, // filled below
|
|
128
|
+
this.appRef,
|
|
129
|
+
previouslyFocused,
|
|
130
|
+
restoreFocus,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const childInjector = Injector.create({
|
|
134
|
+
parent: this.injector,
|
|
135
|
+
providers: [
|
|
136
|
+
{ provide: DIALOG_DATA, useValue: config?.data },
|
|
137
|
+
{ provide: KjDialogRef, useValue: dialogRef },
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
let viewRef: EmbeddedViewRef<unknown> | ComponentRef<unknown>;
|
|
142
|
+
|
|
143
|
+
if (content instanceof TemplateRef) {
|
|
144
|
+
const vcr = this.appRef.components[0]?.injector.get(ViewContainerRef, null);
|
|
145
|
+
if (!vcr) throw new Error('KjDialogService: no root ViewContainerRef available');
|
|
146
|
+
viewRef = vcr.createEmbeddedView(content as TemplateRef<unknown>, {}, { injector: childInjector });
|
|
147
|
+
this.appRef.attachView(viewRef as EmbeddedViewRef<unknown>);
|
|
148
|
+
(viewRef as EmbeddedViewRef<unknown>).rootNodes.forEach(n => container.appendChild(n));
|
|
149
|
+
} else {
|
|
150
|
+
const compRef = createComponent(content as Type<unknown>, {
|
|
151
|
+
environmentInjector: this.appRef.injector,
|
|
152
|
+
elementInjector: childInjector,
|
|
153
|
+
});
|
|
154
|
+
this.appRef.attachView(compRef.hostView);
|
|
155
|
+
container.appendChild((compRef.hostView as EmbeddedViewRef<unknown>).rootNodes[0]);
|
|
156
|
+
viewRef = compRef;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Patch the viewRef into the dialogRef (bypass readonly via cast)
|
|
160
|
+
(dialogRef as unknown as { viewRef: typeof viewRef }).viewRef = viewRef;
|
|
161
|
+
|
|
162
|
+
document.body.appendChild(container);
|
|
163
|
+
|
|
164
|
+
// Auto-focus first tabbable element
|
|
165
|
+
if (config?.autoFocus !== false) {
|
|
166
|
+
const target = typeof config?.autoFocus === 'string'
|
|
167
|
+
? container.querySelector<HTMLElement>(config.autoFocus)
|
|
168
|
+
: container.querySelector<HTMLElement>(
|
|
169
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
170
|
+
);
|
|
171
|
+
target?.focus();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return dialogRef;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { fireEvent } from '@testing-library/angular';
|
|
2
|
+
import { render } from '@testing-library/angular';
|
|
3
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
4
|
+
import {
|
|
5
|
+
KjDialogTrigger, KjDialog, KjDialogOverlay,
|
|
6
|
+
KjDialogTitle, KjDialogClose,
|
|
7
|
+
} from './dialog';
|
|
8
|
+
|
|
9
|
+
expect.extend(toHaveNoViolations);
|
|
10
|
+
|
|
11
|
+
const imports = [
|
|
12
|
+
KjDialogTrigger, KjDialog, KjDialogOverlay,
|
|
13
|
+
KjDialogTitle, KjDialogClose,
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
describe('KjDialogTrigger', () => {
|
|
17
|
+
it('renders trigger button', async () => {
|
|
18
|
+
const { getByRole } = await render(
|
|
19
|
+
`<button [kjDialogTrigger]="dlg">Open</button>
|
|
20
|
+
<ng-template #dlg>
|
|
21
|
+
<div kjDialogOverlay><div kjDialog><h2 kjDialogTitle>T</h2></div></div>
|
|
22
|
+
</ng-template>`,
|
|
23
|
+
{ imports },
|
|
24
|
+
);
|
|
25
|
+
expect(getByRole('button', { name: 'Open' })).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('trigger has aria-haspopup=dialog', async () => {
|
|
29
|
+
const { getByRole } = await render(
|
|
30
|
+
`<button [kjDialogTrigger]="dlg">Open</button>
|
|
31
|
+
<ng-template #dlg>
|
|
32
|
+
<div kjDialogOverlay><div kjDialog><h2 kjDialogTitle>T</h2></div></div>
|
|
33
|
+
</ng-template>`,
|
|
34
|
+
{ imports },
|
|
35
|
+
);
|
|
36
|
+
expect(getByRole('button', { name: 'Open' })).toHaveAttribute('aria-haspopup', 'dialog');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('trigger has aria-expanded=false initially', async () => {
|
|
40
|
+
const { getByRole } = await render(
|
|
41
|
+
`<button [kjDialogTrigger]="dlg">Open</button>
|
|
42
|
+
<ng-template #dlg>
|
|
43
|
+
<div kjDialogOverlay><div kjDialog><h2 kjDialogTitle>T</h2></div></div>
|
|
44
|
+
</ng-template>`,
|
|
45
|
+
{ imports },
|
|
46
|
+
);
|
|
47
|
+
expect(getByRole('button', { name: 'Open' })).toHaveAttribute('aria-expanded', 'false');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('opens dialog and sets aria-expanded=true on click', async () => {
|
|
51
|
+
const { getByRole } = await render(
|
|
52
|
+
`<button [kjDialogTrigger]="dlg">Open</button>
|
|
53
|
+
<ng-template #dlg>
|
|
54
|
+
<div kjDialogOverlay><div kjDialog><h2 kjDialogTitle>Settings</h2></div></div>
|
|
55
|
+
</ng-template>`,
|
|
56
|
+
{ imports },
|
|
57
|
+
);
|
|
58
|
+
const trigger = getByRole('button', { name: 'Open' });
|
|
59
|
+
fireEvent.click(trigger);
|
|
60
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('mounts dialog content in document.body on open', async () => {
|
|
64
|
+
const { getByRole } = await render(
|
|
65
|
+
`<button [kjDialogTrigger]="dlg">Open</button>
|
|
66
|
+
<ng-template #dlg>
|
|
67
|
+
<div kjDialogOverlay>
|
|
68
|
+
<div kjDialog><h2 kjDialogTitle>My Dialog</h2></div>
|
|
69
|
+
</div>
|
|
70
|
+
</ng-template>`,
|
|
71
|
+
{ imports },
|
|
72
|
+
);
|
|
73
|
+
fireEvent.click(getByRole('button', { name: 'Open' }));
|
|
74
|
+
expect(document.body.querySelector('[data-kj-dialog-container]')).toBeTruthy();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('closes dialog on overlay click (close on backdrop enabled by default)', async () => {
|
|
78
|
+
const { getByRole, getByText } = await render(
|
|
79
|
+
`<button [kjDialogTrigger]="dlg">Open</button>
|
|
80
|
+
<ng-template #dlg>
|
|
81
|
+
<div kjDialogOverlay>
|
|
82
|
+
<div kjDialog><h2 kjDialogTitle>T</h2></div>
|
|
83
|
+
</div>
|
|
84
|
+
</ng-template>`,
|
|
85
|
+
{ imports },
|
|
86
|
+
);
|
|
87
|
+
const trigger = getByRole('button', { name: 'Open' });
|
|
88
|
+
fireEvent.click(trigger);
|
|
89
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
90
|
+
|
|
91
|
+
const overlay = document.querySelector('[kjDialogOverlay]') ?? document.body.querySelector('div[data-kj-dialog-container] > div');
|
|
92
|
+
if (overlay) fireEvent.click(overlay);
|
|
93
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('closes dialog on Escape key when closeOnEscape is true', async () => {
|
|
97
|
+
const { getByRole } = await render(
|
|
98
|
+
`<button [kjDialogTrigger]="dlg">Open</button>
|
|
99
|
+
<ng-template #dlg>
|
|
100
|
+
<div kjDialogOverlay><div kjDialog><h2 kjDialogTitle>T</h2></div></div>
|
|
101
|
+
</ng-template>`,
|
|
102
|
+
{ imports },
|
|
103
|
+
);
|
|
104
|
+
const trigger = getByRole('button', { name: 'Open' });
|
|
105
|
+
fireEvent.click(trigger);
|
|
106
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true');
|
|
107
|
+
|
|
108
|
+
fireEvent.keyDown(document, { key: 'Escape', code: 'Escape' });
|
|
109
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('passes axe audit on trigger', async () => {
|
|
113
|
+
const { container } = await render(
|
|
114
|
+
`<button [kjDialogTrigger]="dlg">Open dialog</button>
|
|
115
|
+
<ng-template #dlg>
|
|
116
|
+
<div kjDialogOverlay><div kjDialog><h2 kjDialogTitle>Settings</h2></div></div>
|
|
117
|
+
</ng-template>`,
|
|
118
|
+
{ imports },
|
|
119
|
+
);
|
|
120
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
121
|
+
});
|
|
122
|
+
});
|