@akonwi/mica 0.1.0 → 0.2.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
@@ -28,7 +28,7 @@ form validation) comes from the browser, not from a runtime.
28
28
  first-class distribution channel, not a workaround:
29
29
 
30
30
  ```sh
31
- curl -O https://raw.githubusercontent.com/akonwi/mica/v0.1.0/mica.css
31
+ curl -O https://raw.githubusercontent.com/akonwi/mica/v0.2.0/mica.css
32
32
  ```
33
33
 
34
34
  **npm** — for toolchains:
@@ -44,7 +44,7 @@ import "@akonwi/mica/mica.css";
44
44
  **CDN** — for trying it out:
45
45
 
46
46
  ```html
47
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@akonwi/mica@0.1/mica.css">
47
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@akonwi/mica@0.2/mica.css">
48
48
  ```
49
49
 
50
50
  ## The three tiers
@@ -70,11 +70,21 @@ CSS by spec — overriding mica never requires specificity games. Theming is
70
70
  swapping token values (`--hue`, semantic color roles, spacing scale); dark
71
71
  mode is `light-dark()` — automatic, no classes.
72
72
 
73
+ ## Browser support
74
+
75
+ Mica targets [Baseline](https://web.dev/baseline) widely-available.
76
+ Newer features (anchor positioning, customizable select) are used behind
77
+ graceful fallbacks — each component's docs page notes what degrades and
78
+ how. Nothing breaks; some things get plainer.
79
+
73
80
  ## Docs
74
81
 
75
- Read [VISION.md](VISION.md) for the philosophy, [TIERS.md](TIERS.md) for
76
- the tier system, and the `docs/` pages for every component. Or open
77
- `demo.html` — the whole library on one page.
82
+ **[akonwi.io/mica](https://akonwi.io/mica/)** every component, built with
83
+ mica itself (view source). The [demo](https://akonwi.io/mica/demo.html) is
84
+ the whole library on one page.
85
+
86
+ Read [VISION.md](VISION.md) for the philosophy and [TIERS.md](TIERS.md)
87
+ for the tier system.
78
88
 
79
89
  ## License
80
90
 
package/drawer.js ADDED
@@ -0,0 +1,80 @@
1
+ /* mica/drawer.js — Tier 2: swipe-to-dismiss for dialog.drawer bottom
2
+ * sheets on small screens.
3
+ *
4
+ * Enhances working markup, never replaces it: without this module the
5
+ * drawer opens/closes via buttons and Esc; with it, the mobile sheet
6
+ * grows a grab handle (CSS keys off :root[data-drawer-gestures] — an
7
+ * affordance may only appear when the behavior exists) and vaul-style
8
+ * drag physics: the sheet follows a downward drag, resists upward,
9
+ * and dismisses past 1/3 of its height or on a flick.
10
+ *
11
+ * Drag surface: the handle / header strip. Drag-from-content is
12
+ * deliberately out of scope — it requires scroll-vs-drag heuristics
13
+ * (vaul's hardest code) for little gain over a clear grab target.
14
+ */
15
+
16
+ const SHEET = matchMedia("(max-width: 40rem)");
17
+ const CLOSE_DISTANCE = 1 / 3; // of sheet height
18
+ const CLOSE_VELOCITY = 0.6; // px/ms, downward flick
19
+
20
+ document.documentElement.setAttribute("data-drawer-gestures", "");
21
+
22
+ document.addEventListener("pointerdown", (down) => {
23
+ if (!SHEET.matches) return;
24
+ const dialog =
25
+ down.target instanceof Element &&
26
+ down.target.closest("dialog.drawer[open]");
27
+ if (!dialog) return;
28
+ if (down.target.closest("button, a, input, select, textarea")) return;
29
+
30
+ // start only from the header or the top (handle) strip
31
+ const rect = dialog.getBoundingClientRect();
32
+ const inHeader = down.target.closest("dialog.drawer > header");
33
+ if (!inHeader && down.clientY - rect.top > 32) return;
34
+
35
+ let dy = 0;
36
+ let lastY = down.clientY;
37
+ let lastT = down.timeStamp;
38
+ let velocity = 0;
39
+
40
+ dialog.setPointerCapture(down.pointerId);
41
+ const baseTransition = dialog.style.transition;
42
+ dialog.style.transition = "none";
43
+
44
+ const move = (e) => {
45
+ dy = e.clientY - down.clientY;
46
+ velocity = (e.clientY - lastY) / Math.max(1, e.timeStamp - lastT);
47
+ lastY = e.clientY;
48
+ lastT = e.timeStamp;
49
+ // follow downward; resist upward
50
+ dialog.style.translate = `0 ${dy > 0 ? dy : dy / 4}px`;
51
+ };
52
+
53
+ const up = () => {
54
+ dialog.removeEventListener("pointermove", move);
55
+ dialog.removeEventListener("pointerup", up);
56
+ dialog.removeEventListener("pointercancel", up);
57
+ dialog.style.transition = baseTransition;
58
+ if (dy > rect.height * CLOSE_DISTANCE || velocity > CLOSE_VELOCITY) {
59
+ // continue the motion DOWNWARD off-screen. close() fires its exit
60
+ // (backdrop fade + display flip at the end via allow-discrete);
61
+ // the inline translate overrides the small CSS exit offset so the
62
+ // sheet keeps sliding down instead of snapping back up into view
63
+ // (the flash). Clear it only at transitionend — display:none by
64
+ // then, so clearing is invisible and the next open starts clean.
65
+ dialog.addEventListener(
66
+ "transitionend",
67
+ (e) => { if (e.propertyName === "translate") dialog.style.translate = ""; },
68
+ { once: true },
69
+ );
70
+ requestAnimationFrame(() => { dialog.style.translate = "0 100dvh"; });
71
+ dialog.close();
72
+ } else {
73
+ dialog.style.translate = ""; // spring back
74
+ }
75
+ };
76
+
77
+ dialog.addEventListener("pointermove", move);
78
+ dialog.addEventListener("pointerup", up);
79
+ dialog.addEventListener("pointercancel", up);
80
+ });
package/invoker.js ADDED
@@ -0,0 +1,25 @@
1
+ /* mica/invoker.js — temporary shim for <button commandfor command>.
2
+ *
3
+ * Invoker commands are Baseline newly-available (Chrome 135+, Firefox
4
+ * 144+, Safari/iOS 26.2+). Mica's dialog recipes use them; include this
5
+ * shim until support is widely available. Where the browser handles
6
+ * commands natively, this module installs nothing.
7
+ *
8
+ * Scope: the dialog commands mica's recipes use — show-modal and close.
9
+ * (Popover recipes use popovertarget, which is already widely available.)
10
+ * Note: in shimmed browsers a commandfor button inside a <form> would
11
+ * still submit it — mica's recipes never place one there.
12
+ */
13
+ if (!("commandForElement" in HTMLButtonElement.prototype)) {
14
+ document.addEventListener("click", (event) => {
15
+ const button =
16
+ event.target instanceof Element &&
17
+ event.target.closest("button[commandfor][command]");
18
+ if (!button || button.disabled) return;
19
+ const dialog = document.getElementById(button.getAttribute("commandfor"));
20
+ if (!(dialog instanceof HTMLDialogElement)) return;
21
+ const command = button.getAttribute("command");
22
+ if (command === "show-modal" && !dialog.open) dialog.showModal();
23
+ else if (command === "close" && dialog.open) dialog.close();
24
+ });
25
+ }
package/mica.css CHANGED
@@ -44,11 +44,17 @@
44
44
  --focus-ring-offset: 2px;
45
45
  --focus-ring-color: var(--color-accent);
46
46
 
47
- /* checkmark glyph — SVG URIs can't read custom properties, so this
48
- ships as a light-dark pair tracking on-primary (white / near-black) */
49
- --check-glyph: light-dark(
50
- url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23fff' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E"),
51
- url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23151922' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E"));
47
+ /* checkmark glyph — SVG URIs can't read custom properties, and
48
+ light-dark() only accepts <color>s: feeding it url()s is invalid
49
+ (Chromium computes it to none; iOS Safari drops the glyph at
50
+ paint). So: light glyph by default, dark swapped in by the media
51
+ query after this block. The pair tracks on-primary (white /
52
+ near-black, since primary inverts in dark).
53
+ Forcing a scheme against the OS? Also pin the glyph:
54
+ :root { color-scheme: dark; --check-glyph: var(--check-glyph-dark); } */
55
+ --check-glyph-light: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23fff' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E");
56
+ --check-glyph-dark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23151922' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E");
57
+ --check-glyph: var(--check-glyph-light);
52
58
 
53
59
  /* -------------------------------------------------------------- *
54
60
  * Color. Two knobs — change the theme by changing the knobs:
@@ -151,6 +157,14 @@
151
157
  --color-on-warn: oklch(20% 0.03 var(--warn-hue));
152
158
  --color-warn-text: light-dark(oklch(42% calc(var(--status-chroma) * 0.9) var(--warn-hue)), oklch(80% calc(var(--status-chroma) * 0.9) var(--warn-hue)));
153
159
  }
160
+
161
+ /* scheme swap for image-valued tokens — the one place light-dark()
162
+ can't help (colors only). See --check-glyph above. */
163
+ @media (prefers-color-scheme: dark) {
164
+ :root {
165
+ --check-glyph: var(--check-glyph-dark);
166
+ }
167
+ }
154
168
  }
155
169
 
156
170
  /* ------------------------------------------------------------------ *
@@ -241,6 +255,14 @@
241
255
  transition-duration: 0.01ms !important;
242
256
  scroll-behavior: auto !important;
243
257
  }
258
+ /* `*` never matches pseudo-elements — ::backdrop needs its own rule
259
+ (and per convention gets its own block: an unrecognized selector
260
+ in the list above would kill the whole kill-switch). Found via
261
+ snapshot flakiness: the backdrop fade survived reduced-motion. */
262
+ *::backdrop {
263
+ animation-duration: 0.01ms !important;
264
+ transition-duration: 0.01ms !important;
265
+ }
244
266
  }
245
267
  }
246
268
 
@@ -594,6 +616,63 @@
594
616
  }
595
617
  }
596
618
 
619
+ /* segmented control: native radios fused into one control. The input
620
+ remains the form/keyboard/AT primitive; the label carries the visual
621
+ state because inputs cannot safely own pseudo-elements. */
622
+ :where(m-segmented) {
623
+ display: inline-flex;
624
+ inline-size: fit-content;
625
+ border: 1px solid var(--color-border-strong);
626
+ border-radius: var(--radius-sm);
627
+ }
628
+
629
+ :where(m-segmented) > :where(label) {
630
+ position: relative;
631
+ display: grid;
632
+ place-items: center;
633
+ min-block-size: var(--control-height);
634
+ padding-inline: var(--space-sm);
635
+ margin: 0;
636
+ font-size: 0.875rem;
637
+ font-weight: 400;
638
+ line-height: 1;
639
+ cursor: pointer;
640
+ user-select: none;
641
+ }
642
+
643
+ :where(m-segmented) > :where(label + label) {
644
+ border-inline-start: 1px solid var(--color-border-strong);
645
+ }
646
+
647
+ :where(m-segmented) > :where(label) > :where(input[type="radio"]) {
648
+ position: absolute;
649
+ inline-size: 1px;
650
+ block-size: 1px;
651
+ margin: -1px;
652
+ padding: 0;
653
+ overflow: hidden;
654
+ clip-path: inset(50%);
655
+ white-space: nowrap;
656
+ }
657
+
658
+ :where(m-segmented) > :where(label):has(> input:checked) {
659
+ background: var(--color-primary);
660
+ color: var(--color-on-primary);
661
+ font-weight: 600;
662
+ }
663
+
664
+ :where(m-segmented) > :where(label):has(> input:focus-visible) {
665
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
666
+ outline-offset: var(--focus-ring-offset);
667
+ z-index: 1;
668
+ }
669
+
670
+ :where(m-segmented) > :where(label):has(> input:disabled) {
671
+ color: var(--color-text-muted);
672
+ cursor: not-allowed;
673
+ opacity: 0.55;
674
+ }
675
+
597
676
  /* switch: a checkbox wearing a track; the thumb is a positioned
598
677
  gradient (pseudo-elements don't render on inputs). square, per the
599
678
  design language — a solid linear-gradient block */
@@ -642,6 +721,57 @@
642
721
  cursor: not-allowed;
643
722
  }
644
723
 
724
+ /* --- badge: quiet metadata, not a control ------------------------ */
725
+ :where(m-badge) {
726
+ display: inline-flex;
727
+ align-items: center;
728
+ justify-content: center;
729
+ min-inline-size: var(--m-badge-min-width, auto);
730
+ min-block-size: 1.5rem;
731
+ padding-inline: var(--m-badge-padding, var(--space-xs));
732
+ border: 1px solid var(--m-badge-border, var(--color-border));
733
+ border-radius: var(--radius-sm);
734
+ background: var(--m-badge-background, transparent);
735
+ color: var(--m-badge-color, var(--color-text-muted));
736
+ font-family: var(--m-badge-font, inherit);
737
+ font-size: 0.75rem;
738
+ font-weight: 500;
739
+ font-variant-numeric: var(--m-badge-numerals, normal);
740
+ line-height: 1;
741
+ white-space: nowrap;
742
+ }
743
+
744
+ :where(m-badge[variant="primary"]) {
745
+ --m-badge-border: var(--color-primary);
746
+ --m-badge-background: var(--color-primary);
747
+ --m-badge-color: var(--color-on-primary);
748
+ }
749
+
750
+ :where(m-badge[variant="success"]) {
751
+ --m-badge-border: var(--color-success-border);
752
+ --m-badge-background: var(--color-success-surface);
753
+ --m-badge-color: var(--color-success-text);
754
+ }
755
+
756
+ :where(m-badge[variant="warning"]) {
757
+ --m-badge-border: var(--color-warn-border);
758
+ --m-badge-background: var(--color-warn-surface);
759
+ --m-badge-color: var(--color-warn-text);
760
+ }
761
+
762
+ :where(m-badge[variant="danger"]) {
763
+ --m-badge-border: var(--color-danger-border);
764
+ --m-badge-background: var(--color-danger-surface);
765
+ --m-badge-color: var(--color-danger-text);
766
+ }
767
+
768
+ :where(m-badge[count]) {
769
+ --m-badge-min-width: 1.5rem;
770
+ --m-badge-padding: var(--space-2xs);
771
+ --m-badge-font: var(--font-mono);
772
+ --m-badge-numerals: tabular-nums;
773
+ }
774
+
645
775
  /* --- content: tables, lists, code, quotes ------------------------ */
646
776
  :where(table) {
647
777
  inline-size: 100%;
@@ -808,25 +938,49 @@
808
938
  /* composition: dialog > header (title + description) / body / footer.
809
939
  layout lives on [open] — unconditional display would defeat the
810
940
  UA's display:none on closed dialogs */
811
- :where(dialog[open]) {
812
- display: flex;
941
+ /* Composition is UNCONDITIONAL so it survives the exit: the dialog
942
+ stays rendered (display transitions via allow-discrete) for 0.15s
943
+ after [open] is removed. Structure gated on [open] snaps back to
944
+ defaults the instant [open] goes — flex-direction reverts to row,
945
+ children lose their sizing — and the content visibly deforms while
946
+ fading out. Only `display` stays conditional (unconditional flex
947
+ would defeat the UA's display:none on a closed dialog); opacity and
948
+ scale are the open visual state that transitions on exit. */
949
+ :where(dialog) {
813
950
  flex-direction: column;
814
- opacity: 1;
815
- scale: 1;
816
951
 
817
952
  & > :where(*) {
818
953
  margin-block: 0; /* the dialog owns spacing, like a stack */
819
954
  }
820
955
 
821
956
  /* the body: whatever sits between header and footer. one wrapper
822
- element; it pads itself and is the scrollable region */
957
+ element; it pads itself and is the scrollable region.
958
+ flex-basis must stay `auto` (not `flex: 1`'s 0%): iOS Safari sizes
959
+ the dialog's fit-content height from the basis, so 0% collapses
960
+ the body to a one-line scroll strip (iOS 26.0; Chromium and newer
961
+ WebKit use the content contribution either way). */
823
962
  & > :where(:not(header, footer, button.close)) {
824
963
  padding: var(--space-md);
825
- flex: 1;
964
+ flex: 1 1 auto;
826
965
  min-block-size: 0;
827
966
  overflow-y: auto;
967
+ /* break the scroll chain: at the region's scroll limit iOS
968
+ otherwise hands the gesture to the page behind the modal —
969
+ the root's overflow:hidden can't stop a chained scroll. */
970
+ overscroll-behavior: contain;
828
971
  font-size: 0.875rem; /* body must not outweigh the 14px title */
829
972
  }
973
+
974
+ /* when the dialog hits its max height, only the body compresses */
975
+ & > :where(header, footer) {
976
+ flex-shrink: 0;
977
+ }
978
+ }
979
+
980
+ :where(dialog[open]) {
981
+ display: flex;
982
+ opacity: 1;
983
+ scale: 1;
830
984
  }
831
985
 
832
986
  /* corner close button — <button class="close" commandfor="…"
@@ -906,7 +1060,18 @@
906
1060
  }
907
1061
 
908
1062
  :where(dialog)::backdrop {
909
- background: transparent;
1063
+ /* explicit oklch alpha, not the `transparent` keyword: iOS won't
1064
+ interpolate oklch()→transparent and cuts the scrim instantly,
1065
+ desyncing it from the sheet. same-space endpoints animate. */
1066
+ background: oklch(0 0 0 / 0);
1067
+ /* size to the LARGE viewport, don't trust inset: shipped iOS sizes
1068
+ the top layer to the small viewport, so an inset-based backdrop
1069
+ ends above the real bottom edge when the toolbar collapses —
1070
+ page content peeked through the strip. (Radix/vaul overlays are
1071
+ ordinary fixed divs and don't have this problem; ::backdrop
1072
+ lives in the top layer, so we overpaint instead.) */
1073
+ inline-size: 100lvw;
1074
+ block-size: 100lvh;
910
1075
  transition:
911
1076
  background-color 0.15s,
912
1077
  overlay 0.15s allow-discrete,
@@ -917,7 +1082,7 @@
917
1082
  }
918
1083
  @starting-style {
919
1084
  :where(dialog[open])::backdrop {
920
- background: transparent;
1085
+ background: oklch(0 0 0 / 0);
921
1086
  }
922
1087
  }
923
1088
 
@@ -927,8 +1092,14 @@
927
1092
  :where(dialog.drawer) {
928
1093
  margin-inline: auto 0;
929
1094
  margin-block: 0;
930
- block-size: 100svh;
931
- max-block-size: 100svh;
1095
+ /* full height WITHOUT an explicit block-size: the UA gives
1096
+ dialog:modal `position: fixed; inset-block: 0` — leaving height
1097
+ auto stretches between both edges, and on iOS that bottom edge
1098
+ tracks toolbar collapse like any bottom-fixed bar. Explicit
1099
+ 100svh left a gap behind the collapsed toolbar; 100dvh lagged
1100
+ in the top layer on shipped iOS. Don't reintroduce either. */
1101
+ block-size: auto;
1102
+ max-block-size: none;
932
1103
  inline-size: 75vw;
933
1104
  max-inline-size: 24rem; /* sheet's max-w-sm */
934
1105
  border: 0;
@@ -940,10 +1111,10 @@
940
1111
  }
941
1112
  :where(dialog.drawer[open]) {
942
1113
  translate: 0 0;
943
-
944
- & > :where(:not(header, footer, button.close)) {
945
- font-size: inherit; /* panel runs 12/relaxed throughout */
946
- }
1114
+ }
1115
+ /* unconditional (survives the exit, like the rest of composition) */
1116
+ :where(dialog.drawer) > :where(:not(header, footer, button.close)) {
1117
+ font-size: inherit; /* panel runs 12/relaxed throughout */
947
1118
  }
948
1119
  @starting-style {
949
1120
  :where(dialog.drawer[open]) {
@@ -962,6 +1133,90 @@
962
1133
  border-block-start: 0;
963
1134
  }
964
1135
 
1136
+ /* small screens: the drawer becomes a bottom sheet — shadcn's own
1137
+ mobile drawer (vaul/radix) is exactly this. Anchored to the bottom
1138
+ edge via auto top margin: bottom attachment is the one thing iOS
1139
+ tracks reliably through toolbar collapse (same mechanism as bottom
1140
+ nav bars). Explicit 100svh gapped behind the collapsed toolbar and
1141
+ 100dvh lagged in the top layer — don't reintroduce heights here. */
1142
+ @media (max-width: 40rem) {
1143
+ :where(dialog.drawer) {
1144
+ inset-block: auto 0; /* release the UA's top anchor… */
1145
+ margin-block: 0; /* …so height resolves from content */
1146
+ block-size: auto; /* content height, like vaul */
1147
+ max-block-size: calc(100dvh - 3rem);
1148
+ inline-size: 100vw;
1149
+ max-inline-size: none;
1150
+ border-inline-start: 0;
1151
+ border-block-start: 1px solid var(--color-border);
1152
+ /* Two box-shadows, both painted by the SHEET element so they
1153
+ persist through the exit (display:allow-discrete keeps the box
1154
+ rendered) — unlike ::backdrop, which iOS drops from the top
1155
+ layer the instant you close, cutting the scrim. So the dim
1156
+ rides the sheet here and stays in sync with the slide:
1157
+ 1) bleed slab below the edge (vaul's trick) — fills any strip
1158
+ iOS exposes under the top layer when the toolbar collapses;
1159
+ 2) full-screen scrim via 100vmax spread — the visible dim,
1160
+ its alpha animating with the sheet. 100vmax keeps coverage
1161
+ even as the shadow rides the sliding sheet down. */
1162
+ box-shadow:
1163
+ 0 4rem 0 0 var(--color-surface-overlay),
1164
+ 0 0 0 100vmax oklch(0 0 0 / 0);
1165
+ transition:
1166
+ box-shadow 0.15s, translate 0.15s,
1167
+ overlay 0.15s allow-discrete, display 0.15s allow-discrete;
1168
+ /* the sheet SLIDES fully off the bottom and stays opaque — only
1169
+ the scrim fades. A small nudge + opacity fade read as a
1170
+ dissolve-in-place, wrong for a bottom sheet. */
1171
+ translate: 0 100%;
1172
+ opacity: 1;
1173
+ }
1174
+ :where(dialog.drawer[open]) {
1175
+ translate: 0 0;
1176
+ box-shadow:
1177
+ 0 4rem 0 0 var(--color-surface-overlay),
1178
+ 0 0 0 100vmax oklch(0 0 0 / 0.4);
1179
+ }
1180
+ @starting-style {
1181
+ :where(dialog.drawer[open]) {
1182
+ translate: 0 100%;
1183
+ box-shadow:
1184
+ 0 4rem 0 0 var(--color-surface-overlay),
1185
+ 0 0 0 100vmax oklch(0 0 0 / 0);
1186
+ }
1187
+ }
1188
+ /* the box-shadow scrim replaces ::backdrop here; keep the native
1189
+ backdrop clear so they don't double up or flash on the iOS cut. */
1190
+ :where(dialog.drawer[open])::backdrop {
1191
+ background: oklch(0 0 0 / 0);
1192
+ }
1193
+
1194
+ /* grab handle — only when drawer.js is loaded (an affordance
1195
+ without its behavior would fake interactivity; TIERS.md).
1196
+ the handle is the first flex item of the open sheet. */
1197
+ :where(:root[data-drawer-gestures]) :where(dialog.drawer[open])::before {
1198
+ content: "";
1199
+ align-self: center;
1200
+ flex-shrink: 0;
1201
+ inline-size: 2.5rem;
1202
+ block-size: 0.25rem;
1203
+ margin-block-start: var(--space-sm);
1204
+ background: var(--color-border-strong);
1205
+ border-radius: var(--radius-full);
1206
+ }
1207
+ :where(:root[data-drawer-gestures]) :where(dialog.drawer) > :where(header) {
1208
+ touch-action: none; /* the drag surface must own its touches */
1209
+ }
1210
+ }
1211
+
1212
+ /* scroll lock: the page must not scroll behind any modal dialog.
1213
+ CSS-only best effort (→ no JS tier violation); iOS honors
1214
+ overflow:hidden on the root for modern versions. */
1215
+ :where(html):has(:where(dialog:modal)) {
1216
+ overflow: hidden;
1217
+ overscroll-behavior: none;
1218
+ }
1219
+
965
1220
  /* --- accordion / disclosure: details + summary -------------------- *
966
1221
  * shadcn accordion look; exclusivity is <details name="group">.
967
1222
  * open/close animation via ::details-content + interpolate-size,
@@ -1042,18 +1297,27 @@
1042
1297
  opacity: 1;
1043
1298
  scale: 1;
1044
1299
  }
1045
- /* enter animates; close is instant (house rule for transient layers) */
1046
- :where([popover]:popover-open) {
1047
- transition:
1048
- opacity 0.1s,
1049
- scale 0.1s,
1050
- overlay 0.1s allow-discrete,
1051
- display 0.1s allow-discrete;
1052
- }
1053
- @starting-style {
1300
+ /* enter animates; close is instant (house rule for transient layers).
1301
+ Gated on `overlay` support: iOS lacks it, and its top-layer
1302
+ transitions are broken — a popover with an @starting-style entry
1303
+ freezes at the start (opacity:0 = invisible) with no reflow to kick
1304
+ it, and there's no CSS way to force one. Where overlay is
1305
+ unsupported, skip the entrance so popovers render at their resting
1306
+ opacity:1 instead of vanishing. (This is why toasts "didn't work"
1307
+ on iOS.) */
1308
+ @supports (overlay: auto) {
1054
1309
  :where([popover]:popover-open) {
1055
- opacity: 0;
1056
- scale: 0.95;
1310
+ transition:
1311
+ opacity 0.1s,
1312
+ scale 0.1s,
1313
+ overlay 0.1s allow-discrete,
1314
+ display 0.1s allow-discrete;
1315
+ }
1316
+ @starting-style {
1317
+ :where([popover]:popover-open) {
1318
+ opacity: 0;
1319
+ scale: 0.95;
1320
+ }
1057
1321
  }
1058
1322
  }
1059
1323
  /* anchor to the invoker where the platform can */
@@ -1246,18 +1510,24 @@
1246
1510
  border-inline-start: 2px solid var(--color-warn);
1247
1511
  }
1248
1512
  }
1249
- /* arrives from below rather than zooming; reflow (restacking) eases */
1513
+ /* restacking motion always (inset-block-end); the entrance slide/fade
1514
+ only where overlay transitions work — see the base popover note. */
1250
1515
  :where([popover].toast:popover-open) {
1251
- transition:
1252
- opacity 0.1s,
1253
- translate 0.1s,
1254
- inset-block-end 0.15s,
1255
- overlay 0.1s allow-discrete,
1256
- display 0.1s allow-discrete;
1516
+ transition: inset-block-end 0.15s;
1257
1517
  }
1258
- @starting-style {
1518
+ @supports (overlay: auto) {
1259
1519
  :where([popover].toast:popover-open) {
1260
- translate: 0 0.75rem;
1520
+ transition:
1521
+ opacity 0.1s,
1522
+ translate 0.1s,
1523
+ inset-block-end 0.15s,
1524
+ overlay 0.1s allow-discrete,
1525
+ display 0.1s allow-discrete;
1526
+ }
1527
+ @starting-style {
1528
+ :where([popover].toast:popover-open) {
1529
+ translate: 0 0.75rem;
1530
+ }
1261
1531
  }
1262
1532
  }
1263
1533
 
@@ -1348,13 +1618,15 @@
1348
1618
  m-grid,
1349
1619
  m-sidecar,
1350
1620
  m-switcher,
1351
- m-reel {
1621
+ m-reel,
1622
+ m-frame,
1623
+ m-cover {
1352
1624
  display: block;
1353
1625
  box-sizing: border-box;
1354
1626
  }
1355
1627
 
1356
1628
  /* Shared attribute vocabulary -> custom properties */
1357
- :is(m-vstack, m-hstack, m-grid, m-sidecar, m-switcher, m-reel) {
1629
+ :is(m-vstack, m-hstack, m-grid, m-sidecar, m-switcher, m-reel, m-cover) {
1358
1630
  &[gap="none"] { --gap: 0; }
1359
1631
  &[gap="2xs"] { --gap: var(--space-2xs); }
1360
1632
  &[gap="xs"] { --gap: var(--space-xs); }
@@ -1533,4 +1805,53 @@
1533
1805
  &[snap="none"] { scroll-snap-type: none; }
1534
1806
  &[snap="mandatory"] { scroll-snap-type: inline mandatory; }
1535
1807
  }
1808
+
1809
+ /* --- m-frame: hold an aspect ratio, crop media to fit ------------ */
1810
+ m-frame {
1811
+ aspect-ratio: var(--ratio, 16 / 9);
1812
+ overflow: hidden;
1813
+ display: flex;
1814
+ align-items: center;
1815
+ justify-content: center;
1816
+
1817
+ &[ratio="square"] { --ratio: 1; }
1818
+ &[ratio="16:9"] { --ratio: 16 / 9; }
1819
+ &[ratio="4:3"] { --ratio: 4 / 3; }
1820
+ &[ratio="3:2"] { --ratio: 3 / 2; }
1821
+ &[ratio="2:1"] { --ratio: 2 / 1; }
1822
+ &[ratio="9:16"] { --ratio: 9 / 16; }
1823
+
1824
+ /* media fills and crops; anything else just gets centered */
1825
+ & > :is(img, video),
1826
+ & > picture > img {
1827
+ inline-size: 100%;
1828
+ block-size: 100%;
1829
+ object-fit: cover;
1830
+ }
1831
+ }
1832
+
1833
+ /* --- m-cover: fill the viewport, center the principal element ---- *
1834
+ * The principal element (h1 by default, or mark one child with
1835
+ * .principal) is vertically centered; children before it pin to the
1836
+ * top, children after it pin to the bottom. */
1837
+ m-cover {
1838
+ display: flex;
1839
+ flex-direction: column;
1840
+ min-block-size: var(--min-height, 100svh);
1841
+ gap: var(--gap, var(--space-md));
1842
+ padding: var(--pad, var(--space-md));
1843
+
1844
+ &[pad="none"] { --pad: 0; }
1845
+ &[pad="sm"] { --pad: var(--space-sm); }
1846
+ &[pad="md"] { --pad: var(--space-md); }
1847
+ &[pad="lg"] { --pad: var(--space-lg); }
1848
+ &[pad="xl"] { --pad: var(--space-xl); }
1849
+
1850
+ & > * { margin-block: 0; }
1851
+
1852
+ & > :is(h1, .principal) { margin-block: auto; }
1853
+
1854
+ /* an explicit .principal wins over the h1 default */
1855
+ &:has(> .principal) > h1:not(.principal) { margin-block: 0; }
1856
+ }
1536
1857
  }
package/package.json CHANGED
@@ -1,9 +1,16 @@
1
1
  {
2
2
  "name": "@akonwi/mica",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Custom elements. Native behavior. Nearly no JavaScript.",
5
- "keywords": ["css", "custom-elements", "web-components", "layout", "no-build", "html-first"],
6
- "homepage": "https://github.com/akonwi/mica",
5
+ "keywords": [
6
+ "css",
7
+ "custom-elements",
8
+ "web-components",
9
+ "layout",
10
+ "no-build",
11
+ "html-first"
12
+ ],
13
+ "homepage": "https://akonwi.io/mica/",
7
14
  "repository": {
8
15
  "type": "git",
9
16
  "url": "git+https://github.com/akonwi/mica.git"
@@ -14,6 +21,8 @@
14
21
  "exports": {
15
22
  ".": "./mica.css",
16
23
  "./mica.css": "./mica.css",
24
+ "./invoker.js": "./invoker.js",
25
+ "./drawer.js": "./drawer.js",
17
26
  "./field.js": "./field.js",
18
27
  "./select.js": "./select.js",
19
28
  "./tabs.js": "./tabs.js",
@@ -22,11 +31,24 @@
22
31
  },
23
32
  "files": [
24
33
  "mica.css",
34
+ "invoker.js",
35
+ "drawer.js",
25
36
  "field.js",
26
37
  "select.js",
27
38
  "tabs.js",
28
39
  "toast.js",
29
40
  "combobox.js"
30
41
  ],
31
- "sideEffects": true
42
+ "sideEffects": true,
43
+ "devDependencies": {
44
+ "axe-core": "^4.12.1",
45
+ "pixelmatch": "^7.2.0",
46
+ "playwright": "^1.61.1",
47
+ "pngjs": "^7.0.0"
48
+ },
49
+ "scripts": {
50
+ "snapshot": "bun tools/snapshot.ts",
51
+ "snapshot:check": "bun tools/snapshot.ts --check",
52
+ "snapshot:setup": "playwright install chromium webkit"
53
+ }
32
54
  }