@akonwi/mica 0.2.2 → 0.4.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 +2 -2
- package/drawer.js +5 -4
- package/mica.css +81 -46
- package/package.json +2 -2
- package/toast.js +14 -6
package/README.md
CHANGED
|
@@ -12,8 +12,8 @@ there's nothing there but HTML and a stylesheet.
|
|
|
12
12
|
<m-vstack gap="lg">
|
|
13
13
|
<h1>That's it</h1>
|
|
14
14
|
<m-hstack gap="sm">
|
|
15
|
-
<button
|
|
16
|
-
<button
|
|
15
|
+
<button data-variant="primary">Save</button>
|
|
16
|
+
<button data-variant="ghost">Cancel</button>
|
|
17
17
|
</m-hstack>
|
|
18
18
|
</m-vstack>
|
|
19
19
|
```
|
package/drawer.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
/* mica/drawer.js — Tier 2: swipe-to-dismiss for dialog
|
|
2
|
-
* sheets on small screens.
|
|
1
|
+
/* mica/drawer.js — Tier 2: swipe-to-dismiss for dialog[data-drawer]
|
|
2
|
+
* bottom sheets on small screens.
|
|
3
3
|
*
|
|
4
4
|
* Enhances working markup, never replaces it: without this module the
|
|
5
5
|
* drawer opens/closes via buttons and Esc; with it, the mobile sheet
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
const SHEET = matchMedia("(max-width: 40rem)");
|
|
17
17
|
const CLOSE_DISTANCE = 1 / 3; // of sheet height
|
|
18
18
|
const CLOSE_VELOCITY = 0.6; // px/ms, downward flick
|
|
19
|
+
const DRAWER = "dialog:is([data-drawer], .drawer)";
|
|
19
20
|
|
|
20
21
|
document.documentElement.setAttribute("data-drawer-gestures", "");
|
|
21
22
|
|
|
@@ -23,13 +24,13 @@ document.addEventListener("pointerdown", (down) => {
|
|
|
23
24
|
if (!SHEET.matches) return;
|
|
24
25
|
const dialog =
|
|
25
26
|
down.target instanceof Element &&
|
|
26
|
-
down.target.closest(
|
|
27
|
+
down.target.closest(`${DRAWER}[open]`);
|
|
27
28
|
if (!dialog) return;
|
|
28
29
|
if (down.target.closest("button, a, input, select, textarea")) return;
|
|
29
30
|
|
|
30
31
|
// start only from the header or the top (handle) strip
|
|
31
32
|
const rect = dialog.getBoundingClientRect();
|
|
32
|
-
const inHeader = down.target.closest(
|
|
33
|
+
const inHeader = down.target.closest(`${DRAWER} > header`);
|
|
33
34
|
if (!inHeader && down.clientY - rect.top > 32) return;
|
|
34
35
|
|
|
35
36
|
let dy = 0;
|
package/mica.css
CHANGED
|
@@ -268,8 +268,8 @@
|
|
|
268
268
|
|
|
269
269
|
/* ------------------------------------------------------------------ *
|
|
270
270
|
* Elements — Tier 1. Native elements styled directly; behavior is the
|
|
271
|
-
* browser's.
|
|
272
|
-
*
|
|
271
|
+
* browser's. Presentation APIs are attributes; user CSS still wins
|
|
272
|
+
* because these rules live in a layer.
|
|
273
273
|
* ------------------------------------------------------------------ */
|
|
274
274
|
@layer mica.elements {
|
|
275
275
|
/* --- button (and things dressed as buttons) ---------------------- */
|
|
@@ -298,8 +298,8 @@
|
|
|
298
298
|
background: var(--neutral-4);
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
-
/*
|
|
302
|
-
&:where(.primary) {
|
|
301
|
+
/* Class selectors remain aliases for the former class-based API. */
|
|
302
|
+
&:where([data-variant="primary"], .primary) {
|
|
303
303
|
background: var(--color-primary);
|
|
304
304
|
border-color: transparent;
|
|
305
305
|
color: var(--color-on-primary);
|
|
@@ -316,7 +316,7 @@
|
|
|
316
316
|
secondary button when primary is near-black */
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
-
&:where(.danger) {
|
|
319
|
+
&:where([data-variant="danger"], .danger) {
|
|
320
320
|
background: var(--color-danger);
|
|
321
321
|
border-color: transparent;
|
|
322
322
|
color: var(--color-on-danger);
|
|
@@ -333,9 +333,9 @@
|
|
|
333
333
|
}
|
|
334
334
|
|
|
335
335
|
/* deliberately zero rest affordance — a ghost is invisible until
|
|
336
|
-
hover/focus. use
|
|
337
|
-
lone action on a page. */
|
|
338
|
-
&:where(.ghost) {
|
|
336
|
+
hover/focus. use data-variant="ghost" for toolbars and icon rows,
|
|
337
|
+
not as the lone action on a page. */
|
|
338
|
+
&:where([data-variant="ghost"], .ghost) {
|
|
339
339
|
background: transparent;
|
|
340
340
|
border-color: transparent;
|
|
341
341
|
|
|
@@ -348,11 +348,11 @@
|
|
|
348
348
|
}
|
|
349
349
|
|
|
350
350
|
/* sizes (shadcn: h-8 px-3 / h-10 px-6, text-sm throughout) */
|
|
351
|
-
&:where(.small) {
|
|
351
|
+
&:where([data-size="small"], .small) {
|
|
352
352
|
block-size: var(--control-height-sm);
|
|
353
353
|
padding-inline: var(--space-sm);
|
|
354
354
|
}
|
|
355
|
-
&:where(.large) {
|
|
355
|
+
&:where([data-size="large"], .large) {
|
|
356
356
|
block-size: var(--control-height-lg);
|
|
357
357
|
padding-inline: var(--space-lg);
|
|
358
358
|
}
|
|
@@ -572,7 +572,7 @@
|
|
|
572
572
|
}
|
|
573
573
|
}
|
|
574
574
|
|
|
575
|
-
:where(input[type="checkbox"]:not(.switch)) {
|
|
575
|
+
:where(input[type="checkbox"]:not(.switch, [role="switch"])) {
|
|
576
576
|
&:checked {
|
|
577
577
|
background-image: var(--check-glyph);
|
|
578
578
|
}
|
|
@@ -644,16 +644,9 @@
|
|
|
644
644
|
border-inline-start: 1px solid var(--color-border-strong);
|
|
645
645
|
}
|
|
646
646
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
block-size: 1px;
|
|
651
|
-
margin: -1px;
|
|
652
|
-
padding: 0;
|
|
653
|
-
overflow: hidden;
|
|
654
|
-
clip-path: inset(50%);
|
|
655
|
-
white-space: nowrap;
|
|
656
|
-
}
|
|
647
|
+
/* The radio itself is hidden but focusable and announced — the label
|
|
648
|
+
is the visible control. Shares the one hiding rule at the end of
|
|
649
|
+
this layer (see "visually hidden"). */
|
|
657
650
|
|
|
658
651
|
:where(m-segmented) > :where(label):has(> input:checked) {
|
|
659
652
|
background: var(--color-primary);
|
|
@@ -675,8 +668,9 @@
|
|
|
675
668
|
|
|
676
669
|
/* switch: a checkbox wearing a track; the thumb is a positioned
|
|
677
670
|
gradient (pseudo-elements don't render on inputs). square, per the
|
|
678
|
-
design language — a solid linear-gradient block
|
|
679
|
-
|
|
671
|
+
design language — a solid linear-gradient block. .switch remains
|
|
672
|
+
an alias for the former class-based API. */
|
|
673
|
+
:where(input[type="checkbox"]:is([role="switch"], .switch)) {
|
|
680
674
|
inline-size: 2.25rem;
|
|
681
675
|
border-color: transparent;
|
|
682
676
|
border-radius: var(--radius-sm);
|
|
@@ -1088,8 +1082,9 @@
|
|
|
1088
1082
|
|
|
1089
1083
|
/* drawer (shadcn sheet anatomy): inline-end panel, fade + 2.5rem
|
|
1090
1084
|
nudge rather than a full slide; no section dividers; footer pinned
|
|
1091
|
-
to the bottom with stacked actions
|
|
1092
|
-
|
|
1085
|
+
to the bottom with stacked actions. .drawer remains an alias for
|
|
1086
|
+
the former class-based API. */
|
|
1087
|
+
:where(dialog[data-drawer], dialog.drawer) {
|
|
1093
1088
|
margin-inline: auto 0;
|
|
1094
1089
|
margin-block: 0;
|
|
1095
1090
|
/* full height WITHOUT an explicit block-size: the UA gives
|
|
@@ -1109,23 +1104,24 @@
|
|
|
1109
1104
|
scale: 1;
|
|
1110
1105
|
translate: 2.5rem 0;
|
|
1111
1106
|
}
|
|
1112
|
-
:where(dialog.drawer[open]) {
|
|
1107
|
+
:where(dialog[data-drawer][open], dialog.drawer[open]) {
|
|
1113
1108
|
translate: 0 0;
|
|
1114
1109
|
}
|
|
1115
1110
|
/* unconditional (survives the exit, like the rest of composition) */
|
|
1116
|
-
:where(dialog
|
|
1111
|
+
:where(dialog[data-drawer], dialog.drawer)
|
|
1112
|
+
> :where(:not(header, footer, button.close)) {
|
|
1117
1113
|
font-size: inherit; /* panel runs 12/relaxed throughout */
|
|
1118
1114
|
}
|
|
1119
1115
|
@starting-style {
|
|
1120
|
-
:where(dialog.drawer[open]) {
|
|
1116
|
+
:where(dialog[data-drawer][open], dialog.drawer[open]) {
|
|
1121
1117
|
translate: 2.5rem 0;
|
|
1122
1118
|
}
|
|
1123
1119
|
}
|
|
1124
1120
|
|
|
1125
|
-
:where(dialog.drawer) > :where(header) {
|
|
1121
|
+
:where(dialog[data-drawer], dialog.drawer) > :where(header) {
|
|
1126
1122
|
border-block-end: 0;
|
|
1127
1123
|
}
|
|
1128
|
-
:where(dialog.drawer) > :where(footer) {
|
|
1124
|
+
:where(dialog[data-drawer], dialog.drawer) > :where(footer) {
|
|
1129
1125
|
margin-block-start: auto;
|
|
1130
1126
|
flex-direction: column;
|
|
1131
1127
|
align-items: stretch;
|
|
@@ -1140,7 +1136,7 @@
|
|
|
1140
1136
|
nav bars). Explicit 100svh gapped behind the collapsed toolbar and
|
|
1141
1137
|
100dvh lagged in the top layer — don't reintroduce heights here. */
|
|
1142
1138
|
@media (max-width: 40rem) {
|
|
1143
|
-
:where(dialog.drawer) {
|
|
1139
|
+
:where(dialog[data-drawer], dialog.drawer) {
|
|
1144
1140
|
inset-block: auto 0; /* release the UA's top anchor… */
|
|
1145
1141
|
margin-block: 0; /* …so height resolves from content */
|
|
1146
1142
|
block-size: auto; /* content height, like vaul */
|
|
@@ -1171,14 +1167,14 @@
|
|
|
1171
1167
|
translate: 0 100%;
|
|
1172
1168
|
opacity: 1;
|
|
1173
1169
|
}
|
|
1174
|
-
:where(dialog.drawer[open]) {
|
|
1170
|
+
:where(dialog[data-drawer][open], dialog.drawer[open]) {
|
|
1175
1171
|
translate: 0 0;
|
|
1176
1172
|
box-shadow:
|
|
1177
1173
|
0 4rem 0 0 var(--color-surface-overlay),
|
|
1178
1174
|
0 0 0 100vmax oklch(0 0 0 / 0.4);
|
|
1179
1175
|
}
|
|
1180
1176
|
@starting-style {
|
|
1181
|
-
:where(dialog.drawer[open]) {
|
|
1177
|
+
:where(dialog[data-drawer][open], dialog.drawer[open]) {
|
|
1182
1178
|
translate: 0 100%;
|
|
1183
1179
|
box-shadow:
|
|
1184
1180
|
0 4rem 0 0 var(--color-surface-overlay),
|
|
@@ -1187,14 +1183,15 @@
|
|
|
1187
1183
|
}
|
|
1188
1184
|
/* the box-shadow scrim replaces ::backdrop here; keep the native
|
|
1189
1185
|
backdrop clear so they don't double up or flash on the iOS cut. */
|
|
1190
|
-
:where(dialog.drawer[open])::backdrop {
|
|
1186
|
+
:where(dialog[data-drawer][open], dialog.drawer[open])::backdrop {
|
|
1191
1187
|
background: oklch(0 0 0 / 0);
|
|
1192
1188
|
}
|
|
1193
1189
|
|
|
1194
1190
|
/* grab handle — only when drawer.js is loaded (an affordance
|
|
1195
1191
|
without its behavior would fake interactivity; TIERS.md).
|
|
1196
1192
|
the handle is the first flex item of the open sheet. */
|
|
1197
|
-
:where(:root[data-drawer-gestures])
|
|
1193
|
+
:where(:root[data-drawer-gestures])
|
|
1194
|
+
:where(dialog[data-drawer][open], dialog.drawer[open])::before {
|
|
1198
1195
|
content: "";
|
|
1199
1196
|
align-self: center;
|
|
1200
1197
|
flex-shrink: 0;
|
|
@@ -1204,7 +1201,8 @@
|
|
|
1204
1201
|
background: var(--color-border-strong);
|
|
1205
1202
|
border-radius: var(--radius-full);
|
|
1206
1203
|
}
|
|
1207
|
-
:where(:root[data-drawer-gestures])
|
|
1204
|
+
:where(:root[data-drawer-gestures])
|
|
1205
|
+
:where(dialog[data-drawer], dialog.drawer) > :where(header) {
|
|
1208
1206
|
touch-action: none; /* the drag surface must own its touches */
|
|
1209
1207
|
}
|
|
1210
1208
|
}
|
|
@@ -1365,7 +1363,7 @@
|
|
|
1365
1363
|
color: var(--color-on-accent);
|
|
1366
1364
|
}
|
|
1367
1365
|
|
|
1368
|
-
|
|
1366
|
+
&:is([data-variant="danger"], .danger) {
|
|
1369
1367
|
color: var(--color-danger-text);
|
|
1370
1368
|
|
|
1371
1369
|
&:hover:not(:disabled, [aria-disabled="true"]),
|
|
@@ -1510,14 +1508,14 @@
|
|
|
1510
1508
|
color: var(--color-text-muted);
|
|
1511
1509
|
}
|
|
1512
1510
|
|
|
1513
|
-
/*
|
|
1514
|
-
&:where(.success) {
|
|
1511
|
+
/* Status classes remain aliases for the former class-based API. */
|
|
1512
|
+
&:where([data-variant="success"], .success) {
|
|
1515
1513
|
border-inline-start: 2px solid var(--color-success);
|
|
1516
1514
|
}
|
|
1517
|
-
&:where(.danger) {
|
|
1515
|
+
&:where([data-variant="danger"], .danger) {
|
|
1518
1516
|
border-inline-start: 2px solid var(--color-danger);
|
|
1519
1517
|
}
|
|
1520
|
-
&:where(.warn) {
|
|
1518
|
+
&:where([data-variant="warning"], .warn) {
|
|
1521
1519
|
border-inline-start: 2px solid var(--color-warn);
|
|
1522
1520
|
}
|
|
1523
1521
|
}
|
|
@@ -1611,6 +1609,41 @@
|
|
|
1611
1609
|
:where(m-error[active]) {
|
|
1612
1610
|
display: block;
|
|
1613
1611
|
}
|
|
1612
|
+
|
|
1613
|
+
/* --- visually hidden: off-screen, still announced ------------------ *
|
|
1614
|
+
*
|
|
1615
|
+
* The one utility mica ships, and an accessibility primitive rather
|
|
1616
|
+
* than a style: content that assistive tech must reach but sighted
|
|
1617
|
+
* users must not see. `display: none` and `inline-size: 0` would drop
|
|
1618
|
+
* it from the accessibility tree; this keeps a 1px box in flow-ish
|
|
1619
|
+
* and clips it away.
|
|
1620
|
+
*
|
|
1621
|
+
* An attribute, not a class: `class` stays the app's, `data-*` is
|
|
1622
|
+
* valid HTML and needs no React `HTMLAttributes` augmentation, and
|
|
1623
|
+
* `.visually-hidden`/`.sr-only` are the most copy-pasted names in
|
|
1624
|
+
* accessibility — a consumer arriving with their own copy is the
|
|
1625
|
+
* likely case. Lives at the end of this layer, not a new one, because
|
|
1626
|
+
* the layer list is a published contract; being last lets it beat the
|
|
1627
|
+
* element rules it is applied to (`caption`, `legend`, headings).
|
|
1628
|
+
*
|
|
1629
|
+
* <caption data-visually-hidden>Standings</caption>
|
|
1630
|
+
* <a href="#main" data-visually-hidden="focusable">Skip to content</a>
|
|
1631
|
+
*
|
|
1632
|
+
* The `focusable` variant is the skip-link primitive: hidden until it
|
|
1633
|
+
* takes focus, then it simply stops being hidden — no undo rule, so
|
|
1634
|
+
* the element keeps whatever styling the app gave it. */
|
|
1635
|
+
[data-visually-hidden]:not([data-visually-hidden="focusable"]),
|
|
1636
|
+
[data-visually-hidden="focusable"]:not(:focus, :focus-within),
|
|
1637
|
+
:where(m-segmented) > :where(label) > :where(input[type="radio"]) {
|
|
1638
|
+
position: absolute;
|
|
1639
|
+
inline-size: 1px;
|
|
1640
|
+
block-size: 1px;
|
|
1641
|
+
margin: -1px;
|
|
1642
|
+
padding: 0;
|
|
1643
|
+
overflow: hidden;
|
|
1644
|
+
clip-path: inset(50%);
|
|
1645
|
+
white-space: nowrap;
|
|
1646
|
+
}
|
|
1614
1647
|
}
|
|
1615
1648
|
|
|
1616
1649
|
/* ------------------------------------------------------------------ *
|
|
@@ -1843,8 +1876,8 @@
|
|
|
1843
1876
|
|
|
1844
1877
|
/* --- m-cover: fill the viewport, center the principal element ---- *
|
|
1845
1878
|
* The principal element (h1 by default, or mark one child with
|
|
1846
|
-
*
|
|
1847
|
-
* top, children after it pin to the bottom.
|
|
1879
|
+
* data-principal) is vertically centered; children before it pin to
|
|
1880
|
+
* the top, children after it pin to the bottom. */
|
|
1848
1881
|
m-cover {
|
|
1849
1882
|
display: flex;
|
|
1850
1883
|
flex-direction: column;
|
|
@@ -1860,9 +1893,11 @@
|
|
|
1860
1893
|
|
|
1861
1894
|
& > * { margin-block: 0; }
|
|
1862
1895
|
|
|
1863
|
-
& > :is(h1, .principal) { margin-block: auto; }
|
|
1896
|
+
& > :is(h1, [data-principal], .principal) { margin-block: auto; }
|
|
1864
1897
|
|
|
1865
|
-
/*
|
|
1866
|
-
|
|
1898
|
+
/* An explicit marker wins over the h1 default. .principal remains
|
|
1899
|
+
an alias for the former class-based API. */
|
|
1900
|
+
&:has(> :is([data-principal], .principal))
|
|
1901
|
+
> h1:not([data-principal], .principal) { margin-block: 0; }
|
|
1867
1902
|
}
|
|
1868
1903
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akonwi/mica",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Custom elements. Native behavior. Nearly no JavaScript.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"css",
|
|
@@ -55,4 +55,4 @@
|
|
|
55
55
|
"snapshot:check": "bun tools/snapshot.ts --check",
|
|
56
56
|
"snapshot:setup": "playwright install chromium webkit"
|
|
57
57
|
}
|
|
58
|
-
}
|
|
58
|
+
}
|
package/toast.js
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
*
|
|
7
7
|
* - stacking: open toasts stack upward; JS ships one number per toast
|
|
8
8
|
* (--m-toast-offset), CSS owns the geometry and the reflow motion
|
|
9
|
-
* - auto-dismiss: `duration` attribute in ms (default 5000,
|
|
10
|
-
* paused while hovered
|
|
9
|
+
* - auto-dismiss: `data-duration` attribute in ms (default 5000,
|
|
10
|
+
* "0" = sticky), paused while hovered
|
|
11
11
|
* - toast(title, { description, variant, duration }): spawns the same
|
|
12
|
-
* recipe markup
|
|
12
|
+
* recipe markup (`variant`: success, warning, or danger), shows it,
|
|
13
|
+
* and removes it after dismissal
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
const GAP = 8;
|
|
@@ -30,8 +31,10 @@ function restack() {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
function startTimer(el) {
|
|
33
|
-
const
|
|
34
|
-
|
|
34
|
+
const duration =
|
|
35
|
+
el.getAttribute("data-duration") ?? el.getAttribute("duration");
|
|
36
|
+
const ms = duration !== null
|
|
37
|
+
? Number(duration)
|
|
35
38
|
: DEFAULT_DURATION;
|
|
36
39
|
if (!ms) return;
|
|
37
40
|
timers.set(el, setTimeout(() => el.hidePopover(), ms));
|
|
@@ -74,10 +77,15 @@ document.addEventListener(
|
|
|
74
77
|
export function toast(title, { description = "", variant = "", duration } = {}) {
|
|
75
78
|
const el = document.createElement("div");
|
|
76
79
|
el.popover = "manual";
|
|
80
|
+
// Keep the generated class for consumers of the former API while
|
|
81
|
+
// emitting the attribute vocabulary used by declared toasts.
|
|
77
82
|
el.className = `toast${variant ? ` ${variant}` : ""}`;
|
|
83
|
+
if (variant) {
|
|
84
|
+
el.setAttribute("data-variant", variant === "warn" ? "warning" : variant);
|
|
85
|
+
}
|
|
78
86
|
el.setAttribute("role", "status");
|
|
79
87
|
el.dataset.ephemeral = "";
|
|
80
|
-
if (duration !== undefined) el.setAttribute("duration", String(duration));
|
|
88
|
+
if (duration !== undefined) el.setAttribute("data-duration", String(duration));
|
|
81
89
|
|
|
82
90
|
const x = document.createElement("button");
|
|
83
91
|
x.className = "close";
|