@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 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`. Each is a tiny standalone module that enhances
66
- working markup — never renders it. There is no shared runtime.
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).
@@ -0,0 +1,5 @@
1
+ /* Optional ancestor-disclosure enhancement; native details is the fallback. */
2
+ declare global {
3
+ interface HTMLElementTagNameMap { "m-breadcrumb-overflow": HTMLElement; }
4
+ }
5
+ export {};
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
@@ -0,0 +1,7 @@
1
+ /* Side-effect module: optional responsive navigation for <m-header>. */
2
+ declare global {
3
+ interface HTMLElementTagNameMap {
4
+ "m-header": HTMLElement;
5
+ }
6
+ }
7
+ export {};
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);