@akonwi/mica 0.9.0 → 0.11.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/README.md +27 -2
- package/breadcrumbs.d.ts +5 -0
- package/breadcrumbs.js +74 -0
- package/header.d.ts +7 -0
- package/header.js +172 -0
- package/mica.css +872 -12
- package/package.json +34 -3
- package/sidebar.d.ts +11 -0
- package/sidebar.js +135 -0
- package/table.d.ts +5 -0
- package/table.js +59 -0
- package/tabs.d.ts +2 -1
- package/tabs.js +139 -17
- package/types/react.d.ts +9 -1
package/README.md
CHANGED
|
@@ -62,8 +62,9 @@ Every component states where its behavior comes from
|
|
|
62
62
|
tooltips, toasts. Delete the stylesheet and everything still works.
|
|
63
63
|
- **JS-enhanced — script, honestly.** Where accessibility genuinely requires
|
|
64
64
|
JS: `tabs.js`, `combobox.js`, `field.js` (declarative validation),
|
|
65
|
-
`select.js`, `toast.js
|
|
66
|
-
working markup — never renders it. There
|
|
65
|
+
`select.js`, `toast.js`, `header.js`, `sidebar.js` (responsive navigation). Each is a tiny
|
|
66
|
+
standalone module that enhances working markup — never renders it. There
|
|
67
|
+
is no shared runtime.
|
|
67
68
|
|
|
68
69
|
`m-avatar` accepts authored initials or a direct native `img`, both CSS-only.
|
|
69
70
|
`avatar.js` is a separate opt-in visual enhancement: importing it locally
|
|
@@ -96,3 +97,27 @@ Read [VISION.md](VISION.md) for the philosophy and
|
|
|
96
97
|
## License
|
|
97
98
|
|
|
98
99
|
[MIT](LICENSE)
|
|
100
|
+
|
|
101
|
+
Nested header navigation uses `nav[data-navigation-menu]` with native
|
|
102
|
+
`details`/`summary` groups and authored `[data-nav-panel]` content. Import
|
|
103
|
+
`header.js` for floating desktop panels and inline mobile disclosures. See
|
|
104
|
+
[the navigation menu docs](https://akonwi.io/mica/docs/navigation-menu.html).
|
|
105
|
+
|
|
106
|
+
Bottom navigation uses `nav[data-bottom-nav]` and native links, with optional
|
|
107
|
+
consumer-authored icons. No JavaScript module is needed. See the
|
|
108
|
+
[bottom navigation docs](https://akonwi.io/mica/docs/bottom-navigation.html)
|
|
109
|
+
for a shell that keeps content clear of the bar.
|
|
110
|
+
|
|
111
|
+
Breadcrumbs use `nav[data-breadcrumbs]` with an ordered list of links and a
|
|
112
|
+
current-page label. Optional `breadcrumbs.js` enhances an authored ancestor
|
|
113
|
+
disclosure. See [the breadcrumbs docs](https://akonwi.io/mica/docs/breadcrumbs.html).
|
|
114
|
+
|
|
115
|
+
Data collections compose native `table[data-table]`, a stable filter/selection
|
|
116
|
+
toolbar, and `nav[data-pagination]`. The app owns all data operations; optional
|
|
117
|
+
`table.js` only adds overflow indicators to `m-table-scroll`. See the
|
|
118
|
+
[data table](https://akonwi.io/mica/docs/data-table.html) and
|
|
119
|
+
[pagination](https://akonwi.io/mica/docs/pagination.html) docs.
|
|
120
|
+
|
|
121
|
+
Inline alerts and guidance use CSS-only `m-callout` with neutral, success,
|
|
122
|
+
warning, and danger treatments. Icons and actions are optional authored
|
|
123
|
+
content; see [callout docs](https://akonwi.io/mica/docs/callout.html).
|
package/breadcrumbs.d.ts
ADDED
package/breadcrumbs.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/* Optional enhancement for authored breadcrumb ancestor disclosures.
|
|
2
|
+
* Native details works without this module. No content or roles are generated. */
|
|
3
|
+
class MBreadcrumbOverflow extends HTMLElement {
|
|
4
|
+
#cleanup;
|
|
5
|
+
connectedCallback() {
|
|
6
|
+
queueMicrotask(() => {
|
|
7
|
+
if (!this.isConnected || this.#cleanup) return;
|
|
8
|
+
const details = this.querySelector(':scope > details');
|
|
9
|
+
const summary = details?.firstElementChild;
|
|
10
|
+
const menu = details?.querySelector(':scope > [data-breadcrumb-menu]');
|
|
11
|
+
if (!summary?.matches('summary') || !menu || !this.closest('nav[data-breadcrumbs]')) return;
|
|
12
|
+
const controller = new AbortController();
|
|
13
|
+
const { signal } = controller;
|
|
14
|
+
let frame = 0;
|
|
15
|
+
const close = () => { details.open = false; };
|
|
16
|
+
const position = () => {
|
|
17
|
+
frame = 0;
|
|
18
|
+
if (!details.open) return;
|
|
19
|
+
const r = summary.getBoundingClientRect();
|
|
20
|
+
const v = window.visualViewport;
|
|
21
|
+
const left = v?.offsetLeft ?? 0, top = v?.offsetTop ?? 0;
|
|
22
|
+
const width = v?.width ?? document.documentElement.clientWidth, height = v?.height ?? innerHeight;
|
|
23
|
+
const css = getComputedStyle(menu);
|
|
24
|
+
const gutter = parseFloat(css.paddingLeft) || 8;
|
|
25
|
+
const gap = parseFloat(css.marginTop) || 0;
|
|
26
|
+
const w = menu.getBoundingClientRect().width;
|
|
27
|
+
const start = getComputedStyle(this).direction === 'rtl' ? r.right - w : r.left;
|
|
28
|
+
const below = Math.max(0, top + height - r.bottom - gap - gutter);
|
|
29
|
+
const above = Math.max(0, r.top - top - gap - gutter);
|
|
30
|
+
const desired = menu.scrollHeight + 2 * (parseFloat(css.borderTopWidth) || 0);
|
|
31
|
+
const flip = below < Math.min(desired, above);
|
|
32
|
+
menu.style.setProperty('--m-breadcrumb-left', `${Math.max(left + gutter, Math.min(start, left + width - w - gutter))}px`);
|
|
33
|
+
menu.style.setProperty('--m-breadcrumb-height', `${flip ? above : below}px`);
|
|
34
|
+
menu.style.setProperty('--m-breadcrumb-top', `${flip ? r.top - Math.min(desired, above) - 2 * gap : r.bottom}px`);
|
|
35
|
+
};
|
|
36
|
+
const schedule = () => { if (!frame) frame = requestAnimationFrame(position); };
|
|
37
|
+
details.addEventListener('toggle', schedule, { signal });
|
|
38
|
+
details.addEventListener('keydown', event => {
|
|
39
|
+
if (event.key === 'Escape' && details.open) {
|
|
40
|
+
event.preventDefault(); event.stopPropagation(); close(); summary.focus();
|
|
41
|
+
}
|
|
42
|
+
}, { signal });
|
|
43
|
+
document.addEventListener('click', event => { if (!this.contains(event.target)) close(); }, { signal });
|
|
44
|
+
details.addEventListener('focusout', event => {
|
|
45
|
+
if (event.relatedTarget) { if (!this.contains(event.relatedTarget)) close(); }
|
|
46
|
+
else requestAnimationFrame(() => { if (!signal.aborted && !this.contains(document.activeElement)) close(); });
|
|
47
|
+
}, { signal });
|
|
48
|
+
menu.addEventListener('click', event => {
|
|
49
|
+
if (!event.target.closest('a[href]') || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
50
|
+
queueMicrotask(() => {
|
|
51
|
+
if (event.defaultPrevented) return;
|
|
52
|
+
const focused = menu.contains(document.activeElement);
|
|
53
|
+
close();
|
|
54
|
+
if (focused) summary.focus();
|
|
55
|
+
});
|
|
56
|
+
}, { signal });
|
|
57
|
+
window.addEventListener('resize', schedule, { signal, passive: true });
|
|
58
|
+
window.addEventListener('scroll', schedule, { signal, passive: true, capture: true });
|
|
59
|
+
window.visualViewport?.addEventListener('resize', schedule, { signal });
|
|
60
|
+
window.visualViewport?.addEventListener('scroll', schedule, { signal });
|
|
61
|
+
const observer = new ResizeObserver(schedule);
|
|
62
|
+
observer.observe(menu); observer.observe(summary);
|
|
63
|
+
this.setAttribute('data-m-breadcrumb-ready', '');
|
|
64
|
+
schedule();
|
|
65
|
+
this.#cleanup = () => {
|
|
66
|
+
controller.abort(); observer.disconnect(); cancelAnimationFrame(frame); close();
|
|
67
|
+
this.removeAttribute('data-m-breadcrumb-ready');
|
|
68
|
+
for (const name of ['left', 'top', 'height']) menu.style.removeProperty(`--m-breadcrumb-${name}`);
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
disconnectedCallback() { this.#cleanup?.(); this.#cleanup = undefined; }
|
|
73
|
+
}
|
|
74
|
+
if (!customElements.get('m-breadcrumb-overflow')) customElements.define('m-breadcrumb-overflow', MBreadcrumbOverflow);
|
package/header.d.ts
ADDED
package/header.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/* Optional responsive enhancement for <m-header>.
|
|
2
|
+
* Authored nav stays inline without this module. At narrow container widths,
|
|
3
|
+
* the same nav becomes a native popover invoked by button[data-menu].
|
|
4
|
+
* No generated links, routing, shared runtime, or menu keyboard emulation.
|
|
5
|
+
*/
|
|
6
|
+
class MHeader extends HTMLElement {
|
|
7
|
+
#cleanup;
|
|
8
|
+
|
|
9
|
+
connectedCallback() {
|
|
10
|
+
queueMicrotask(() => {
|
|
11
|
+
if (!this.isConnected || this.#cleanup) return;
|
|
12
|
+
const nav = this.querySelector(':scope > nav');
|
|
13
|
+
const button = this.querySelector(':scope > button[data-menu]');
|
|
14
|
+
if (!nav || nav.hasAttribute('popover')) return;
|
|
15
|
+
const canCollapse = Boolean(button && nav.id && button.getAttribute('popovertarget') === nav.id && 'showPopover' in nav);
|
|
16
|
+
|
|
17
|
+
const controller = new AbortController();
|
|
18
|
+
const { signal } = controller;
|
|
19
|
+
let frame = 0;
|
|
20
|
+
const groups = nav.hasAttribute('data-navigation-menu')
|
|
21
|
+
? [...nav.querySelectorAll(':scope > details')].filter(d => d.firstElementChild?.matches('summary') && d.querySelector(':scope > [data-nav-panel]'))
|
|
22
|
+
: [];
|
|
23
|
+
const closeGroups = () => { for (const group of groups) group.open = false; };
|
|
24
|
+
const positionGroups = () => {
|
|
25
|
+
if (this.hasAttribute('data-m-header-collapsed')) return;
|
|
26
|
+
const viewport = window.visualViewport;
|
|
27
|
+
const left = viewport?.offsetLeft ?? 0;
|
|
28
|
+
const top = viewport?.offsetTop ?? 0;
|
|
29
|
+
const width = viewport?.width ?? document.documentElement.clientWidth;
|
|
30
|
+
const height = viewport?.height ?? innerHeight;
|
|
31
|
+
for (const group of groups) {
|
|
32
|
+
if (!group.open) continue;
|
|
33
|
+
const panel = group.querySelector(':scope > [data-nav-panel]');
|
|
34
|
+
const r = group.firstElementChild.getBoundingClientRect();
|
|
35
|
+
// A computed length resolves rem/calc tokens to pixels, unlike reading
|
|
36
|
+
// the raw custom property. Gap is inert on the block panel itself.
|
|
37
|
+
const gap = Math.max(0, parseFloat(getComputedStyle(panel).rowGap) || 0);
|
|
38
|
+
panel.style.setProperty('--m-nav-max-width', `${Math.max(0, width - 2 * gap)}px`);
|
|
39
|
+
const panelWidth = panel.getBoundingClientRect().width;
|
|
40
|
+
const start = getComputedStyle(nav).direction === 'rtl' ? r.right - panelWidth : r.left;
|
|
41
|
+
panel.style.setProperty('--m-nav-left', `${Math.max(left + gap, Math.min(start, left + width - gap - panelWidth))}px`);
|
|
42
|
+
const below = Math.max(0, top + height - r.bottom - 2 * gap);
|
|
43
|
+
const above = Math.max(0, r.top - top - 2 * gap);
|
|
44
|
+
const desired = panel.scrollHeight + 2 * (parseFloat(getComputedStyle(panel).borderTopWidth) || 0);
|
|
45
|
+
const flip = below < Math.min(desired, above);
|
|
46
|
+
panel.style.setProperty('--m-nav-height', `${flip ? above : below}px`);
|
|
47
|
+
panel.style.setProperty('--m-nav-top', `${flip ? r.top - gap - Math.min(desired, above) : r.bottom + gap}px`);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const close = () => { if (nav.matches(':popover-open')) nav.hidePopover(); };
|
|
51
|
+
const position = () => {
|
|
52
|
+
frame = 0;
|
|
53
|
+
positionGroups();
|
|
54
|
+
if (!nav.matches(':popover-open')) return;
|
|
55
|
+
const r = this.getBoundingClientRect();
|
|
56
|
+
const viewport = window.visualViewport;
|
|
57
|
+
const left = viewport?.offsetLeft ?? 0;
|
|
58
|
+
const top = viewport?.offsetTop ?? 0;
|
|
59
|
+
const width = viewport?.width ?? document.documentElement.clientWidth;
|
|
60
|
+
const height = viewport?.height ?? window.innerHeight;
|
|
61
|
+
const gutter = parseFloat(getComputedStyle(nav).paddingTop) || 8;
|
|
62
|
+
const available = Math.max(0, width - 2 * gutter);
|
|
63
|
+
const menuWidth = Math.min(r.width, available);
|
|
64
|
+
nav.style.setProperty('--m-header-menu-width', `${menuWidth}px`);
|
|
65
|
+
nav.style.setProperty('--m-header-menu-left', `${Math.max(left + gutter, Math.min(r.left, left + width - gutter - menuWidth))}px`);
|
|
66
|
+
// Measure the rendered links: labels may wrap, fonts may be enlarged,
|
|
67
|
+
// and compact-only actions need not have the same height as links.
|
|
68
|
+
// Measure content without expanding the scroll container: changing its
|
|
69
|
+
// height during a scroll event resets scrollTop and strands lower links.
|
|
70
|
+
const rows = [...nav.children].map(e => e.getBoundingClientRect()).filter(r => r.height);
|
|
71
|
+
const css = getComputedStyle(nav);
|
|
72
|
+
const desired = (rows.length ? Math.max(...rows.map(r => r.bottom)) - Math.min(...rows.map(r => r.top)) : 0)
|
|
73
|
+
+ parseFloat(css.paddingTop) + parseFloat(css.paddingBottom)
|
|
74
|
+
+ parseFloat(css.borderTopWidth) + parseFloat(css.borderBottomWidth);
|
|
75
|
+
const below = Math.max(0, top + height - r.bottom - 2 * gutter);
|
|
76
|
+
const above = Math.max(0, r.top - top - 2 * gutter);
|
|
77
|
+
const flip = below < Math.min(desired, above);
|
|
78
|
+
const menuHeight = Math.min(desired, flip ? above : below);
|
|
79
|
+
nav.style.setProperty('--m-header-menu-height', `${menuHeight}px`);
|
|
80
|
+
nav.style.setProperty('--m-header-menu-top', `${flip ? Math.max(top + gutter, r.top - gutter - menuHeight) : Math.max(top + gutter, r.bottom + gutter)}px`);
|
|
81
|
+
};
|
|
82
|
+
const schedule = () => {
|
|
83
|
+
if (!frame) frame = requestAnimationFrame(position);
|
|
84
|
+
};
|
|
85
|
+
const measure = () => {
|
|
86
|
+
const collapsed = canCollapse && getComputedStyle(button).getPropertyValue('--m-header-collapse').trim() === '1';
|
|
87
|
+
if (collapsed !== this.hasAttribute('data-m-header-collapsed')) {
|
|
88
|
+
const active = document.activeElement;
|
|
89
|
+
const hidingFocus = collapsed && (nav.contains(active) || this.querySelector(':scope > [data-actions]')?.contains(active));
|
|
90
|
+
const triggerFocus = !collapsed && active === button;
|
|
91
|
+
close();
|
|
92
|
+
closeGroups();
|
|
93
|
+
if (collapsed) nav.setAttribute('popover', 'auto');
|
|
94
|
+
else nav.removeAttribute('popover');
|
|
95
|
+
this.toggleAttribute('data-m-header-collapsed', collapsed);
|
|
96
|
+
if (hidingFocus) button.focus();
|
|
97
|
+
else if (!collapsed && nav.contains(active)) {
|
|
98
|
+
const group = groups.find(d => d.contains(active));
|
|
99
|
+
(group ? group.firstElementChild : active).focus();
|
|
100
|
+
}
|
|
101
|
+
else if (triggerFocus) nav.querySelector('a[href], summary')?.focus();
|
|
102
|
+
}
|
|
103
|
+
schedule();
|
|
104
|
+
};
|
|
105
|
+
// beforetoggle fires while the popover is still hidden. Measuring in
|
|
106
|
+
// toggle gives actual link geometry, before the next paint.
|
|
107
|
+
nav.addEventListener('toggle', event => { if (event.target !== nav) return; if (nav.matches(':popover-open')) position(); else closeGroups(); }, { signal });
|
|
108
|
+
nav.addEventListener('click', event => {
|
|
109
|
+
const link = event.target.closest('a[href]');
|
|
110
|
+
if (link && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) queueMicrotask(() => { if (!event.defaultPrevented) { closeGroups(); close(); } });
|
|
111
|
+
}, { signal });
|
|
112
|
+
window.addEventListener('scroll', schedule, { signal, capture: true, passive: true });
|
|
113
|
+
window.addEventListener('resize', measure, { signal, passive: true });
|
|
114
|
+
window.visualViewport?.addEventListener('resize', schedule, { signal, passive: true });
|
|
115
|
+
window.visualViewport?.addEventListener('scroll', schedule, { signal, passive: true });
|
|
116
|
+
const observer = new ResizeObserver(measure);
|
|
117
|
+
observer.observe(this);
|
|
118
|
+
for (const group of groups) {
|
|
119
|
+
group.addEventListener('toggle', () => {
|
|
120
|
+
if (group.open) for (const other of groups) if (other !== group) other.open = false;
|
|
121
|
+
schedule();
|
|
122
|
+
}, { signal });
|
|
123
|
+
observer.observe(group.querySelector(':scope > [data-nav-panel]'));
|
|
124
|
+
}
|
|
125
|
+
if (groups.length) {
|
|
126
|
+
nav.setAttribute('data-m-navigation-ready', '');
|
|
127
|
+
nav.addEventListener('keydown', event => {
|
|
128
|
+
if (event.key !== 'Escape') return;
|
|
129
|
+
const group = groups.find(d => d.open);
|
|
130
|
+
if (!group) return;
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
event.stopPropagation();
|
|
133
|
+
group.open = false;
|
|
134
|
+
group.firstElementChild.focus();
|
|
135
|
+
}, { signal });
|
|
136
|
+
document.addEventListener('click', event => {
|
|
137
|
+
if (!nav.contains(event.target)) closeGroups();
|
|
138
|
+
}, { signal });
|
|
139
|
+
nav.addEventListener('focusout', event => {
|
|
140
|
+
if (event.relatedTarget) {
|
|
141
|
+
if (!nav.contains(event.relatedTarget)) closeGroups();
|
|
142
|
+
} else requestAnimationFrame(() => {
|
|
143
|
+
if (!signal.aborted && !nav.contains(document.activeElement)) closeGroups();
|
|
144
|
+
});
|
|
145
|
+
}, { signal });
|
|
146
|
+
}
|
|
147
|
+
this.#cleanup = () => {
|
|
148
|
+
controller.abort();
|
|
149
|
+
observer.disconnect();
|
|
150
|
+
cancelAnimationFrame(frame);
|
|
151
|
+
close();
|
|
152
|
+
closeGroups();
|
|
153
|
+
nav.removeAttribute('data-m-navigation-ready');
|
|
154
|
+
for (const group of groups) {
|
|
155
|
+
const panel = group.querySelector(':scope > [data-nav-panel]');
|
|
156
|
+
for (const name of ['left', 'top', 'height', 'max-width']) panel.style.removeProperty(`--m-nav-${name}`);
|
|
157
|
+
}
|
|
158
|
+
nav.removeAttribute('popover');
|
|
159
|
+
this.removeAttribute('data-m-header-collapsed');
|
|
160
|
+
for (const name of ['width', 'left', 'height', 'top']) nav.style.removeProperty(`--m-header-menu-${name}`);
|
|
161
|
+
};
|
|
162
|
+
measure();
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
disconnectedCallback() {
|
|
167
|
+
this.#cleanup?.();
|
|
168
|
+
this.#cleanup = undefined;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!customElements.get('m-header')) customElements.define('m-header', MHeader);
|