@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,35 @@
1
+ // Public API for @kouji-ui/core
2
+
3
+ export const KJ_CORE_VERSION = '0.0.1';
4
+
5
+ // ── Primitives ──────────────────────────────────────────────
6
+ export * from './primitives/index';
7
+
8
+ // ── Accessibility utilities ──────────────────────────────────
9
+ export * from './a11y/index';
10
+
11
+ // ── Foundation components ────────────────────────────────────
12
+ export * from './button/index';
13
+ export * from './input/index';
14
+ export * from './checkbox/index';
15
+ export * from './radio/index';
16
+ export * from './toggle/index';
17
+ export * from './badge/index';
18
+ export * from './avatar/index';
19
+
20
+ // ── Overlay components ───────────────────────────────────────
21
+ export * from './dialog/index';
22
+ export * from './tooltip/index';
23
+ export * from './popover/index';
24
+ export * from './menu/index';
25
+ export * from './toast/index';
26
+
27
+ // ── Data components ──────────────────────────────────────────
28
+ export * from './table/index';
29
+ export * from './form/index';
30
+ export * from './tabs/index';
31
+ export * from './accordion/index';
32
+ export * from './select/index';
33
+
34
+ // ── Charts ───────────────────────────────────────────────────
35
+ export * from './chart/index';
@@ -0,0 +1,2 @@
1
+ export { KjRadioGroup, KjRadio } from './radio';
2
+ export { KJ_RADIO_GROUP, type KjRadioContext } from './radio.context';
@@ -0,0 +1,3 @@
1
+ import { InjectionToken, Signal } from '@angular/core';
2
+ export interface KjRadioContext { value: Signal<unknown>; select: (value: unknown) => void; }
3
+ export const KJ_RADIO_GROUP = new InjectionToken<KjRadioContext>('KjRadioGroup');
@@ -0,0 +1,36 @@
1
+ import { render, fireEvent } from '@testing-library/angular';
2
+ import { axe, toHaveNoViolations } from 'jest-axe';
3
+ import { KjRadioGroup, KjRadio } from './radio';
4
+
5
+ expect.extend(toHaveNoViolations);
6
+
7
+ const template = `<div kjRadioGroup [(kjValue)]="selected" aria-label="Opts"><div kjRadio [kjRadioValue]="'a'" tabindex="0">A</div><div kjRadio [kjRadioValue]="'b'" tabindex="-1">B</div></div>`;
8
+ const imports = [KjRadioGroup, KjRadio];
9
+
10
+ describe('KjRadioGroup', () => {
11
+ it('has radiogroup role', async () => {
12
+ const { getByRole } = await render(template, { imports, componentProperties: { selected: 'a' } });
13
+ expect(getByRole('radiogroup')).toBeInTheDocument();
14
+ });
15
+
16
+ it('radio items have role=radio', async () => {
17
+ const { getAllByRole } = await render(template, { imports, componentProperties: { selected: 'a' } });
18
+ expect(getAllByRole('radio')).toHaveLength(2);
19
+ });
20
+
21
+ it('selected radio has aria-checked=true', async () => {
22
+ const { getAllByRole } = await render(template, { imports, componentProperties: { selected: 'a' } });
23
+ expect(getAllByRole('radio')[0]).toHaveAttribute('aria-checked', 'true');
24
+ });
25
+
26
+ it('clicking radio selects it', async () => {
27
+ const { getAllByRole } = await render(template, { imports, componentProperties: { selected: 'a' } });
28
+ fireEvent.click(getAllByRole('radio')[1]);
29
+ expect(getAllByRole('radio')[1]).toHaveAttribute('aria-checked', 'true');
30
+ });
31
+
32
+ it('passes axe audit', async () => {
33
+ const { container } = await render(template, { imports, componentProperties: { selected: 'a' } });
34
+ expect(await axe(container)).toHaveNoViolations();
35
+ });
36
+ });
@@ -0,0 +1,55 @@
1
+ import { Directive, computed, inject, input, model } from '@angular/core';
2
+ import { KJ_RADIO_GROUP, KjRadioContext } from './radio.context';
3
+ import { KjDisabled, KjFocusRing, KjFormControl } from '../primitives';
4
+
5
+ /**
6
+ * Container for a group of radio buttons. Manages the selected value.
7
+ * @example
8
+ * ```html
9
+ * <div kjRadioGroup [(kjValue)]="size" aria-label="Size">
10
+ * <div kjRadio [kjRadioValue]="'s'" tabindex="0">Small</div>
11
+ * </div>
12
+ * ```
13
+ * @category Core/Inputs
14
+ */
15
+ @Directive({
16
+ selector: '[kjRadioGroup]', standalone: true,
17
+ providers: [{ provide: KJ_RADIO_GROUP, useExisting: KjRadioGroup }],
18
+ host: { role: 'radiogroup' },
19
+ })
20
+ export class KjRadioGroup implements KjRadioContext {
21
+ kjValue = model<unknown>(undefined);
22
+ readonly value = this.kjValue.asReadonly();
23
+ select(val: unknown): void { this.kjValue.set(val); }
24
+ }
25
+
26
+ /**
27
+ * Individual radio button within a `[kjRadioGroup]`. Includes CVA for forms integration.
28
+ * @example `<div kjRadio [kjRadioValue]="'a'" tabindex="0">Option A</div>`
29
+ * @category Core/Inputs
30
+ */
31
+ @Directive({
32
+ selector: '[kjRadio]', standalone: true,
33
+ hostDirectives: [
34
+ { directive: KjDisabled, inputs: ['kjDisabled'] },
35
+ KjFocusRing,
36
+ KjFormControl,
37
+ ],
38
+ host: {
39
+ role: 'radio',
40
+ '[attr.aria-checked]': 'checked().toString()',
41
+ '[attr.data-checked]': 'checked() ? "" : null',
42
+ '(click)': 'select()',
43
+ '(keydown.space)': 'onSpace($event)',
44
+ '(keydown.enter)': 'select()',
45
+ '(blur)': 'formCtrl.notifyTouched()',
46
+ },
47
+ })
48
+ export class KjRadio {
49
+ private readonly group = inject(KJ_RADIO_GROUP);
50
+ readonly formCtrl = inject(KjFormControl);
51
+ kjRadioValue = input.required<unknown>();
52
+ readonly checked = computed(() => this.group.value() === this.kjRadioValue());
53
+ select(): void { this.group.select(this.kjRadioValue()); this.formCtrl.notifyChange(this.kjRadioValue()); }
54
+ onSpace(e: Event): void { e.preventDefault(); this.select(); }
55
+ }
@@ -0,0 +1,2 @@
1
+ export { KjSelect, KjSelectTrigger, KjSelectContent, KjOption } from './select';
2
+ export { KJ_SELECT, type KjSelectContext } from './select.context';
@@ -0,0 +1,3 @@
1
+ import { InjectionToken, Signal } from '@angular/core';
2
+ export interface KjSelectContext { value: Signal<unknown>; open: Signal<boolean>; select: (value: unknown) => void; toggle: () => void; hide: () => void; }
3
+ export const KJ_SELECT = new InjectionToken<KjSelectContext>('KjSelect');
@@ -0,0 +1,145 @@
1
+ import { render, fireEvent } from '@testing-library/angular';
2
+ import { axe, toHaveNoViolations } from 'jest-axe';
3
+ import { KjSelect, KjSelectTrigger, KjSelectContent, KjOption } from './select';
4
+
5
+ expect.extend(toHaveNoViolations);
6
+
7
+ const imports = [KjSelect, KjSelectTrigger, KjSelectContent, KjOption];
8
+ const template = `
9
+ <div kjSelect [(kjSelectValue)]="val">
10
+ <button kjSelectTrigger aria-haspopup="listbox">Select fruit</button>
11
+ <div kjSelectContent>
12
+ <div kjOption [kjOptionValue]="'apple'">Apple</div>
13
+ <div kjOption [kjOptionValue]="'banana'">Banana</div>
14
+ <div kjOption [kjOptionValue]="'cherry'">Cherry</div>
15
+ </div>
16
+ </div>`;
17
+
18
+ describe('KjSelect', () => {
19
+ it('listbox hidden by default', async () => {
20
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
21
+ expect(container.querySelector('[kjSelectContent]')).toHaveAttribute('hidden', '');
22
+ });
23
+
24
+ it('trigger opens listbox', async () => {
25
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
26
+ fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
27
+ expect(container.querySelector('[kjSelectContent]')).not.toHaveAttribute('hidden');
28
+ });
29
+
30
+ it('trigger sets aria-expanded to false by default', async () => {
31
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
32
+ expect(container.querySelector('[kjSelectTrigger]')).toHaveAttribute('aria-expanded', 'false');
33
+ });
34
+
35
+ it('trigger sets aria-expanded to true when open', async () => {
36
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
37
+ fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
38
+ expect(container.querySelector('[kjSelectTrigger]')).toHaveAttribute('aria-expanded', 'true');
39
+ });
40
+
41
+ it('clicking option selects and closes', async () => {
42
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
43
+ fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
44
+ fireEvent.click(container.querySelector('[kjOption]')!);
45
+ expect(container.querySelector('[kjSelectContent]')).toHaveAttribute('hidden', '');
46
+ });
47
+
48
+ it('selected option has aria-selected="true"', async () => {
49
+ const { container } = await render(template, { imports, componentProperties: { val: 'apple' } });
50
+ const options = container.querySelectorAll('[kjOption]');
51
+ expect(options[0]).toHaveAttribute('aria-selected', 'true');
52
+ expect(options[1]).toHaveAttribute('aria-selected', 'false');
53
+ });
54
+
55
+ it('listbox has role="listbox"', async () => {
56
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
57
+ expect(container.querySelector('[kjSelectContent]')).toHaveAttribute('role', 'listbox');
58
+ });
59
+
60
+ it('options have role="option"', async () => {
61
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
62
+ const options = container.querySelectorAll('[kjOption]');
63
+ options.forEach(opt => expect(opt).toHaveAttribute('role', 'option'));
64
+ });
65
+
66
+ it('ArrowDown moves focus to next option', async () => {
67
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
68
+ fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
69
+ const content = container.querySelector('[kjSelectContent]')!;
70
+ fireEvent.keyDown(content, { key: 'ArrowDown' });
71
+ const options = container.querySelectorAll('[kjOption]');
72
+ expect(document.activeElement).toBe(options[0]);
73
+ });
74
+
75
+ it('ArrowDown then ArrowDown moves to second option', async () => {
76
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
77
+ fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
78
+ const content = container.querySelector('[kjSelectContent]')!;
79
+ fireEvent.keyDown(content, { key: 'ArrowDown' });
80
+ fireEvent.keyDown(content, { key: 'ArrowDown' });
81
+ const options = container.querySelectorAll('[kjOption]');
82
+ expect(document.activeElement).toBe(options[1]);
83
+ });
84
+
85
+ it('Home key moves focus to first option', async () => {
86
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
87
+ fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
88
+ const content = container.querySelector('[kjSelectContent]')!;
89
+ // Move down first
90
+ fireEvent.keyDown(content, { key: 'ArrowDown' });
91
+ fireEvent.keyDown(content, { key: 'ArrowDown' });
92
+ // Then Home
93
+ fireEvent.keyDown(content, { key: 'Home' });
94
+ const options = container.querySelectorAll('[kjOption]');
95
+ expect(document.activeElement).toBe(options[0]);
96
+ });
97
+
98
+ it('End key moves focus to last option', async () => {
99
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
100
+ fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
101
+ const content = container.querySelector('[kjSelectContent]')!;
102
+ fireEvent.keyDown(content, { key: 'End' });
103
+ const options = container.querySelectorAll('[kjOption]');
104
+ expect(document.activeElement).toBe(options[2]);
105
+ });
106
+
107
+ it('Enter selects focused option', async () => {
108
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
109
+ fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
110
+ const content = container.querySelector('[kjSelectContent]')!;
111
+ fireEvent.keyDown(content, { key: 'ArrowDown' });
112
+ fireEvent.keyDown(content, { key: 'Enter' });
113
+ expect(container.querySelector('[kjSelectContent]')).toHaveAttribute('hidden', '');
114
+ });
115
+
116
+ it('Space selects focused option', async () => {
117
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
118
+ fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
119
+ const content = container.querySelector('[kjSelectContent]')!;
120
+ fireEvent.keyDown(content, { key: 'ArrowDown' });
121
+ fireEvent.keyDown(content, { key: ' ' });
122
+ expect(container.querySelector('[kjSelectContent]')).toHaveAttribute('hidden', '');
123
+ });
124
+
125
+ it('type-ahead jumps to matching option', async () => {
126
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
127
+ fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
128
+ const content = container.querySelector('[kjSelectContent]')!;
129
+ fireEvent.keyDown(content, { key: 'b' });
130
+ const options = container.querySelectorAll('[kjOption]');
131
+ expect(document.activeElement).toBe(options[1]);
132
+ });
133
+
134
+ it('Escape closes the listbox', async () => {
135
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
136
+ fireEvent.click(container.querySelector('[kjSelectTrigger]')!);
137
+ fireEvent.keyDown(document, { key: 'Escape' });
138
+ expect(container.querySelector('[kjSelectContent]')).toHaveAttribute('hidden', '');
139
+ });
140
+
141
+ it('passes axe audit', async () => {
142
+ const { container } = await render(template, { imports, componentProperties: { val: '' } });
143
+ expect(await axe(container)).toHaveNoViolations();
144
+ });
145
+ });
@@ -0,0 +1,250 @@
1
+ import {
2
+ afterNextRender,
3
+ computed,
4
+ Directive,
5
+ effect,
6
+ ElementRef,
7
+ inject,
8
+ input,
9
+ model,
10
+ signal,
11
+ } from '@angular/core';
12
+ import { KjDisabled, KjFocusRing } from '../primitives';
13
+ import { KJ_SELECT } from './select.context';
14
+
15
+ /**
16
+ * Root select container. Manages open/close state via `kjSelectValue` model.
17
+ *
18
+ * @example
19
+ * ```html
20
+ * <div kjSelect [(kjSelectValue)]="selected">
21
+ * <button kjSelectTrigger aria-haspopup="listbox">Choose fruit</button>
22
+ * <div kjSelectContent>
23
+ * <div kjOption [kjOptionValue]="'apple'">Apple</div>
24
+ * </div>
25
+ * </div>
26
+ * ```
27
+ * @category Core/Inputs
28
+ */
29
+ @Directive({
30
+ selector: '[kjSelect]',
31
+ standalone: true,
32
+ providers: [{ provide: KJ_SELECT, useExisting: KjSelect }],
33
+ })
34
+ export class KjSelect {
35
+ /** The currently selected value. Supports two-way binding. */
36
+ readonly kjSelectValue = model<unknown>(undefined);
37
+
38
+ /** Read-only signal for the current value. */
39
+ readonly value = this.kjSelectValue.asReadonly();
40
+
41
+ private readonly _open = signal(false);
42
+
43
+ /** Whether the listbox is currently open. */
44
+ readonly open = this._open.asReadonly();
45
+
46
+ /**
47
+ * Selects a value and closes the listbox.
48
+ * @param val The value to select.
49
+ */
50
+ select(val: unknown): void {
51
+ this.kjSelectValue.set(val);
52
+ this._open.set(false);
53
+ }
54
+
55
+ /** Toggles the open state of the listbox. */
56
+ toggle(): void {
57
+ this._open.update(v => !v);
58
+ }
59
+
60
+ /** Closes the listbox. */
61
+ hide(): void {
62
+ this._open.set(false);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Trigger button that opens/closes the select listbox.
68
+ * Automatically binds `aria-expanded` to reflect open state.
69
+ *
70
+ * @example
71
+ * ```html
72
+ * <button kjSelectTrigger aria-haspopup="listbox">Choose fruit</button>
73
+ * ```
74
+ * @category Core/Inputs
75
+ */
76
+ @Directive({
77
+ selector: '[kjSelectTrigger]',
78
+ standalone: true,
79
+ hostDirectives: [KjFocusRing],
80
+ host: {
81
+ '[attr.aria-expanded]': 'ctx.open().toString()',
82
+ '(click)': '$event.stopPropagation(); ctx.toggle()',
83
+ },
84
+ })
85
+ export class KjSelectTrigger {
86
+ /** @internal */
87
+ readonly ctx = inject(KJ_SELECT);
88
+ }
89
+
90
+ /**
91
+ * Listbox container. Provides keyboard navigation (Arrow keys, Home, End,
92
+ * Enter, Space, type-ahead) and ARIA listbox semantics. Hidden when closed.
93
+ *
94
+ * @example
95
+ * ```html
96
+ * <div kjSelectContent>
97
+ * <div kjOption [kjOptionValue]="'apple'">Apple</div>
98
+ * </div>
99
+ * ```
100
+ * @category Core/Inputs
101
+ */
102
+ @Directive({
103
+ selector: '[kjSelectContent]',
104
+ standalone: true,
105
+ host: {
106
+ 'role': 'listbox',
107
+ '[attr.hidden]': '!ctx.open() ? "" : null',
108
+ '[attr.aria-activedescendant]': 'activeId()',
109
+ '(keydown)': 'onKeydown($event)',
110
+ '(document:keydown.escape)': 'ctx.hide()',
111
+ '(document:click)': 'onDocClick()',
112
+ '(click)': '$event.stopPropagation()',
113
+ },
114
+ })
115
+ export class KjSelectContent {
116
+ private readonly el = inject(ElementRef<HTMLElement>);
117
+ /** @internal */
118
+ readonly ctx = inject(KJ_SELECT);
119
+
120
+ private readonly _activeIndex = signal(-1);
121
+
122
+ /** @internal */
123
+ readonly activeId = computed(() => {
124
+ const items = this.getOptions();
125
+ const idx = this._activeIndex();
126
+ return items[idx]?.id ?? null;
127
+ });
128
+
129
+ /** @internal */
130
+ onDocClick(): void {
131
+ if (this.ctx.open()) this.ctx.hide();
132
+ }
133
+
134
+ /** @internal */
135
+ onKeydown(e: KeyboardEvent): void {
136
+ const items = this.getOptions();
137
+ if (!items.length) return;
138
+ let idx = this._activeIndex();
139
+
140
+ switch (e.key) {
141
+ case 'ArrowDown':
142
+ e.preventDefault();
143
+ idx = Math.min(idx + 1, items.length - 1);
144
+ this._activeIndex.set(idx);
145
+ items[idx]?.el.focus();
146
+ break;
147
+ case 'ArrowUp':
148
+ e.preventDefault();
149
+ idx = Math.max(idx - 1, 0);
150
+ this._activeIndex.set(idx);
151
+ items[idx]?.el.focus();
152
+ break;
153
+ case 'Home':
154
+ e.preventDefault();
155
+ this._activeIndex.set(0);
156
+ items[0]?.el.focus();
157
+ break;
158
+ case 'End':
159
+ e.preventDefault();
160
+ this._activeIndex.set(items.length - 1);
161
+ items[items.length - 1]?.el.focus();
162
+ break;
163
+ case 'Enter':
164
+ case ' ':
165
+ e.preventDefault();
166
+ if (idx >= 0) {
167
+ const val = items[idx]?.value;
168
+ if (val !== undefined) this.ctx.select(val);
169
+ }
170
+ break;
171
+ default: {
172
+ const char = e.key.length === 1 ? e.key.toLowerCase() : null;
173
+ if (char) {
174
+ const match = items.findIndex(item =>
175
+ item.el.textContent?.trim().toLowerCase().startsWith(char)
176
+ );
177
+ if (match >= 0) {
178
+ this._activeIndex.set(match);
179
+ items[match].el.focus();
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ private getOptions(): Array<{ el: HTMLElement; value: unknown; id: string }> {
187
+ return (Array.from(
188
+ this.el.nativeElement.querySelectorAll('[kjOption]')
189
+ ) as HTMLElement[]).map((el, i) => ({
190
+ el,
191
+ value: (el as any).__kjOptionValue,
192
+ id: el.id || `kj-option-${i}`,
193
+ }));
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Individual option inside a `kjSelectContent`. Clicking or pressing Enter/Space
199
+ * calls `select()` on the parent context.
200
+ *
201
+ * @example
202
+ * ```html
203
+ * <div kjOption [kjOptionValue]="'apple'">Apple</div>
204
+ * ```
205
+ * @category Core/Inputs
206
+ */
207
+ @Directive({
208
+ selector: '[kjOption]',
209
+ standalone: true,
210
+ hostDirectives: [
211
+ { directive: KjDisabled, inputs: ['kjDisabled'] },
212
+ ],
213
+ host: {
214
+ 'role': 'option',
215
+ '[attr.tabindex]': '"0"',
216
+ '[attr.aria-selected]': 'selected().toString()',
217
+ '[attr.aria-disabled]': 'disabled() ? "true" : null',
218
+ '(click)': 'handleClick()',
219
+ '(keydown.enter)': '$event.preventDefault(); handleClick()',
220
+ '(keydown.space)': '$event.preventDefault(); handleClick()',
221
+ },
222
+ })
223
+ export class KjOption {
224
+ private readonly ctx = inject(KJ_SELECT);
225
+ private readonly el = inject(ElementRef<HTMLElement>);
226
+ /** @internal */
227
+ readonly disabled = inject(KjDisabled).disabled;
228
+
229
+ /** The value this option represents. */
230
+ readonly kjOptionValue = input.required<unknown>();
231
+
232
+ /** @internal */
233
+ readonly selected = computed(() => this.ctx.value() === this.kjOptionValue());
234
+
235
+ constructor() {
236
+ effect(() => {
237
+ (this.el.nativeElement as any).__kjOptionValue = this.kjOptionValue();
238
+ });
239
+ afterNextRender(() => {
240
+ if (!this.el.nativeElement.id) {
241
+ this.el.nativeElement.id = `kj-option-${Math.random().toString(36).slice(2, 7)}`;
242
+ }
243
+ });
244
+ }
245
+
246
+ /** @internal */
247
+ handleClick(): void {
248
+ if (!this.disabled()) this.ctx.select(this.kjOptionValue());
249
+ }
250
+ }
@@ -0,0 +1,82 @@
1
+ /* ─────────────────────────────────────────────────────────────
2
+ kouji-ui docs example themes
3
+ Three theme layers: kj.tokens (defaults) + kj.theme (overrides)
4
+ ──────────────────────────────────────────────────────────── */
5
+
6
+ @layer kj.tokens, kj.theme;
7
+
8
+ /* ── Default (dark) tokens — applied to :root and .kj-theme-default ── */
9
+ @layer kj.tokens {
10
+ :root,
11
+ .kj-theme-default {
12
+ --kj-bg: #0c0c0c;
13
+ --kj-surface: #1a1a1a;
14
+ --kj-text: #f0ede6;
15
+ --kj-text-muted: #888;
16
+ --kj-border: #333;
17
+ --kj-accent: #b8f500;
18
+ --kj-accent-on: #0c0c0c;
19
+ --kj-destructive: #ef4444;
20
+ --kj-radius-sm: 0px;
21
+ --kj-radius-md: 0px;
22
+ --kj-radius-lg: 0px;
23
+ --kj-shadow-sm: none;
24
+ --kj-shadow-md: none;
25
+ --kj-shadow-hard: none;
26
+ --kj-font: 'JetBrains Mono', monospace;
27
+ --kj-btn-border: none;
28
+ --kj-btn-outline-border: 1px solid rgba(240, 237, 230, 0.6);
29
+ --kj-btn-ghost-color: rgba(240, 237, 230, 0.55);
30
+ --kj-backdrop: rgba(0, 0, 0, 0.75);
31
+ --kj-transition: opacity 0.15s;
32
+ }
33
+ }
34
+
35
+ /* ── Theme overrides ── */
36
+ @layer kj.theme {
37
+ .kj-theme-retro {
38
+ --kj-bg: #fef9c3;
39
+ --kj-surface: #fef9c3;
40
+ --kj-text: #000;
41
+ --kj-text-muted: #444;
42
+ --kj-border: #000;
43
+ --kj-accent: #16a34a;
44
+ --kj-accent-on: #fff;
45
+ --kj-destructive: #dc2626;
46
+ --kj-radius-sm: 0px;
47
+ --kj-radius-md: 0px;
48
+ --kj-radius-lg: 0px;
49
+ --kj-shadow-sm: 2px 2px 0 #000;
50
+ --kj-shadow-md: 4px 4px 0 #000;
51
+ --kj-shadow-hard: 6px 6px 0 #000;
52
+ --kj-font: 'Courier New', monospace;
53
+ --kj-btn-border: 2px solid #000;
54
+ --kj-btn-outline-border: 2px solid #000;
55
+ --kj-btn-ghost-color: #555;
56
+ --kj-backdrop: rgba(0, 0, 0, 0.6);
57
+ --kj-transition: transform 0.08s, box-shadow 0.08s;
58
+ }
59
+
60
+ .kj-theme-finance {
61
+ --kj-bg: #f9fafb;
62
+ --kj-surface: #fff;
63
+ --kj-text: #111827;
64
+ --kj-text-muted: #6b7280;
65
+ --kj-border: #e5e7eb;
66
+ --kj-accent: #3b82f6;
67
+ --kj-accent-on: #fff;
68
+ --kj-destructive: #ef4444;
69
+ --kj-radius-sm: 4px;
70
+ --kj-radius-md: 6px;
71
+ --kj-radius-lg: 12px;
72
+ --kj-shadow-sm: 0 4px 12px rgba(0, 0, 0, 0.1);
73
+ --kj-shadow-md: 0 8px 30px rgba(0, 0, 0, 0.1);
74
+ --kj-shadow-hard: 0 20px 60px rgba(0, 0, 0, 0.12);
75
+ --kj-font: system-ui, -apple-system, sans-serif;
76
+ --kj-btn-border: 1px solid transparent;
77
+ --kj-btn-outline-border: 1px solid #6b7280;
78
+ --kj-btn-ghost-color: #374151;
79
+ --kj-backdrop: rgba(0, 0, 0, 0.4);
80
+ --kj-transition: background 0.12s;
81
+ }
82
+ }
@@ -0,0 +1 @@
1
+ export { KjTable, KjTableHeader, KJ_TABLE, type KjSortDirection } from './table';