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.
- package/LICENSE +21 -0
- package/README.md +1078 -0
- package/dist/kern.css +705 -0
- package/dist/kern.js +529 -0
- package/dist/kern.min.css +1 -0
- package/dist/kern.min.js +17 -0
- package/package.json +43 -0
- package/src/base/index.css +3 -0
- package/src/base/reset.css +9 -0
- package/src/base/typography.css +26 -0
- package/src/components/accordion.css +21 -0
- package/src/components/alert.css +13 -0
- package/src/components/avatar.css +9 -0
- package/src/components/badge.css +18 -0
- package/src/components/breadcrumb.css +8 -0
- package/src/components/button.css +43 -0
- package/src/components/card.css +13 -0
- package/src/components/dialog.css +19 -0
- package/src/components/drawer.css +12 -0
- package/src/components/dropdown.css +22 -0
- package/src/components/form.css +53 -0
- package/src/components/index.css +22 -0
- package/src/components/pagination.css +6 -0
- package/src/components/progress.css +15 -0
- package/src/components/sidebar.css +16 -0
- package/src/components/skeleton.css +8 -0
- package/src/components/stepper.css +12 -0
- package/src/components/switch.css +11 -0
- package/src/components/table.css +17 -0
- package/src/components/tabs.css +20 -0
- package/src/components/toast.css +31 -0
- package/src/components/tooltip.css +31 -0
- package/src/kern.css +14 -0
- package/src/kern.js +427 -0
- package/src/layout/divider.css +5 -0
- package/src/layout/grid.css +12 -0
- package/src/layout/index.css +4 -0
- package/src/layout/stack.css +13 -0
- package/src/tokens/accent.css +20 -0
- package/src/tokens/colors.css +37 -0
- package/src/tokens/components.css +17 -0
- package/src/tokens/index.css +10 -0
- package/src/tokens/motion.css +12 -0
- package/src/tokens/radius.css +20 -0
- package/src/tokens/shadow.css +25 -0
- package/src/tokens/spacing.css +13 -0
- package/src/tokens/typography.css +10 -0
- package/src/tokens/z-index.css +9 -0
- package/src/utils/helpers.css +13 -0
- package/src/utils/index.css +4 -0
- package/src/utils/keyframes.css +11 -0
- package/src/utils/responsive.css +8 -0
- package/src-js/api.js +103 -0
- package/src-js/behaviors/accordion.js +51 -0
- package/src-js/behaviors/table-sort.js +61 -0
- package/src-js/behaviors/toggle.js +27 -0
- package/src-js/boot.js +37 -0
- package/src-js/components/dialog.js +72 -0
- package/src-js/components/drawer.js +60 -0
- package/src-js/components/dropdown.js +88 -0
- package/src-js/components/tabs.js +92 -0
- package/src-js/components/toaster.js +89 -0
- package/src-js/kern.js +34 -0
- 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,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);
|