@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.
Files changed (123) hide show
  1. package/.turbo/turbo-build.log +27 -0
  2. package/CHANGELOG.md +7 -0
  3. package/README.md +64 -0
  4. package/ng-package.json +7 -0
  5. package/package.json +39 -0
  6. package/src/a11y/.gitkeep +0 -0
  7. package/src/a11y/aria-describedby.spec.ts +55 -0
  8. package/src/a11y/aria-describedby.ts +33 -0
  9. package/src/a11y/focus-trap.spec.ts +86 -0
  10. package/src/a11y/focus-trap.ts +70 -0
  11. package/src/a11y/index.ts +9 -0
  12. package/src/a11y/live-region.spec.ts +58 -0
  13. package/src/a11y/live-region.ts +48 -0
  14. package/src/a11y/roving-tabindex.spec.ts +62 -0
  15. package/src/a11y/roving-tabindex.ts +104 -0
  16. package/src/a11y/visually-hidden.spec.ts +34 -0
  17. package/src/a11y/visually-hidden.ts +24 -0
  18. package/src/accordion/accordion.context.ts +22 -0
  19. package/src/accordion/accordion.spec.ts +32 -0
  20. package/src/accordion/accordion.ts +129 -0
  21. package/src/accordion/index.ts +2 -0
  22. package/src/avatar/avatar.spec.ts +18 -0
  23. package/src/avatar/avatar.ts +26 -0
  24. package/src/avatar/index.ts +1 -0
  25. package/src/badge/badge.spec.ts +20 -0
  26. package/src/badge/badge.ts +14 -0
  27. package/src/badge/index.ts +1 -0
  28. package/src/button/button.example.ts +30 -0
  29. package/src/button/button.finance.example.ts +37 -0
  30. package/src/button/button.retro.example.ts +47 -0
  31. package/src/button/button.sizes.example.ts +25 -0
  32. package/src/button/button.spec.ts +32 -0
  33. package/src/button/button.ts +44 -0
  34. package/src/button/index.ts +1 -0
  35. package/src/chart/chart.spec.ts +27 -0
  36. package/src/chart/chart.ts +48 -0
  37. package/src/chart/index.ts +1 -0
  38. package/src/checkbox/checkbox.spec.ts +35 -0
  39. package/src/checkbox/checkbox.ts +82 -0
  40. package/src/checkbox/index.ts +1 -0
  41. package/src/dialog/dialog.confirm.example.ts +57 -0
  42. package/src/dialog/dialog.context.ts +11 -0
  43. package/src/dialog/dialog.example.ts +40 -0
  44. package/src/dialog/dialog.finance.example.ts +47 -0
  45. package/src/dialog/dialog.retro.example.ts +46 -0
  46. package/src/dialog/dialog.service.ts +176 -0
  47. package/src/dialog/dialog.spec.ts +122 -0
  48. package/src/dialog/dialog.ts +201 -0
  49. package/src/dialog/index.ts +9 -0
  50. package/src/example-components.ts +33 -0
  51. package/src/form/form.context.ts +3 -0
  52. package/src/form/form.spec.ts +25 -0
  53. package/src/form/form.ts +46 -0
  54. package/src/form/index.ts +2 -0
  55. package/src/input/index.ts +1 -0
  56. package/src/input/input.spec.ts +43 -0
  57. package/src/input/input.ts +80 -0
  58. package/src/menu/index.ts +2 -0
  59. package/src/menu/menu.context.ts +10 -0
  60. package/src/menu/menu.example.ts +39 -0
  61. package/src/menu/menu.spec.ts +168 -0
  62. package/src/menu/menu.ts +176 -0
  63. package/src/popover/index.ts +12 -0
  64. package/src/popover/popover.context.ts +18 -0
  65. package/src/popover/popover.example.ts +50 -0
  66. package/src/popover/popover.finance.example.ts +52 -0
  67. package/src/popover/popover.retro.example.ts +53 -0
  68. package/src/popover/popover.spec.ts +52 -0
  69. package/src/popover/popover.ts +124 -0
  70. package/src/primitives/.gitkeep +0 -0
  71. package/src/primitives/forms/form-control.spec.ts +59 -0
  72. package/src/primitives/forms/form-control.ts +90 -0
  73. package/src/primitives/forms/index.ts +1 -0
  74. package/src/primitives/index.ts +3 -0
  75. package/src/primitives/interaction/disabled.spec.ts +47 -0
  76. package/src/primitives/interaction/disabled.ts +24 -0
  77. package/src/primitives/interaction/focus-ring.spec.ts +62 -0
  78. package/src/primitives/interaction/focus-ring.ts +57 -0
  79. package/src/primitives/interaction/index.ts +2 -0
  80. package/src/primitives/overlay/index.ts +1 -0
  81. package/src/primitives/overlay/overlay.spec.ts +90 -0
  82. package/src/primitives/overlay/overlay.ts +102 -0
  83. package/src/public-api.ts +35 -0
  84. package/src/radio/index.ts +2 -0
  85. package/src/radio/radio.context.ts +3 -0
  86. package/src/radio/radio.spec.ts +36 -0
  87. package/src/radio/radio.ts +55 -0
  88. package/src/select/index.ts +2 -0
  89. package/src/select/select.context.ts +3 -0
  90. package/src/select/select.spec.ts +145 -0
  91. package/src/select/select.ts +250 -0
  92. package/src/styles/docs-themes.css +82 -0
  93. package/src/table/index.ts +1 -0
  94. package/src/table/table.spec.ts +113 -0
  95. package/src/table/table.ts +126 -0
  96. package/src/tabs/index.ts +2 -0
  97. package/src/tabs/tabs.context.ts +9 -0
  98. package/src/tabs/tabs.spec.ts +40 -0
  99. package/src/tabs/tabs.ts +130 -0
  100. package/src/test-setup.ts +5 -0
  101. package/src/toast/index.ts +21 -0
  102. package/src/toast/toast.example.ts +134 -0
  103. package/src/toast/toast.finance.example.ts +68 -0
  104. package/src/toast/toast.retro.example.ts +69 -0
  105. package/src/toast/toast.service.ts +153 -0
  106. package/src/toast/toast.spec.ts +94 -0
  107. package/src/toast/toast.strategy.ts +102 -0
  108. package/src/toast/toast.ts +307 -0
  109. package/src/toast/toast.types.ts +5 -0
  110. package/src/toggle/index.ts +1 -0
  111. package/src/toggle/toggle.spec.ts +24 -0
  112. package/src/toggle/toggle.ts +40 -0
  113. package/src/tooltip/index.ts +5 -0
  114. package/src/tooltip/tooltip.example.ts +46 -0
  115. package/src/tooltip/tooltip.finance.example.ts +54 -0
  116. package/src/tooltip/tooltip.placements.example.ts +56 -0
  117. package/src/tooltip/tooltip.retro.example.ts +65 -0
  118. package/src/tooltip/tooltip.spec.ts +104 -0
  119. package/src/tooltip/tooltip.ts +129 -0
  120. package/tsconfig.lib.json +13 -0
  121. package/tsconfig.lib.prod.json +11 -0
  122. package/tsconfig.spec.json +10 -0
  123. 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,2 @@
1
+ export { KjAccordion, KjAccordionItem, KjAccordionTrigger, KjAccordionContent } from './accordion';
2
+ export { KJ_ACCORDION, KJ_ACCORDION_ITEM } from './accordion.context';
@@ -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';