@shabboo/kit 0.0.0-stage → 0.1.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/CHANGELOG.md +7 -0
- package/DESIGN.md +128 -0
- package/LICENSE +21 -0
- package/README.md +75 -2
- package/dist/motion.d.ts +14 -0
- package/dist/motion.js +23 -0
- package/dist/ui/icons.d.ts +24 -0
- package/dist/ui/icons.js +23 -0
- package/package.json +83 -3
- package/src/motion.tsx +33 -0
- package/src/styles/fonts.css +8 -0
- package/src/styles/print.css +36 -0
- package/src/styles/styles.css +246 -0
- package/src/styles/tokens.css +303 -0
- package/src/ui/Badge.astro +95 -0
- package/src/ui/Breadcrumbs.astro +28 -0
- package/src/ui/Button.astro +190 -0
- package/src/ui/Callout.astro +112 -0
- package/src/ui/Card.astro +79 -0
- package/src/ui/Dialog.astro +168 -0
- package/src/ui/Disclosure.astro +197 -0
- package/src/ui/EntryItem.astro +43 -0
- package/src/ui/Field.astro +167 -0
- package/src/ui/Figure.astro +47 -0
- package/src/ui/HCard.astro +86 -0
- package/src/ui/Icon.astro +59 -0
- package/src/ui/Kbd.astro +35 -0
- package/src/ui/Label.astro +15 -0
- package/src/ui/Pagination.astro +124 -0
- package/src/ui/PixelFrame.astro +121 -0
- package/src/ui/Quote.astro +49 -0
- package/src/ui/Tag.astro +91 -0
- package/src/ui/TagList.astro +68 -0
- package/src/ui/Timeline.astro +23 -0
- package/src/ui/TimelineItem.astro +105 -0
- package/src/ui/icons.ts +27 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A button, or a link that looks like one (`href` set).
|
|
4
|
+
* variant: primary (filled accent) · ghost (outlined) · quiet (text only).
|
|
5
|
+
* A <button> defaults to type="button"; pass type="submit" inside a form (or a Dialog's actions).
|
|
6
|
+
* aria-disabled="true" keeps it focusable but inert: a small script cancels its clicks, Enter and Space included.
|
|
7
|
+
* The script ships only with such a button; a page without one gets no JS from Button.
|
|
8
|
+
*/
|
|
9
|
+
import type { HTMLAttributes } from "astro/types";
|
|
10
|
+
|
|
11
|
+
type Common = {
|
|
12
|
+
variant?: "primary" | "ghost" | "quiet";
|
|
13
|
+
size?: "sm" | "md" | "lg";
|
|
14
|
+
};
|
|
15
|
+
type Props = Common & ((HTMLAttributes<"a"> & { href: string }) | (HTMLAttributes<"button"> & { href?: undefined }));
|
|
16
|
+
|
|
17
|
+
const { variant = "ghost", size = "md", class: className, ...rest } = Astro.props;
|
|
18
|
+
const inert = String(rest["aria-disabled"]) === "true";
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
rest.href !== undefined ? (
|
|
23
|
+
<a class:list={["btn", className]} data-variant={variant} data-size={size} {...(rest as HTMLAttributes<"a">)}>
|
|
24
|
+
<slot />
|
|
25
|
+
</a>
|
|
26
|
+
) : (
|
|
27
|
+
<button
|
|
28
|
+
class:list={["btn", className]}
|
|
29
|
+
data-variant={variant}
|
|
30
|
+
data-size={size}
|
|
31
|
+
type="button"
|
|
32
|
+
{...(rest as HTMLAttributes<"button">)}
|
|
33
|
+
>
|
|
34
|
+
<slot />
|
|
35
|
+
</button>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
/*
|
|
41
|
+
* aria-disabled only styles a control; this makes it inert too, so a keyboard user can't follow the link or
|
|
42
|
+
* submit the form (implicit submission clicks the default button, so it is caught here as well).
|
|
43
|
+
* Shipped only with a button that needs it, and bound once per page, so pages without one stay JS-free.
|
|
44
|
+
*/
|
|
45
|
+
inert && (
|
|
46
|
+
<script is:inline>
|
|
47
|
+
{`if (!window.__shabBtnGuard) {
|
|
48
|
+
window.__shabBtnGuard = true;
|
|
49
|
+
document.addEventListener("click", (e) => {
|
|
50
|
+
if (!e.target?.closest?.('.btn[aria-disabled="true"]')) return;
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
e.stopImmediatePropagation();
|
|
53
|
+
}, true);
|
|
54
|
+
}`}
|
|
55
|
+
</script>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
<style>
|
|
60
|
+
.btn {
|
|
61
|
+
--btn-pad: var(--control-pad-md);
|
|
62
|
+
position: relative;
|
|
63
|
+
display: inline-flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
justify-content: center;
|
|
66
|
+
gap: var(--space-2);
|
|
67
|
+
min-block-size: var(--control-h-md);
|
|
68
|
+
padding-inline: var(--btn-pad);
|
|
69
|
+
border: 1px solid transparent;
|
|
70
|
+
border-radius: var(--radius);
|
|
71
|
+
background: none;
|
|
72
|
+
color: var(--text);
|
|
73
|
+
font-family: var(--font-mono);
|
|
74
|
+
font-size: var(--step--1);
|
|
75
|
+
letter-spacing: var(--tracking-mono);
|
|
76
|
+
line-height: 1;
|
|
77
|
+
text-decoration: none;
|
|
78
|
+
white-space: nowrap;
|
|
79
|
+
cursor: pointer;
|
|
80
|
+
}
|
|
81
|
+
.btn[data-size="sm"] {
|
|
82
|
+
--btn-pad: var(--control-pad-sm);
|
|
83
|
+
min-block-size: var(--control-h-sm);
|
|
84
|
+
font-size: var(--step--2);
|
|
85
|
+
}
|
|
86
|
+
.btn[data-size="lg"] {
|
|
87
|
+
--btn-pad: var(--control-pad-lg);
|
|
88
|
+
min-block-size: var(--control-h-lg);
|
|
89
|
+
font-size: var(--step-0);
|
|
90
|
+
}
|
|
91
|
+
.btn[data-variant="primary"] {
|
|
92
|
+
background: var(--fill-accent);
|
|
93
|
+
border-color: var(--fill-accent-strong);
|
|
94
|
+
color: var(--text-on-fill);
|
|
95
|
+
}
|
|
96
|
+
.btn[data-variant="primary"]:hover {
|
|
97
|
+
background: var(--fill-accent-strong);
|
|
98
|
+
}
|
|
99
|
+
.btn[data-variant="ghost"] {
|
|
100
|
+
border-color: var(--stroke-control);
|
|
101
|
+
}
|
|
102
|
+
.btn[data-variant="ghost"]:hover {
|
|
103
|
+
border-color: var(--stroke-strong);
|
|
104
|
+
background: var(--state-hover);
|
|
105
|
+
color: var(--text-accent);
|
|
106
|
+
}
|
|
107
|
+
.btn:is([data-variant="ghost"], [data-variant="quiet"]):active {
|
|
108
|
+
background: var(--state-active);
|
|
109
|
+
}
|
|
110
|
+
.btn[data-variant="quiet"] {
|
|
111
|
+
color: var(--text-muted);
|
|
112
|
+
}
|
|
113
|
+
.btn[data-variant="quiet"]:hover {
|
|
114
|
+
background: var(--state-hover);
|
|
115
|
+
color: var(--text-accent);
|
|
116
|
+
}
|
|
117
|
+
/*
|
|
118
|
+
* drawn small, tapped big: sm and md reach --target-min through an invisible hit area (% insets: the border
|
|
119
|
+
* can't skew it). Not quiet: it sits in running text (WCAG's inline exception), where a taller hit area would
|
|
120
|
+
* take the clicks of the lines above and below.
|
|
121
|
+
*/
|
|
122
|
+
.btn:not([data-variant="quiet"])::before {
|
|
123
|
+
content: "";
|
|
124
|
+
position: absolute;
|
|
125
|
+
inset: min(0px, (100% - var(--target-min)) / 2);
|
|
126
|
+
}
|
|
127
|
+
/* pixel press: a hard 3px ledge under filled and outlined buttons; hover lifts a frame, press flattens it */
|
|
128
|
+
.btn:is([data-variant="primary"], [data-variant="ghost"]) {
|
|
129
|
+
box-shadow: 0 3px 0 0 var(--stroke-control);
|
|
130
|
+
}
|
|
131
|
+
.btn[data-variant="primary"] {
|
|
132
|
+
box-shadow: 0 3px 0 0 var(--surface-sunken), 0 3px 0 1px var(--fill-accent-strong);
|
|
133
|
+
}
|
|
134
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
135
|
+
.btn {
|
|
136
|
+
transition:
|
|
137
|
+
background-color var(--dur-step) var(--ease-step),
|
|
138
|
+
border-color var(--dur-step) var(--ease-step),
|
|
139
|
+
color var(--dur-step) var(--ease-step),
|
|
140
|
+
translate var(--dur-step) steps(2, end),
|
|
141
|
+
box-shadow var(--dur-step) steps(2, end);
|
|
142
|
+
}
|
|
143
|
+
.btn:is([data-variant="primary"], [data-variant="ghost"]):hover {
|
|
144
|
+
translate: 0 -1px;
|
|
145
|
+
}
|
|
146
|
+
.btn:is([data-variant="primary"], [data-variant="ghost"]):active {
|
|
147
|
+
translate: 0 3px;
|
|
148
|
+
box-shadow: none;
|
|
149
|
+
transition-duration: 0s;
|
|
150
|
+
}
|
|
151
|
+
/* directional icons step forward on hover */
|
|
152
|
+
.btn:hover :global(.icon.mirror) {
|
|
153
|
+
translate: 3px 0;
|
|
154
|
+
transition: translate var(--dur-step) steps(2, end);
|
|
155
|
+
}
|
|
156
|
+
.btn:hover:dir(rtl) :global(.icon.mirror) {
|
|
157
|
+
translate: -3px 0;
|
|
158
|
+
}
|
|
159
|
+
/* quiet buttons get a terminal cursor, just past the label; out of flow, so the button never grows */
|
|
160
|
+
.btn[data-variant="quiet"]:hover::after {
|
|
161
|
+
content: "_";
|
|
162
|
+
position: absolute;
|
|
163
|
+
inset-inline-start: calc(100% - var(--btn-pad) + var(--space-2) - 0.25em);
|
|
164
|
+
animation: btn-cursor var(--blink) steps(1, end) infinite;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
@keyframes btn-cursor {
|
|
168
|
+
50% {
|
|
169
|
+
opacity: 0;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/* disabled is gray, never a faded accent: no fill color, no ledge, no hover */
|
|
173
|
+
.btn:is(:disabled, [aria-disabled="true"]) {
|
|
174
|
+
border-color: var(--stroke-subtle);
|
|
175
|
+
background: var(--fill-disabled);
|
|
176
|
+
box-shadow: none;
|
|
177
|
+
color: var(--text-disabled);
|
|
178
|
+
cursor: not-allowed;
|
|
179
|
+
pointer-events: none;
|
|
180
|
+
}
|
|
181
|
+
.btn[data-variant="quiet"]:is(:disabled, [aria-disabled="true"]) {
|
|
182
|
+
border-color: transparent;
|
|
183
|
+
background: none;
|
|
184
|
+
}
|
|
185
|
+
/* letter-spacing breaks Arabic-script joining */
|
|
186
|
+
.btn:is(:lang(fa), :lang(ar)) {
|
|
187
|
+
font-family: var(--font-arabic);
|
|
188
|
+
letter-spacing: 0;
|
|
189
|
+
}
|
|
190
|
+
</style>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A boxed aside in running text. tone: note (accent) · warning · aside (muted)
|
|
4
|
+
* · error · success · info: status callouts. Every tone but aside leads its title with an icon.
|
|
5
|
+
* slot "title" or the `title` prop: an optional label line.
|
|
6
|
+
* It renders a <div role="note"> by default; pass as="aside" only when it is truly tangential to the page,
|
|
7
|
+
* since <aside> is a landmark and shows up in screen readers' landmark lists.
|
|
8
|
+
*/
|
|
9
|
+
import type { HTMLAttributes } from "astro/types";
|
|
10
|
+
import Icon from "./Icon.astro";
|
|
11
|
+
import type { IconName } from "./icons";
|
|
12
|
+
|
|
13
|
+
type Tone = "note" | "warning" | "aside" | "error" | "success" | "info";
|
|
14
|
+
|
|
15
|
+
interface Props extends HTMLAttributes<"div"> {
|
|
16
|
+
tone?: Tone;
|
|
17
|
+
title?: string;
|
|
18
|
+
as?: "div" | "aside";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const ICON: Partial<Record<Tone, IconName>> = {
|
|
22
|
+
note: "flower",
|
|
23
|
+
info: "info",
|
|
24
|
+
warning: "warning",
|
|
25
|
+
error: "close",
|
|
26
|
+
success: "check",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const { tone = "note", title, as: Root = "div", class: className, ...rest } = Astro.props;
|
|
30
|
+
const icon = ICON[tone];
|
|
31
|
+
const hasTitle = Boolean(title) || Astro.slots.has("title");
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
<Root
|
|
35
|
+
class:list={["callout", className, { untitled: !hasTitle && icon }]}
|
|
36
|
+
data-tone={tone}
|
|
37
|
+
role={Root === "div" ? "note" : undefined}
|
|
38
|
+
{...rest}
|
|
39
|
+
>
|
|
40
|
+
{/* without a title the icon still leads, so a status is never told by color alone */}
|
|
41
|
+
{!hasTitle && icon && <Icon name={icon} size="sm" class="callout-mark" />}
|
|
42
|
+
{
|
|
43
|
+
hasTitle && (
|
|
44
|
+
<p class="callout-title label">
|
|
45
|
+
{icon && <Icon name={icon} size="sm" />}
|
|
46
|
+
<slot name="title">{title}</slot>
|
|
47
|
+
</p>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
<div class="callout-body"><slot /></div>
|
|
51
|
+
</Root>
|
|
52
|
+
|
|
53
|
+
<style>
|
|
54
|
+
.callout {
|
|
55
|
+
--callout-color: var(--text-accent);
|
|
56
|
+
display: grid;
|
|
57
|
+
gap: var(--space-2);
|
|
58
|
+
padding: var(--space-4) var(--space-5);
|
|
59
|
+
border: 1px solid var(--stroke);
|
|
60
|
+
border-inline-start: 2px solid var(--fill-accent-strong);
|
|
61
|
+
border-radius: var(--radius);
|
|
62
|
+
background: var(--surface-raised);
|
|
63
|
+
}
|
|
64
|
+
.callout[data-tone="warning"] {
|
|
65
|
+
--callout-color: var(--status-warning);
|
|
66
|
+
border-inline-start-color: var(--status-warning);
|
|
67
|
+
background: var(--status-warning-fill);
|
|
68
|
+
}
|
|
69
|
+
.callout[data-tone="error"] {
|
|
70
|
+
--callout-color: var(--status-error);
|
|
71
|
+
border-inline-start-color: var(--status-error);
|
|
72
|
+
background: var(--status-error-fill);
|
|
73
|
+
}
|
|
74
|
+
.callout[data-tone="success"] {
|
|
75
|
+
--callout-color: var(--status-success);
|
|
76
|
+
border-inline-start-color: var(--status-success);
|
|
77
|
+
background: var(--status-success-fill);
|
|
78
|
+
}
|
|
79
|
+
.callout[data-tone="info"] {
|
|
80
|
+
--callout-color: var(--status-info);
|
|
81
|
+
border-inline-start-color: var(--status-info);
|
|
82
|
+
background: var(--status-info-fill);
|
|
83
|
+
}
|
|
84
|
+
.callout[data-tone="aside"] {
|
|
85
|
+
--callout-color: var(--text-muted);
|
|
86
|
+
border-inline-start-color: var(--stroke);
|
|
87
|
+
background: none;
|
|
88
|
+
font-style: italic;
|
|
89
|
+
}
|
|
90
|
+
.callout.untitled {
|
|
91
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
92
|
+
align-items: baseline;
|
|
93
|
+
column-gap: var(--space-3);
|
|
94
|
+
}
|
|
95
|
+
.callout :global(.callout-mark) {
|
|
96
|
+
color: var(--callout-color);
|
|
97
|
+
}
|
|
98
|
+
.callout-title {
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
gap: var(--space-2);
|
|
102
|
+
color: var(--callout-color);
|
|
103
|
+
}
|
|
104
|
+
.callout-body > :global(* + *) {
|
|
105
|
+
margin-block-start: var(--space-3);
|
|
106
|
+
}
|
|
107
|
+
/* Persian and Arabic script is never slanted */
|
|
108
|
+
.callout:is(:lang(fa), :lang(ar)),
|
|
109
|
+
.callout:is(:lang(fa), :lang(ar)) :where(*) {
|
|
110
|
+
font-style: normal;
|
|
111
|
+
}
|
|
112
|
+
</style>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A card whose whole surface is a link. The heading link is rendered here (not slotted) so the
|
|
4
|
+
* stretched ::after can reach it; it carries `p-name u-url` and stays the accessible name.
|
|
5
|
+
* slot "meta": the label line above the heading · default slot: body below it.
|
|
6
|
+
*/
|
|
7
|
+
import type { HTMLAttributes } from "astro/types";
|
|
8
|
+
|
|
9
|
+
interface Props extends HTMLAttributes<"article"> {
|
|
10
|
+
href: string;
|
|
11
|
+
title: string;
|
|
12
|
+
headingLevel?: 2 | 3 | 4;
|
|
13
|
+
size?: "sm" | "md";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { href, title, headingLevel = 3, size = "md", class: className, ...rest } = Astro.props;
|
|
17
|
+
const H = `h${headingLevel}` as "h2" | "h3" | "h4";
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
<article class:list={["card", className]} data-size={size} {...rest}>
|
|
21
|
+
<slot name="meta" />
|
|
22
|
+
<H class="card-title"><a class="p-name u-url" href={href}>{title}</a></H>
|
|
23
|
+
<slot />
|
|
24
|
+
</article>
|
|
25
|
+
|
|
26
|
+
<style>
|
|
27
|
+
.card {
|
|
28
|
+
position: relative;
|
|
29
|
+
display: grid;
|
|
30
|
+
gap: var(--space-2);
|
|
31
|
+
align-content: start;
|
|
32
|
+
padding: var(--space-5);
|
|
33
|
+
border: 1px solid var(--stroke);
|
|
34
|
+
border-radius: var(--radius);
|
|
35
|
+
background: var(--surface-raised);
|
|
36
|
+
transition: border-color var(--dur-step) var(--ease-step);
|
|
37
|
+
}
|
|
38
|
+
.card[data-size="sm"] {
|
|
39
|
+
padding: var(--space-4);
|
|
40
|
+
}
|
|
41
|
+
.card:hover,
|
|
42
|
+
.card:focus-within {
|
|
43
|
+
border-color: var(--stroke-strong);
|
|
44
|
+
}
|
|
45
|
+
/* hover lifts the card two frames up-and-over, leaving a hard pixel shadow behind */
|
|
46
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
47
|
+
.card {
|
|
48
|
+
transition:
|
|
49
|
+
border-color var(--dur-step) var(--ease-step),
|
|
50
|
+
translate var(--dur-step) steps(2, end),
|
|
51
|
+
box-shadow var(--dur-step) steps(2, end);
|
|
52
|
+
}
|
|
53
|
+
.card:hover,
|
|
54
|
+
.card:focus-within {
|
|
55
|
+
translate: -3px -3px;
|
|
56
|
+
box-shadow: 3px 3px 0 0 var(--stroke-strong);
|
|
57
|
+
}
|
|
58
|
+
.card:hover:dir(rtl),
|
|
59
|
+
.card:focus-within:dir(rtl) {
|
|
60
|
+
translate: 3px -3px;
|
|
61
|
+
box-shadow: -3px 3px 0 0 var(--stroke-strong);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
.card-title {
|
|
65
|
+
font-size: var(--step-2);
|
|
66
|
+
}
|
|
67
|
+
.card[data-size="sm"] .card-title {
|
|
68
|
+
font-size: var(--step-1);
|
|
69
|
+
}
|
|
70
|
+
.card-title a {
|
|
71
|
+
text-decoration: none;
|
|
72
|
+
}
|
|
73
|
+
/* the whole card is clickable; the heading link stays the accessible name */
|
|
74
|
+
.card-title a::after {
|
|
75
|
+
content: "";
|
|
76
|
+
position: absolute;
|
|
77
|
+
inset: 0;
|
|
78
|
+
}
|
|
79
|
+
</style>
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* A modal on a real <dialog>. Open it from any button with invoker commands:
|
|
4
|
+
* <Button command="show-modal" commandfor="my-dialog">Open</Button>
|
|
5
|
+
* Closing needs no JS (a form method="dialog" button, Esc, or a click outside where `closedby` works).
|
|
6
|
+
* A tiny script backs up invoker commands in browsers that don't have them yet.
|
|
7
|
+
*/
|
|
8
|
+
import type { HTMLAttributes } from "astro/types";
|
|
9
|
+
import Icon from "./Icon.astro";
|
|
10
|
+
|
|
11
|
+
interface Props extends HTMLAttributes<"dialog"> {
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
size?: "sm" | "md" | "lg";
|
|
15
|
+
/** label of the close button, localized by the caller */
|
|
16
|
+
closeLabel?: string;
|
|
17
|
+
/**
|
|
18
|
+
* what may dismiss it: "any" (Esc or a click outside), "closerequest" (Esc only: use it when a stray
|
|
19
|
+
* click would lose typed input), "none" (only its own buttons)
|
|
20
|
+
*/
|
|
21
|
+
closedby?: "any" | "closerequest" | "none";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { id, title, size = "md", closeLabel = "Close", closedby = "any", class: className, ...rest } = Astro.props;
|
|
25
|
+
const titleId = `${id}-title`;
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<dialog
|
|
29
|
+
id={id}
|
|
30
|
+
class:list={["dialog", className]}
|
|
31
|
+
data-size={size}
|
|
32
|
+
aria-labelledby={titleId}
|
|
33
|
+
{...{ closedby }}
|
|
34
|
+
{...rest}
|
|
35
|
+
>
|
|
36
|
+
<form method="dialog" class="dialog-head">
|
|
37
|
+
<h2 id={titleId}>{title}</h2>
|
|
38
|
+
<button class="x" aria-label={closeLabel}><Icon name="close" /></button>
|
|
39
|
+
</form>
|
|
40
|
+
<div class="dialog-body"><slot /></div>
|
|
41
|
+
{
|
|
42
|
+
Astro.slots.has("actions") && (
|
|
43
|
+
<form method="dialog" class="dialog-actions">
|
|
44
|
+
<slot name="actions" />
|
|
45
|
+
</form>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
</dialog>
|
|
49
|
+
|
|
50
|
+
<script>
|
|
51
|
+
// invoker commands (command / commandfor) are native in new browsers; this covers the rest
|
|
52
|
+
if (!("command" in HTMLButtonElement.prototype)) {
|
|
53
|
+
document.addEventListener("click", (event) => {
|
|
54
|
+
const button = (event.target as Element | null)?.closest?.("button[commandfor]");
|
|
55
|
+
if (!button) return;
|
|
56
|
+
const target = document.getElementById(button.getAttribute("commandfor") ?? "");
|
|
57
|
+
if (!(target instanceof HTMLDialogElement)) return;
|
|
58
|
+
const command = button.getAttribute("command");
|
|
59
|
+
if (command === "show-modal") target.showModal();
|
|
60
|
+
else if (command === "close") target.close();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<style>
|
|
66
|
+
.dialog {
|
|
67
|
+
/* the global reset zeroes margins; a modal dialog centers with auto */
|
|
68
|
+
margin: auto;
|
|
69
|
+
inline-size: min(100% - 2 * var(--gutter), 32rem);
|
|
70
|
+
max-block-size: min(100% - 2 * var(--gutter), 40rem);
|
|
71
|
+
padding: 0;
|
|
72
|
+
border: 1px solid var(--stroke);
|
|
73
|
+
border-radius: var(--radius);
|
|
74
|
+
background: var(--surface-raised);
|
|
75
|
+
color: var(--text);
|
|
76
|
+
box-shadow: 0 0 0 1px var(--fill-glow), 0 30px 80px -20px var(--surface);
|
|
77
|
+
}
|
|
78
|
+
.dialog[data-size="sm"] {
|
|
79
|
+
inline-size: min(100% - 2 * var(--gutter), 22rem);
|
|
80
|
+
}
|
|
81
|
+
.dialog[data-size="lg"] {
|
|
82
|
+
inline-size: min(100% - 2 * var(--gutter), 48rem);
|
|
83
|
+
}
|
|
84
|
+
.dialog::backdrop {
|
|
85
|
+
background: var(--overlay);
|
|
86
|
+
}
|
|
87
|
+
/* opens like a CRT: a scan from the middle out in hard frames, a sideways glitch, a flicker behind */
|
|
88
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
89
|
+
.dialog[open] {
|
|
90
|
+
animation: dialog-scan calc(var(--dur-step) * 2) steps(6, end);
|
|
91
|
+
}
|
|
92
|
+
.dialog[open]::backdrop {
|
|
93
|
+
animation: dialog-flicker calc(var(--dur-step) * 2) steps(1, end);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
@keyframes dialog-scan {
|
|
97
|
+
0% {
|
|
98
|
+
clip-path: inset(50% 0 50% 0);
|
|
99
|
+
translate: -4px 0;
|
|
100
|
+
}
|
|
101
|
+
50% {
|
|
102
|
+
translate: 3px 0;
|
|
103
|
+
}
|
|
104
|
+
100% {
|
|
105
|
+
clip-path: inset(0 0 0 0);
|
|
106
|
+
translate: 0 0;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
@keyframes dialog-flicker {
|
|
110
|
+
0% {
|
|
111
|
+
opacity: 0;
|
|
112
|
+
}
|
|
113
|
+
30% {
|
|
114
|
+
opacity: 0.5;
|
|
115
|
+
}
|
|
116
|
+
55% {
|
|
117
|
+
opacity: 0.2;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
.dialog-head {
|
|
121
|
+
display: flex;
|
|
122
|
+
align-items: center;
|
|
123
|
+
justify-content: space-between;
|
|
124
|
+
gap: var(--space-4);
|
|
125
|
+
padding: var(--space-4) var(--space-5);
|
|
126
|
+
border-block-end: 1px dashed var(--stroke);
|
|
127
|
+
}
|
|
128
|
+
h2 {
|
|
129
|
+
font-size: var(--step-1);
|
|
130
|
+
}
|
|
131
|
+
.x {
|
|
132
|
+
position: relative;
|
|
133
|
+
display: grid;
|
|
134
|
+
place-items: center;
|
|
135
|
+
inline-size: var(--control-h-sm);
|
|
136
|
+
block-size: var(--control-h-sm);
|
|
137
|
+
border: 1px solid transparent;
|
|
138
|
+
border-radius: var(--radius);
|
|
139
|
+
background: none;
|
|
140
|
+
color: var(--text-muted);
|
|
141
|
+
cursor: pointer;
|
|
142
|
+
}
|
|
143
|
+
/* drawn small, tapped big: the hit area reaches --target-min, inside the head's padding (% insets: the border can't skew it) */
|
|
144
|
+
.x::before {
|
|
145
|
+
content: "";
|
|
146
|
+
position: absolute;
|
|
147
|
+
inset: min(0px, (100% - var(--target-min)) / 2);
|
|
148
|
+
}
|
|
149
|
+
.x:hover {
|
|
150
|
+
border-color: var(--stroke);
|
|
151
|
+
background: var(--state-hover);
|
|
152
|
+
color: var(--text);
|
|
153
|
+
}
|
|
154
|
+
.x:active {
|
|
155
|
+
background: var(--state-active);
|
|
156
|
+
}
|
|
157
|
+
.dialog-body {
|
|
158
|
+
padding: var(--space-5);
|
|
159
|
+
color: var(--text-muted);
|
|
160
|
+
}
|
|
161
|
+
.dialog-actions {
|
|
162
|
+
display: flex;
|
|
163
|
+
flex-wrap: wrap;
|
|
164
|
+
justify-content: flex-end;
|
|
165
|
+
gap: var(--space-3);
|
|
166
|
+
padding: 0 var(--space-5) var(--space-5);
|
|
167
|
+
}
|
|
168
|
+
</style>
|