@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 +15 -5
- package/drawer.js +80 -0
- package/invoker.js +25 -0
- package/mica.css +362 -41
- package/package.json +26 -4
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.
|
|
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.
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
:
|
|
812
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
931
|
-
|
|
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
|
-
|
|
945
|
-
|
|
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
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
@
|
|
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
|
-
|
|
1056
|
-
|
|
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
|
-
/*
|
|
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
|
-
@
|
|
1518
|
+
@supports (overlay: auto) {
|
|
1259
1519
|
:where([popover].toast:popover-open) {
|
|
1260
|
-
|
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Custom elements. Native behavior. Nearly no JavaScript.",
|
|
5
|
-
"keywords": [
|
|
6
|
-
|
|
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
|
}
|