kern-ui 0.1.0

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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1078 -0
  3. package/dist/kern.css +705 -0
  4. package/dist/kern.js +529 -0
  5. package/dist/kern.min.css +1 -0
  6. package/dist/kern.min.js +17 -0
  7. package/package.json +43 -0
  8. package/src/base/index.css +3 -0
  9. package/src/base/reset.css +9 -0
  10. package/src/base/typography.css +26 -0
  11. package/src/components/accordion.css +21 -0
  12. package/src/components/alert.css +13 -0
  13. package/src/components/avatar.css +9 -0
  14. package/src/components/badge.css +18 -0
  15. package/src/components/breadcrumb.css +8 -0
  16. package/src/components/button.css +43 -0
  17. package/src/components/card.css +13 -0
  18. package/src/components/dialog.css +19 -0
  19. package/src/components/drawer.css +12 -0
  20. package/src/components/dropdown.css +22 -0
  21. package/src/components/form.css +53 -0
  22. package/src/components/index.css +22 -0
  23. package/src/components/pagination.css +6 -0
  24. package/src/components/progress.css +15 -0
  25. package/src/components/sidebar.css +16 -0
  26. package/src/components/skeleton.css +8 -0
  27. package/src/components/stepper.css +12 -0
  28. package/src/components/switch.css +11 -0
  29. package/src/components/table.css +17 -0
  30. package/src/components/tabs.css +20 -0
  31. package/src/components/toast.css +31 -0
  32. package/src/components/tooltip.css +31 -0
  33. package/src/kern.css +14 -0
  34. package/src/kern.js +427 -0
  35. package/src/layout/divider.css +5 -0
  36. package/src/layout/grid.css +12 -0
  37. package/src/layout/index.css +4 -0
  38. package/src/layout/stack.css +13 -0
  39. package/src/tokens/accent.css +20 -0
  40. package/src/tokens/colors.css +37 -0
  41. package/src/tokens/components.css +17 -0
  42. package/src/tokens/index.css +10 -0
  43. package/src/tokens/motion.css +12 -0
  44. package/src/tokens/radius.css +20 -0
  45. package/src/tokens/shadow.css +25 -0
  46. package/src/tokens/spacing.css +13 -0
  47. package/src/tokens/typography.css +10 -0
  48. package/src/tokens/z-index.css +9 -0
  49. package/src/utils/helpers.css +13 -0
  50. package/src/utils/index.css +4 -0
  51. package/src/utils/keyframes.css +11 -0
  52. package/src/utils/responsive.css +8 -0
  53. package/src-js/api.js +103 -0
  54. package/src-js/behaviors/accordion.js +51 -0
  55. package/src-js/behaviors/table-sort.js +61 -0
  56. package/src-js/behaviors/toggle.js +27 -0
  57. package/src-js/boot.js +37 -0
  58. package/src-js/components/dialog.js +72 -0
  59. package/src-js/components/drawer.js +60 -0
  60. package/src-js/components/dropdown.js +88 -0
  61. package/src-js/components/tabs.js +92 -0
  62. package/src-js/components/toaster.js +89 -0
  63. package/src-js/kern.js +34 -0
  64. package/src-js/utils.js +33 -0
@@ -0,0 +1,13 @@
1
+ /* kern/src/utils/helpers.css */
2
+ [data-sr-only]{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}
3
+ [data-truncate]{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
4
+ [data-surface]{background:var(--k-surface)}
5
+ [data-surface="2"]{background:var(--k-surface-2)}
6
+ [data-rounded]{border-radius:var(--k-r)}
7
+ [data-rounded="full"]{border-radius:var(--k-r-full)}
8
+ [data-container]{width:100%;max-width:1200px;margin:0 auto;padding:0 var(--k-sp-6)}
9
+ [data-container="sm"]{max-width:720px}
10
+ [data-container="lg"]{max-width:1440px}
11
+ [data-scroll]{overflow:auto}
12
+ [data-scroll="x"]{overflow-y:hidden;overflow-x:auto}
13
+ [data-scroll="y"]{overflow-x:hidden;overflow-y:auto}
@@ -0,0 +1,4 @@
1
+ /* kern/src/utils/index.css */
2
+ @import './helpers.css';
3
+ @import './keyframes.css';
4
+ @import './responsive.css';
@@ -0,0 +1,11 @@
1
+ /* kern/src/utils/keyframes.css — all @keyframes */
2
+ @keyframes k-spin{to{transform:rotate(360deg)}}
3
+ @keyframes k-fade-in{from{opacity:0}to{opacity:1}}
4
+ @keyframes k-dialog-in{from{opacity:0;transform:translateY(12px) scale(.97)}to{opacity:1;transform:none}}
5
+ @keyframes k-toast-in{from{opacity:0;transform:translateX(16px)}to{opacity:1;transform:none}}
6
+ @keyframes k-toast-out{from{opacity:1;transform:translateX(0);max-height:100px}to{opacity:0;transform:translateX(16px);max-height:0;padding:0;margin:0}}
7
+ @keyframes k-drawer-in{from{transform:translateX(100%)}to{transform:none}}
8
+ @keyframes k-drawer-in-left{from{transform:translateX(-100%)}to{transform:none}}
9
+ @keyframes k-skeleton{0%{background-position:200% 0}100%{background-position:-200% 0}}
10
+ @keyframes k-progress-stripe{from{background-position:0 0}to{background-position:28px 0}}
11
+ @keyframes k-progress-indeterminate{0%{transform:translateX(-100%)}100%{transform:translateX(350%)}}
@@ -0,0 +1,8 @@
1
+ /* kern/src/utils/responsive.css */
2
+ @media(max-width:768px){
3
+ [data-sidebar]{display:none}
4
+ [data-sidebar][data-mobile-open]{display:flex;position:fixed;inset:0;z-index:var(--k-z-modal);width:280px}
5
+ dialog{width:calc(100vw - 1rem)}
6
+ [data-drawer-panel]{width:100%;max-width:100%}
7
+ h1{font-size:1.875rem}h2{font-size:1.5rem}
8
+ }
package/src-js/api.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The global Kern API object.
3
+ * Consumed by kern.js and exposed as window.Kern.
4
+ */
5
+
6
+ import { initAccordions } from './behaviors/accordion.js';
7
+ import { initToggles } from './behaviors/toggle.js';
8
+ import { initTableSort } from './behaviors/table-sort.js';
9
+
10
+ export const Kern = {
11
+ version: '0.1.0',
12
+
13
+ /**
14
+ * Show a toast notification.
15
+ * @param {string|Object} options - string shorthand or options object
16
+ */
17
+ toast(options = {}) {
18
+ if (typeof options === 'string') options = { message: options };
19
+
20
+ const position = options.position ?? 'bottom-right';
21
+
22
+ // Find or create a toaster at the given position
23
+ let toaster = document.querySelector(`kern-toaster[data-position="${position}"]`)
24
+ ?? document.querySelector('kern-toaster');
25
+
26
+ if (!toaster) {
27
+ toaster = document.createElement('kern-toaster');
28
+ toaster.setAttribute('data-position', position);
29
+ document.body.appendChild(toaster);
30
+ }
31
+
32
+ return toaster.add(options);
33
+ },
34
+
35
+ /**
36
+ * Open a dialog by ID or selector.
37
+ * @param {string|HTMLElement} selector
38
+ */
39
+ dialog(selector) {
40
+ const el = typeof selector === 'string'
41
+ ? (document.getElementById(selector) ?? document.querySelector(selector))
42
+ : selector;
43
+
44
+ const wc = el?.closest?.('kern-dialog') ?? el;
45
+
46
+ if (typeof wc?.open === 'function') wc.open();
47
+ else if (el instanceof HTMLDialogElement) el.showModal();
48
+
49
+ return wc;
50
+ },
51
+
52
+ /**
53
+ * Open a drawer by ID or selector.
54
+ * @param {string|HTMLElement} selector
55
+ */
56
+ drawer(selector) {
57
+ const el = typeof selector === 'string'
58
+ ? (document.getElementById(selector) ?? document.querySelector(selector))
59
+ : selector;
60
+
61
+ const wc = el?.closest?.('kern-drawer') ?? el;
62
+
63
+ if (typeof wc?.open === 'function') wc.open();
64
+ return wc;
65
+ },
66
+
67
+ /**
68
+ * Set the accent color preset.
69
+ * @param {string} name - amber | blue | green | red | violet | rose | cyan | orange
70
+ */
71
+ setAccent(name) {
72
+ document.documentElement.setAttribute('data-accent', name);
73
+ },
74
+
75
+ /**
76
+ * Set the theme.
77
+ * @param {'dark'|'light'} theme
78
+ */
79
+ setTheme(theme) {
80
+ document.documentElement.setAttribute('data-theme', theme);
81
+ try { localStorage.setItem('kern-theme', theme); } catch (_) { }
82
+ },
83
+
84
+ /**
85
+ * Set the global border radius preset.
86
+ * @param {'none'|'sharp'|'default'|'round'} name
87
+ */
88
+ setRadius(name) {
89
+ if (name === 'default') document.documentElement.removeAttribute('data-radius');
90
+ else document.documentElement.setAttribute('data-radius', name);
91
+ },
92
+
93
+ /**
94
+ * (Re-)initialize all vanilla JS behaviors in a root element.
95
+ * Called automatically on boot and for dynamically injected HTML.
96
+ * @param {Element|Document} root
97
+ */
98
+ init(root = document) {
99
+ initAccordions(root);
100
+ initToggles(root);
101
+ initTableSort(root);
102
+ },
103
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * kern/src-js/behaviors/accordion.js
3
+ * Wires [data-accordion-trigger] click behavior.
4
+ * Called by kern.js boot + MutationObserver.
5
+ *
6
+ * HTML:
7
+ * <div data-accordion> <!-- add data-multi for multi-open -->
8
+ * <div data-accordion-item>
9
+ * <button data-accordion-trigger>Title</button>
10
+ * <div data-accordion-content>
11
+ * <div data-accordion-body>Body text</div>
12
+ * </div>
13
+ * </div>
14
+ * </div>
15
+ */
16
+
17
+ export function initAccordions(root = document) {
18
+ root.querySelectorAll('[data-accordion-trigger]').forEach(trigger => {
19
+ if (trigger._kernInit) return;
20
+ trigger._kernInit = true;
21
+
22
+ trigger.setAttribute('aria-expanded', 'false');
23
+ trigger.setAttribute('type', 'button');
24
+
25
+ const content = trigger
26
+ .closest('[data-accordion-item]')
27
+ ?.querySelector('[data-accordion-content]');
28
+
29
+ if (!content) return;
30
+
31
+ trigger.addEventListener('click', () => {
32
+ const isOpen = trigger.getAttribute('aria-expanded') === 'true';
33
+ const accordion = trigger.closest('[data-accordion]');
34
+
35
+ // Close others unless data-multi is present
36
+ if (accordion && !accordion.hasAttribute('data-multi')) {
37
+ accordion.querySelectorAll('[data-accordion-trigger]').forEach(other => {
38
+ if (other === trigger) return;
39
+ other.setAttribute('aria-expanded', 'false');
40
+ other
41
+ .closest('[data-accordion-item]')
42
+ ?.querySelector('[data-accordion-content]')
43
+ ?.removeAttribute('data-open');
44
+ });
45
+ }
46
+
47
+ trigger.setAttribute('aria-expanded', isOpen ? 'false' : 'true');
48
+ isOpen ? content.removeAttribute('data-open') : content.setAttribute('data-open', '');
49
+ });
50
+ });
51
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * kern/src-js/behaviors/table-sort.js
3
+ * Client-side column sorting for <th data-sort> columns.
4
+ *
5
+ * Usage:
6
+ * <table>
7
+ * <thead><tr>
8
+ * <th>Name</th>
9
+ * <th data-sort>Role</th> <!-- sortable -->
10
+ * </tr></thead>
11
+ * <tbody>...</tbody>
12
+ * </table>
13
+ *
14
+ * Cycles: unsorted → asc → desc
15
+ */
16
+
17
+ export function initTableSort(root = document) {
18
+ root.querySelectorAll('th[data-sort]').forEach(th => {
19
+ if (th._kernInit) return;
20
+ th._kernInit = true;
21
+
22
+ th.style.cursor = 'pointer';
23
+ th.setAttribute('role', 'columnheader');
24
+ th.setAttribute('aria-sort', 'none');
25
+
26
+ th.addEventListener('click', () => {
27
+ const table = th.closest('table');
28
+ const tbody = table?.querySelector('tbody');
29
+ if (!tbody) return;
30
+
31
+ const colIndex = [...th.parentElement.children].indexOf(th);
32
+ const current = th.getAttribute('data-sort');
33
+ const asc = current !== 'asc';
34
+
35
+ // Reset all other sortable headers
36
+ table.querySelectorAll('th[data-sort]').forEach(h => {
37
+ h.setAttribute('data-sort', '');
38
+ h.setAttribute('aria-sort', 'none');
39
+ });
40
+
41
+ th.setAttribute('data-sort', asc ? 'asc' : 'desc');
42
+ th.setAttribute('aria-sort', asc ? 'ascending' : 'descending');
43
+
44
+ const rows = [...tbody.querySelectorAll('tr')];
45
+
46
+ rows.sort((a, b) => {
47
+ const av = a.cells[colIndex]?.textContent.trim() ?? '';
48
+ const bv = b.cells[colIndex]?.textContent.trim() ?? '';
49
+
50
+ // Try numeric sort first
51
+ const an = parseFloat(av.replace(/[^0-9.-]/g, ''));
52
+ const bn = parseFloat(bv.replace(/[^0-9.-]/g, ''));
53
+
54
+ if (!isNaN(an) && !isNaN(bn)) return asc ? an - bn : bn - an;
55
+ return asc ? av.localeCompare(bv) : bv.localeCompare(av);
56
+ });
57
+
58
+ rows.forEach(row => tbody.appendChild(row));
59
+ });
60
+ });
61
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * kern/src-js/behaviors/toggle.js
3
+ * Wires data-toggle="target-id" to open kern WC dialogs/drawers.
4
+ *
5
+ * Usage:
6
+ * <button data-toggle="my-dialog">Open</button>
7
+ * <kern-dialog><dialog id="my-dialog">...</dialog></kern-dialog>
8
+ */
9
+
10
+ export function initToggles(root = document) {
11
+ root.querySelectorAll('[data-toggle]').forEach(el => {
12
+ if (el._kernInit) return;
13
+ el._kernInit = true;
14
+
15
+ el.addEventListener('click', () => {
16
+ const targetId = el.getAttribute('data-toggle');
17
+ const target = document.getElementById(targetId);
18
+ if (!target) return;
19
+
20
+ // Walk up to find the WC wrapper (kern-dialog, kern-drawer, etc.)
21
+ const wc = target.closest('kern-dialog, kern-drawer') || target;
22
+
23
+ if (typeof wc.toggle === 'function') wc.toggle();
24
+ else if (typeof wc.open === 'function') wc.open();
25
+ });
26
+ });
27
+ }
package/src-js/boot.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * kern/src-js/boot.js
3
+ * Initialization sequence:
4
+ * 1. Restore saved theme from localStorage
5
+ * 2. Run Kern.init() on DOMContentLoaded
6
+ * 3. Watch for new DOM nodes via MutationObserver
7
+ */
8
+
9
+ import { Kern } from './api.js';
10
+
11
+ function boot() {
12
+ // Restore persisted theme before first paint
13
+ try {
14
+ const saved = localStorage.getItem('kern-theme');
15
+ if (saved) document.documentElement.setAttribute('data-theme', saved);
16
+ } catch (_) {}
17
+
18
+ // Initialize existing DOM
19
+ Kern.init();
20
+
21
+ // Auto-initialize dynamically injected HTML (htmx, alpine, fetch-swapped content, etc.)
22
+ new MutationObserver((mutations) => {
23
+ mutations.forEach(mutation => {
24
+ mutation.addedNodes.forEach(node => {
25
+ if (node.nodeType === Node.ELEMENT_NODE) {
26
+ Kern.init(node);
27
+ }
28
+ });
29
+ });
30
+ }).observe(document.body, { childList: true, subtree: true });
31
+ }
32
+
33
+ if (document.readyState === 'loading') {
34
+ document.addEventListener('DOMContentLoaded', boot);
35
+ } else {
36
+ boot();
37
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * kern/src-js/components/dialog.js
3
+ * <kern-dialog> Web Component — wraps native <dialog>
4
+ *
5
+ * Usage:
6
+ * <kern-dialog>
7
+ * <dialog id="my-dialog">
8
+ * <div data-dialog-header>
9
+ * <div><div data-dialog-title>Title</div></div>
10
+ * <button data-dialog-close>✕</button>
11
+ * </div>
12
+ * <div data-dialog-body>...</div>
13
+ * <div data-dialog-footer>...</div>
14
+ * </dialog>
15
+ * </kern-dialog>
16
+ *
17
+ * Trigger: data-toggle="dialog-id" on any element
18
+ * JS API: el.open() | el.close() | el.toggle()
19
+ * Kern.dialog('id')
20
+ * Events: kern:dialog-open | kern:dialog-close
21
+ */
22
+
23
+ import { emit, firstFocusable } from '../utils.js';
24
+
25
+ export class KernDialog extends HTMLElement {
26
+ connectedCallback() {
27
+ this._dialog = this.querySelector('dialog');
28
+ if (!this._dialog) return;
29
+ this._setup();
30
+ }
31
+
32
+ _setup() {
33
+ const d = this._dialog;
34
+
35
+ // Wire close buttons
36
+ this.querySelectorAll('[data-dialog-close]').forEach(btn => {
37
+ btn.addEventListener('click', () => this.close());
38
+ });
39
+
40
+ // Backdrop click to close (unless opted out)
41
+ d.addEventListener('click', (e) => {
42
+ if (e.target === d && !d.hasAttribute('data-no-backdrop-close')) this.close();
43
+ });
44
+
45
+ // ESC (native cancel event)
46
+ d.addEventListener('cancel', (e) => {
47
+ e.preventDefault();
48
+ this.close();
49
+ });
50
+ }
51
+
52
+ open() {
53
+ if (!this._dialog) return;
54
+ this._dialog.showModal();
55
+ // Focus first focusable element
56
+ const f = firstFocusable(this._dialog);
57
+ if (f) setTimeout(() => f.focus(), 40);
58
+ emit(this, 'kern:dialog-open');
59
+ }
60
+
61
+ close() {
62
+ if (!this._dialog || !this._dialog.open) return;
63
+ this._dialog.close();
64
+ emit(this, 'kern:dialog-close');
65
+ }
66
+
67
+ toggle() {
68
+ this._dialog?.open ? this.close() : this.open();
69
+ }
70
+ }
71
+
72
+ customElements.define('kern-dialog', KernDialog);
@@ -0,0 +1,60 @@
1
+ /**
2
+ * kern/src-js/components/drawer.js
3
+ * <kern-drawer> Web Component
4
+ *
5
+ * Usage:
6
+ * <kern-drawer>
7
+ * <div data-drawer-backdrop></div>
8
+ * <div data-drawer-panel>
9
+ * <div data-drawer-header>
10
+ * <span data-drawer-title>Title</span>
11
+ * <button data-drawer-close>✕</button>
12
+ * </div>
13
+ * <div data-drawer-body>...</div>
14
+ * <div data-drawer-footer>...</div>
15
+ * </div>
16
+ * </kern-drawer>
17
+ *
18
+ * Options: data-side="left" data-size="sm|lg"
19
+ * JS API: el.open() | el.close() | el.toggle()
20
+ * Events: kern:drawer-open | kern:drawer-close
21
+ */
22
+
23
+ import { emit, firstFocusable } from '../utils.js';
24
+
25
+ export class KernDrawer extends HTMLElement {
26
+ connectedCallback() {
27
+ this._panel = this.querySelector('[data-drawer-panel]');
28
+ this._backdrop = this.querySelector('[data-drawer-backdrop]');
29
+
30
+ this._onKey = (e) => { if (e.key === 'Escape') this.close(); };
31
+
32
+ this.querySelectorAll('[data-drawer-close]').forEach(btn => {
33
+ btn.addEventListener('click', () => this.close());
34
+ });
35
+
36
+ this._backdrop?.addEventListener('click', () => this.close());
37
+ }
38
+
39
+ open() {
40
+ this.setAttribute('open', '');
41
+ document.body.style.overflow = 'hidden';
42
+ document.addEventListener('keydown', this._onKey);
43
+ const f = firstFocusable(this._panel);
44
+ if (f) setTimeout(() => f.focus(), 60);
45
+ emit(this, 'kern:drawer-open');
46
+ }
47
+
48
+ close() {
49
+ this.removeAttribute('open');
50
+ document.body.style.overflow = '';
51
+ document.removeEventListener('keydown', this._onKey);
52
+ emit(this, 'kern:drawer-close');
53
+ }
54
+
55
+ toggle() {
56
+ this.hasAttribute('open') ? this.close() : this.open();
57
+ }
58
+ }
59
+
60
+ customElements.define('kern-drawer', KernDrawer);
@@ -0,0 +1,88 @@
1
+ /**
2
+ * kern/src-js/components/dropdown.js
3
+ * <kern-dropdown> Web Component
4
+ *
5
+ * Usage:
6
+ * <kern-dropdown>
7
+ * <button data-dropdown-trigger>Open ▾</button>
8
+ * <div data-dropdown-content>
9
+ * <a data-dropdown-item href="#">Item</a>
10
+ * </div>
11
+ * </kern-dropdown>
12
+ *
13
+ * Alignment: data-align="end"
14
+ * JS API: el.open() | el.close() | el.toggle()
15
+ * Events: kern:dropdown-open | kern:dropdown-close
16
+ */
17
+
18
+ import { emit } from '../utils.js';
19
+
20
+ export class KernDropdown extends HTMLElement {
21
+ connectedCallback() {
22
+ this._trigger = this.querySelector('[data-dropdown-trigger]') || this.firstElementChild;
23
+ this._content = this.querySelector('[data-dropdown-content]');
24
+ this._onOutside = (e) => { if (!this.contains(e.target)) this.close(); };
25
+
26
+ if (!this._trigger) return;
27
+
28
+ this._trigger.setAttribute('aria-haspopup', 'true');
29
+ this._trigger.setAttribute('aria-expanded', 'false');
30
+
31
+ this._trigger.addEventListener('click', (e) => {
32
+ e.stopPropagation();
33
+ this.hasAttribute('open') ? this.close() : this.open();
34
+ });
35
+
36
+ this._trigger.addEventListener('keydown', (e) => {
37
+ if (e.key === 'ArrowDown') { e.preventDefault(); this.open(); this._focusFirst(); }
38
+ if (e.key === 'Escape') this.close();
39
+ });
40
+
41
+ if (this._content) {
42
+ const items = () => [...this._content.querySelectorAll('[data-dropdown-item]:not([disabled])')];
43
+
44
+ this._content.addEventListener('keydown', (e) => {
45
+ const list = items();
46
+ const idx = list.indexOf(document.activeElement);
47
+ if (e.key === 'ArrowDown') { e.preventDefault(); list[idx + 1]?.focus() ?? list[0]?.focus(); }
48
+ if (e.key === 'ArrowUp') { e.preventDefault(); idx > 0 ? list[idx - 1]?.focus() : this._trigger.focus(); }
49
+ if (e.key === 'Escape') { this.close(); this._trigger.focus(); }
50
+ if (e.key === 'Tab') this.close();
51
+ });
52
+
53
+ items().forEach(item => {
54
+ item.setAttribute('role', 'menuitem');
55
+ item.setAttribute('tabindex', '-1');
56
+ });
57
+ }
58
+ }
59
+
60
+ disconnectedCallback() {
61
+ document.removeEventListener('click', this._onOutside);
62
+ }
63
+
64
+ open() {
65
+ this.setAttribute('open', '');
66
+ this._trigger?.setAttribute('aria-expanded', 'true');
67
+ document.addEventListener('click', this._onOutside);
68
+ emit(this, 'kern:dropdown-open');
69
+ }
70
+
71
+ close() {
72
+ this.removeAttribute('open');
73
+ this._trigger?.setAttribute('aria-expanded', 'false');
74
+ document.removeEventListener('click', this._onOutside);
75
+ emit(this, 'kern:dropdown-close');
76
+ }
77
+
78
+ toggle() {
79
+ this.hasAttribute('open') ? this.close() : this.open();
80
+ }
81
+
82
+ _focusFirst() {
83
+ const first = this._content?.querySelector('[data-dropdown-item]:not([disabled])');
84
+ setTimeout(() => first?.focus(), 10);
85
+ }
86
+ }
87
+
88
+ customElements.define('kern-dropdown', KernDropdown);
@@ -0,0 +1,92 @@
1
+ /**
2
+ * kern/src-js/components/tabs.js
3
+ * <kern-tabs> Web Component
4
+ *
5
+ * Usage:
6
+ * <kern-tabs>
7
+ * <div data-tabs-list>
8
+ * <button data-tab>Tab 1</button>
9
+ * <button data-tab>Tab 2</button>
10
+ * </div>
11
+ * <div data-tab-panel>Panel 1</div>
12
+ * <div data-tab-panel>Panel 2</div>
13
+ * </kern-tabs>
14
+ *
15
+ * Variants: data-variant="pills"
16
+ * JS API: el.goto(index) | el.setActive(index)
17
+ * Events: kern:tab-change → { index }
18
+ */
19
+
20
+ import { emit } from '../utils.js';
21
+
22
+ export class KernTabs extends HTMLElement {
23
+ connectedCallback() {
24
+ this._init();
25
+ }
26
+
27
+ _init() {
28
+ const tabs = [...this.querySelectorAll('[data-tab]')];
29
+ const panels = [...this.querySelectorAll('[data-tab-panel]')];
30
+
31
+ // Find the initial active index (first by default)
32
+ const initialActive = Math.max(0, tabs.findIndex(t => t.hasAttribute('data-active')));
33
+
34
+ tabs.forEach((tab, i) => {
35
+ // ARIA
36
+ tab.setAttribute('role', 'tab');
37
+ tab.setAttribute('tabindex', i === initialActive ? '0' : '-1');
38
+ tab.setAttribute('aria-selected', i === initialActive ? 'true' : 'false');
39
+
40
+ // Wire panel id
41
+ if (panels[i]) {
42
+ const panelId = panels[i].id || `k-panel-${Math.random().toString(36).slice(2)}`;
43
+ panels[i].id = panelId;
44
+ tab.setAttribute('aria-controls', panelId);
45
+ panels[i].setAttribute('role', 'tabpanel');
46
+ panels[i].setAttribute('aria-labelledby', tab.id || `k-tab-${i}`);
47
+ }
48
+
49
+ tab.addEventListener('click', () => this.goto(i));
50
+
51
+ tab.addEventListener('keydown', (e) => {
52
+ const count = tabs.length;
53
+ if (e.key === 'ArrowRight') { e.preventDefault(); this.goto((i + 1) % count); }
54
+ if (e.key === 'ArrowLeft') { e.preventDefault(); this.goto((i - 1 + count) % count); }
55
+ if (e.key === 'Home') { e.preventDefault(); this.goto(0); }
56
+ if (e.key === 'End') { e.preventDefault(); this.goto(count - 1); }
57
+ });
58
+ });
59
+
60
+ // Activate initial panel (no focus)
61
+ this._activate(initialActive, false);
62
+ }
63
+
64
+ goto(index) {
65
+ this._activate(index, true);
66
+ }
67
+
68
+ setActive(index) {
69
+ this._activate(index, false);
70
+ }
71
+
72
+ _activate(index, focus = false) {
73
+ const tabs = [...this.querySelectorAll('[data-tab]')];
74
+ const panels = [...this.querySelectorAll('[data-tab-panel]')];
75
+
76
+ tabs.forEach((tab, i) => {
77
+ const active = i === index;
78
+ tab.setAttribute('aria-selected', active ? 'true' : 'false');
79
+ tab.setAttribute('tabindex', active ? '0' : '-1');
80
+ });
81
+
82
+ panels.forEach((panel, i) => {
83
+ i === index ? panel.setAttribute('data-active', '') : panel.removeAttribute('data-active');
84
+ });
85
+
86
+ if (focus && tabs[index]) tabs[index].focus();
87
+
88
+ emit(this, 'kern:tab-change', { index });
89
+ }
90
+ }
91
+
92
+ customElements.define('kern-tabs', KernTabs);