@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,168 @@
1
+ import { render, fireEvent } from '@testing-library/angular';
2
+ import { axe, toHaveNoViolations } from 'jest-axe';
3
+ import { KjMenu, KjMenuTrigger, KjMenuContent, KjMenuItem, KjMenuClose } from './menu';
4
+
5
+ expect.extend(toHaveNoViolations);
6
+
7
+ const imports = [KjMenu, KjMenuTrigger, KjMenuContent, KjMenuItem, KjMenuClose];
8
+
9
+ const template = `
10
+ <div kjMenu>
11
+ <button kjMenuTrigger>Actions</button>
12
+ <div kjMenuContent>
13
+ <button kjMenuItem>Edit</button>
14
+ <button kjMenuItem>Delete</button>
15
+ </div>
16
+ </div>`;
17
+
18
+ describe('KjMenu', () => {
19
+ it('renders trigger button', async () => {
20
+ const { getByRole } = await render(template, { imports });
21
+ expect(getByRole('button', { name: 'Actions' })).toBeInTheDocument();
22
+ });
23
+
24
+ it('trigger has aria-haspopup="menu"', async () => {
25
+ const { getByRole } = await render(template, { imports });
26
+ expect(getByRole('button', { name: 'Actions' })).toHaveAttribute('aria-haspopup', 'menu');
27
+ });
28
+
29
+ it('trigger has aria-expanded="false" initially', async () => {
30
+ const { getByRole } = await render(template, { imports });
31
+ expect(getByRole('button', { name: 'Actions' })).toHaveAttribute('aria-expanded', 'false');
32
+ });
33
+
34
+ it('menu content is hidden initially', async () => {
35
+ const { getByRole } = await render(template, { imports });
36
+ const menu = getByRole('menu', { hidden: true });
37
+ expect(menu).toHaveAttribute('hidden');
38
+ });
39
+
40
+ it('opens on trigger click', async () => {
41
+ const { getByRole } = await render(template, { imports });
42
+ fireEvent.click(getByRole('button', { name: 'Actions' }));
43
+ const menu = getByRole('menu');
44
+ expect(menu).not.toHaveAttribute('hidden');
45
+ });
46
+
47
+ it('sets aria-expanded="true" when open', async () => {
48
+ const { getByRole } = await render(template, { imports });
49
+ fireEvent.click(getByRole('button', { name: 'Actions' }));
50
+ expect(getByRole('button', { name: 'Actions' })).toHaveAttribute('aria-expanded', 'true');
51
+ });
52
+
53
+ it('closes on second trigger click (toggle)', async () => {
54
+ const { getByRole } = await render(template, { imports });
55
+ const trigger = getByRole('button', { name: 'Actions' });
56
+ fireEvent.click(trigger);
57
+ fireEvent.click(trigger);
58
+ const menu = getByRole('menu', { hidden: true });
59
+ expect(menu).toHaveAttribute('hidden');
60
+ });
61
+
62
+ it('closes on Escape key', async () => {
63
+ const { getByRole } = await render(template, { imports });
64
+ fireEvent.click(getByRole('button', { name: 'Actions' }));
65
+ fireEvent.keyDown(document, { key: 'Escape' });
66
+ const menu = getByRole('menu', { hidden: true });
67
+ expect(menu).toHaveAttribute('hidden');
68
+ });
69
+
70
+ it('closes on click outside', async () => {
71
+ const { getByRole } = await render(template, { imports });
72
+ fireEvent.click(getByRole('button', { name: 'Actions' }));
73
+ fireEvent.click(document.body);
74
+ const menu = getByRole('menu', { hidden: true });
75
+ expect(menu).toHaveAttribute('hidden');
76
+ });
77
+
78
+ it('closes when a menu item is clicked', async () => {
79
+ const { getByRole } = await render(template, { imports });
80
+ fireEvent.click(getByRole('button', { name: 'Actions' }));
81
+ fireEvent.click(getByRole('menuitem', { name: 'Edit' }));
82
+ const menu = getByRole('menu', { hidden: true });
83
+ expect(menu).toHaveAttribute('hidden');
84
+ });
85
+
86
+ it('menu items have role="menuitem"', async () => {
87
+ const { getAllByRole } = await render(template, { imports });
88
+ fireEvent.click(getAllByRole('button')[0]);
89
+ const items = getAllByRole('menuitem');
90
+ expect(items).toHaveLength(2);
91
+ });
92
+
93
+ it('navigates down with ArrowDown', async () => {
94
+ const { getByRole, getAllByRole } = await render(template, { imports });
95
+ fireEvent.click(getByRole('button', { name: 'Actions' }));
96
+ const menu = getByRole('menu');
97
+ const items = getAllByRole('menuitem');
98
+ items[0].focus();
99
+ fireEvent.keyDown(menu, { key: 'ArrowDown' });
100
+ expect(document.activeElement).toBe(items[1]);
101
+ });
102
+
103
+ it('navigates up with ArrowUp', async () => {
104
+ const { getByRole, getAllByRole } = await render(template, { imports });
105
+ fireEvent.click(getByRole('button', { name: 'Actions' }));
106
+ const menu = getByRole('menu');
107
+ const items = getAllByRole('menuitem');
108
+ items[1].focus();
109
+ fireEvent.keyDown(menu, { key: 'ArrowUp' });
110
+ expect(document.activeElement).toBe(items[0]);
111
+ });
112
+
113
+ it('wraps around from last to first on ArrowDown', async () => {
114
+ const { getByRole, getAllByRole } = await render(template, { imports });
115
+ fireEvent.click(getByRole('button', { name: 'Actions' }));
116
+ const menu = getByRole('menu');
117
+ const items = getAllByRole('menuitem');
118
+ items[items.length - 1].focus();
119
+ fireEvent.keyDown(menu, { key: 'ArrowDown' });
120
+ expect(document.activeElement).toBe(items[0]);
121
+ });
122
+
123
+ it('focuses first item on Home key', async () => {
124
+ const { getByRole, getAllByRole } = await render(template, { imports });
125
+ fireEvent.click(getByRole('button', { name: 'Actions' }));
126
+ const menu = getByRole('menu');
127
+ const items = getAllByRole('menuitem');
128
+ items[1].focus();
129
+ fireEvent.keyDown(menu, { key: 'Home' });
130
+ expect(document.activeElement).toBe(items[0]);
131
+ });
132
+
133
+ it('focuses last item on End key', async () => {
134
+ const { getByRole, getAllByRole } = await render(template, { imports });
135
+ fireEvent.click(getByRole('button', { name: 'Actions' }));
136
+ const menu = getByRole('menu');
137
+ const items = getAllByRole('menuitem');
138
+ items[0].focus();
139
+ fireEvent.keyDown(menu, { key: 'End' });
140
+ expect(document.activeElement).toBe(items[items.length - 1]);
141
+ });
142
+
143
+ it('kjMenuClose closes the menu on click', async () => {
144
+ const closeTemplate = `
145
+ <div kjMenu>
146
+ <button kjMenuTrigger>Open</button>
147
+ <div kjMenuContent>
148
+ <button kjMenuClose>Cancel</button>
149
+ </div>
150
+ </div>`;
151
+ const { getByRole } = await render(closeTemplate, { imports });
152
+ fireEvent.click(getByRole('button', { name: 'Open' }));
153
+ fireEvent.click(getByRole('button', { name: 'Cancel' }));
154
+ const menu = getByRole('menu', { hidden: true });
155
+ expect(menu).toHaveAttribute('hidden');
156
+ });
157
+
158
+ it('passes axe audit when closed', async () => {
159
+ const { container } = await render(template, { imports });
160
+ expect(await axe(container)).toHaveNoViolations();
161
+ });
162
+
163
+ it('passes axe audit when open', async () => {
164
+ const { container, getByRole } = await render(template, { imports });
165
+ fireEvent.click(getByRole('button', { name: 'Actions' }));
166
+ expect(await axe(container)).toHaveNoViolations();
167
+ });
168
+ });
@@ -0,0 +1,176 @@
1
+ import { Directive, ElementRef, inject, signal } from '@angular/core';
2
+ import { KJ_MENU, KjMenuContext } from './menu.context';
3
+
4
+ /**
5
+ * Root menu container. Manages open/close state and provides context to child directives.
6
+ * Pairs with `[kjMenuTrigger]`, `[kjMenuContent]`, and `[kjMenuItem]`.
7
+ *
8
+ * Click outside or Escape closes the menu. Clicking the trigger toggles it.
9
+ * Arrow-key navigation, Home/End, and typeahead are handled by `[kjMenuContent]`.
10
+ *
11
+ * @example
12
+ * ```html
13
+ * <div kjMenu>
14
+ * <button kjMenuTrigger>Actions</button>
15
+ * <div kjMenuContent>
16
+ * <button kjMenuItem>Edit</button>
17
+ * <button kjMenuItem>Delete</button>
18
+ * </div>
19
+ * </div>
20
+ * ```
21
+ * @doc
22
+ * @doc-example Basic
23
+ * @doc-theme default
24
+ * @doc-file menu.example.ts
25
+ * @category Core/Navigation
26
+ */
27
+ @Directive({
28
+ selector: '[kjMenu]',
29
+ standalone: true,
30
+ providers: [{ provide: KJ_MENU, useExisting: KjMenu }],
31
+ exportAs: 'kjMenu',
32
+ })
33
+ export class KjMenu implements KjMenuContext {
34
+ private readonly _open = signal(false);
35
+ readonly open = this._open.asReadonly();
36
+
37
+ toggle(): void { this._open.update(v => !v); }
38
+ close(): void { this._open.set(false); }
39
+ }
40
+
41
+ /**
42
+ * Trigger element that toggles the parent menu.
43
+ * Automatically sets `aria-haspopup` and `aria-expanded` on the host element.
44
+ *
45
+ * @category Core/Navigation
46
+ */
47
+ @Directive({
48
+ selector: '[kjMenuTrigger]',
49
+ standalone: true,
50
+ host: {
51
+ '[attr.aria-haspopup]': '"menu"',
52
+ '[attr.aria-expanded]': 'ctx.open().toString()',
53
+ // stopPropagation prevents the document:click on kjMenuContent from immediately closing
54
+ '(click)': '$event.stopPropagation(); ctx.toggle()',
55
+ },
56
+ })
57
+ export class KjMenuTrigger {
58
+ readonly ctx = inject(KJ_MENU);
59
+ }
60
+
61
+ /**
62
+ * Menu content panel. Hidden when closed via the `hidden` attribute.
63
+ * Provides keyboard navigation: Arrow keys, Home/End, Escape, and typeahead.
64
+ * Click-outside dismissal is handled via a document-level click listener.
65
+ *
66
+ * @category Core/Navigation
67
+ */
68
+ @Directive({
69
+ selector: '[kjMenuContent]',
70
+ standalone: true,
71
+ host: {
72
+ 'role': 'menu',
73
+ '[attr.hidden]': '!ctx.open() ? "" : null',
74
+ // Prevent clicks inside from propagating to document click handler
75
+ '(click)': '$event.stopPropagation()',
76
+ // Keyboard navigation within the menu
77
+ '(keydown)': 'onKeydown($event)',
78
+ // Close on Escape key
79
+ '(document:keydown.escape)': 'ctx.open() && ctx.close()',
80
+ // Close on any click outside (trigger stopPropagation prevents self-close)
81
+ '(document:click)': 'ctx.open() && ctx.close()',
82
+ },
83
+ })
84
+ export class KjMenuContent {
85
+ private readonly el = inject(ElementRef<HTMLElement>);
86
+ readonly ctx = inject(KJ_MENU);
87
+
88
+ onKeydown(e: KeyboardEvent): void {
89
+ const items = this.getFocusableItems();
90
+ if (!items.length) return;
91
+ const current = items.indexOf(document.activeElement as HTMLElement);
92
+
93
+ switch (e.key) {
94
+ case 'ArrowDown':
95
+ e.preventDefault();
96
+ items[(current + 1) % items.length].focus();
97
+ break;
98
+ case 'ArrowUp':
99
+ e.preventDefault();
100
+ items[(current - 1 + items.length) % items.length].focus();
101
+ break;
102
+ case 'Home':
103
+ e.preventDefault();
104
+ items[0].focus();
105
+ break;
106
+ case 'End':
107
+ e.preventDefault();
108
+ items[items.length - 1].focus();
109
+ break;
110
+ default: {
111
+ // Type-ahead: jump to next item starting with pressed character
112
+ const char = e.key.length === 1 ? e.key.toLowerCase() : null;
113
+ if (char) {
114
+ const match =
115
+ items.find((item, i) =>
116
+ i > current && item.textContent?.trim().toLowerCase().startsWith(char)
117
+ ) ??
118
+ items.find(item =>
119
+ item.textContent?.trim().toLowerCase().startsWith(char)
120
+ );
121
+ match?.focus();
122
+ }
123
+ }
124
+ }
125
+ }
126
+
127
+ private getFocusableItems(): HTMLElement[] {
128
+ const nodes = this.el.nativeElement.querySelectorAll('[kjMenuItem]');
129
+ return (Array.from(nodes) as HTMLElement[]).filter(el => !el.hasAttribute('disabled'));
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Individual menu item. Closes the menu on click or keyboard activation (Enter/Space).
135
+ *
136
+ * @example
137
+ * ```html
138
+ * <button kjMenuItem>Delete</button>
139
+ * ```
140
+ * @category Core/Navigation
141
+ */
142
+ @Directive({
143
+ selector: '[kjMenuItem]',
144
+ standalone: true,
145
+ host: {
146
+ 'role': 'menuitem',
147
+ '[attr.tabindex]': '"0"',
148
+ '(click)': 'ctx.close()',
149
+ '(keydown.enter)': '$event.preventDefault(); ctx.close()',
150
+ '(keydown.space)': '$event.preventDefault(); ctx.close()',
151
+ },
152
+ })
153
+ export class KjMenuItem {
154
+ readonly ctx = inject(KJ_MENU);
155
+ }
156
+
157
+ /**
158
+ * Closes the parent menu when clicked. Convenience directive for non-item close buttons.
159
+ * Place anywhere inside `[kjMenu]`.
160
+ *
161
+ * @example
162
+ * ```html
163
+ * <button kjMenuClose>Cancel</button>
164
+ * ```
165
+ * @category Core/Navigation
166
+ */
167
+ @Directive({
168
+ selector: '[kjMenuClose]',
169
+ standalone: true,
170
+ host: {
171
+ '(click)': 'ctx.close()',
172
+ },
173
+ })
174
+ export class KjMenuClose {
175
+ readonly ctx = inject(KJ_MENU);
176
+ }
@@ -0,0 +1,12 @@
1
+ export {
2
+ KjPopover,
3
+ KjPopoverTrigger,
4
+ KjPopoverContent,
5
+ KjPopoverClose,
6
+ } from './popover';
7
+ export {
8
+ KJ_POPOVER,
9
+ type KjPopoverContext,
10
+ type KjPopoverSide,
11
+ type KjPopoverAlign,
12
+ } from './popover.context';
@@ -0,0 +1,18 @@
1
+ import { InjectionToken, Signal, InputSignal } from '@angular/core';
2
+
3
+ /** Preferred side for the popover relative to its trigger. */
4
+ export type KjPopoverSide = 'top' | 'bottom' | 'left' | 'right';
5
+ /** Alignment of the popover along the cross-axis. */
6
+ export type KjPopoverAlign = 'start' | 'center' | 'end';
7
+
8
+ /** Context shared between popover directives. */
9
+ export interface KjPopoverContext {
10
+ readonly open: Signal<boolean>;
11
+ readonly popoverId: string;
12
+ kjPopoverSide: Signal<KjPopoverSide> | InputSignal<KjPopoverSide>;
13
+ kjPopoverAlign: Signal<KjPopoverAlign> | InputSignal<KjPopoverAlign>;
14
+ toggle: () => void;
15
+ hide: () => void;
16
+ }
17
+
18
+ export const KJ_POPOVER = new InjectionToken<KjPopoverContext>('KjPopover');
@@ -0,0 +1,50 @@
1
+ import { Component } from '@angular/core';
2
+ import {
3
+ KjPopover, KjPopoverTrigger,
4
+ KjPopoverContent, KjPopoverClose,
5
+ } from './popover';
6
+ import { KjButton } from '../button/button';
7
+
8
+ @Component({
9
+ selector: 'kj-example-popover-basic',
10
+ standalone: true,
11
+ imports: [
12
+ KjPopover, KjPopoverTrigger,
13
+ KjPopoverContent, KjPopoverClose,
14
+ KjButton,
15
+ ],
16
+ styleUrls: ['../styles/docs-themes.css'],
17
+ styles: [`
18
+ :host { display: flex; align-items: flex-start; justify-content: center; padding: 3rem 2rem 10rem; background: var(--kj-bg); font-family: var(--kj-font); min-height: 220px; }
19
+ .anchor { position: relative; display: inline-block; }
20
+ button[kjButton] { padding: 0.5rem 1.25rem; border: 1px solid var(--kj-border); background: transparent; color: var(--kj-text); cursor: pointer; font-family: inherit; font-size: 0.875rem; }
21
+ button[kjButton]:hover { border-color: var(--kj-accent); color: var(--kj-accent); }
22
+ [kjPopoverContent] {
23
+ position: absolute; top: calc(100% + 8px); left: 0; z-index: 20;
24
+ background: var(--kj-surface); border: 1px solid var(--kj-border); padding: 1rem; min-width: 14rem;
25
+ color: var(--kj-text);
26
+ }
27
+ [kjPopoverContent][hidden] { display: none; }
28
+ [kjPopoverContent][data-align="end"] { left: auto; right: 0; }
29
+ .popover-title { font-size: 0.875rem; font-weight: 600; margin: 0 0 0.5rem; color: var(--kj-accent); }
30
+ .popover-body { font-size: 0.8125rem; color: var(--kj-text-muted); margin: 0 0 1rem; line-height: 1.5; }
31
+ .popover-footer { display: flex; justify-content: flex-end; gap: 0.5rem; }
32
+ .popover-footer button[kjButton] { padding: 0.35rem 0.875rem; font-size: 0.8125rem; }
33
+ [data-variant="default"] { border-color: var(--kj-accent); color: var(--kj-accent-on); background: var(--kj-accent); }
34
+ [data-variant="ghost"] { border-color: transparent; background: transparent; color: var(--kj-text-muted); }
35
+ `],
36
+ template: `
37
+ <div class="anchor" kjPopover>
38
+ <button kjButton kjPopoverTrigger>Open Popover</button>
39
+ <div kjPopoverContent>
40
+ <p class="popover-title">Notification Settings</p>
41
+ <p class="popover-body">Control how and when you receive notifications from this app.</p>
42
+ <div class="popover-footer">
43
+ <button kjButton kjPopoverClose [kjVariant]="'ghost'">Cancel</button>
44
+ <button kjButton kjPopoverClose [kjVariant]="'default'">Save</button>
45
+ </div>
46
+ </div>
47
+ </div>
48
+ `,
49
+ })
50
+ export class PopoverBasicExample {}
@@ -0,0 +1,52 @@
1
+ import { Component } from '@angular/core';
2
+ import {
3
+ KjPopover, KjPopoverTrigger,
4
+ KjPopoverContent, KjPopoverClose,
5
+ } from './popover';
6
+
7
+ @Component({
8
+ selector: 'kj-example-popover-finance',
9
+ standalone: true,
10
+ imports: [
11
+ KjPopover, KjPopoverTrigger,
12
+ KjPopoverContent, KjPopoverClose,
13
+ ],
14
+ styleUrls: ['../styles/docs-themes.css'],
15
+ host: { class: 'kj-theme-finance' },
16
+ styles: [`
17
+ :host { display: flex; align-items: flex-start; justify-content: center; padding: 3rem 2rem 10rem; background: var(--kj-bg); font-family: var(--kj-font); min-height: 220px; }
18
+ .anchor { position: relative; display: inline-block; }
19
+ button {
20
+ padding: 0.4rem 1rem; background: var(--kj-accent); color: var(--kj-accent-on); border: 1px solid var(--kj-accent);
21
+ border-radius: var(--kj-radius-md); cursor: pointer; font-family: inherit; font-size: 0.875rem; font-weight: 500;
22
+ }
23
+ button:hover { background: #2563eb; }
24
+ [kjPopoverContent] {
25
+ position: absolute; top: calc(100% + 8px); left: 0; z-index: 20;
26
+ background: var(--kj-surface); border: 1px solid var(--kj-border); border-radius: var(--kj-radius-lg);
27
+ box-shadow: var(--kj-shadow-md); padding: 1rem; min-width: 14rem;
28
+ }
29
+ [kjPopoverContent][hidden] { display: none; }
30
+ .popover-title { font-size: 0.875rem; font-weight: 600; color: var(--kj-text); margin: 0 0 0.375rem; }
31
+ .popover-body { font-size: 0.8125rem; color: var(--kj-text-muted); margin: 0 0 1rem; line-height: 1.5; }
32
+ .popover-footer { display: flex; justify-content: flex-end; gap: 0.5rem; }
33
+ .btn-cancel { padding: 0.35rem 0.875rem; background: var(--kj-surface); color: #374151; border: 1px solid var(--kj-border); border-radius: var(--kj-radius-sm); cursor: pointer; font-size: 0.8125rem; font-weight: 500; }
34
+ .btn-cancel:hover { background: #f3f4f6; }
35
+ .btn-primary { padding: 0.35rem 0.875rem; background: var(--kj-accent); color: var(--kj-accent-on); border: 1px solid var(--kj-accent); border-radius: var(--kj-radius-sm); cursor: pointer; font-size: 0.8125rem; font-weight: 500; }
36
+ .btn-primary:hover { background: #2563eb; }
37
+ `],
38
+ template: `
39
+ <div class="anchor" kjPopover>
40
+ <button kjPopoverTrigger>Preferences</button>
41
+ <div kjPopoverContent>
42
+ <p class="popover-title">Notification Preferences</p>
43
+ <p class="popover-body">Choose how you receive alerts and account notifications.</p>
44
+ <div class="popover-footer">
45
+ <button class="btn-cancel" kjPopoverClose>Cancel</button>
46
+ <button class="btn-primary" kjPopoverClose>Apply</button>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ `,
51
+ })
52
+ export class PopoverFinanceExample {}
@@ -0,0 +1,53 @@
1
+ import { Component } from '@angular/core';
2
+ import {
3
+ KjPopover, KjPopoverTrigger,
4
+ KjPopoverContent, KjPopoverClose,
5
+ } from './popover';
6
+
7
+ @Component({
8
+ selector: 'kj-example-popover-retro',
9
+ standalone: true,
10
+ imports: [
11
+ KjPopover, KjPopoverTrigger,
12
+ KjPopoverContent, KjPopoverClose,
13
+ ],
14
+ styleUrls: ['../styles/docs-themes.css'],
15
+ host: { class: 'kj-theme-retro' },
16
+ styles: [`
17
+ :host { display: flex; align-items: flex-start; justify-content: center; padding: 3rem 2rem 10rem; background: var(--kj-bg); font-family: var(--kj-font); min-height: 220px; color: var(--kj-text); }
18
+ .anchor { position: relative; display: inline-block; }
19
+ button {
20
+ padding: 0.4rem 1rem; font-family: var(--kj-font); font-size: 0.8rem; font-weight: 700;
21
+ letter-spacing: 0.06em; text-transform: uppercase; background: var(--kj-surface); color: var(--kj-text);
22
+ border: var(--kj-btn-border); border-radius: 0; cursor: pointer;
23
+ box-shadow: var(--kj-shadow-sm); transition: var(--kj-transition);
24
+ }
25
+ button:hover { transform: translate(-1px, -1px); box-shadow: var(--kj-shadow-md); }
26
+ [kjPopoverContent] {
27
+ position: absolute; top: calc(100% + 8px); left: 0; z-index: 20;
28
+ background: var(--kj-bg); border: var(--kj-btn-border); box-shadow: var(--kj-shadow-hard);
29
+ padding: 1rem; min-width: 14rem; font-family: var(--kj-font);
30
+ }
31
+ [kjPopoverContent][hidden] { display: none; }
32
+ .popover-title { font-size: 0.75rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin: 0 0 0.5rem; border-bottom: var(--kj-btn-border); padding-bottom: 0.375rem; color: var(--kj-text); }
33
+ .popover-body { font-size: 0.75rem; color: var(--kj-text-muted); margin: 0.5rem 0 1rem; line-height: 1.6; }
34
+ .popover-footer { display: flex; justify-content: flex-end; gap: 0.5rem; }
35
+ .popover-footer button { padding: 0.3rem 0.75rem; font-size: 0.7rem; box-shadow: var(--kj-shadow-sm); }
36
+ .popover-footer button:hover { box-shadow: var(--kj-shadow-md); }
37
+ .btn-primary { background: var(--kj-accent); color: var(--kj-accent-on); border-color: var(--kj-border); }
38
+ `],
39
+ template: `
40
+ <div class="anchor" kjPopover>
41
+ <button kjPopoverTrigger>Settings</button>
42
+ <div kjPopoverContent>
43
+ <p class="popover-title">Notifications</p>
44
+ <p class="popover-body">Configure notification frequency and delivery preferences.</p>
45
+ <div class="popover-footer">
46
+ <button kjPopoverClose>Cancel</button>
47
+ <button kjPopoverClose class="btn-primary">Save</button>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ `,
52
+ })
53
+ export class PopoverRetroExample {}
@@ -0,0 +1,52 @@
1
+ import { render, fireEvent } from '@testing-library/angular';
2
+ import { axe, toHaveNoViolations } from 'jest-axe';
3
+ import {
4
+ KjPopover, KjPopoverTrigger,
5
+ KjPopoverContent, KjPopoverClose,
6
+ } from './popover';
7
+
8
+ expect.extend(toHaveNoViolations);
9
+ const imports = [KjPopover, KjPopoverTrigger, KjPopoverContent, KjPopoverClose];
10
+ const template = `<div kjPopover><button kjPopoverTrigger>Open</button><div kjPopoverContent role="dialog" aria-label="Options">Content</div></div>`;
11
+
12
+ describe('KjPopover', () => {
13
+ it('content hidden by default', async () => {
14
+ const { container } = await render(template, { imports });
15
+ expect(container.querySelector('[kjPopoverContent]')).toHaveAttribute('hidden', '');
16
+ });
17
+
18
+ it('trigger toggles content', async () => {
19
+ const { container } = await render(template, { imports });
20
+ fireEvent.click(container.querySelector('[kjPopoverTrigger]')!);
21
+ expect(container.querySelector('[kjPopoverContent]')).not.toHaveAttribute('hidden');
22
+ });
23
+
24
+ it('trigger sets aria-expanded', async () => {
25
+ const { container } = await render(template, { imports });
26
+ const t = container.querySelector('[kjPopoverTrigger]')!;
27
+ expect(t).toHaveAttribute('aria-expanded', 'false');
28
+ fireEvent.click(t);
29
+ expect(t).toHaveAttribute('aria-expanded', 'true');
30
+ });
31
+
32
+ it('trigger sets aria-controls pointing to content id', async () => {
33
+ const { container } = await render(template, { imports });
34
+ const t = container.querySelector('[kjPopoverTrigger]')!;
35
+ const c = container.querySelector('[kjPopoverContent]')!;
36
+ expect(t.getAttribute('aria-controls')).toBe(c.id);
37
+ });
38
+
39
+ it('kjPopoverClose closes the popover', async () => {
40
+ const closeTemplate = `<div kjPopover><button kjPopoverTrigger>Open</button><div kjPopoverContent role="dialog" aria-label="Options"><button kjPopoverClose>Close</button></div></div>`;
41
+ const { container } = await render(closeTemplate, { imports });
42
+ fireEvent.click(container.querySelector('[kjPopoverTrigger]')!);
43
+ expect(container.querySelector('[kjPopoverContent]')).not.toHaveAttribute('hidden');
44
+ fireEvent.click(container.querySelector('[kjPopoverClose]')!);
45
+ expect(container.querySelector('[kjPopoverContent]')).toHaveAttribute('hidden', '');
46
+ });
47
+
48
+ it('passes axe audit', async () => {
49
+ const { container } = await render(template, { imports });
50
+ expect(await axe(container)).toHaveNoViolations();
51
+ });
52
+ });