@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,201 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ApplicationRef, Directive, DestroyRef, EmbeddedViewRef,
|
|
3
|
+
HostListener, Injector, TemplateRef,
|
|
4
|
+
computed, inject, input, output, signal,
|
|
5
|
+
PLATFORM_ID,
|
|
6
|
+
} from '@angular/core';
|
|
7
|
+
import { isPlatformBrowser } from '@angular/common';
|
|
8
|
+
import { KJ_DIALOG, KjDialogContext } from './dialog.context';
|
|
9
|
+
|
|
10
|
+
let _dialogIdCounter = 0;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Trigger that opens the dialog. Place on any button or interactive element.
|
|
14
|
+
* Takes a `TemplateRef` input that defines the full overlay structure.
|
|
15
|
+
*
|
|
16
|
+
* The template should contain `[kjDialogOverlay]` (backdrop) wrapping `[kjDialog]` (panel).
|
|
17
|
+
* Inside the panel use `[kjDialogTitle]` and `[kjDialogClose]`.
|
|
18
|
+
* Export `kjDialog` as a template ref (`#dlg="kjDialog"`) to call `dlg.close(result?)`.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```html
|
|
22
|
+
* <button kjButton [kjDialogTrigger]="myDialog" (kjDialogClosed)="onResult($event)">Open</button>
|
|
23
|
+
*
|
|
24
|
+
* <ng-template #myDialog>
|
|
25
|
+
* <div kjDialogOverlay>
|
|
26
|
+
* <div kjDialog #dlg="kjDialog">
|
|
27
|
+
* <h2 kjDialogTitle>Title</h2>
|
|
28
|
+
* <button kjDialogClose>Cancel</button>
|
|
29
|
+
* <button (click)="dlg.close('saved')">Save</button>
|
|
30
|
+
* </div>
|
|
31
|
+
* </div>
|
|
32
|
+
* </ng-template>
|
|
33
|
+
* ```
|
|
34
|
+
* @doc
|
|
35
|
+
* @doc-example Basic
|
|
36
|
+
* @doc-theme default
|
|
37
|
+
* @doc-file dialog.example.ts
|
|
38
|
+
* @doc-theme retro
|
|
39
|
+
* @doc-file dialog.retro.example.ts
|
|
40
|
+
* @doc-theme finance
|
|
41
|
+
* @doc-file dialog.finance.example.ts
|
|
42
|
+
* @doc-example Confirmation
|
|
43
|
+
* @doc-file dialog.confirm.example.ts
|
|
44
|
+
* @category Core/Overlays
|
|
45
|
+
*/
|
|
46
|
+
@Directive({
|
|
47
|
+
selector: '[kjDialogTrigger]',
|
|
48
|
+
standalone: true,
|
|
49
|
+
providers: [{ provide: KJ_DIALOG, useExisting: KjDialogTrigger }],
|
|
50
|
+
host: {
|
|
51
|
+
'[attr.aria-haspopup]': '"dialog"',
|
|
52
|
+
'[attr.aria-expanded]': 'open().toString()',
|
|
53
|
+
'(click)': 'openDialog()',
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
export class KjDialogTrigger implements KjDialogContext {
|
|
57
|
+
private readonly appRef = inject(ApplicationRef);
|
|
58
|
+
private readonly injector = inject(Injector);
|
|
59
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
60
|
+
private readonly platformId = inject(PLATFORM_ID);
|
|
61
|
+
|
|
62
|
+
readonly kjDialogTrigger = input.required<TemplateRef<unknown>>();
|
|
63
|
+
readonly kjDialogCloseOnEscape = input<boolean>(true);
|
|
64
|
+
readonly kjDialogCloseOnBackdrop = input<boolean>(true);
|
|
65
|
+
readonly kjDialogClosed = output<unknown>();
|
|
66
|
+
|
|
67
|
+
readonly dialogId = `kj-dialog-${++_dialogIdCounter}`;
|
|
68
|
+
|
|
69
|
+
private readonly _open = signal(false);
|
|
70
|
+
readonly open = this._open.asReadonly();
|
|
71
|
+
readonly closeOnEscape = computed(() => this.kjDialogCloseOnEscape());
|
|
72
|
+
readonly closeOnBackdrop = computed(() => this.kjDialogCloseOnBackdrop());
|
|
73
|
+
|
|
74
|
+
private viewRef?: EmbeddedViewRef<unknown>;
|
|
75
|
+
private containerEl?: HTMLElement;
|
|
76
|
+
|
|
77
|
+
constructor() {
|
|
78
|
+
this.destroyRef.onDestroy(() => this.close());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
openDialog(): void {
|
|
82
|
+
if (this._open() || !isPlatformBrowser(this.platformId)) return;
|
|
83
|
+
|
|
84
|
+
// Use TemplateRef.createEmbeddedView directly (not vcr.createEmbeddedView) so
|
|
85
|
+
// the view is NOT pre-attached to any ViewContainer — ApplicationRef can own it.
|
|
86
|
+
// Pass the trigger's injector so KJ_DIALOG is resolvable within the template.
|
|
87
|
+
this.viewRef = this.kjDialogTrigger().createEmbeddedView(undefined as never, this.injector);
|
|
88
|
+
this.appRef.attachView(this.viewRef);
|
|
89
|
+
|
|
90
|
+
// Mount in a fixed container appended to body
|
|
91
|
+
this.containerEl = document.createElement('div');
|
|
92
|
+
this.containerEl.setAttribute('data-kj-dialog-container', '');
|
|
93
|
+
this.viewRef.rootNodes.forEach(node => this.containerEl!.appendChild(node));
|
|
94
|
+
document.body.appendChild(this.containerEl);
|
|
95
|
+
|
|
96
|
+
this._open.set(true);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
close(result?: unknown): void {
|
|
100
|
+
if (!this._open()) return;
|
|
101
|
+
this._open.set(false);
|
|
102
|
+
|
|
103
|
+
if (this.viewRef) {
|
|
104
|
+
this.appRef.detachView(this.viewRef);
|
|
105
|
+
this.viewRef.destroy();
|
|
106
|
+
this.viewRef = undefined;
|
|
107
|
+
}
|
|
108
|
+
this.containerEl?.remove();
|
|
109
|
+
this.containerEl = undefined;
|
|
110
|
+
|
|
111
|
+
this.kjDialogClosed.emit(result);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@HostListener('document:keydown.escape')
|
|
115
|
+
onEscape(): void {
|
|
116
|
+
if (this._open() && this.closeOnEscape()) this.close();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Panel container for the dialog. Place inside the template provided to `[kjDialogTrigger]`.
|
|
122
|
+
* Auto-sets `role="dialog"`, `aria-modal`, and `aria-labelledby` (requires `[kjDialogTitle]` inside).
|
|
123
|
+
* Export as `#dlg="kjDialog"` to call `dlg.close(result?)` from event handlers.
|
|
124
|
+
*
|
|
125
|
+
* @category Core/Overlays
|
|
126
|
+
*/
|
|
127
|
+
@Directive({
|
|
128
|
+
selector: '[kjDialog]',
|
|
129
|
+
standalone: true,
|
|
130
|
+
exportAs: 'kjDialog',
|
|
131
|
+
host: {
|
|
132
|
+
'[attr.role]': '"dialog"',
|
|
133
|
+
'[attr.aria-modal]': '"true"',
|
|
134
|
+
'[attr.aria-labelledby]': 'ctx.dialogId + "-title"',
|
|
135
|
+
'(click)': '$event.stopPropagation()',
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
export class KjDialog {
|
|
139
|
+
readonly ctx = inject(KJ_DIALOG);
|
|
140
|
+
|
|
141
|
+
close(result?: unknown): void {
|
|
142
|
+
this.ctx.close(result);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Backdrop/overlay element. Place inside the template wrapping `[kjDialog]`.
|
|
148
|
+
* Closes the dialog on click when `[kjDialogOverlayCloseOnClick]` is true (default).
|
|
149
|
+
*
|
|
150
|
+
* @category Core/Overlays
|
|
151
|
+
*/
|
|
152
|
+
@Directive({
|
|
153
|
+
selector: '[kjDialogOverlay]',
|
|
154
|
+
standalone: true,
|
|
155
|
+
host: {
|
|
156
|
+
'(click)': 'onOverlayClick()',
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
export class KjDialogOverlay {
|
|
160
|
+
private readonly ctx = inject(KJ_DIALOG);
|
|
161
|
+
readonly kjDialogOverlayCloseOnClick = input<boolean>(true);
|
|
162
|
+
|
|
163
|
+
onOverlayClick(): void {
|
|
164
|
+
if (this.ctx.closeOnBackdrop() && this.kjDialogOverlayCloseOnClick()) {
|
|
165
|
+
this.ctx.close();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Marks the dialog title. Sets `id` for `aria-labelledby` wiring on `[kjDialog]`.
|
|
172
|
+
*
|
|
173
|
+
* @category Core/Overlays
|
|
174
|
+
*/
|
|
175
|
+
@Directive({
|
|
176
|
+
selector: '[kjDialogTitle]',
|
|
177
|
+
standalone: true,
|
|
178
|
+
host: {
|
|
179
|
+
'[attr.id]': 'ctx.dialogId + "-title"',
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
export class KjDialogTitle {
|
|
183
|
+
readonly ctx = inject(KJ_DIALOG);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Closes the dialog on click without a result value.
|
|
188
|
+
* For closing with a result, use `#dlg="kjDialog"` then `(click)="dlg.close(value)"`.
|
|
189
|
+
*
|
|
190
|
+
* @category Core/Overlays
|
|
191
|
+
*/
|
|
192
|
+
@Directive({
|
|
193
|
+
selector: '[kjDialogClose]',
|
|
194
|
+
standalone: true,
|
|
195
|
+
host: {
|
|
196
|
+
'(click)': 'ctx.close()',
|
|
197
|
+
},
|
|
198
|
+
})
|
|
199
|
+
export class KjDialogClose {
|
|
200
|
+
readonly ctx = inject(KJ_DIALOG);
|
|
201
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export {
|
|
2
|
+
KjDialogTrigger,
|
|
3
|
+
KjDialog,
|
|
4
|
+
KjDialogOverlay,
|
|
5
|
+
KjDialogClose,
|
|
6
|
+
KjDialogTitle,
|
|
7
|
+
} from './dialog';
|
|
8
|
+
export { KJ_DIALOG, type KjDialogContext } from './dialog.context';
|
|
9
|
+
export { KjDialogService, KjDialogRef, DIALOG_DATA, type KjDialogOpenConfig } from './dialog.service';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// AOT-compiled example components for documentation.
|
|
2
|
+
// One entry per directive × theme combination.
|
|
3
|
+
|
|
4
|
+
// Button
|
|
5
|
+
export { ButtonExample } from './button/button.example';
|
|
6
|
+
export { ButtonRetroExample } from './button/button.retro.example';
|
|
7
|
+
export { ButtonFinanceExample } from './button/button.finance.example';
|
|
8
|
+
export { ButtonSizesExample } from './button/button.sizes.example';
|
|
9
|
+
|
|
10
|
+
// Dialog
|
|
11
|
+
export { DialogBasicExample } from './dialog/dialog.example';
|
|
12
|
+
export { DialogRetroExample } from './dialog/dialog.retro.example';
|
|
13
|
+
export { DialogFinanceExample } from './dialog/dialog.finance.example';
|
|
14
|
+
export { DialogConfirmExample } from './dialog/dialog.confirm.example';
|
|
15
|
+
|
|
16
|
+
// Tooltip
|
|
17
|
+
export { TooltipBasicExample } from './tooltip/tooltip.example';
|
|
18
|
+
export { TooltipRetroExample } from './tooltip/tooltip.retro.example';
|
|
19
|
+
export { TooltipFinanceExample } from './tooltip/tooltip.finance.example';
|
|
20
|
+
export { TooltipPlacementsExample } from './tooltip/tooltip.placements.example';
|
|
21
|
+
|
|
22
|
+
// Popover
|
|
23
|
+
export { PopoverBasicExample } from './popover/popover.example';
|
|
24
|
+
export { PopoverRetroExample } from './popover/popover.retro.example';
|
|
25
|
+
export { PopoverFinanceExample } from './popover/popover.finance.example';
|
|
26
|
+
|
|
27
|
+
// Menu
|
|
28
|
+
export { MenuBasicExample } from './menu/menu.example';
|
|
29
|
+
|
|
30
|
+
// Toast
|
|
31
|
+
export { ToastBasicExample } from './toast/toast.example';
|
|
32
|
+
export { ToastRetroExample } from './toast/toast.retro.example';
|
|
33
|
+
export { ToastFinanceExample } from './toast/toast.finance.example';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { render } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import { KjFormField, KjFormError, KjFormLabel } from './form';
|
|
4
|
+
|
|
5
|
+
expect.extend(toHaveNoViolations);
|
|
6
|
+
const imports = [KjFormField, KjFormError, KjFormLabel];
|
|
7
|
+
|
|
8
|
+
describe('KjFormField', () => {
|
|
9
|
+
it('sets data-invalid when invalid', async () => {
|
|
10
|
+
const { container } = await render(`<div kjFormField [kjFieldInvalid]="true"><label kjFormLabel for="f">Name</label><input id="f" /><span kjFormError>Required</span></div>`, { imports });
|
|
11
|
+
expect(container.querySelector('[kjFormField]')).toHaveAttribute('data-invalid', '');
|
|
12
|
+
});
|
|
13
|
+
it('error visible when invalid', async () => {
|
|
14
|
+
const { getByText } = await render(`<div kjFormField [kjFieldInvalid]="true"><label kjFormLabel for="f">Name</label><input id="f" /><span kjFormError>Required</span></div>`, { imports });
|
|
15
|
+
expect(getByText('Required')).not.toHaveAttribute('hidden');
|
|
16
|
+
});
|
|
17
|
+
it('error hidden when valid', async () => {
|
|
18
|
+
const { getByText } = await render(`<div kjFormField [kjFieldInvalid]="false"><label kjFormLabel for="f">Name</label><input id="f" /><span kjFormError>Required</span></div>`, { imports });
|
|
19
|
+
expect(getByText('Required').closest('[kjFormError]')).toHaveAttribute('hidden', '');
|
|
20
|
+
});
|
|
21
|
+
it('passes axe audit', async () => {
|
|
22
|
+
const { container } = await render(`<div kjFormField [kjFieldInvalid]="false"><label kjFormLabel for="e">Email</label><input id="e" type="email" /></div>`, { imports });
|
|
23
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/form/form.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Directive, computed, inject, input } from '@angular/core';
|
|
2
|
+
import { KJ_FORM_FIELD, KjFormFieldContext } from './form.context';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Container for a form field. Groups label, input, and error message.
|
|
6
|
+
* @example
|
|
7
|
+
* ```html
|
|
8
|
+
* <div kjFormField [kjFieldInvalid]="ctrl.invalid && ctrl.touched">
|
|
9
|
+
* <label kjFormLabel for="name">Name</label>
|
|
10
|
+
* <input id="name" kjInput />
|
|
11
|
+
* <span kjFormError>Name is required</span>
|
|
12
|
+
* </div>
|
|
13
|
+
* ```
|
|
14
|
+
* @category Core/Inputs
|
|
15
|
+
*/
|
|
16
|
+
@Directive({
|
|
17
|
+
selector: '[kjFormField]', standalone: true,
|
|
18
|
+
providers: [{ provide: KJ_FORM_FIELD, useExisting: KjFormField }],
|
|
19
|
+
host: { '[attr.data-invalid]': 'kjFieldInvalid() ? "" : null' },
|
|
20
|
+
})
|
|
21
|
+
export class KjFormField implements KjFormFieldContext {
|
|
22
|
+
/** Whether the field is in an invalid state. */
|
|
23
|
+
kjFieldInvalid = input<boolean>(false);
|
|
24
|
+
readonly invalid = computed(() => this.kjFieldInvalid());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Label element within a form field.
|
|
29
|
+
* @category Core/Inputs
|
|
30
|
+
*/
|
|
31
|
+
@Directive({
|
|
32
|
+
selector: '[kjFormLabel]', standalone: true,
|
|
33
|
+
host: { '[attr.data-invalid]': 'ctx.invalid() ? "" : null' },
|
|
34
|
+
})
|
|
35
|
+
export class KjFormLabel { readonly ctx = inject(KJ_FORM_FIELD); }
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Error message element. Visible only when field is invalid.
|
|
39
|
+
* @example `<span kjFormError>Email is invalid</span>`
|
|
40
|
+
* @category Core/Inputs
|
|
41
|
+
*/
|
|
42
|
+
@Directive({
|
|
43
|
+
selector: '[kjFormError]', standalone: true,
|
|
44
|
+
host: { role: 'alert', 'aria-live': 'polite', '[attr.hidden]': '!ctx.invalid() ? "" : null' },
|
|
45
|
+
})
|
|
46
|
+
export class KjFormError { readonly ctx = inject(KJ_FORM_FIELD); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KjInput } from './input';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { render } from '@testing-library/angular';
|
|
2
|
+
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
|
3
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
4
|
+
import { KjInput } from './input';
|
|
5
|
+
|
|
6
|
+
expect.extend(toHaveNoViolations);
|
|
7
|
+
|
|
8
|
+
describe('KjInput', () => {
|
|
9
|
+
it('renders without error', async () => {
|
|
10
|
+
const { container } = await render(`<input kjInput type="text" />`, { imports: [KjInput] });
|
|
11
|
+
expect(container.querySelector('input')).toBeInTheDocument();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('sets aria-invalid and data-invalid when touched + invalid', async () => {
|
|
15
|
+
const ctrl = new FormControl('', { updateOn: 'blur' });
|
|
16
|
+
const { container } = await render(
|
|
17
|
+
`<input kjInput [formControl]="ctrl" [kjInvalid]="ctrl.invalid" />`,
|
|
18
|
+
{ imports: [KjInput, ReactiveFormsModule], componentProperties: { ctrl } },
|
|
19
|
+
);
|
|
20
|
+
const input = container.querySelector('input')!;
|
|
21
|
+
input.dispatchEvent(new Event('blur'));
|
|
22
|
+
await new Promise(r => setTimeout(r, 0));
|
|
23
|
+
// touched is set, but ctrl is not invalid by default with no validators
|
|
24
|
+
expect(input).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('works with formControl binding', async () => {
|
|
28
|
+
const ctrl = new FormControl('hello');
|
|
29
|
+
const { container } = await render(
|
|
30
|
+
`<input kjInput [formControl]="ctrl" />`,
|
|
31
|
+
{ imports: [KjInput, ReactiveFormsModule], componentProperties: { ctrl } },
|
|
32
|
+
);
|
|
33
|
+
expect((container.querySelector('input') as HTMLInputElement).value).toBe('hello');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('passes axe audit', async () => {
|
|
37
|
+
const { container } = await render(
|
|
38
|
+
`<label for="n">Name</label><input id="n" kjInput type="text" />`,
|
|
39
|
+
{ imports: [KjInput] },
|
|
40
|
+
);
|
|
41
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Directive, ElementRef, effect, inject, input } from '@angular/core';
|
|
2
|
+
import { KjDisabled, KjFocusRing, KjFormControl } from '../primitives';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Enhances a native `<input>` with Angular forms integration, disabled/invalid state, and focus-ring.
|
|
6
|
+
* Supports `formControl`, `formControlName`, and `ngModel` bindings.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```html
|
|
10
|
+
* <input kjInput type="email" [formControl]="emailCtrl" [kjInvalid]="emailCtrl.invalid" />
|
|
11
|
+
* ```
|
|
12
|
+
* @doc
|
|
13
|
+
* @doc-file input.example.ts
|
|
14
|
+
* ```typescript
|
|
15
|
+
* import { Component } from '@angular/core';
|
|
16
|
+
* import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms';
|
|
17
|
+
* import { KjInput } from '@kouji-ui/core';
|
|
18
|
+
*
|
|
19
|
+
* @Component({
|
|
20
|
+
* standalone: true,
|
|
21
|
+
* imports: [KjInput, ReactiveFormsModule],
|
|
22
|
+
* styles: [`
|
|
23
|
+
* .form { padding: 2rem; background: #0c0c0c; display: flex; flex-direction: column; gap: 1rem; max-width: 400px; }
|
|
24
|
+
* [kjInput] { padding: 0.625rem 0.875rem; background: #111; border: 1px solid #222; color: #f0ede6; font-family: 'JetBrains Mono', monospace; font-size: 0.875rem; width: 100%; }
|
|
25
|
+
* [kjInput]:focus { outline: none; border-color: #b8f500; }
|
|
26
|
+
* [aria-invalid="true"] { border-color: #ef4444; }
|
|
27
|
+
* .error { color: #ef4444; font-size: 0.75rem; font-family: 'JetBrains Mono', monospace; }
|
|
28
|
+
* label { color: #888; font-size: 0.75rem; font-family: 'JetBrains Mono', monospace; }
|
|
29
|
+
* `],
|
|
30
|
+
* template: `
|
|
31
|
+
* <div class="form">
|
|
32
|
+
* <label for="email">Email</label>
|
|
33
|
+
* <input id="email" kjInput type="email" [formControl]="emailCtrl"
|
|
34
|
+
* [kjInvalid]="emailCtrl.invalid && emailCtrl.touched"
|
|
35
|
+
* placeholder="you@example.com" />
|
|
36
|
+
* @if (emailCtrl.invalid && emailCtrl.touched) {
|
|
37
|
+
* <span class="error">Please enter a valid email.</span>
|
|
38
|
+
* }
|
|
39
|
+
* <label for="disabled">Disabled input</label>
|
|
40
|
+
* <input id="disabled" kjInput type="text" [kjDisabled]="true" value="Not editable" />
|
|
41
|
+
* </div>
|
|
42
|
+
* `,
|
|
43
|
+
* })
|
|
44
|
+
* export class InputExampleComponent {
|
|
45
|
+
* emailCtrl = new FormControl('', [Validators.email, Validators.required]);
|
|
46
|
+
* }
|
|
47
|
+
* ```
|
|
48
|
+
* @category Core/Inputs
|
|
49
|
+
*/
|
|
50
|
+
@Directive({
|
|
51
|
+
selector: '[kjInput]',
|
|
52
|
+
standalone: true,
|
|
53
|
+
hostDirectives: [
|
|
54
|
+
{ directive: KjDisabled, inputs: ['kjDisabled'] },
|
|
55
|
+
KjFocusRing,
|
|
56
|
+
KjFormControl,
|
|
57
|
+
],
|
|
58
|
+
host: {
|
|
59
|
+
'[attr.aria-invalid]': 'formCtrl.touched() && kjInvalid() ? "true" : null',
|
|
60
|
+
'[attr.data-invalid]': 'formCtrl.touched() && kjInvalid() ? "" : null',
|
|
61
|
+
'[attr.disabled]': 'formCtrl.disabled() ? "" : null',
|
|
62
|
+
'(input)': 'formCtrl.notifyChange($any($event.target).value)',
|
|
63
|
+
'(blur)': 'formCtrl.notifyTouched()',
|
|
64
|
+
},
|
|
65
|
+
})
|
|
66
|
+
export class KjInput {
|
|
67
|
+
readonly formCtrl = inject(KjFormControl);
|
|
68
|
+
private readonly el = inject<ElementRef<HTMLInputElement>>(ElementRef);
|
|
69
|
+
|
|
70
|
+
/** Whether the input is in an invalid state. Combined with `touched` for ARIA. */
|
|
71
|
+
kjInvalid = input<boolean>(false);
|
|
72
|
+
|
|
73
|
+
constructor() {
|
|
74
|
+
// Reflect the CVA value signal back to the native input element.
|
|
75
|
+
effect(() => {
|
|
76
|
+
const val = this.formCtrl.value();
|
|
77
|
+
this.el.nativeElement.value = val == null ? '' : String(val);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { InjectionToken, Signal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/** Context shared between menu directives. */
|
|
4
|
+
export interface KjMenuContext {
|
|
5
|
+
readonly open: Signal<boolean>;
|
|
6
|
+
toggle(): void;
|
|
7
|
+
close(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const KJ_MENU = new InjectionToken<KjMenuContext>('KjMenu');
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { KjMenu, KjMenuTrigger, KjMenuContent, KjMenuItem } from './menu';
|
|
3
|
+
import { KjButton } from '../button/button';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'kj-example-menu-basic',
|
|
7
|
+
standalone: true,
|
|
8
|
+
imports: [KjMenu, KjMenuTrigger, KjMenuContent, KjMenuItem, KjButton],
|
|
9
|
+
styles: [`
|
|
10
|
+
:host { display: flex; align-items: flex-start; justify-content: center; padding: 3rem 2rem 10rem; background: #0c0c0c; font-family: 'JetBrains Mono', monospace; min-height: 220px; }
|
|
11
|
+
.anchor { position: relative; display: inline-block; }
|
|
12
|
+
button[kjButton] { padding: 0.5rem 1.25rem; border: 1px solid #444; background: transparent; color: #f0ede6; cursor: pointer; font-family: inherit; font-size: 0.875rem; }
|
|
13
|
+
button[kjButton]:hover { border-color: #b8f500; color: #b8f500; }
|
|
14
|
+
[kjMenuContent] {
|
|
15
|
+
position: absolute; top: calc(100% + 4px); left: 0; z-index: 20;
|
|
16
|
+
background: #1a1a1a; border: 1px solid #333; padding: 0.25rem 0; min-width: 10rem;
|
|
17
|
+
display: flex; flex-direction: column;
|
|
18
|
+
}
|
|
19
|
+
[kjMenuContent][hidden] { display: none; }
|
|
20
|
+
[kjMenuItem] {
|
|
21
|
+
background: transparent; border: none; color: #f0ede6; font-family: inherit;
|
|
22
|
+
font-size: 0.875rem; padding: 0.5rem 1rem; text-align: left; cursor: pointer; width: 100%;
|
|
23
|
+
}
|
|
24
|
+
[kjMenuItem]:hover, [kjMenuItem]:focus { background: #252525; color: #b8f500; outline: none; }
|
|
25
|
+
[kjMenuItem][disabled] { color: #555; cursor: not-allowed; }
|
|
26
|
+
`],
|
|
27
|
+
template: `
|
|
28
|
+
<div class="anchor" kjMenu>
|
|
29
|
+
<button kjButton kjMenuTrigger>Actions</button>
|
|
30
|
+
<div kjMenuContent>
|
|
31
|
+
<button kjMenuItem>Edit</button>
|
|
32
|
+
<button kjMenuItem>Duplicate</button>
|
|
33
|
+
<button kjMenuItem>Archive</button>
|
|
34
|
+
<button kjMenuItem disabled>Delete</button>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
`,
|
|
38
|
+
})
|
|
39
|
+
export class MenuBasicExample {}
|