@akonwi/mica 0.10.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);
package/mica.css CHANGED
@@ -764,6 +764,68 @@
764
764
  --m-badge-color: var(--color-danger-text);
765
765
  }
766
766
 
767
+ /* Callouts are CSS-only content. Variants never imply role=alert.
768
+ Optional icons and dismissal controls are authored, not generated.
769
+ For a neutral panel with colored emphasis, override --callout-background
770
+ and --callout-border to the shared surface and border roles. */
771
+ :where(m-callout) {
772
+ --callout-background: var(--color-surface-raised);
773
+ --callout-border: var(--color-border);
774
+ --callout-emphasis: var(--color-text);
775
+ display: flex;
776
+ align-items: flex-start;
777
+ gap: var(--callout-gap, var(--space-sm));
778
+ padding: var(--callout-padding, var(--space-md));
779
+ border: 1px solid var(--callout-border);
780
+ border-radius: var(--radius-md);
781
+ background: var(--callout-background);
782
+ color: var(--color-text);
783
+ font-size: var(--callout-font-size, .875rem);
784
+ line-height: var(--callout-line-height, 1.5);
785
+ }
786
+ :where(m-callout[variant="success"]) {
787
+ --callout-background: var(--color-success-surface);
788
+ --callout-border: var(--color-success-border);
789
+ --callout-emphasis: var(--color-success-text);
790
+ }
791
+ :where(m-callout[variant="warning"]) {
792
+ --callout-background: var(--color-warn-surface);
793
+ --callout-border: var(--color-warn-border);
794
+ --callout-emphasis: var(--color-warn-text);
795
+ }
796
+ :where(m-callout[variant="danger"]) {
797
+ --callout-background: var(--color-danger-surface);
798
+ --callout-border: var(--color-danger-border);
799
+ --callout-emphasis: var(--color-danger-text);
800
+ }
801
+ :where(m-callout[hidden]) { display: none; }
802
+ :where(m-callout > [data-callout-body]) { flex: 1; min-inline-size: 0; overflow-wrap: anywhere; }
803
+ :where(m-callout [data-callout-title]) {
804
+ margin: 0 0 var(--space-2xs);
805
+ color: var(--callout-emphasis);
806
+ font: inherit;
807
+ font-weight: var(--callout-title-weight, 600);
808
+ }
809
+ :where(m-callout > [data-callout-body] > p:not([data-callout-title])) { margin: 0; }
810
+ :where(m-callout > [data-callout-body] > p + p) { margin-block-start: var(--space-xs); }
811
+ :where(m-callout a) { color: var(--callout-emphasis); }
812
+ :where(m-callout > [data-callout-icon]) {
813
+ inline-size: var(--callout-icon-size, 1.125rem);
814
+ block-size: var(--callout-icon-size, 1.125rem);
815
+ flex: 0 0 auto;
816
+ margin-block-start: var(--callout-icon-offset, .125rem);
817
+ color: var(--callout-emphasis);
818
+ }
819
+ :where(m-callout [data-callout-actions]) { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin-block-start: var(--space-sm); }
820
+ :where(m-callout > button[data-callout-dismiss]) {
821
+ flex: 0 0 auto;
822
+ inline-size: var(--callout-dismiss-size, 2rem);
823
+ block-size: var(--callout-dismiss-size, 2rem);
824
+ padding: 0;
825
+ margin-block-start: calc(-1 * var(--space-2xs));
826
+ margin-inline-end: calc(-1 * var(--space-2xs));
827
+ }
828
+
767
829
  :where(m-badge[count]) {
768
830
  --m-badge-min-width: 1.5rem;
769
831
  --m-badge-padding: var(--space-2xs);
@@ -2011,6 +2073,556 @@
2011
2073
  display: block;
2012
2074
  }
2013
2075
 
2076
+ /* --- header navigation: native links + optional header.js collapse --- */
2077
+ :where(m-header) {
2078
+ container: m-header / inline-size;
2079
+ display: flex;
2080
+ flex-wrap: wrap;
2081
+ align-items: center;
2082
+ gap: var(--header-gap, var(--space-md));
2083
+ padding: var(--header-pad, var(--space-sm)) var(--header-gutter, var(--space-md));
2084
+ border-block-end: 1px solid var(--color-border);
2085
+ background: var(--color-surface);
2086
+ color: var(--color-text);
2087
+ }
2088
+ :where(m-header > [data-brand]) {
2089
+ display: inline-flex;
2090
+ align-items: center;
2091
+ gap: var(--space-xs);
2092
+ min-block-size: var(--control-height);
2093
+ color: var(--color-text);
2094
+ font-weight: var(--header-strong-weight, 650);
2095
+ text-decoration: none;
2096
+ overflow-wrap: anywhere;
2097
+ }
2098
+ :where(m-header > nav) {
2099
+ display: flex;
2100
+ flex: 1 1 auto;
2101
+ flex-wrap: wrap;
2102
+ align-items: center;
2103
+ gap: var(--space-2xs);
2104
+ min-inline-size: 0;
2105
+ justify-content: var(--header-nav-align, start);
2106
+ }
2107
+ :where(m-header[align="end"]) { --header-nav-align: end; }
2108
+ :where(m-header > nav > a) {
2109
+ display: inline-flex;
2110
+ align-items: center;
2111
+ min-block-size: var(--control-height);
2112
+ padding: var(--space-2xs) var(--space-sm);
2113
+ border-radius: var(--radius-md);
2114
+ color: var(--color-text-muted);
2115
+ font-size: var(--header-font-size, .875rem);
2116
+ font-weight: var(--header-font-weight, 500);
2117
+ text-decoration: none;
2118
+ overflow-wrap: anywhere;
2119
+ }
2120
+ :where(m-header > nav > a):hover:not([aria-disabled="true"]) { background: var(--color-surface-raised); color: var(--color-text); }
2121
+ :where(m-header > nav > a)[aria-current="page"]{ background: var(--color-surface-raised); color: var(--color-text); font-weight: var(--header-strong-weight, 650); }
2122
+ :where(m-header > nav > a)[aria-disabled="true"]{ color: var(--color-text-muted); opacity: .5; }
2123
+ :where(m-header > [data-actions]) { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-xs); margin-inline-start: auto; }
2124
+ /* A small enhancement switches the same nav between inline and native
2125
+ popover. Without script, all links stay visible in the wrapping header. */
2126
+ :where(m-header > [data-menu]) { display: none; }
2127
+ :where(m-header > nav > [data-compact]) { display: none; }
2128
+ :where(m-header[data-m-header-collapsed]) { flex-wrap: nowrap; }
2129
+ :where(m-header[data-m-header-collapsed] > [data-brand]) { min-inline-size: 0; }
2130
+ :where(m-header[data-m-header-collapsed] > [data-actions]) { display: none; }
2131
+ :where(m-header[data-m-header-collapsed] > [data-menu]) { display: inline-flex; margin-inline-start: auto; flex: none; }
2132
+ :where(m-header > nav[popover]):not(:popover-open) { display: none; }
2133
+ :where(m-header > nav[popover]):popover-open{
2134
+ display: flex;
2135
+ flex-direction: column;
2136
+ flex-wrap: nowrap;
2137
+ justify-content: start;
2138
+ align-items: stretch;
2139
+ gap: var(--space-2xs);
2140
+ position: fixed;
2141
+ position-area: none;
2142
+ max-inline-size: none;
2143
+ scale: 1;
2144
+ inset: auto;
2145
+ top: var(--m-header-menu-top);
2146
+ left: var(--m-header-menu-left);
2147
+ width: var(--m-header-menu-width);
2148
+ max-block-size: var(--m-header-menu-height);
2149
+ overflow-y: auto;
2150
+ margin: 0;
2151
+ padding: var(--space-xs);
2152
+ border: 1px solid var(--color-border-strong);
2153
+ border-radius: var(--radius-md);
2154
+ background: var(--color-surface-overlay);
2155
+ color: var(--color-text);
2156
+ box-shadow: none;
2157
+ opacity: 1;
2158
+ transform: none;
2159
+ transition: none;
2160
+ animation: none;
2161
+ }
2162
+ :where(m-header > nav[popover] > a) { min-block-size: var(--header-target-height, 2.75rem); flex-shrink: 0; }
2163
+ :where(m-header > nav[popover] > [data-compact]) { display: inline-flex; }
2164
+ :where(m-header > nav[popover] > :not([data-compact]) + [data-compact]) { border-block-start: 1px solid var(--color-border); }
2165
+ :where(m-header > [data-menu] svg) { inline-size: 1rem; block-size: 1rem; }
2166
+
2167
+
2168
+ :where(m-header > [data-menu]) { --m-header-collapse: 0; }
2169
+ @container m-header (inline-size < 40rem) {
2170
+ :where(m-header > [data-menu]) { --m-header-collapse: 1; }
2171
+ }
2172
+
2173
+
2174
+ /* Navigation disclosures: native details remain usable without header.js.
2175
+ Enhancement adds floating placement; mobile keeps content in flow.
2176
+ Panel contents are authored links and layouts, never ARIA menu widgets. */
2177
+ :where(m-header > nav[data-navigation-menu]) {
2178
+ --nav-panel-width: 34rem;
2179
+ --nav-panel-gap: var(--space-xs);
2180
+ --nav-panel-pad: var(--space-xs);
2181
+ --nav-panel-link-pad: var(--space-sm);
2182
+ --nav-panel-description-size: .8125rem;
2183
+ --nav-panel-heading-size: .6875rem;
2184
+ --nav-panel-z: 10;
2185
+ }
2186
+ :where(m-header > nav[data-navigation-menu] > details) { margin: 0; padding: 0; border: 0; background: none; max-inline-size: 100%; }
2187
+ :where(m-header > nav[data-navigation-menu] > details)::details-content { transition: none; overflow: visible; }
2188
+ :where(m-header > nav[data-navigation-menu] > details > summary) {
2189
+ display: flex;
2190
+ align-items: center;
2191
+ gap: var(--space-xs);
2192
+ min-block-size: var(--control-height);
2193
+ padding: var(--space-2xs) var(--space-sm);
2194
+ font-size: var(--header-font-size, .875rem);
2195
+ font-weight: var(--header-font-weight, 500);
2196
+ color: var(--color-text-muted);
2197
+ border-radius: var(--radius-md);
2198
+ }
2199
+ :where(m-header > nav[data-navigation-menu] > details > summary):hover {
2200
+ text-decoration: none; color: var(--color-text); background: var(--color-surface-raised);
2201
+ }
2202
+ :where(m-header > nav[data-navigation-menu] > details)[open] > :where(summary) {
2203
+ color: var(--color-text); background: var(--color-surface-raised);
2204
+ }
2205
+ :where(m-header > nav[data-navigation-menu] [data-nav-panel]) {
2206
+ inline-size: min(var(--nav-panel-width), var(--m-nav-max-width, calc(100vw - 2 * var(--nav-panel-gap))));
2207
+ max-inline-size: 100%;
2208
+ max-block-size: var(--m-nav-height, none);
2209
+ margin: 0;
2210
+ padding: var(--nav-panel-pad);
2211
+ row-gap: var(--nav-panel-gap);
2212
+ border: 1px solid var(--color-border);
2213
+ border-radius: var(--radius-md);
2214
+ background: var(--color-surface-overlay);
2215
+ overflow: auto;
2216
+ }
2217
+ :where(m-header > nav[data-m-navigation-ready] > details > [data-nav-panel]) {
2218
+ position: fixed; z-index: var(--nav-panel-z);
2219
+ left: var(--m-nav-left); top: var(--m-nav-top);
2220
+ }
2221
+ :where(m-header > nav[data-navigation-menu] [data-nav-panel] a) {
2222
+ display: block;
2223
+ padding: var(--nav-panel-link-pad);
2224
+ border-radius: var(--radius-md);
2225
+ color: var(--color-text);
2226
+ text-decoration: none;
2227
+ font-size: var(--header-font-size, .875rem);
2228
+ overflow-wrap: anywhere;
2229
+ }
2230
+ :where(m-header > nav[data-navigation-menu] [data-nav-panel] a):hover:not([aria-disabled="true"]),
2231
+ :where(m-header > nav[data-navigation-menu] [data-nav-panel] a)[aria-current="page"] { background: var(--color-surface-raised); }
2232
+ :where(m-header > nav[data-navigation-menu] [data-nav-panel] a)[aria-disabled="true"] { opacity: .5; }
2233
+ :where(m-header > nav[data-navigation-menu] [data-nav-panel] strong) { display: block; font-weight: var(--header-strong-weight, 650); }
2234
+ :where(m-header > nav[data-navigation-menu] [data-nav-panel] small) {
2235
+ display: block; margin-block-start: var(--space-2xs);
2236
+ font-size: var(--nav-panel-description-size); color: var(--color-text-muted);
2237
+ }
2238
+ :where(m-header > nav[data-navigation-menu] [data-nav-panel] h2) {
2239
+ margin: 0; padding: var(--nav-panel-link-pad);
2240
+ font-size: var(--nav-panel-heading-size); font-weight: var(--header-font-weight, 500); color: var(--color-text-muted);
2241
+ }
2242
+ :where(m-header[data-m-header-collapsed] > nav[data-navigation-menu] > details) { flex-shrink: 0; }
2243
+ :where(m-header[data-m-header-collapsed] > nav[data-navigation-menu] > details > summary) { min-block-size: var(--header-target-height, 2.75rem); }
2244
+ :where(m-header[data-m-header-collapsed] > nav[data-navigation-menu] > details > [data-nav-panel]) {
2245
+ position: static; inline-size: auto; max-block-size: none;
2246
+ margin: var(--space-xs) var(--space-sm); padding: 0 0 0 var(--space-xs);
2247
+ padding-inline: var(--space-xs) 0;
2248
+ border: 0; border-inline-start: 1px solid var(--color-border);
2249
+ border-radius: 0; background: transparent; overflow: visible;
2250
+ }
2251
+
2252
+ /* Data collections: application-owned rows and state, native table semantics.
2253
+ Column widths and sticky offsets are authored for the application's schema.
2254
+ Selection actions replace filters; keep filters inert while selection is active.
2255
+ For a taller action set, increase --table-toolbar-min-height to reserve space. */
2256
+ :where(:root) {
2257
+ --table-font-size: .8125rem;
2258
+ --table-padding: var(--space-lg);
2259
+ --table-cell-padding: var(--space-sm);
2260
+ --table-min-width: 54rem;
2261
+ --table-toolbar-min-height: 0px;
2262
+ --table-selected-background: var(--color-surface-raised);
2263
+ --table-edge-width: var(--space-md);
2264
+ --table-edge-color: color-mix(in oklch, var(--color-text) 14%, transparent);
2265
+ --table-state-padding: var(--space-2xl);
2266
+ }
2267
+ :where([data-data-table]) { container: mica-table / inline-size; background: var(--color-surface); border: 1px solid var(--color-border); min-inline-size: 0; }
2268
+ :where([data-table-heading]) { display:flex; justify-content:space-between; align-items:center; gap:var(--space-md); padding:var(--table-padding) var(--table-padding) var(--space-sm); }
2269
+ :where([data-table-heading] > button) { flex-shrink:0; white-space:nowrap; }
2270
+ :where([data-table-heading] h2) { margin:0; font-size:var(--table-heading-size, 1.375rem); }
2271
+ :where([data-table-heading] p) { margin:var(--space-2xs) 0 0; color:var(--color-text-muted); font-size:var(--table-font-size); }
2272
+ :where([data-table-toolbar]) { position:relative; min-block-size:var(--table-toolbar-min-height); }
2273
+ :where([data-table-filters], [data-table-selection]) { display:flex; flex-wrap:wrap; align-items:center; gap:var(--space-xs); padding:var(--space-sm) var(--table-padding); }
2274
+ :where([data-table-filters] [data-table-search]) { flex:1; min-inline-size:12rem; }
2275
+ :where([data-table-search] input) { inline-size:100%; }
2276
+ :where([data-table-filters] select) { max-inline-size:11rem; }
2277
+ :where([data-table-filters] button) { white-space:nowrap; }
2278
+ :where([data-table-selection]) { position:absolute; inset:0; align-content:center; gap:var(--space-sm); font-size:var(--table-font-size); }
2279
+ :where([data-table-toolbar]):has(> [data-table-selection]:not([hidden])) > :where([data-table-filters]) { visibility:hidden; }
2280
+ :where([data-table-selection] [data-table-clear]) { margin-inline-start:auto; }
2281
+ :where([data-data-table] [hidden], [data-pagination] [hidden], [data-table-selection][hidden], [data-table-filters] [hidden], [data-table] [hidden]) { display:none; }
2282
+ :where(m-table-scroll) { display:block; position:relative; min-inline-size:0; }
2283
+ :where(m-table-scroll)::before, :where(m-table-scroll)::after { content:''; position:absolute; inset-block:1px; inline-size:var(--table-edge-width); z-index:4; pointer-events:none; opacity:0; }
2284
+ :where(m-table-scroll)::before { left:var(--m-table-edge-left, 0px); background:linear-gradient(to right,var(--table-edge-color),transparent); }
2285
+ :where(m-table-scroll)::after { right:var(--m-table-edge-right, 0px); background:linear-gradient(to left,var(--table-edge-color),transparent); }
2286
+ :where(m-table-scroll[data-m-overflow-left])::before, :where(m-table-scroll[data-m-overflow-right])::after { opacity:1; }
2287
+ :where([data-table-region]) { position:relative; overflow:auto; overscroll-behavior:contain; border-block:1px solid var(--color-border); }
2288
+ :where(table[data-table]) { margin:0; inline-size:100%; min-inline-size:var(--table-min-width, 54rem); border-collapse:separate; border-spacing:0; table-layout:fixed; }
2289
+ :where([data-table] th, [data-table] td) { padding:var(--table-cell-padding, var(--space-sm)); vertical-align:middle; font-size:var(--table-font-size, .8125rem); }
2290
+ :where([data-table] thead th) { background:var(--color-surface-raised); color:var(--color-text-muted); font-weight:500; border-block-end:1px solid var(--color-border); }
2291
+ :where([data-table] th button) { display:inline-flex; align-items:center; gap:var(--space-xs); padding:0; min-block-size:var(--table-sort-target, 2rem); border:0; background:none; color:inherit; font:inherit; text-align:inherit; white-space:nowrap; }
2292
+ :where([data-table] th[aria-sort]) button { color:var(--color-text); }
2293
+ :where([data-table] td a) { color:var(--color-text); font-weight:600; text-decoration:none; }
2294
+ :where([data-table] td) a:hover { text-decoration:underline; }
2295
+ :where([data-table-description]) { display:block; color:var(--color-text-muted); margin-block-start:var(--space-2xs); font-size:var(--table-description-size, .75rem); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
2296
+ :where([data-table] tbody tr:last-child td) { border-block-end:0; }
2297
+ :where([data-table] tbody tr[data-selected]) > td { background:var(--table-selected-background, var(--color-surface-raised)); }
2298
+ :where([data-table-state]) { padding:var(--table-state-padding, var(--space-2xl)) var(--table-padding, var(--space-lg)); text-align:center; white-space:normal; }
2299
+ :where([data-table-state] strong) { display:block; color:var(--color-text); font-size:var(--table-state-font-size, 1rem); }
2300
+ :where([data-table-state] p) { margin-block:var(--space-xs) var(--space-md); color:var(--color-text-muted); }
2301
+ :where([data-table-region][data-state] table) { min-inline-size:100%; table-layout:auto; }
2302
+ :where([data-table-region][data-state] thead) { display:none; }
2303
+ :where([data-table-footer]) { container: mica-pagination / inline-size; display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:var(--space-md); padding:var(--space-md) var(--table-padding); font-size:var(--table-font-size); }
2304
+ :where([data-page-meta]) { display:flex; align-items:center; gap:var(--space-md); flex-wrap:wrap; color:var(--color-text-muted); }
2305
+ :where([data-page-size]) { display:flex; align-items:center; gap:var(--space-xs); }
2306
+ :where([data-page-size] select) { inline-size:auto; min-block-size:2rem; padding-block:var(--space-2xs); font-size:1rem; }
2307
+ :where(nav[data-pagination]) { --pagination-target:2.125rem; --pagination-font-size:.8125rem; display:flex; flex-wrap:wrap; align-items:center; gap:var(--space-2xs); }
2308
+ :where([data-pagination] > a, [data-pagination] > button, [data-pagination] > [aria-disabled]) { display:inline-flex; justify-content:center; align-items:center; min-inline-size:var(--pagination-target); min-block-size:var(--pagination-target); padding:var(--space-2xs) var(--space-xs); border:0; background:transparent; color:var(--color-text); font-size:var(--pagination-font-size); text-decoration:none; }
2309
+ :where([data-pagination]) > a:hover { background:var(--color-surface-raised); }
2310
+ :where([data-pagination]) > [aria-current=page] { background:var(--color-surface-raised); font-weight:650; color:var(--color-text); }
2311
+ :where([data-pagination]) > :is([aria-disabled=true], :disabled) { color:var(--color-text-muted); cursor:default; }
2312
+ :where([data-page-status]) { display:none; color:var(--color-text-muted); font-size:var(--pagination-font-size); }
2313
+ @container mica-table (max-width:44rem) {
2314
+ :where([data-table-heading]) { padding:var(--space-md); }
2315
+ :where([data-table-filters]) { padding:0 var(--space-md) var(--space-sm); }
2316
+ :where([data-table-search]) { flex-basis:100%; }
2317
+ :where([data-table-filters] select) { flex:1; min-inline-size:0; max-inline-size:none; }
2318
+ :where([data-table-selection]) { padding-inline:var(--space-md); }
2319
+ :where([data-table-footer]) { padding:var(--space-md); gap:var(--space-sm); }
2320
+ :where([data-table] [data-table-sticky]) { position:sticky; inset-inline-start:var(--table-sticky-offset, 0px); background:var(--color-surface); z-index:1; }
2321
+ :where([data-table] thead [data-table-sticky]) { background:var(--color-surface-raised); z-index:3; }
2322
+ }
2323
+ /* A compact status is opt-in: include data-page-status to let numbered
2324
+ links collapse. Without it, every authored page link remains visible. */
2325
+ @container mica-pagination (max-width:40rem) {
2326
+ :where([data-page-meta]) { inline-size:100%; justify-content:space-between; gap:var(--space-xs); }
2327
+ :where([data-pagination]):has([data-page-status]) { inline-size:100%; justify-content:space-between; }
2328
+ :where([data-pagination]):has([data-page-status]) > :is([data-page-number], [data-page-ellipsis]) { display:none; }
2329
+ :where([data-pagination] [data-page-status]) { display:block; }
2330
+ }
2331
+
2332
+ /* Breadcrumbs: native hierarchy with decorative separators. The current
2333
+ page is plain text; nothing is automatically removed on narrow screens.
2334
+ Override --breadcrumb-current-weight or --breadcrumb-separator-color
2335
+ to tune emphasis without changing the authored trail. */
2336
+ :where(nav[data-breadcrumbs]) {
2337
+ --breadcrumb-font-size: .8125rem;
2338
+ --breadcrumb-gap: var(--space-xs);
2339
+ --breadcrumb-row-gap: var(--space-2xs);
2340
+ --breadcrumb-current-weight: 500;
2341
+ --breadcrumb-separator-size: .3rem;
2342
+ --breadcrumb-separator-color: var(--color-text-muted);
2343
+ --breadcrumb-menu-width: 14rem;
2344
+ --breadcrumb-overflow-font-size: 1rem;
2345
+ --breadcrumb-menu-target: 2.25rem;
2346
+ --breadcrumb-menu-z: 5;
2347
+ font-size: var(--breadcrumb-font-size);
2348
+ line-height: 1.5;
2349
+ color: var(--color-text-muted);
2350
+ }
2351
+ :where(nav[data-breadcrumbs] ol) {
2352
+ display: flex; align-items: center; flex-wrap: wrap;
2353
+ list-style: none; margin: 0; padding: 0;
2354
+ column-gap: var(--breadcrumb-gap); row-gap: var(--breadcrumb-row-gap);
2355
+ }
2356
+ :where(nav[data-breadcrumbs] li) {
2357
+ display: flex; align-items: center; gap: var(--breadcrumb-gap);
2358
+ min-inline-size: 0; max-inline-size: 100%;
2359
+ }
2360
+ :where(nav[data-breadcrumbs] > ol > li + li)::before {
2361
+ content: ''; display: block; flex: none;
2362
+ inline-size: var(--breadcrumb-separator-size); block-size: var(--breadcrumb-separator-size);
2363
+ border: solid var(--breadcrumb-separator-color); border-width: 1px 1px 0 0;
2364
+ transform: rotate(45deg); margin-inline: var(--space-2xs);
2365
+ }
2366
+ :where(nav[data-breadcrumbs]:dir(rtl) > ol > li + li)::before { transform: rotate(225deg); }
2367
+ :where(nav[data-breadcrumbs] a) {
2368
+ color: var(--color-text-muted); text-decoration: none;
2369
+ overflow-wrap: anywhere; padding-block: var(--space-2xs);
2370
+ }
2371
+ :where(nav[data-breadcrumbs] a):hover { text-decoration: underline; color: var(--color-text); }
2372
+ :where(nav[data-breadcrumbs] [aria-current="page"]) {
2373
+ color: var(--color-text); font-weight: var(--breadcrumb-current-weight);
2374
+ overflow-wrap: anywhere; padding-block: var(--space-2xs);
2375
+ }
2376
+ :where(m-breadcrumb-overflow) { display: block; min-inline-size: 0; }
2377
+ :where(nav[data-breadcrumbs] details) { position: relative; margin: 0; border: 0; padding: 0; flex: none; }
2378
+ :where(nav[data-breadcrumbs] summary) {
2379
+ display: grid; place-items: center;
2380
+ inline-size: var(--breadcrumb-menu-target); min-block-size: var(--breadcrumb-menu-target);
2381
+ padding: 0; list-style: none; font-size: var(--breadcrumb-overflow-font-size);
2382
+ font-weight: var(--breadcrumb-current-weight); border-radius: var(--radius-md);
2383
+ color: var(--color-text-muted); cursor: pointer;
2384
+ }
2385
+ :where(nav[data-breadcrumbs] summary)::-webkit-details-marker { display: none; }
2386
+ :where(nav[data-breadcrumbs] summary)::after { display: none; }
2387
+ :where(nav[data-breadcrumbs] summary):hover,
2388
+ :where(nav[data-breadcrumbs] details)[open] > :where(summary) {
2389
+ background: var(--color-surface-raised); color: var(--color-text); text-decoration: none;
2390
+ }
2391
+ :where(nav[data-breadcrumbs] details)::details-content { transition: none; overflow: visible; }
2392
+ :where(nav[data-breadcrumbs] [data-breadcrumb-menu]) {
2393
+ position: absolute; inset-block-start: 100%; inset-inline-start: 0;
2394
+ inline-size: var(--breadcrumb-menu-width); max-inline-size: calc(100vw - var(--space-2xl));
2395
+ max-block-size: var(--m-breadcrumb-height, none); overflow: auto;
2396
+ z-index: var(--breadcrumb-menu-z); display: flex; flex-direction: column;
2397
+ align-items: stretch; flex-wrap: nowrap; gap: 0;
2398
+ margin: var(--space-2xs) 0 0; padding: var(--space-xs);
2399
+ border: 1px solid var(--color-border); border-radius: var(--radius-md);
2400
+ background: var(--color-surface-overlay);
2401
+ }
2402
+ :where(m-breadcrumb-overflow[data-m-breadcrumb-ready] [data-breadcrumb-menu]) {
2403
+ position: fixed; inset: auto; left: var(--m-breadcrumb-left); top: var(--m-breadcrumb-top);
2404
+ }
2405
+ :where(nav[data-breadcrumbs] [data-breadcrumb-menu] li) { display: block; max-inline-size: none; flex: none; }
2406
+ :where(nav[data-breadcrumbs] [data-breadcrumb-menu] a) {
2407
+ display: block; padding: var(--space-xs); min-block-size: var(--breadcrumb-menu-target);
2408
+ }
2409
+ :where(nav[data-breadcrumbs] [data-breadcrumb-menu] a):hover { background: var(--color-surface-raised); text-decoration: none; }
2410
+
2411
+ /* Bottom navigation is ordinary site navigation, not a tab widget.
2412
+ Place it in the last row of an app shell; no fixed positioning or
2413
+ breakpoint is imposed. For emphasis without fill, override
2414
+ --bottom-nav-active-background: transparent on the nav. */
2415
+ :where(nav[data-bottom-nav]) {
2416
+ --bottom-nav-pad: var(--space-xs);
2417
+ --bottom-nav-gap: var(--space-2xs);
2418
+ --bottom-nav-target-height: 3.5rem;
2419
+ --bottom-nav-font-size: .75rem;
2420
+ --bottom-nav-font-weight: 500;
2421
+ --bottom-nav-active-weight: 650;
2422
+ --bottom-nav-icon-size: 1.125rem;
2423
+ --bottom-nav-active-background: var(--color-surface-raised);
2424
+ display: flex;
2425
+ gap: var(--bottom-nav-gap);
2426
+ padding: var(--bottom-nav-pad);
2427
+ padding-block-end: max(var(--bottom-nav-pad), env(safe-area-inset-bottom));
2428
+ /* Safe-area insets are physical, even in an RTL document. */
2429
+ padding-left: max(var(--bottom-nav-pad), env(safe-area-inset-left));
2430
+ padding-right: max(var(--bottom-nav-pad), env(safe-area-inset-right));
2431
+ border-block-start: 1px solid var(--color-border);
2432
+ background: var(--color-surface);
2433
+ }
2434
+ :where(nav[data-bottom-nav]:not(:has([data-bottom-icon]))) {
2435
+ --bottom-nav-target-height: 2.75rem;
2436
+ --bottom-nav-font-size: .8125rem;
2437
+ }
2438
+ :where(nav[data-bottom-nav] > a) {
2439
+ display: flex;
2440
+ flex: 1;
2441
+ flex-direction: column;
2442
+ justify-content: center;
2443
+ align-items: center;
2444
+ gap: var(--bottom-nav-gap);
2445
+ min-inline-size: 0;
2446
+ min-block-size: var(--bottom-nav-target-height);
2447
+ padding: var(--space-xs) var(--space-2xs);
2448
+ border-radius: var(--radius-md);
2449
+ color: var(--color-text-muted);
2450
+ font-size: var(--bottom-nav-font-size);
2451
+ font-weight: var(--bottom-nav-font-weight);
2452
+ line-height: 1.25;
2453
+ text-align: center;
2454
+ text-decoration: none;
2455
+ overflow-wrap: anywhere;
2456
+ }
2457
+ :where(nav[data-bottom-nav] > a):hover:not([aria-disabled="true"]) {
2458
+ background: var(--color-surface-raised);
2459
+ color: var(--color-text);
2460
+ }
2461
+ :where(nav[data-bottom-nav] > a)[aria-current="page"] {
2462
+ background: var(--bottom-nav-active-background);
2463
+ color: var(--color-text);
2464
+ font-weight: var(--bottom-nav-active-weight);
2465
+ }
2466
+ :where(nav[data-bottom-nav] > a)[aria-disabled="true"] { opacity: .45; cursor: default; }
2467
+ :where(nav[data-bottom-nav] [data-bottom-icon]) {
2468
+ inline-size: var(--bottom-nav-icon-size);
2469
+ block-size: var(--bottom-nav-icon-size);
2470
+ flex: none;
2471
+ }
2472
+
2473
+ /* --- sidebar: authored navigation, optional sidebar.js shell behavior --- */
2474
+ :where(m-sidebar-layout) {
2475
+ --sidebar-width: 15.5rem;
2476
+ --sidebar-rail-width: 3.75rem;
2477
+ --sidebar-mobile-width: 18rem;
2478
+ --sidebar-pad: var(--space-xs);
2479
+ --sidebar-icon-size: 1rem;
2480
+ --sidebar-font-size: .875rem;
2481
+ --sidebar-group-size: .6875rem;
2482
+ --sidebar-subnav-size: .8125rem;
2483
+ --sidebar-active-weight: 600;
2484
+ --sidebar-close-size: 2.75rem;
2485
+ --sidebar-inset: 0px;
2486
+ --sidebar-content-border: 0px;
2487
+ --sidebar-edge: 1px;
2488
+ display: grid;
2489
+ grid-template-columns: var(--m-sidebar-track, var(--sidebar-width)) minmax(0, 1fr);
2490
+ block-size: var(--sidebar-height, 100svh);
2491
+ min-block-size: 0;
2492
+ background: var(--color-surface-raised);
2493
+ }
2494
+ :where(m-sidebar-layout[variant="inset"]) {
2495
+ --sidebar-inset: var(--space-xs);
2496
+ --sidebar-content-border: 1px;
2497
+ --sidebar-edge: 0px;
2498
+ }
2499
+ :where(m-sidebar-layout > [data-sidebar-main]) {
2500
+ min-inline-size: 0;
2501
+ min-block-size: 0;
2502
+ overflow: auto;
2503
+ margin-block: var(--sidebar-inset);
2504
+ margin-inline: 0 var(--sidebar-inset);
2505
+ border: var(--sidebar-content-border) solid var(--color-border);
2506
+ background: var(--color-surface);
2507
+ }
2508
+ :where(m-sidebar) {
2509
+ display: flex;
2510
+ flex-direction: column;
2511
+ min-inline-size: 0;
2512
+ min-block-size: 0;
2513
+ block-size: 100%;
2514
+ padding: var(--sidebar-pad, var(--space-xs));
2515
+ border-inline-end: var(--sidebar-edge, 1px) solid var(--color-border);
2516
+ background: var(--color-surface-raised);
2517
+ }
2518
+ :where(m-sidebar > header, m-sidebar > footer) { flex: none; }
2519
+ :where(m-sidebar > header) { display: flex; align-items: center; }
2520
+ :where(m-sidebar > nav) {
2521
+ flex: 1 1 auto;
2522
+ min-block-size: 0;
2523
+ overflow-y: auto;
2524
+ overscroll-behavior: contain;
2525
+ padding-block: var(--space-md);
2526
+ }
2527
+ :where(m-sidebar > nav section) { margin: 0 0 var(--space-lg); }
2528
+ :where(m-sidebar > nav h2) {
2529
+ margin: 0;
2530
+ padding: var(--space-xs);
2531
+ font-size: var(--sidebar-group-size, .6875rem);
2532
+ font-weight: 500;
2533
+ color: var(--color-text-muted);
2534
+ }
2535
+ :where(m-sidebar [data-sidebar-item], m-sidebar [data-sidebar-subnav] > a) {
2536
+ display: flex;
2537
+ align-items: center;
2538
+ gap: var(--space-xs);
2539
+ min-block-size: var(--control-height);
2540
+ block-size: auto;
2541
+ padding: var(--space-xs);
2542
+ border: 0;
2543
+ border-radius: var(--radius-md);
2544
+ background: transparent;
2545
+ color: var(--color-text-muted);
2546
+ font-size: var(--sidebar-font-size, .875rem);
2547
+ text-decoration: none;
2548
+ text-align: start;
2549
+ }
2550
+ :where(m-sidebar [data-sidebar-item], m-sidebar [data-sidebar-subnav] > a):hover:not(:disabled, [aria-disabled="true"]) {
2551
+ color: var(--color-text);
2552
+ background: var(--color-surface);
2553
+ }
2554
+ :where(m-sidebar a)[aria-current="page"] {
2555
+ color: var(--color-text);
2556
+ background: var(--color-surface);
2557
+ font-weight: var(--sidebar-active-weight, 600);
2558
+ /* Active is a quiet fill, never a framed button. */
2559
+ box-shadow: none;
2560
+ }
2561
+ :where(m-sidebar [data-sidebar-item])[aria-disabled="true"] { opacity: .45; }
2562
+ :where(m-sidebar [data-sidebar-icon]) {
2563
+ inline-size: var(--sidebar-icon-size, 1rem);
2564
+ block-size: var(--sidebar-icon-size, 1rem);
2565
+ flex: none;
2566
+ object-fit: contain;
2567
+ }
2568
+ :where(m-sidebar [data-sidebar-label]) { flex: 1; min-inline-size: 0; }
2569
+ :where(m-sidebar [data-sidebar-badge]) { margin-inline-start: auto; color: var(--color-text-muted); font-size: var(--sidebar-subnav-size, .8125rem); }
2570
+ :where(m-sidebar > header > [data-sidebar-item], m-sidebar > footer > [data-sidebar-item]) { inline-size: 100%; }
2571
+ :where(m-sidebar details) { margin: 0; padding: 0; border: 0; background: none; }
2572
+ :where(m-sidebar summary) { cursor: pointer; list-style: none; }
2573
+ :where(m-sidebar summary)::-webkit-details-marker { display: none; }
2574
+ :where(m-sidebar summary)::after { display: none; }
2575
+ :where(m-sidebar [data-sidebar-chevron]) { inline-size: var(--space-sm); block-size: var(--space-sm); flex: none; }
2576
+ :where(m-sidebar details)[open] > :where(summary) > :where([data-sidebar-chevron]) { transform: rotate(90deg); }
2577
+ :where(m-sidebar [data-sidebar-subnav]) {
2578
+ margin: var(--space-2xs) 0 var(--space-xs) var(--sidebar-icon-size, 1rem);
2579
+ margin-inline: var(--sidebar-icon-size, 1rem) 0;
2580
+ padding: 0;
2581
+ padding-inline-start: var(--space-md);
2582
+ border-inline-start: 1px solid var(--color-border);
2583
+ }
2584
+ :where(m-sidebar [data-sidebar-subnav] > a) { font-size: var(--sidebar-subnav-size, .8125rem); }
2585
+ :where([data-sidebar-toggle], [data-sidebar-close]) { display: none; }
2586
+ :where(m-sidebar-layout[data-m-sidebar-rail]) { --m-sidebar-track: var(--sidebar-rail-width); }
2587
+ :where(m-sidebar-layout[data-m-sidebar-rail] m-sidebar) :where([data-sidebar-label], [data-sidebar-badge], h2, [data-sidebar-chevron], [data-sidebar-subnav]) { display: none; }
2588
+ :where(m-sidebar-layout[data-m-sidebar-rail] m-sidebar [data-sidebar-item]) { justify-content: center; }
2589
+ :where(m-sidebar-layout[data-m-sidebar-rail] m-sidebar details:has([aria-current="page"]) > summary) { color: var(--color-text); background: var(--color-surface); }
2590
+ :where(m-sidebar-layout[data-m-sidebar-can-rail] [data-sidebar-toggle], m-sidebar-layout[data-m-sidebar-mobile] [data-sidebar-toggle]) { display: inline-flex; }
2591
+ :where(m-sidebar-layout[data-m-sidebar-mobile]) { grid-template-columns: minmax(0, 1fr); --sidebar-inset: 0px; --sidebar-content-border: 0px; }
2592
+ :where(dialog[data-sidebar-dialog]) {
2593
+ position: fixed;
2594
+ inset: 0 auto 0 0;
2595
+ inset-inline: 0 auto;
2596
+ margin: 0;
2597
+ padding: 0;
2598
+ inline-size: min(var(--sidebar-mobile-width, 18rem), calc(100vw - var(--space-2xl)));
2599
+ max-inline-size: none;
2600
+ block-size: 100dvh;
2601
+ max-block-size: 100dvh;
2602
+ border: 0;
2603
+ border-radius: 0;
2604
+ background: var(--color-surface-raised);
2605
+ opacity: 1;
2606
+ scale: 1;
2607
+ transform: none;
2608
+ transition: none;
2609
+ animation: none;
2610
+ }
2611
+ :where(dialog[data-sidebar-dialog])::backdrop { transition: none; }
2612
+ :where(dialog[data-sidebar-dialog] > m-sidebar) {
2613
+ flex: 1 1 auto;
2614
+ overflow: hidden;
2615
+ border: 0;
2616
+ padding: var(--sidebar-pad, var(--space-xs));
2617
+ padding-block-start: max(var(--sidebar-pad, var(--space-xs)), env(safe-area-inset-top));
2618
+ padding-block-end: max(var(--sidebar-pad, var(--space-xs)), env(safe-area-inset-bottom));
2619
+ }
2620
+ :where(dialog[data-sidebar-dialog] m-sidebar > header > [data-sidebar-item]) { flex: 1; inline-size: 0; min-inline-size: 0; }
2621
+ :where(dialog[data-sidebar-dialog] [data-sidebar-close]) { display: inline-flex; flex: none; inline-size: var(--sidebar-close-size, 2.75rem); block-size: var(--sidebar-close-size, 2.75rem); padding: var(--space-xs); }
2622
+ @media (max-width: 44rem) {
2623
+ :where(m-sidebar-layout:not([data-m-sidebar-ready])) { grid-template-columns: minmax(0, 1fr); block-size: auto; }
2624
+ }
2625
+
2014
2626
  /* --- visually hidden: off-screen, still announced ------------------ *
2015
2627
  *
2016
2628
  * The one utility mica ships, and an accessibility primitive rather
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akonwi/mica",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Custom elements. Native behavior. Nearly no JavaScript.",
5
5
  "keywords": [
6
6
  "css",
@@ -55,6 +55,22 @@
55
55
  },
56
56
  "./types/react": {
57
57
  "types": "./types/react.d.ts"
58
+ },
59
+ "./header.js": {
60
+ "types": "./header.d.ts",
61
+ "default": "./header.js"
62
+ },
63
+ "./sidebar.js": {
64
+ "types": "./sidebar.d.ts",
65
+ "default": "./sidebar.js"
66
+ },
67
+ "./breadcrumbs.js": {
68
+ "types": "./breadcrumbs.d.ts",
69
+ "default": "./breadcrumbs.js"
70
+ },
71
+ "./table.js": {
72
+ "types": "./table.d.ts",
73
+ "default": "./table.js"
58
74
  }
59
75
  },
60
76
  "files": [
@@ -76,7 +92,15 @@
76
92
  "combobox.js",
77
93
  "combobox.d.ts",
78
94
  "types/react.d.ts",
79
- "THIRD_PARTY_LICENSES.md"
95
+ "THIRD_PARTY_LICENSES.md",
96
+ "header.js",
97
+ "header.d.ts",
98
+ "sidebar.js",
99
+ "sidebar.d.ts",
100
+ "breadcrumbs.js",
101
+ "breadcrumbs.d.ts",
102
+ "table.js",
103
+ "table.d.ts"
80
104
  ],
81
105
  "sideEffects": true,
82
106
  "devDependencies": {
@@ -93,6 +117,13 @@
93
117
  "docs:serve": "bun run docs:build && bun tools/serve.ts",
94
118
  "snapshot": "bun tools/snapshot.ts",
95
119
  "snapshot:check": "bun tools/snapshot.ts --check",
96
- "snapshot:setup": "playwright install chromium webkit"
120
+ "snapshot:setup": "playwright install chromium webkit",
121
+ "header:check": "bun tools/header-check.ts",
122
+ "sidebar:check": "bun tools/sidebar-check.ts",
123
+ "navigation-menu:check": "bun tools/navigation-menu-check.ts",
124
+ "bottom-navigation:check": "bun tools/bottom-navigation-check.ts",
125
+ "breadcrumbs:check": "bun tools/breadcrumbs-check.ts",
126
+ "data-table:check": "bun tools/data-table-check.ts",
127
+ "callout:check": "bun tools/callout-check.ts"
97
128
  }
98
129
  }
package/sidebar.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ export interface MSidebarLayoutElement extends HTMLElement {
2
+ open(): void;
3
+ close(): void;
4
+ toggle(): void;
5
+ }
6
+ declare global {
7
+ interface HTMLElementTagNameMap {
8
+ "m-sidebar": HTMLElement;
9
+ "m-sidebar-layout": MSidebarLayoutElement;
10
+ }
11
+ }
package/sidebar.js ADDED
@@ -0,0 +1,135 @@
1
+ /* Optional sidebar shell enhancement. The panel, dialog, controls, and all
2
+ * icons are authored HTML. Only the same panel is moved into the native
3
+ * dialog on narrow containers; no UI or navigation is generated. */
4
+ class MSidebarLayout extends HTMLElement {
5
+ #cleanup;
6
+ #actions;
7
+ #refresh;
8
+ static observedAttributes = ['collapse'];
9
+ attributeChangedCallback() { this.#refresh?.(); }
10
+ open() { this.#actions?.open(); }
11
+ close() { this.#actions?.close(); }
12
+ toggle() { this.#actions?.toggle(); }
13
+
14
+ connectedCallback() {
15
+ queueMicrotask(() => {
16
+ if (!this.isConnected || this.#cleanup) return;
17
+ const sidebar = this.querySelector(':scope > m-sidebar');
18
+ const main = this.querySelector(':scope > [data-sidebar-main]');
19
+ const dialog = this.querySelector(':scope > dialog[data-sidebar-dialog]');
20
+ if (!sidebar?.id || !main || !dialog || !('showModal' in dialog) || dialog.open || dialog.children.length) return;
21
+ const triggers = [...main.querySelectorAll('button[data-sidebar-toggle]')].filter(b => b.getAttribute('aria-controls') === sidebar.id);
22
+ const closers = [...sidebar.querySelectorAll('button[data-sidebar-close]')];
23
+ if (!triggers.length || !closers.length) return;
24
+ const originalExpanded = triggers.map(b => b.getAttribute('aria-expanded'));
25
+ const controller = new AbortController();
26
+ const { signal } = controller;
27
+ let mobile = false;
28
+ let rail = false;
29
+ let canRail = false;
30
+ let opener = triggers[0];
31
+ const focusable = 'a[href],button:not(:disabled),summary';
32
+ const closePopovers = () => sidebar.querySelectorAll('[popover]:popover-open').forEach(p => p.hidePopover());
33
+ const update = () => {
34
+ this.toggleAttribute('data-m-sidebar-mobile', mobile);
35
+ this.toggleAttribute('data-m-sidebar-rail', rail && canRail && !mobile);
36
+ this.toggleAttribute('data-m-sidebar-can-rail', canRail && !mobile);
37
+ for (const button of triggers) button.setAttribute('aria-expanded', String(mobile ? dialog.open : !(rail && canRail)));
38
+ };
39
+ const setRail = value => {
40
+ closePopovers();
41
+ const active = document.activeElement;
42
+ rail = value && canRail;
43
+ update();
44
+ if (sidebar.contains(active) && active instanceof HTMLElement && !active.getClientRects().length) triggers[0].focus();
45
+ };
46
+ const open = () => {
47
+ if (mobile) {
48
+ if (!dialog.open) dialog.showModal();
49
+ update();
50
+ } else setRail(false);
51
+ };
52
+ const close = () => {
53
+ if (mobile) {
54
+ closePopovers();
55
+ if (dialog.open) dialog.close();
56
+ update();
57
+ } else setRail(true);
58
+ };
59
+ const toggle = () => mobile ? (dialog.open ? close() : open()) : setRail(!rail);
60
+ const measure = () => {
61
+ const active = document.activeElement;
62
+ // Icon collapse is explicitly requested and requires named, authored
63
+ // icons. Invalid icon anatomy falls back to the expanded desktop panel.
64
+ const items = [...sidebar.querySelectorAll('[data-sidebar-item]')].filter(e => !e.closest('[data-sidebar-subnav], [popover]'));
65
+ canRail = this.getAttribute('collapse') === 'icon' && items.length > 0 && items.every(e => e.querySelector('[data-sidebar-icon]') && (e.hasAttribute('aria-label') || e.hasAttribute('aria-labelledby')));
66
+ const nextMobile = this.getBoundingClientRect().width < 44 * parseFloat(getComputedStyle(document.documentElement).fontSize);
67
+ if (nextMobile !== mobile || sidebar.parentElement !== (nextMobile ? dialog : this)) {
68
+ const hadSidebarFocus = sidebar.contains(active);
69
+ closePopovers();
70
+ if (dialog.open) dialog.close();
71
+ mobile = nextMobile;
72
+ if (mobile) dialog.append(sidebar);
73
+ else this.insertBefore(sidebar, main);
74
+ update();
75
+ if (hadSidebarFocus && active instanceof HTMLElement) {
76
+ if (mobile) opener.focus();
77
+ else {
78
+ // Preserve a focused nested link when returning to desktop.
79
+ if (active.closest('[data-sidebar-subnav]')) rail = false;
80
+ update();
81
+ if (active.matches('[data-sidebar-close]')) sidebar.querySelector(focusable)?.focus();
82
+ else active.focus();
83
+ }
84
+ } else if (!mobile && triggers.includes(active) && !canRail) sidebar.querySelector(focusable)?.focus();
85
+ } else update();
86
+ };
87
+ for (const button of triggers) button.addEventListener('click', () => { opener = button; toggle(); }, { signal });
88
+ for (const button of closers) button.addEventListener('click', close, { signal });
89
+ dialog.addEventListener('close', () => {
90
+ update();
91
+ // WebKit can leave focus on body after closing a modal that contained
92
+ // a popover. Preserve router-assigned focus elsewhere in the page.
93
+ if (mobile && (document.activeElement === document.body || dialog.contains(document.activeElement))) opener.focus();
94
+ }, { signal });
95
+ dialog.addEventListener('click', event => {
96
+ if (event.target !== dialog) return;
97
+ const r = dialog.getBoundingClientRect();
98
+ if (event.clientX < r.left || event.clientX > r.right || event.clientY < r.top || event.clientY > r.bottom) close();
99
+ }, { signal });
100
+ sidebar.addEventListener('click', event => {
101
+ const summary = event.target.closest('summary');
102
+ if (summary && rail && !mobile) {
103
+ event.preventDefault();
104
+ setRail(false);
105
+ summary.parentElement.open = true;
106
+ }
107
+ const link = event.target.closest('a[href]');
108
+ if (link && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) queueMicrotask(() => { if (!event.defaultPrevented && mobile) close(); });
109
+ }, { signal });
110
+ const observer = new ResizeObserver(measure);
111
+ observer.observe(this);
112
+ window.addEventListener('resize', measure, { signal, passive: true });
113
+ this.#actions = { open, close, toggle };
114
+ this.#refresh = measure;
115
+ this.#cleanup = () => {
116
+ controller.abort();
117
+ observer.disconnect();
118
+ closePopovers();
119
+ if (dialog.open) dialog.close();
120
+ this.insertBefore(sidebar, main);
121
+ for (const name of ['ready', 'mobile', 'rail', 'can-rail']) this.removeAttribute(`data-m-sidebar-${name}`);
122
+ triggers.forEach((b, i) => originalExpanded[i] === null ? b.removeAttribute('aria-expanded') : b.setAttribute('aria-expanded', originalExpanded[i]));
123
+ };
124
+ this.setAttribute('data-m-sidebar-ready', '');
125
+ measure();
126
+ });
127
+ }
128
+
129
+ disconnectedCallback() {
130
+ this.#cleanup?.();
131
+ this.#cleanup = this.#actions = this.#refresh = undefined;
132
+ }
133
+ }
134
+
135
+ if (!customElements.get('m-sidebar-layout')) customElements.define('m-sidebar-layout', MSidebarLayout);
package/table.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ /** Optional visual overflow indicators; native scrolling works without JS. */
2
+ declare global {
3
+ interface HTMLElementTagNameMap { "m-table-scroll": HTMLElement; }
4
+ }
5
+ export {};
package/table.js ADDED
@@ -0,0 +1,59 @@
1
+ /** Optional overflow indicators. Authored rows, selection, sorting, and paging
2
+ * remain application-owned. The native scroll region works without this module. */
3
+ class MicaTableScroll extends HTMLElement {
4
+ connectedCallback() {
5
+ this._abort?.abort();
6
+ this._resize?.disconnect();
7
+ this._mutation?.disconnect();
8
+ this._abort = new AbortController();
9
+ this._update = () => {
10
+ cancelAnimationFrame(this._frame);
11
+ this._frame = requestAnimationFrame(() => this._measure());
12
+ };
13
+ this.addEventListener('scroll', this._update, { capture: true, passive: true, signal: this._abort.signal });
14
+ window.addEventListener('resize', this._update, { signal: this._abort.signal });
15
+ this._resize = new ResizeObserver(this._update);
16
+ this._observe = () => {
17
+ this._resize.disconnect();
18
+ this._resize.observe(this);
19
+ const region = this.querySelector(':scope > [data-table-region]');
20
+ if (region) this._resize.observe(region);
21
+ const table = region?.querySelector('table');
22
+ if (table) this._resize.observe(table);
23
+ this._update();
24
+ };
25
+ this._mutation = new MutationObserver(this._observe);
26
+ this._mutation.observe(this, { subtree: true, childList: true, attributes: true, attributeFilter: ['hidden', 'data-state', 'dir'] });
27
+ this._observe();
28
+ }
29
+ _measure() {
30
+ const region = this.querySelector(':scope > [data-table-region]');
31
+ const table = region?.querySelector('table');
32
+ if (!region || !table) return;
33
+ const r = region.getBoundingClientRect(), t = table.getBoundingClientRect();
34
+ const overflow = !region.hasAttribute('data-state') && region.scrollWidth > region.clientWidth + 1;
35
+ this.toggleAttribute('data-m-overflow-left', overflow && t.left < r.left - 1);
36
+ this.toggleAttribute('data-m-overflow-right', overflow && t.right > r.right + 1);
37
+ let left = 0, right = 0;
38
+ const rtl = getComputedStyle(region).direction === 'rtl';
39
+ for (const cell of table.querySelectorAll('thead [data-table-sticky]')) {
40
+ if (getComputedStyle(cell).position !== 'sticky' || !cell.getClientRects().length) continue;
41
+ const c = cell.getBoundingClientRect();
42
+ if (rtl) right = Math.max(right, r.right - c.left);
43
+ else left = Math.max(left, c.right - r.left);
44
+ }
45
+ this.style.setProperty('--m-table-edge-left', `${left}px`);
46
+ this.style.setProperty('--m-table-edge-right', `${right}px`);
47
+ }
48
+ disconnectedCallback() {
49
+ this._abort?.abort();
50
+ this._resize?.disconnect();
51
+ this._mutation?.disconnect();
52
+ cancelAnimationFrame(this._frame);
53
+ this.removeAttribute('data-m-overflow-left');
54
+ this.removeAttribute('data-m-overflow-right');
55
+ this.style.removeProperty('--m-table-edge-left');
56
+ this.style.removeProperty('--m-table-edge-right');
57
+ }
58
+ }
59
+ if (!customElements.get('m-table-scroll')) customElements.define('m-table-scroll', MicaTableScroll);
package/types/react.d.ts CHANGED
@@ -51,6 +51,11 @@ declare module 'react' {
51
51
 
52
52
  // components
53
53
  'm-segmented': MicaElement
54
+ 'm-sidebar': MicaElement
55
+ 'm-sidebar-layout': MicaElement<{ variant?: 'docked' | 'inset'; collapse?: 'icon' }>
56
+ 'm-table-scroll': MicaElement
57
+ 'm-breadcrumb-overflow': MicaElement
58
+ 'm-header': MicaElement<{ align?: 'start' | 'end' }>
54
59
  'm-stepper': MicaElement
55
60
  'm-tabs': MicaElement<{ variant?: 'underline' }>
56
61
  'm-badge': MicaElement<{
@@ -61,6 +66,7 @@ declare module 'react' {
61
66
  blobatar?: string
62
67
  contained?: boolean
63
68
  }>
69
+ 'm-callout': MicaElement<{ variant?: 'neutral' | 'success' | 'warning' | 'danger' }>
64
70
  'm-skeleton': MicaElement
65
71
  'm-toast': MicaElement<{
66
72
  popover?: 'manual'