@shabboo/kit 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,197 @@
1
+ ---
2
+ /**
3
+ * An expandable section on a native <details>: no JS, keyboard and find-in-page for free.
4
+ * `summary` prop or slot "summary"; `name` makes a group where only one is open (an accordion).
5
+ */
6
+ import type { HTMLAttributes } from "astro/types";
7
+ import Icon from "./Icon.astro";
8
+
9
+ interface Props extends HTMLAttributes<"details"> {
10
+ summary?: string;
11
+ size?: "sm" | "md";
12
+ /** boxed: a bordered panel instead of a bare line */
13
+ boxed?: boolean;
14
+ }
15
+
16
+ const { summary, size = "md", boxed = false, class: className, ...rest } = Astro.props;
17
+ ---
18
+
19
+ <details class:list={["disclosure", className, { boxed }]} data-size={size} {...rest}>
20
+ <summary>
21
+ <Icon name="chevron-right" size="sm" class="chev" />
22
+ <span><slot name="summary">{summary}</slot></span>
23
+ </summary>
24
+ <div class="disclosure-body"><slot /></div>
25
+ </details>
26
+
27
+ <script>
28
+ // flags a disclosure the reader just opened, so its glide and dither never run on page load or a page swap;
29
+ // a click on <summary> lands before the toggle, so the first style that sees [open] sees this too
30
+ document.addEventListener("click", (event) => {
31
+ const details = (event.target as Element | null)?.closest?.("details.disclosure > summary")?.parentElement;
32
+ if (!(details instanceof HTMLDetailsElement)) return;
33
+ details.dataset.opening = "";
34
+ setTimeout(() => delete details.dataset.opening, 600);
35
+ });
36
+ </script>
37
+
38
+ <style>
39
+ .disclosure {
40
+ border-block-end: 1px dashed var(--stroke);
41
+ }
42
+ .boxed {
43
+ border: 1px solid var(--stroke);
44
+ border-radius: var(--radius);
45
+ background: var(--surface-raised);
46
+ }
47
+ summary {
48
+ display: flex;
49
+ align-items: center;
50
+ gap: var(--space-2);
51
+ padding-block: var(--space-3);
52
+ cursor: pointer;
53
+ list-style: none;
54
+ }
55
+ summary::-webkit-details-marker {
56
+ display: none;
57
+ }
58
+ .boxed summary {
59
+ padding-inline: var(--space-4);
60
+ }
61
+ .disclosure[data-size="sm"] summary {
62
+ padding-block: var(--space-2);
63
+ font-size: var(--step--1);
64
+ }
65
+ summary:hover {
66
+ background: var(--state-hover);
67
+ color: var(--text-accent);
68
+ }
69
+ summary:active {
70
+ background: var(--state-active);
71
+ }
72
+ /* on touch, a small summary grows to --target-min itself: stacked rows sit too close to grow only the hit area */
73
+ @media (pointer: coarse) {
74
+ .disclosure[data-size="sm"] summary {
75
+ min-block-size: var(--target-min);
76
+ }
77
+ }
78
+ summary :global(.chev) {
79
+ color: var(--text-accent);
80
+ }
81
+ /* the chevron points down when open; in RTL it is already mirrored, so it turns the other way */
82
+ .disclosure[open] > summary :global(.chev) {
83
+ rotate: 90deg;
84
+ }
85
+ .disclosure[open] > summary:dir(rtl) :global(.chev) {
86
+ rotate: -90deg;
87
+ }
88
+ /*
89
+ * Open and close: the height glides, and each line of the body prints in on its own frame while it
90
+ * dithers to solid through 8-bit patterns (25% → 50% → 75% → 100%); closing runs bottom-up.
91
+ * Lines are the body's children, or the whole body when it holds bare text. The height needs
92
+ * interpolate-size and ::details-content; without them it snaps and only the dither runs.
93
+ * Opening plays only while data-opening is set (the reader's click); one rendered open just shows.
94
+ * At rest the mask is off: a mask clips to the border box and would cut off focus rings and shadows.
95
+ */
96
+ @media (prefers-reduced-motion: no-preference) {
97
+ summary :global(.chev) {
98
+ transition: rotate calc(var(--dur-step) * 0.6) steps(2, end);
99
+ }
100
+ .disclosure {
101
+ interpolate-size: allow-keywords;
102
+ }
103
+ .disclosure::details-content {
104
+ block-size: 0;
105
+ overflow: clip;
106
+ transition:
107
+ block-size calc(var(--dur-step) * 1.5) var(--ease),
108
+ content-visibility calc(var(--dur-step) * 1.5) allow-discrete;
109
+ }
110
+ .disclosure[open]::details-content {
111
+ block-size: auto;
112
+ }
113
+ @starting-style {
114
+ .disclosure[open][data-opening]::details-content {
115
+ block-size: 0;
116
+ }
117
+ }
118
+ .disclosure-body > :global(*),
119
+ .disclosure-body:not(:has(> *)) {
120
+ --dither: 0;
121
+ mask-image:
122
+ conic-gradient(black 25%, transparent 0),
123
+ conic-gradient(transparent 50%, black 0 75%, transparent 0),
124
+ conic-gradient(transparent 75%, black 0),
125
+ conic-gradient(transparent 25%, black 0 50%, transparent 0);
126
+ mask-size:
127
+ calc(4px * clamp(0, var(--dither), 1)) calc(4px * clamp(0, var(--dither), 1)),
128
+ calc(4px * clamp(0, var(--dither) - 1, 1)) calc(4px * clamp(0, var(--dither) - 1, 1)),
129
+ calc(4px * clamp(0, var(--dither) - 2, 1)) calc(4px * clamp(0, var(--dither) - 2, 1)),
130
+ calc(4px * clamp(0, var(--dither) - 3, 1)) calc(4px * clamp(0, var(--dither) - 3, 1));
131
+ mask-repeat: repeat;
132
+ transition: --dither var(--dur-fast) var(--ease-dither);
133
+ }
134
+ [open] > .disclosure-body > :global(*),
135
+ [open] > .disclosure-body:not(:has(> *)) {
136
+ --dither: 4;
137
+ }
138
+ [open]:not([data-opening]) > .disclosure-body > :global(*),
139
+ [open]:not([data-opening]) > .disclosure-body:not(:has(> *)) {
140
+ mask-image: none;
141
+ }
142
+ /* hidden content gets no style updates, so opening animates in rather than transitions */
143
+ [open][data-opening] > .disclosure-body > :global(*),
144
+ [open][data-opening] > .disclosure-body:not(:has(> *)) {
145
+ animation: disclosure-dither var(--dur-fast) var(--ease-dither) backwards;
146
+ }
147
+ [open][data-opening] > .disclosure-body > :global(:nth-child(2)) {
148
+ animation-delay: 30ms;
149
+ }
150
+ [open][data-opening] > .disclosure-body > :global(:nth-child(3)) {
151
+ animation-delay: 60ms;
152
+ }
153
+ [open][data-opening] > .disclosure-body > :global(:nth-child(4)) {
154
+ animation-delay: 90ms;
155
+ }
156
+ [open][data-opening] > .disclosure-body > :global(:nth-child(n + 5)) {
157
+ animation-delay: 120ms;
158
+ }
159
+ :not([open]) > .disclosure-body > :global(:nth-last-child(2)) {
160
+ transition-delay: 30ms;
161
+ }
162
+ :not([open]) > .disclosure-body > :global(:nth-last-child(3)) {
163
+ transition-delay: 60ms;
164
+ }
165
+ :not([open]) > .disclosure-body > :global(:nth-last-child(4)) {
166
+ transition-delay: 90ms;
167
+ }
168
+ :not([open]) > .disclosure-body > :global(:nth-last-child(n + 5)) {
169
+ transition-delay: 120ms;
170
+ }
171
+ }
172
+ @keyframes disclosure-dither {
173
+ from {
174
+ --dither: 0;
175
+ }
176
+ to {
177
+ --dither: 4;
178
+ }
179
+ }
180
+ .disclosure-body {
181
+ padding-block: 0 var(--space-4);
182
+ padding-inline-start: calc(1em + var(--space-2));
183
+ color: var(--text-muted);
184
+ }
185
+ .boxed .disclosure-body {
186
+ padding-inline: var(--space-4);
187
+ }
188
+ </style>
189
+
190
+ <style is:global>
191
+ /* the dither step (0–4) must be a registered integer so it can transition and animate */
192
+ @property --dither {
193
+ syntax: "<integer>";
194
+ inherits: false;
195
+ initial-value: 4;
196
+ }
197
+ </style>
@@ -0,0 +1,43 @@
1
+ ---
2
+ /**
3
+ * One dated entry in a list (a post, a note): an h-entry with its date label, title link and summary.
4
+ * The caller formats the date for its locale. default slot: extras below the summary (tags).
5
+ */
6
+ import type { HTMLAttributes } from "astro/types";
7
+
8
+ interface Props extends HTMLAttributes<"article"> {
9
+ href: string;
10
+ title: string;
11
+ /** machine-readable date, e.g. an ISO string */
12
+ datetime: string;
13
+ /** the date as shown, already localized */
14
+ date: string;
15
+ summary?: string;
16
+ headingLevel?: 2 | 3 | 4;
17
+ }
18
+
19
+ const { href, title, datetime, date, summary, headingLevel = 3, class: className, ...rest } = Astro.props;
20
+ const H = `h${headingLevel}` as "h2" | "h3" | "h4";
21
+ ---
22
+
23
+ <article class:list={["entry h-entry", className]} {...rest}>
24
+ <p class="label">
25
+ <time class="dt-published" datetime={datetime}>{date}</time>
26
+ </p>
27
+ <H class="entry-title"><a class="p-name u-url" href={href}>{title}</a></H>
28
+ {summary && <p class="p-summary">{summary}</p>}
29
+ <slot />
30
+ </article>
31
+
32
+ <style>
33
+ .entry-title {
34
+ font-size: var(--step-2);
35
+ margin-block: var(--space-1) var(--space-2);
36
+ }
37
+ .entry-title a {
38
+ text-decoration: none;
39
+ }
40
+ .p-summary {
41
+ color: var(--text-muted);
42
+ }
43
+ </style>
@@ -0,0 +1,167 @@
1
+ ---
2
+ /**
3
+ * A form control with its label, hint and error, wired with aria-describedby / aria-invalid.
4
+ * control: input (default) · textarea · select (options in the default slot).
5
+ * Extra attributes (name, type, required, placeholder…) go to the control.
6
+ */
7
+ import type { HTMLAttributes } from "astro/types";
8
+ import Icon from "./Icon.astro";
9
+
10
+ type Control = HTMLAttributes<"input"> & HTMLAttributes<"textarea"> & HTMLAttributes<"select">;
11
+
12
+ interface Props extends Omit<Control, "id"> {
13
+ id: string;
14
+ label: string;
15
+ hint?: string;
16
+ error?: string;
17
+ control?: "input" | "textarea" | "select";
18
+ size?: "sm" | "md" | "lg";
19
+ }
20
+
21
+ const {
22
+ id,
23
+ label,
24
+ hint,
25
+ error,
26
+ control = "input",
27
+ size = "md",
28
+ class: className,
29
+ "aria-describedby": extraDescribedBy,
30
+ ...props
31
+ } = Astro.props;
32
+ const Tag = control;
33
+ /* the scope attribute a styled parent passes along belongs on the root, with `class`, not on the control */
34
+ const isScope = (key: string) => key.startsWith("data-astro-cid-");
35
+ const scope = Object.fromEntries(Object.entries(props).filter(([key]) => isScope(key)));
36
+ const rest = Object.fromEntries(Object.entries(props).filter(([key]) => !isScope(key))) as typeof props;
37
+ /* the caller's own descriptions are kept, after the hint and the error */
38
+ const describedBy =
39
+ [hint && `${id}-hint`, error && `${id}-error`, extraDescribedBy].filter(Boolean).join(" ") || undefined;
40
+ ---
41
+
42
+ <div class:list={["field", className]} data-size={size} {...scope}>
43
+ <label for={id}>{label}{rest.required && <span class="req" aria-hidden="true"> *</span>}</label>
44
+ <Tag
45
+ id={id}
46
+ class="control"
47
+ aria-describedby={describedBy}
48
+ aria-invalid={error ? "true" : undefined}
49
+ {...rest}
50
+ ><slot /></Tag>
51
+ {hint && <p class="hint" id={`${id}-hint`}>{hint}</p>}
52
+ {error && <p class="error" id={`${id}-error`}><Icon name="warning" size="sm" />{error}</p>}
53
+ </div>
54
+
55
+ <style>
56
+ .field {
57
+ display: grid;
58
+ gap: var(--space-1);
59
+ min-inline-size: 0;
60
+ }
61
+ label {
62
+ font-family: var(--font-mono);
63
+ font-size: var(--step--2);
64
+ letter-spacing: var(--tracking-mono);
65
+ text-transform: uppercase;
66
+ color: var(--text-muted);
67
+ }
68
+ .field:is(:lang(fa), :lang(ar)) label {
69
+ font-family: var(--font-arabic);
70
+ font-size: var(--step--1);
71
+ letter-spacing: 0;
72
+ }
73
+ .req {
74
+ color: var(--text-signal);
75
+ }
76
+ .control {
77
+ inline-size: 100%;
78
+ min-block-size: var(--control-h-md);
79
+ padding-inline: var(--control-pad-md);
80
+ border: 1px solid var(--stroke-control);
81
+ border-radius: var(--radius);
82
+ background: var(--field);
83
+ color: var(--text);
84
+ font-size: var(--step--1);
85
+ caret-color: var(--text-accent);
86
+ caret-shape: block;
87
+ transition: border-color var(--dur-step) var(--ease-step);
88
+ }
89
+ textarea.control {
90
+ padding-block: var(--space-2);
91
+ min-block-size: 6rem;
92
+ resize: vertical;
93
+ line-height: var(--leading);
94
+ }
95
+ .control::placeholder {
96
+ color: var(--text-placeholder);
97
+ }
98
+ .control:hover {
99
+ border-color: var(--text-muted);
100
+ background: linear-gradient(var(--state-hover), var(--state-hover)), var(--field);
101
+ }
102
+ .control:disabled {
103
+ border-color: var(--stroke-subtle);
104
+ background: var(--fill-disabled);
105
+ color: var(--text-disabled);
106
+ cursor: not-allowed;
107
+ }
108
+ /* [readonly], not :read-only: that also matches checkbox, range, color and file, which can't be read-only */
109
+ .control[readonly]:not(select, :disabled) {
110
+ border-style: dashed;
111
+ background: none;
112
+ }
113
+ .control:focus-visible {
114
+ border-color: var(--stroke-strong);
115
+ outline-offset: 1px;
116
+ }
117
+ .field[data-size="sm"] .control {
118
+ min-block-size: var(--control-h-sm);
119
+ padding-inline: var(--control-pad-sm);
120
+ font-size: var(--step--2);
121
+ }
122
+ .field[data-size="lg"] .control {
123
+ min-block-size: var(--control-h-lg);
124
+ padding-inline: var(--control-pad-lg);
125
+ font-size: var(--step-0);
126
+ }
127
+ /* on touch, a field grows to --target-min itself: a native control has no pseudo-element to carry a hit area */
128
+ @media (pointer: coarse) {
129
+ .field[data-size] .control:not(textarea) {
130
+ min-block-size: var(--target-min);
131
+ }
132
+ }
133
+ .control[aria-invalid="true"] {
134
+ border-color: var(--status-error);
135
+ }
136
+ /* a wrong value judders once when the field is focused */
137
+ @media (prefers-reduced-motion: no-preference) {
138
+ .control[aria-invalid="true"]:focus-visible {
139
+ animation: field-judder calc(var(--dur-step) * 1.5) steps(1, end);
140
+ }
141
+ }
142
+ @keyframes field-judder {
143
+ 0% {
144
+ translate: -3px 0;
145
+ }
146
+ 33% {
147
+ translate: 3px 0;
148
+ }
149
+ 66% {
150
+ translate: -1px 0;
151
+ }
152
+ 100% {
153
+ translate: 0 0;
154
+ }
155
+ }
156
+ .hint {
157
+ font-size: var(--step--1);
158
+ color: var(--text-helper);
159
+ }
160
+ .error {
161
+ display: flex;
162
+ align-items: baseline;
163
+ gap: var(--space-2);
164
+ font-size: var(--step--1);
165
+ color: var(--status-error);
166
+ }
167
+ </style>
@@ -0,0 +1,47 @@
1
+ ---
2
+ /**
3
+ * Media with a caption. The media goes in the default slot (an <img>, astro:assets <Image>, <video>…);
4
+ * the caption is the `caption` prop or slot "caption".
5
+ */
6
+ import type { HTMLAttributes } from "astro/types";
7
+
8
+ interface Props extends HTMLAttributes<"figure"> {
9
+ caption?: string;
10
+ /** frame: a thin border around the media */
11
+ framed?: boolean;
12
+ }
13
+
14
+ const { caption, framed = false, class: className, ...rest } = Astro.props;
15
+ const hasCaption = Boolean(caption) || Astro.slots.has("caption");
16
+ ---
17
+
18
+ <figure class:list={["figure", className, { framed }]} {...rest}>
19
+ <div class="figure-media"><slot /></div>
20
+ {
21
+ hasCaption && (
22
+ <figcaption class="label">
23
+ <slot name="caption">{caption}</slot>
24
+ </figcaption>
25
+ )
26
+ }
27
+ </figure>
28
+
29
+ <style>
30
+ .figure {
31
+ display: grid;
32
+ gap: var(--space-2);
33
+ }
34
+ .figure-media > :global(*) {
35
+ inline-size: 100%;
36
+ border-radius: var(--radius);
37
+ }
38
+ .framed .figure-media {
39
+ padding: var(--space-2);
40
+ border: 1px solid var(--stroke);
41
+ border-radius: var(--radius);
42
+ background: var(--surface-raised);
43
+ }
44
+ figcaption {
45
+ text-transform: none;
46
+ }
47
+ </style>
@@ -0,0 +1,86 @@
1
+ ---
2
+ /** A person card (microformats h-card): photo, name, a line about them and their links. */
3
+ import type { HTMLAttributes } from "astro/types";
4
+
5
+ interface Props extends HTMLAttributes<"div"> {
6
+ name: string;
7
+ url?: string;
8
+ photo?: string;
9
+ /** a short line under the name (p-note) */
10
+ note?: string;
11
+ links?: { label: string; href: string }[];
12
+ size?: "sm" | "md";
13
+ }
14
+
15
+ const { name, url, photo, note, links = [], size = "md", class: className, ...rest } = Astro.props;
16
+ ---
17
+
18
+ <div class:list={["hcard h-card", className]} data-size={size} {...rest}>
19
+ {photo && <img class="u-photo" src={photo} alt="" width="64" height="64" loading="lazy" decoding="async" />}
20
+ <div class="hcard-text">
21
+ {url ? <a class="p-name u-url" href={url} rel="me">{name}</a> : <span class="p-name">{name}</span>}
22
+ {note && <p class="p-note">{note}</p>}
23
+ {
24
+ links.length > 0 && (
25
+ <ul class="hcard-links label">
26
+ {links.map((l) => (
27
+ <li>
28
+ <a class="u-url" href={l.href} rel="me">
29
+ {l.label}
30
+ </a>
31
+ </li>
32
+ ))}
33
+ </ul>
34
+ )
35
+ }
36
+ </div>
37
+ </div>
38
+
39
+ <style>
40
+ .hcard {
41
+ display: flex;
42
+ align-items: center;
43
+ gap: var(--space-4);
44
+ }
45
+ .u-photo {
46
+ inline-size: 4rem;
47
+ block-size: 4rem;
48
+ border-radius: 50%;
49
+ border: 1px solid var(--stroke);
50
+ object-fit: cover;
51
+ }
52
+ .hcard[data-size="sm"] .u-photo {
53
+ inline-size: 2.5rem;
54
+ block-size: 2.5rem;
55
+ }
56
+ .hcard-text {
57
+ display: grid;
58
+ gap: var(--space-1);
59
+ }
60
+ .p-name {
61
+ font-size: var(--step-1);
62
+ text-decoration: none;
63
+ }
64
+ .hcard[data-size="sm"] .p-name {
65
+ font-size: var(--step-0);
66
+ }
67
+ .p-note {
68
+ color: var(--text-muted);
69
+ font-style: italic;
70
+ }
71
+ .hcard-links {
72
+ display: flex;
73
+ flex-wrap: wrap;
74
+ gap: var(--space-3);
75
+ list-style: none;
76
+ padding: 0;
77
+ }
78
+ .hcard-links a {
79
+ text-decoration: none;
80
+ }
81
+ /* Persian and Arabic script is never slanted */
82
+ :is(:lang(fa), :lang(ar)) :where(*),
83
+ :is(:lang(fa), :lang(ar)) {
84
+ font-style: normal;
85
+ }
86
+ </style>
@@ -0,0 +1,59 @@
1
+ ---
2
+ /**
3
+ * A small inline SVG icon set, drawn on a 24px grid with a 1.5 stroke in currentColor.
4
+ * Decorative by default (aria-hidden); pass `label` when the icon is the only content.
5
+ * Directional icons (arrows, chevrons) mirror in RTL.
6
+ */
7
+ import type { HTMLAttributes } from "astro/types";
8
+
9
+ import { type IconName, PATHS } from "./icons";
10
+
11
+ const MIRRORED: IconName[] = ["arrow-right", "arrow-left", "chevron-right"];
12
+
13
+ interface Props extends HTMLAttributes<"svg"> {
14
+ name: IconName;
15
+ size?: "sm" | "md" | "lg";
16
+ /** accessible name; without it the icon is hidden from assistive tech */
17
+ label?: string;
18
+ }
19
+
20
+ const { name, size = "md", label, class: className, ...rest } = Astro.props;
21
+ ---
22
+
23
+ <svg
24
+ class:list={["icon", className, { mirror: MIRRORED.includes(name) }]}
25
+ data-size={size}
26
+ viewBox="0 0 24 24"
27
+ fill="none"
28
+ stroke="currentColor"
29
+ stroke-width="1.5"
30
+ stroke-linecap="round"
31
+ stroke-linejoin="round"
32
+ role={label ? "img" : undefined}
33
+ aria-label={label}
34
+ aria-hidden={label ? undefined : "true"}
35
+ focusable="false"
36
+ {...rest}><path d={PATHS[name]}></path></svg
37
+ >
38
+
39
+ <style>
40
+ .icon {
41
+ display: inline-block;
42
+ flex: none;
43
+ inline-size: var(--icon-md);
44
+ block-size: var(--icon-md);
45
+ vertical-align: -0.2em;
46
+ }
47
+ .icon[data-size="sm"] {
48
+ inline-size: var(--icon-sm);
49
+ block-size: var(--icon-sm);
50
+ vertical-align: -0.125em;
51
+ }
52
+ .icon[data-size="lg"] {
53
+ inline-size: var(--icon-lg);
54
+ block-size: var(--icon-lg);
55
+ }
56
+ .mirror:dir(rtl) {
57
+ transform: scaleX(-1);
58
+ }
59
+ </style>
@@ -0,0 +1,35 @@
1
+ ---
2
+ /** A keyboard key, e.g. <Kbd>Esc</Kbd>. */
3
+ import type { HTMLAttributes } from "astro/types";
4
+
5
+ interface Props extends HTMLAttributes<"kbd"> {
6
+ size?: "sm" | "md";
7
+ }
8
+
9
+ const { size = "md", class: className, ...rest } = Astro.props;
10
+ ---
11
+
12
+ <kbd class:list={["kbd", className]} data-size={size} {...rest}><slot /></kbd>
13
+
14
+ <style>
15
+ .kbd {
16
+ display: inline-block;
17
+ min-inline-size: 1.6em;
18
+ padding: 0.05em 0.4em;
19
+ border: 1px solid var(--stroke);
20
+ border-block-end-width: 2px;
21
+ border-radius: var(--radius);
22
+ background: var(--surface-sunken);
23
+ color: var(--text);
24
+ font-family: var(--font-mono);
25
+ font-size: 0.8em;
26
+ line-height: 1.5;
27
+ text-align: center;
28
+ }
29
+ .kbd:is(:lang(fa), :lang(ar)) {
30
+ font-family: var(--font-arabic);
31
+ }
32
+ .kbd[data-size="sm"] {
33
+ font-size: 0.7em;
34
+ }
35
+ </style>
@@ -0,0 +1,15 @@
1
+ ---
2
+ /**
3
+ * The mono uppercase metadata label used everywhere. It applies the global `.label` utility,
4
+ * so the fa/ar override in global.css (Arabic face, no letter-spacing) keeps working.
5
+ */
6
+ import type { HTMLAttributes } from "astro/types";
7
+
8
+ interface Props extends HTMLAttributes<"p"> {
9
+ as?: "p" | "span" | "div" | "h2" | "h3" | "h4";
10
+ }
11
+
12
+ const { as: Tag = "p", class: className, ...rest } = Astro.props;
13
+ ---
14
+
15
+ <Tag class:list={["label", className]} {...rest}><slot /></Tag>