@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,34 @@
|
|
|
1
|
+
import { render } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import { KjVisuallyHidden } from './visually-hidden';
|
|
4
|
+
|
|
5
|
+
expect.extend(toHaveNoViolations);
|
|
6
|
+
|
|
7
|
+
describe('KjVisuallyHidden', () => {
|
|
8
|
+
it('applies visually-hidden inline styles', async () => {
|
|
9
|
+
const { container } = await render(
|
|
10
|
+
`<span kjVisuallyHidden>Screen reader only</span>`,
|
|
11
|
+
{ imports: [KjVisuallyHidden] },
|
|
12
|
+
);
|
|
13
|
+
const el = container.querySelector('span')!;
|
|
14
|
+
const style = el.getAttribute('style') ?? '';
|
|
15
|
+
expect(style).toContain('position');
|
|
16
|
+
expect(style).toContain('absolute');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('content remains in the DOM for screen readers', async () => {
|
|
20
|
+
const { getByText } = await render(
|
|
21
|
+
`<span kjVisuallyHidden>Screen reader only</span>`,
|
|
22
|
+
{ imports: [KjVisuallyHidden] },
|
|
23
|
+
);
|
|
24
|
+
expect(getByText('Screen reader only')).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('passes axe accessibility audit', async () => {
|
|
28
|
+
const { container } = await render(
|
|
29
|
+
`<button><span kjVisuallyHidden>Close dialog</span></button>`,
|
|
30
|
+
{ imports: [KjVisuallyHidden] },
|
|
31
|
+
);
|
|
32
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Directive } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Visually hides an element while keeping it accessible to screen readers.
|
|
5
|
+
* Uses the standard visually-hidden CSS technique via inline styles.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```html
|
|
9
|
+
* <button>
|
|
10
|
+
* <kj-icon name="close" />
|
|
11
|
+
* <span kjVisuallyHidden>Close dialog</span>
|
|
12
|
+
* </button>
|
|
13
|
+
* ```
|
|
14
|
+
* @category Core/Accessibility
|
|
15
|
+
*/
|
|
16
|
+
@Directive({
|
|
17
|
+
selector: '[kjVisuallyHidden]',
|
|
18
|
+
standalone: true,
|
|
19
|
+
host: {
|
|
20
|
+
style:
|
|
21
|
+
'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0',
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
export class KjVisuallyHidden {}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { InjectionToken, Signal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/** Context interface for the root accordion directive. */
|
|
4
|
+
export interface KjAccordionContext {
|
|
5
|
+
readonly openIds: Signal<ReadonlySet<string>>;
|
|
6
|
+
toggle(id: string): void;
|
|
7
|
+
isOpen(id: string): boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Context interface for an individual accordion item directive. */
|
|
11
|
+
export interface KjAccordionItemContext {
|
|
12
|
+
readonly expanded: Signal<boolean>;
|
|
13
|
+
toggle(): void;
|
|
14
|
+
open(): void;
|
|
15
|
+
close(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Injection token for the root accordion directive. */
|
|
19
|
+
export const KJ_ACCORDION = new InjectionToken<KjAccordionContext>('KjAccordion');
|
|
20
|
+
|
|
21
|
+
/** Injection token for an individual accordion item directive. */
|
|
22
|
+
export const KJ_ACCORDION_ITEM = new InjectionToken<KjAccordionItemContext>('KjAccordionItem');
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { render, fireEvent } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import { KjAccordion, KjAccordionItem, KjAccordionTrigger, KjAccordionContent } from './accordion';
|
|
4
|
+
|
|
5
|
+
expect.extend(toHaveNoViolations);
|
|
6
|
+
const imports = [KjAccordion, KjAccordionItem, KjAccordionTrigger, KjAccordionContent];
|
|
7
|
+
const template = `<div kjAccordion><div kjAccordionItem [kjItemValue]="'i1'"><button kjAccordionTrigger>Section</button><div kjAccordionContent>Content</div></div></div>`;
|
|
8
|
+
|
|
9
|
+
describe('KjAccordion', () => {
|
|
10
|
+
it('content hidden by default', async () => {
|
|
11
|
+
const { container } = await render(template, { imports });
|
|
12
|
+
expect(container.querySelector('[kjAccordionContent]')).toHaveAttribute('hidden', '');
|
|
13
|
+
});
|
|
14
|
+
it('click trigger expands', async () => {
|
|
15
|
+
const { container } = await render(template, { imports });
|
|
16
|
+
fireEvent.click(container.querySelector('[kjAccordionTrigger]')!);
|
|
17
|
+
expect(container.querySelector('[kjAccordionContent]')).not.toHaveAttribute('hidden');
|
|
18
|
+
});
|
|
19
|
+
it('trigger has aria-expanded=false when closed', async () => {
|
|
20
|
+
const { container } = await render(template, { imports });
|
|
21
|
+
expect(container.querySelector('[kjAccordionTrigger]')).toHaveAttribute('aria-expanded', 'false');
|
|
22
|
+
});
|
|
23
|
+
it('trigger has aria-expanded=true when open', async () => {
|
|
24
|
+
const { container } = await render(template, { imports });
|
|
25
|
+
fireEvent.click(container.querySelector('[kjAccordionTrigger]')!);
|
|
26
|
+
expect(container.querySelector('[kjAccordionTrigger]')).toHaveAttribute('aria-expanded', 'true');
|
|
27
|
+
});
|
|
28
|
+
it('passes axe audit', async () => {
|
|
29
|
+
const { container } = await render(template, { imports });
|
|
30
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Directive, computed, inject, input, signal } from '@angular/core';
|
|
2
|
+
import { KJ_ACCORDION, KJ_ACCORDION_ITEM } from './accordion.context';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Root accordion container. Manages open/close state for accordion items.
|
|
6
|
+
* Supports `'single'` (only one item open) or `'multiple'` (any number open).
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```html
|
|
10
|
+
* <div kjAccordion [kjAccordionType]="'multiple'">
|
|
11
|
+
* <div kjAccordionItem [kjItemValue]="'item-1'">...</div>
|
|
12
|
+
* </div>
|
|
13
|
+
* ```
|
|
14
|
+
* @category Core/Navigation
|
|
15
|
+
*/
|
|
16
|
+
@Directive({
|
|
17
|
+
selector: '[kjAccordion]',
|
|
18
|
+
standalone: true,
|
|
19
|
+
providers: [{ provide: KJ_ACCORDION, useExisting: KjAccordion }],
|
|
20
|
+
})
|
|
21
|
+
export class KjAccordion {
|
|
22
|
+
/** Whether multiple items can be open simultaneously. Defaults to `'single'`. */
|
|
23
|
+
readonly kjAccordionType = input<'single' | 'multiple'>('single');
|
|
24
|
+
|
|
25
|
+
private readonly _openIds = signal<ReadonlySet<string>>(new Set());
|
|
26
|
+
/** Set of currently open item values. */
|
|
27
|
+
readonly openIds = this._openIds.asReadonly();
|
|
28
|
+
|
|
29
|
+
/** Toggles an item open or closed. In single mode, closes all others first. */
|
|
30
|
+
toggle(id: string): void {
|
|
31
|
+
this._openIds.update(ids => {
|
|
32
|
+
const next = new Set(ids);
|
|
33
|
+
if (next.has(id)) {
|
|
34
|
+
next.delete(id);
|
|
35
|
+
} else {
|
|
36
|
+
if (this.kjAccordionType() === 'single') next.clear();
|
|
37
|
+
next.add(id);
|
|
38
|
+
}
|
|
39
|
+
return next;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Returns whether the given item value is currently open. */
|
|
44
|
+
isOpen(id: string): boolean {
|
|
45
|
+
return this._openIds().has(id);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Individual accordion item. Manages its own expanded state via the parent accordion context.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```html
|
|
54
|
+
* <div kjAccordionItem [kjItemValue]="'section-1'">
|
|
55
|
+
* <button kjAccordionTrigger>Section 1</button>
|
|
56
|
+
* <div kjAccordionContent>Content</div>
|
|
57
|
+
* </div>
|
|
58
|
+
* ```
|
|
59
|
+
* @category Core/Navigation
|
|
60
|
+
*/
|
|
61
|
+
@Directive({
|
|
62
|
+
selector: '[kjAccordionItem]',
|
|
63
|
+
standalone: true,
|
|
64
|
+
providers: [{ provide: KJ_ACCORDION_ITEM, useExisting: KjAccordionItem }],
|
|
65
|
+
})
|
|
66
|
+
export class KjAccordionItem {
|
|
67
|
+
private readonly accordion = inject(KJ_ACCORDION) as KjAccordion;
|
|
68
|
+
|
|
69
|
+
/** The unique value identifying this item within the accordion. */
|
|
70
|
+
readonly kjItemValue = input.required<string>();
|
|
71
|
+
|
|
72
|
+
/** Whether this item is currently expanded. */
|
|
73
|
+
readonly expanded = computed(() => this.accordion.isOpen(this.kjItemValue()));
|
|
74
|
+
|
|
75
|
+
/** Toggles this item's expanded state. */
|
|
76
|
+
toggle(): void { this.accordion.toggle(this.kjItemValue()); }
|
|
77
|
+
|
|
78
|
+
/** Opens this item. */
|
|
79
|
+
open(): void { if (!this.expanded()) this.toggle(); }
|
|
80
|
+
|
|
81
|
+
/** Closes this item. */
|
|
82
|
+
close(): void { if (this.expanded()) this.toggle(); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Trigger button for an accordion item. Controls expand/collapse.
|
|
87
|
+
* Sets `aria-expanded` and `data-open` attributes based on the item's expanded state.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```html
|
|
91
|
+
* <button kjAccordionTrigger>Section title</button>
|
|
92
|
+
* ```
|
|
93
|
+
* @category Core/Navigation
|
|
94
|
+
*/
|
|
95
|
+
@Directive({
|
|
96
|
+
selector: '[kjAccordionTrigger]',
|
|
97
|
+
standalone: true,
|
|
98
|
+
host: {
|
|
99
|
+
'[attr.aria-expanded]': 'item.expanded().toString()',
|
|
100
|
+
'[attr.data-open]': 'item.expanded() ? "" : null',
|
|
101
|
+
'(click)': 'item.toggle()',
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
export class KjAccordionTrigger {
|
|
105
|
+
/** The parent accordion item context. */
|
|
106
|
+
readonly item = inject(KJ_ACCORDION_ITEM);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Accordion content panel. Hidden when the parent item is collapsed.
|
|
111
|
+
* Uses the `hidden` attribute for accessibility-compliant visibility.
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```html
|
|
115
|
+
* <div kjAccordionContent>Panel content</div>
|
|
116
|
+
* ```
|
|
117
|
+
* @category Core/Navigation
|
|
118
|
+
*/
|
|
119
|
+
@Directive({
|
|
120
|
+
selector: '[kjAccordionContent]',
|
|
121
|
+
standalone: true,
|
|
122
|
+
host: {
|
|
123
|
+
'[attr.hidden]': '!item.expanded() ? "" : null',
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
export class KjAccordionContent {
|
|
127
|
+
/** The parent accordion item context. */
|
|
128
|
+
readonly item = inject(KJ_ACCORDION_ITEM);
|
|
129
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { render } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import { KjAvatar, KjAvatarImage, KjAvatarFallback } from './avatar';
|
|
4
|
+
|
|
5
|
+
expect.extend(toHaveNoViolations);
|
|
6
|
+
|
|
7
|
+
const imports = [KjAvatar, KjAvatarImage, KjAvatarFallback];
|
|
8
|
+
|
|
9
|
+
describe('KjAvatar', () => {
|
|
10
|
+
it('renders container', async () => {
|
|
11
|
+
const { container } = await render(`<span kjAvatar><img kjAvatarImage src="" alt="JD" /><span kjAvatarFallback>JD</span></span>`, { imports });
|
|
12
|
+
expect(container.querySelector('[kjAvatar]')).toBeInTheDocument();
|
|
13
|
+
});
|
|
14
|
+
it('passes axe audit', async () => {
|
|
15
|
+
const { container } = await render(`<span kjAvatar><img kjAvatarImage src="/p.jpg" alt="Jane Doe" /><span kjAvatarFallback aria-hidden="true">JD</span></span>`, { imports });
|
|
16
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Directive, InjectionToken, inject, signal } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
export interface KjAvatarContext { imageLoaded: ReturnType<typeof signal<boolean>>; }
|
|
4
|
+
export const KJ_AVATAR = new InjectionToken<KjAvatarContext>('KjAvatar');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Container for avatar. Tracks image load state.
|
|
8
|
+
* @example `<span kjAvatar><img kjAvatarImage src="..." alt="..." /><span kjAvatarFallback>JD</span></span>`
|
|
9
|
+
* @category Core/Display
|
|
10
|
+
*/
|
|
11
|
+
@Directive({ selector: '[kjAvatar]', standalone: true, providers: [{ provide: KJ_AVATAR, useExisting: KjAvatar }] })
|
|
12
|
+
export class KjAvatar implements KjAvatarContext { readonly imageLoaded = signal(false); }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Image element within a `[kjAvatar]` container.
|
|
16
|
+
* @category Core/Display
|
|
17
|
+
*/
|
|
18
|
+
@Directive({ selector: '[kjAvatarImage]', standalone: true, host: { '(load)': 'ctx.imageLoaded.set(true)', '(error)': 'ctx.imageLoaded.set(false)', '[attr.data-loaded]': 'ctx.imageLoaded() ? "" : null' } })
|
|
19
|
+
export class KjAvatarImage { readonly ctx = inject(KJ_AVATAR); }
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Fallback shown when image is unavailable.
|
|
23
|
+
* @category Core/Display
|
|
24
|
+
*/
|
|
25
|
+
@Directive({ selector: '[kjAvatarFallback]', standalone: true, host: { '[attr.data-visible]': '!ctx.imageLoaded() ? "" : null' } })
|
|
26
|
+
export class KjAvatarFallback { readonly ctx = inject(KJ_AVATAR); }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KjAvatar, KjAvatarImage, KjAvatarFallback, KJ_AVATAR } from './avatar';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { render } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import { KjBadge } from './badge';
|
|
4
|
+
|
|
5
|
+
expect.extend(toHaveNoViolations);
|
|
6
|
+
|
|
7
|
+
describe('KjBadge', () => {
|
|
8
|
+
it('sets data-variant attribute', async () => {
|
|
9
|
+
const { container } = await render(`<span kjBadge [kjBadgeVariant]="'destructive'">New</span>`, { imports: [KjBadge] });
|
|
10
|
+
expect(container.querySelector('span')).toHaveAttribute('data-variant', 'destructive');
|
|
11
|
+
});
|
|
12
|
+
it('defaults to default variant', async () => {
|
|
13
|
+
const { container } = await render(`<span kjBadge>Beta</span>`, { imports: [KjBadge] });
|
|
14
|
+
expect(container.querySelector('span')).toHaveAttribute('data-variant', 'default');
|
|
15
|
+
});
|
|
16
|
+
it('passes axe audit', async () => {
|
|
17
|
+
const { container } = await render(`<span kjBadge>Beta</span>`, { imports: [KjBadge] });
|
|
18
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Directive, input } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
export type KjBadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Marks an element as a badge via data attributes.
|
|
7
|
+
* @example `<span kjBadge [kjBadgeVariant]="'destructive'">Critical</span>`
|
|
8
|
+
* @category Core/Display
|
|
9
|
+
*/
|
|
10
|
+
@Directive({ selector: '[kjBadge]', standalone: true, host: { '[attr.data-variant]': 'kjBadgeVariant()' } })
|
|
11
|
+
export class KjBadge {
|
|
12
|
+
/** The visual variant of the badge. Defaults to `'default'`. */
|
|
13
|
+
kjBadgeVariant = input<KjBadgeVariant>('default');
|
|
14
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KjBadge, type KjBadgeVariant } from './badge';
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { KjButton } from './button';
|
|
3
|
+
|
|
4
|
+
@Component({
|
|
5
|
+
selector: 'kj-example-button',
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [KjButton],
|
|
8
|
+
styleUrls: ['../styles/docs-themes.css'],
|
|
9
|
+
styles: [`
|
|
10
|
+
:host { display: block; padding: 2rem; background: var(--kj-bg); font-family: var(--kj-font); }
|
|
11
|
+
.row { display: flex; gap: 1rem; flex-wrap: wrap; align-items: center; }
|
|
12
|
+
button[kjButton] { padding: 0.5rem 1.25rem; font-family: var(--kj-font); font-size: 0.875rem; border: var(--kj-btn-border); cursor: pointer; transition: var(--kj-transition); }
|
|
13
|
+
[data-variant="default"] { background: var(--kj-accent); color: var(--kj-accent-on); }
|
|
14
|
+
[data-variant="destructive"] { background: var(--kj-destructive); color: #fff; }
|
|
15
|
+
[data-variant="link"] { background: transparent; color: var(--kj-accent); border: none; padding: 0.5rem 0.5rem; text-decoration: none; text-underline-offset: 4px; }
|
|
16
|
+
[data-variant="link"]:hover { text-decoration: underline; }
|
|
17
|
+
[data-variant="ghost"] { background: transparent; color: var(--kj-btn-ghost-color); }
|
|
18
|
+
[aria-disabled="true"] { opacity: 0.4; cursor: not-allowed; }
|
|
19
|
+
`],
|
|
20
|
+
template: `
|
|
21
|
+
<div class="row">
|
|
22
|
+
<button kjButton [kjVariant]="'default'">Default</button>
|
|
23
|
+
<button kjButton [kjVariant]="'destructive'">Destructive</button>
|
|
24
|
+
<button kjButton [kjVariant]="'link'">Link</button>
|
|
25
|
+
<button kjButton [kjVariant]="'ghost'">Ghost</button>
|
|
26
|
+
<button kjButton [kjDisabled]="true">Disabled</button>
|
|
27
|
+
</div>
|
|
28
|
+
`,
|
|
29
|
+
})
|
|
30
|
+
export class ButtonExample {}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { KjButton } from './button';
|
|
3
|
+
|
|
4
|
+
@Component({
|
|
5
|
+
selector: 'kj-example-button-finance',
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [KjButton],
|
|
8
|
+
styleUrls: ['../styles/docs-themes.css'],
|
|
9
|
+
styles: [`
|
|
10
|
+
:host { display: block; padding: 2rem; background: var(--kj-bg); font-family: var(--kj-font); color: var(--kj-text); }
|
|
11
|
+
.row { display: flex; gap: 0.625rem; flex-wrap: wrap; }
|
|
12
|
+
button[kjButton] {
|
|
13
|
+
padding: 4px 15px; font-family: var(--kj-font); font-size: 14px; font-weight: 400;
|
|
14
|
+
border: 1px solid transparent; border-radius: 6px; cursor: pointer;
|
|
15
|
+
transition: var(--kj-transition); line-height: 1.5;
|
|
16
|
+
}
|
|
17
|
+
button[kjButton][data-variant="default"] { background: var(--kj-accent); color: var(--kj-accent-on); border-color: var(--kj-accent); }
|
|
18
|
+
button[kjButton][data-variant="default"]:hover { background: #4096ff; border-color: #4096ff; }
|
|
19
|
+
button[kjButton][data-variant="destructive"] { background: var(--kj-destructive); color: #fff; border-color: var(--kj-destructive); }
|
|
20
|
+
button[kjButton][data-variant="link"] { background: transparent; color: var(--kj-accent); border-color: transparent; }
|
|
21
|
+
button[kjButton][data-variant="link"]:hover { color: #4096ff; }
|
|
22
|
+
button[kjButton][data-variant="ghost"] { background: transparent; color: var(--kj-text); border: 1px solid var(--kj-text); }
|
|
23
|
+
button[kjButton][data-variant="ghost"]:hover { color: var(--kj-accent); border-color: var(--kj-accent); }
|
|
24
|
+
[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; }
|
|
25
|
+
`],
|
|
26
|
+
host: { class: 'kj-theme-finance' },
|
|
27
|
+
template: `
|
|
28
|
+
<div class="row">
|
|
29
|
+
<button kjButton [kjVariant]="'default'">Default</button>
|
|
30
|
+
<button kjButton [kjVariant]="'destructive'">Destructive</button>
|
|
31
|
+
<button kjButton [kjVariant]="'link'">Link</button>
|
|
32
|
+
<button kjButton [kjVariant]="'ghost'">Ghost</button>
|
|
33
|
+
<button kjButton [kjDisabled]="true">Disabled</button>
|
|
34
|
+
</div>
|
|
35
|
+
`,
|
|
36
|
+
})
|
|
37
|
+
export class ButtonFinanceExample {}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { KjButton } from './button';
|
|
3
|
+
|
|
4
|
+
@Component({
|
|
5
|
+
selector: 'kj-example-button-retro',
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [KjButton],
|
|
8
|
+
styleUrls: ['../styles/docs-themes.css'],
|
|
9
|
+
styles: [`
|
|
10
|
+
:host { display: block; padding: 2rem; background: var(--kj-bg); font-family: var(--kj-font); color: var(--kj-text); }
|
|
11
|
+
.row { display: flex; gap: 0.625rem; flex-wrap: wrap; }
|
|
12
|
+
button[kjButton] {
|
|
13
|
+
padding: 0.35rem 0.875rem; font-family: var(--kj-font); font-size: 0.75rem;
|
|
14
|
+
font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
|
|
15
|
+
border: var(--kj-btn-border); border-radius: var(--kj-radius-md); cursor: pointer;
|
|
16
|
+
box-shadow: var(--kj-shadow-sm); transition: var(--kj-transition);
|
|
17
|
+
}
|
|
18
|
+
button[kjButton]:not([data-variant="link"]):not([data-variant="ghost"]):hover {
|
|
19
|
+
transform: translate(-1px, -1px); box-shadow: var(--kj-shadow-md);
|
|
20
|
+
}
|
|
21
|
+
button[kjButton][data-variant="default"] { background: var(--kj-text); color: var(--kj-bg); }
|
|
22
|
+
button[kjButton][data-variant="destructive"] { background: var(--kj-destructive); color: #fff; }
|
|
23
|
+
button[kjButton][data-variant="link"] {
|
|
24
|
+
background: transparent; color: var(--kj-text); border: none; box-shadow: none;
|
|
25
|
+
border-radius: 0; padding: 0.35rem 0.875rem; text-decoration: none;
|
|
26
|
+
}
|
|
27
|
+
button[kjButton][data-variant="link"]:hover {
|
|
28
|
+
text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 4px;
|
|
29
|
+
}
|
|
30
|
+
button[kjButton][data-variant="ghost"] {
|
|
31
|
+
background: transparent; color: var(--kj-text); border: 2px solid transparent; box-shadow: none; opacity: 0.7;
|
|
32
|
+
}
|
|
33
|
+
button[kjButton][data-variant="ghost"]:hover { opacity: 1; background: rgba(0,0,0,0.06); }
|
|
34
|
+
[aria-disabled="true"] { opacity: 0.4; pointer-events: none; }
|
|
35
|
+
`],
|
|
36
|
+
host: { class: 'kj-theme-retro' },
|
|
37
|
+
template: `
|
|
38
|
+
<div class="row">
|
|
39
|
+
<button kjButton [kjVariant]="'default'">Default</button>
|
|
40
|
+
<button kjButton [kjVariant]="'destructive'">Destructive</button>
|
|
41
|
+
<button kjButton [kjVariant]="'link'">Link</button>
|
|
42
|
+
<button kjButton [kjVariant]="'ghost'">Ghost</button>
|
|
43
|
+
<button kjButton [kjDisabled]="true">Disabled</button>
|
|
44
|
+
</div>
|
|
45
|
+
`,
|
|
46
|
+
})
|
|
47
|
+
export class ButtonRetroExample {}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Component } from '@angular/core';
|
|
2
|
+
import { KjButton } from './button';
|
|
3
|
+
|
|
4
|
+
@Component({
|
|
5
|
+
selector: 'kj-example-button-sizes',
|
|
6
|
+
standalone: true,
|
|
7
|
+
imports: [KjButton],
|
|
8
|
+
styleUrls: ['../styles/docs-themes.css'],
|
|
9
|
+
styles: [`
|
|
10
|
+
:host { display: block; padding: 2rem; background: var(--kj-bg); font-family: var(--kj-font); }
|
|
11
|
+
.row { display: flex; flex-direction: row; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
|
|
12
|
+
button[kjButton] { background: var(--kj-accent); color: var(--kj-accent-on); border: var(--kj-btn-border); cursor: pointer; font-family: var(--kj-font); transition: var(--kj-transition); }
|
|
13
|
+
[data-size="sm"] { padding: 0.3rem 0.75rem; font-size: 0.75rem; }
|
|
14
|
+
[data-size="md"] { padding: 0.5rem 1.25rem; font-size: 0.875rem; }
|
|
15
|
+
[data-size="lg"] { padding: 0.75rem 1.75rem; font-size: 1rem; }
|
|
16
|
+
`],
|
|
17
|
+
template: `
|
|
18
|
+
<div class="row">
|
|
19
|
+
<button kjButton [kjSize]="'sm'">Small</button>
|
|
20
|
+
<button kjButton [kjSize]="'md'">Medium (default)</button>
|
|
21
|
+
<button kjButton [kjSize]="'lg'">Large</button>
|
|
22
|
+
</div>
|
|
23
|
+
`,
|
|
24
|
+
})
|
|
25
|
+
export class ButtonSizesExample {}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { render, fireEvent } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import { KjButton } from './button';
|
|
4
|
+
|
|
5
|
+
expect.extend(toHaveNoViolations);
|
|
6
|
+
|
|
7
|
+
describe('KjButton', () => {
|
|
8
|
+
it('renders a button element', async () => {
|
|
9
|
+
const { getByRole } = await render(`<button kjButton>Click</button>`, { imports: [KjButton] });
|
|
10
|
+
expect(getByRole('button')).toBeInTheDocument();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('sets data-variant attribute', async () => {
|
|
14
|
+
const { getByRole } = await render(`<button kjButton [kjVariant]="'destructive'">Delete</button>`, { imports: [KjButton] });
|
|
15
|
+
expect(getByRole('button')).toHaveAttribute('data-variant', 'destructive');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('sets data-size attribute', async () => {
|
|
19
|
+
const { getByRole } = await render(`<button kjButton [kjSize]="'sm'">Small</button>`, { imports: [KjButton] });
|
|
20
|
+
expect(getByRole('button')).toHaveAttribute('data-size', 'sm');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('sets aria-disabled when disabled via hostDirective', async () => {
|
|
24
|
+
const { getByRole } = await render(`<button kjButton [kjDisabled]="true">Disabled</button>`, { imports: [KjButton] });
|
|
25
|
+
expect(getByRole('button')).toHaveAttribute('aria-disabled', 'true');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('passes axe audit', async () => {
|
|
29
|
+
const { container } = await render(`<button kjButton>Action</button>`, { imports: [KjButton] });
|
|
30
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Directive, input } from '@angular/core';
|
|
2
|
+
import { KjDisabled, KjFocusRing } from '../primitives';
|
|
3
|
+
|
|
4
|
+
export type KjButtonVariant = 'default' | 'destructive' | 'outline' | 'ghost' | 'link';
|
|
5
|
+
export type KjButtonSize = 'sm' | 'md' | 'lg' | 'icon';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Enhances a native ```<button>``` with variant, size, disabled state, and focus-ring behavior.
|
|
9
|
+
* Composes `KjDisabled` and `KjFocusRing` via `hostDirectives`.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```html
|
|
13
|
+
* <button kjButton [kjVariant]="'destructive'" [kjDisabled]="isLoading()">Delete</button>
|
|
14
|
+
* ```
|
|
15
|
+
* @doc
|
|
16
|
+
* @doc-example Variants
|
|
17
|
+
* @doc-theme default
|
|
18
|
+
* @doc-file button.example.ts
|
|
19
|
+
* @doc-theme retro
|
|
20
|
+
* @doc-file button.retro.example.ts
|
|
21
|
+
* @doc-theme finance
|
|
22
|
+
* @doc-file button.finance.example.ts
|
|
23
|
+
* @doc-example Sizes
|
|
24
|
+
* @doc-file button.sizes.example.ts
|
|
25
|
+
* @category Core/Base
|
|
26
|
+
*/
|
|
27
|
+
@Directive({
|
|
28
|
+
selector: '[kjButton]',
|
|
29
|
+
standalone: true,
|
|
30
|
+
hostDirectives: [
|
|
31
|
+
{ directive: KjDisabled, inputs: ['kjDisabled'] },
|
|
32
|
+
KjFocusRing,
|
|
33
|
+
],
|
|
34
|
+
host: {
|
|
35
|
+
'[attr.data-variant]': 'kjVariant()',
|
|
36
|
+
'[attr.data-size]': 'kjSize()',
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
export class KjButton {
|
|
40
|
+
/** The visual variant of the button. Defaults to `'default'`. */
|
|
41
|
+
kjVariant = input<KjButtonVariant>('default');
|
|
42
|
+
/** The size of the button. Defaults to `'md'`. */
|
|
43
|
+
kjSize = input<KjButtonSize>('md');
|
|
44
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KjButton, type KjButtonVariant, type KjButtonSize } from './button';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { render } from '@testing-library/angular';
|
|
2
|
+
import { axe, toHaveNoViolations } from 'jest-axe';
|
|
3
|
+
import { KjChart } from './chart';
|
|
4
|
+
|
|
5
|
+
expect.extend(toHaveNoViolations);
|
|
6
|
+
|
|
7
|
+
describe('KjChart', () => {
|
|
8
|
+
it('renders the host element', async () => {
|
|
9
|
+
const { container } = await render(`<div kjChart [kjChartOption]="{}"></div>`, { imports: [KjChart] });
|
|
10
|
+
expect(container.querySelector('[kjChart]')).toBeInTheDocument();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('sets role=img', async () => {
|
|
14
|
+
const { container } = await render(`<div kjChart [kjChartOption]="{}" kjChartLabel="Sales"></div>`, { imports: [KjChart] });
|
|
15
|
+
expect(container.querySelector('[kjChart]')).toHaveAttribute('role', 'img');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('sets aria-label', async () => {
|
|
19
|
+
const { container } = await render(`<div kjChart [kjChartOption]="{}" kjChartLabel="Revenue chart"></div>`, { imports: [KjChart] });
|
|
20
|
+
expect(container.querySelector('[kjChart]')).toHaveAttribute('aria-label', 'Revenue chart');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('passes axe audit', async () => {
|
|
24
|
+
const { container } = await render(`<div kjChart [kjChartOption]="{}" kjChartLabel="Revenue chart"></div>`, { imports: [KjChart] });
|
|
25
|
+
expect(await axe(container)).toHaveNoViolations();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Directive, DestroyRef, ElementRef, inject, input, afterNextRender, afterEveryRender } from '@angular/core';
|
|
2
|
+
import type { EChartsOption } from 'echarts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wraps Apache ECharts. Initializes after first render, updates reactively, disposes on destroy.
|
|
6
|
+
* Always provide `kjChartLabel` for WCAG AAA compliance.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```html
|
|
10
|
+
* <div kjChart [kjChartOption]="chartOption()" kjChartLabel="Monthly revenue" style="height:300px"></div>
|
|
11
|
+
* ```
|
|
12
|
+
* @category Core/Data
|
|
13
|
+
*/
|
|
14
|
+
@Directive({
|
|
15
|
+
selector: '[kjChart]', standalone: true,
|
|
16
|
+
host: { role: 'img', '[attr.aria-label]': 'kjChartLabel() || null' },
|
|
17
|
+
})
|
|
18
|
+
export class KjChart {
|
|
19
|
+
private readonly el = inject<ElementRef<HTMLElement>>(ElementRef);
|
|
20
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
21
|
+
|
|
22
|
+
/** ECharts option object defining the chart. */
|
|
23
|
+
kjChartOption = input.required<EChartsOption>();
|
|
24
|
+
/** Accessible label for the chart. Required for WCAG AAA compliance. */
|
|
25
|
+
kjChartLabel = input<string>('');
|
|
26
|
+
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
+
private chart: any;
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
afterNextRender(async () => {
|
|
32
|
+
try {
|
|
33
|
+
const echarts = await import('echarts');
|
|
34
|
+
this.chart = echarts.init(this.el.nativeElement);
|
|
35
|
+
this.chart.setOption(this.kjChartOption());
|
|
36
|
+
this.destroyRef.onDestroy(() => this.chart?.dispose());
|
|
37
|
+
} catch {
|
|
38
|
+
// ECharts cannot initialize in non-browser environments (jsdom, SSR)
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEveryRender(() => {
|
|
43
|
+
if (this.chart) {
|
|
44
|
+
this.chart.setOption(this.kjChartOption());
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { KjChart } from './chart';
|