@noahbaumgartner/svelte-ui 0.0.3 → 0.0.5

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.
Files changed (36) hide show
  1. package/README.md +69 -38
  2. package/dist/components/accordion/Accordion.svelte +34 -0
  3. package/dist/components/accordion/Accordion.svelte.d.ts +11 -0
  4. package/dist/components/accordion/AccordionItem.svelte +106 -0
  5. package/dist/components/accordion/AccordionItem.svelte.d.ts +13 -0
  6. package/dist/components/accordion/context.d.ts +5 -0
  7. package/dist/components/accordion/context.js +2 -0
  8. package/dist/components/avatar-group/AvatarGroup.svelte +76 -0
  9. package/dist/components/avatar-group/AvatarGroup.svelte.d.ts +15 -0
  10. package/dist/components/drawer/Drawer.svelte +368 -0
  11. package/dist/components/drawer/Drawer.svelte.d.ts +23 -0
  12. package/dist/components/empty-state/EmptyState.svelte +86 -0
  13. package/dist/components/empty-state/EmptyState.svelte.d.ts +16 -0
  14. package/dist/components/entry/Entry.svelte +83 -0
  15. package/dist/components/entry/Entry.svelte.d.ts +15 -0
  16. package/dist/components/footer/Footer.svelte +89 -0
  17. package/dist/components/footer/Footer.svelte.d.ts +13 -0
  18. package/dist/components/footer/FooterColumn.svelte +34 -0
  19. package/dist/components/footer/FooterColumn.svelte.d.ts +10 -0
  20. package/dist/components/mesh-gradient/MeshGradient.svelte +86 -0
  21. package/dist/components/mesh-gradient/MeshGradient.svelte.d.ts +12 -0
  22. package/dist/components/number-stepper/NumberStepper.svelte +82 -0
  23. package/dist/components/number-stepper/NumberStepper.svelte.d.ts +16 -0
  24. package/dist/components/preview-card/PreviewCard.svelte +166 -0
  25. package/dist/components/preview-card/PreviewCard.svelte.d.ts +20 -0
  26. package/dist/components/progress/Progress.svelte +88 -0
  27. package/dist/components/progress/Progress.svelte.d.ts +12 -0
  28. package/dist/components/select/Select.svelte +391 -0
  29. package/dist/components/select/Select.svelte.d.ts +39 -0
  30. package/dist/components/sidebar/Sidebar.svelte +37 -0
  31. package/dist/components/sidebar/SidebarHeader.svelte +26 -2
  32. package/dist/components/tabs/Tabs.svelte +280 -0
  33. package/dist/components/tabs/Tabs.svelte.d.ts +48 -0
  34. package/dist/index.d.ts +15 -0
  35. package/dist/index.js +14 -0
  36. package/package.json +1 -1
@@ -0,0 +1,368 @@
1
+ <script module lang="ts">
2
+ export type DrawerTriggerProps = {
3
+ onclick: () => void;
4
+ 'aria-haspopup': 'dialog';
5
+ };
6
+ </script>
7
+
8
+ <script lang="ts">
9
+ import type { Snippet } from 'svelte';
10
+ import { X } from '@lucide/svelte';
11
+
12
+ type Props = {
13
+ /**
14
+ * Whether the drawer is open. Bindable. A sheet from the bottom on phones, a panel on the right from 768px up.
15
+ * Escape, the handle or close button, dragging it away and a click on the backdrop close it.
16
+ */
17
+ open?: boolean;
18
+ title: string;
19
+ description?: string;
20
+ /** Renders the trigger. Spread the given props onto it, e.g. `<Button {...props} />`. Omit to control `open` yourself. */
21
+ trigger?: Snippet<[DrawerTriggerProps]>;
22
+ /** The drawer's content; scrolls when it is taller than the screen. The first focusable element in it gets the focus on open. */
23
+ children?: Snippet;
24
+ /** Optional Buttons in the footer, stacked at full width with the primary one last; it ends up on top. Call `close` to close the drawer. */
25
+ actions?: Snippet<[close: () => void]>;
26
+ };
27
+
28
+ let { open = $bindable(false), title, description, trigger, children, actions }: Props = $props();
29
+
30
+ const id = $props.id();
31
+ let dialogEl: HTMLDialogElement;
32
+
33
+ const triggerProps: DrawerTriggerProps = {
34
+ onclick: () => (open = true),
35
+ 'aria-haspopup': 'dialog'
36
+ };
37
+
38
+ function close() {
39
+ open = false;
40
+ }
41
+
42
+ $effect(() => {
43
+ if (open && !dialogEl.open) {
44
+ dialogEl.showModal();
45
+ } else if (!open && dialogEl.open) {
46
+ dialogEl.close();
47
+ }
48
+ });
49
+
50
+ /* Dragging the header towards the edge moves the drawer along; letting go far enough or fast enough closes it */
51
+ const slop = 4;
52
+ let offset = $state(0);
53
+ let dragging = $state(false);
54
+ /* Matches the media query below: the panel on the right leaves sideways */
55
+ let sideways = $state(false);
56
+ let dragged = false;
57
+
58
+ function startDrag(event: PointerEvent) {
59
+ if (event.button !== 0) return;
60
+ sideways = window.matchMedia('(min-width: 768px)').matches;
61
+ const start = sideways ? event.clientX : event.clientY;
62
+ const startTime = event.timeStamp;
63
+
64
+ function move(e: PointerEvent) {
65
+ const distance = (sideways ? e.clientX : e.clientY) - start;
66
+ if (!dragging && Math.abs(distance) < slop) return;
67
+ dragging = true;
68
+ dragged = true;
69
+ offset = Math.max(0, distance);
70
+ }
71
+
72
+ function end(e: PointerEvent) {
73
+ window.removeEventListener('pointermove', move);
74
+ window.removeEventListener('pointerup', end);
75
+ window.removeEventListener('pointercancel', end);
76
+ if (!dragging) return;
77
+ const velocity = offset / Math.max(1, e.timeStamp - startTime);
78
+ const dismiss =
79
+ e.type === 'pointerup' &&
80
+ (offset > (sideways ? dialogEl.offsetWidth : dialogEl.offsetHeight) / 3 || velocity > 0.5);
81
+ dragging = false;
82
+ offset = 0;
83
+ if (dismiss) close();
84
+ // The click that follows a drag ending on the handle must not count as a tap on it
85
+ setTimeout(() => (dragged = false));
86
+ }
87
+
88
+ window.addEventListener('pointermove', move);
89
+ window.addEventListener('pointerup', end);
90
+ window.addEventListener('pointercancel', end);
91
+ }
92
+ </script>
93
+
94
+ {@render trigger?.(triggerProps)}
95
+
96
+ <dialog
97
+ bind:this={dialogEl}
98
+ aria-labelledby="{id}-title"
99
+ aria-describedby={description ? `${id}-description` : undefined}
100
+ class={['drawer', { 'drawer--dragging': dragging }]}
101
+ style:translate={dragging ? (sideways ? `${offset}px 0` : `0 ${offset}px`) : undefined}
102
+ onkeydown={(event) => {
103
+ if (event.key !== 'Escape') return;
104
+ event.preventDefault();
105
+ close();
106
+ }}
107
+ oncancel={(event) => {
108
+ event.preventDefault();
109
+ close();
110
+ }}
111
+ onclose={() => (open = false)}
112
+ onclick={(event) => {
113
+ // Clicks on the ::backdrop target the <dialog> itself, outside its box
114
+ if (event.target !== dialogEl) return;
115
+ const rect = dialogEl.getBoundingClientRect();
116
+ const inside =
117
+ event.clientX >= rect.left &&
118
+ event.clientX <= rect.right &&
119
+ event.clientY >= rect.top &&
120
+ event.clientY <= rect.bottom;
121
+ if (!inside) close();
122
+ }}
123
+ >
124
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
125
+ <div class="drawer-header" onpointerdown={startDrag}>
126
+ <h2 id="{id}-title" class="drawer-title">{title}</h2>
127
+ {#if description}
128
+ <p id="{id}-description" class="drawer-description">{description}</p>
129
+ {/if}
130
+ </div>
131
+ {#if children}
132
+ <div class="drawer-body">{@render children()}</div>
133
+ {/if}
134
+ {#if actions}
135
+ <div class="drawer-actions">{@render actions(close)}</div>
136
+ {/if}
137
+ <!-- Last in the DOM so the content, not the handle, gets the initial focus -->
138
+ <button
139
+ type="button"
140
+ class="drawer-handle"
141
+ aria-label="Close"
142
+ onpointerdown={startDrag}
143
+ onclick={() => {
144
+ if (!dragged) close();
145
+ }}
146
+ >
147
+ <span class="drawer-handle-bar"></span>
148
+ <X class="drawer-handle-icon" aria-hidden="true" />
149
+ </button>
150
+ </dialog>
151
+
152
+ <style>
153
+ /* Locks page scrolling while open; the gutter keeps the layout from shifting */
154
+ :global(html:has(.drawer[open])) {
155
+ overflow: hidden;
156
+ scrollbar-gutter: stable;
157
+ }
158
+
159
+ .drawer {
160
+ box-sizing: border-box;
161
+ width: 100%;
162
+ max-width: 560px;
163
+ max-height: calc(100dvh - 48px);
164
+ flex-direction: column;
165
+ /* Pinned to the bottom edge */
166
+ margin: auto auto 0;
167
+ padding: 0 0 env(safe-area-inset-bottom);
168
+ overflow: hidden;
169
+ background-color: var(--color-bg);
170
+ color: var(--color-text);
171
+ border: 1px solid var(--color-border);
172
+ border-bottom: 0;
173
+ border-radius: 16px 16px 0 0;
174
+ box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.12);
175
+ outline: none;
176
+ translate: 0 100%;
177
+ transition:
178
+ translate 260ms cubic-bezier(0.32, 0.72, 0, 1),
179
+ display 260ms allow-discrete,
180
+ overlay 260ms allow-discrete;
181
+ }
182
+
183
+ .drawer[open] {
184
+ display: flex;
185
+ translate: 0 0;
186
+ }
187
+
188
+ /* Follows the finger directly */
189
+ .drawer--dragging {
190
+ transition: none;
191
+ }
192
+
193
+ .drawer::backdrop {
194
+ background-color: rgba(0, 0, 0, 0);
195
+ transition:
196
+ background-color 260ms ease,
197
+ display 260ms allow-discrete,
198
+ overlay 260ms allow-discrete;
199
+ }
200
+
201
+ .drawer[open]::backdrop {
202
+ background-color: rgba(0, 0, 0, 0.4);
203
+ }
204
+
205
+ @starting-style {
206
+ .drawer[open] {
207
+ translate: 0 100%;
208
+ }
209
+
210
+ .drawer[open]::backdrop {
211
+ background-color: rgba(0, 0, 0, 0);
212
+ }
213
+ }
214
+
215
+ @media (prefers-reduced-motion: reduce) {
216
+ .drawer,
217
+ .drawer::backdrop {
218
+ transition-duration: 0ms;
219
+ }
220
+ }
221
+
222
+ .drawer-header {
223
+ flex-shrink: 0;
224
+ /* Room for the handle */
225
+ padding: 28px 20px 12px;
226
+ cursor: grab;
227
+ user-select: none;
228
+ /* Dragging must not scroll the page or get cancelled by the browser */
229
+ touch-action: none;
230
+ }
231
+
232
+ .drawer--dragging .drawer-header,
233
+ .drawer--dragging .drawer-handle {
234
+ cursor: grabbing;
235
+ }
236
+
237
+ .drawer-title {
238
+ margin: 0;
239
+ font-size: 15px;
240
+ font-weight: 600;
241
+ }
242
+
243
+ .drawer-description {
244
+ margin: 6px 0 0;
245
+ font-size: 13px;
246
+ line-height: 1.5;
247
+ color: var(--color-text-muted);
248
+ }
249
+
250
+ .drawer-body {
251
+ min-height: 0;
252
+ padding: 4px 20px 20px;
253
+ overflow-y: auto;
254
+ overscroll-behavior: contain;
255
+ font-size: 13px;
256
+ }
257
+
258
+ /* Stacked, the primary action (last in the markup) on top */
259
+ .drawer-actions {
260
+ display: flex;
261
+ flex-shrink: 0;
262
+ flex-direction: column-reverse;
263
+ gap: 8px;
264
+ padding: 4px 20px 20px;
265
+ }
266
+
267
+ .drawer-actions :global(.button) {
268
+ width: 100%;
269
+ }
270
+
271
+ .drawer-handle {
272
+ position: absolute;
273
+ top: 0;
274
+ left: 50%;
275
+ padding: 10px 24px;
276
+ background: none;
277
+ border: 0;
278
+ border-radius: 8px;
279
+ cursor: grab;
280
+ translate: -50% 0;
281
+ touch-action: none;
282
+ -webkit-tap-highlight-color: transparent;
283
+ }
284
+
285
+ .drawer-handle:focus-visible {
286
+ outline: 2px solid var(--color-accent);
287
+ outline-offset: -4px;
288
+ }
289
+
290
+ .drawer-handle-bar {
291
+ display: block;
292
+ width: 36px;
293
+ height: 4px;
294
+ background-color: var(--color-border-strong);
295
+ border-radius: 2px;
296
+ transition: background-color 120ms ease;
297
+ }
298
+
299
+ .drawer-handle:hover .drawer-handle-bar {
300
+ background-color: var(--color-text-faint);
301
+ }
302
+
303
+ .drawer-handle :global(.drawer-handle-icon) {
304
+ display: none;
305
+ width: 16px;
306
+ height: 16px;
307
+ }
308
+
309
+ /* Larger screens: a floating panel on the right, with a close button instead of the handle */
310
+ @media (min-width: 768px) {
311
+ .drawer {
312
+ width: 400px;
313
+ max-width: calc(100% - 48px);
314
+ height: calc(100dvh - 2 * var(--drawer-inset));
315
+ max-height: none;
316
+ /* Floats with a gap to the screen edges */
317
+ --drawer-inset: 8px;
318
+ margin: var(--drawer-inset) var(--drawer-inset) var(--drawer-inset) auto;
319
+ padding: 0;
320
+ border: 1px solid var(--color-border);
321
+ border-radius: 20px;
322
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.16);
323
+ translate: calc(100% + var(--drawer-inset)) 0;
324
+ }
325
+
326
+ @starting-style {
327
+ .drawer[open] {
328
+ translate: calc(100% + var(--drawer-inset)) 0;
329
+ }
330
+ }
331
+
332
+ .drawer-header {
333
+ padding: 20px 56px 12px 20px;
334
+ }
335
+
336
+ /* Pushes the actions to the bottom */
337
+ .drawer-body {
338
+ flex: 1;
339
+ }
340
+
341
+ .drawer-handle {
342
+ top: 14px;
343
+ right: 14px;
344
+ left: auto;
345
+ padding: 6px;
346
+ color: var(--color-text-muted);
347
+ cursor: pointer;
348
+ translate: none;
349
+ }
350
+
351
+ .drawer-handle:hover {
352
+ color: var(--color-text);
353
+ background-color: var(--color-surface);
354
+ }
355
+
356
+ .drawer-handle:focus-visible {
357
+ outline-offset: 2px;
358
+ }
359
+
360
+ .drawer-handle-bar {
361
+ display: none;
362
+ }
363
+
364
+ .drawer-handle :global(.drawer-handle-icon) {
365
+ display: block;
366
+ }
367
+ }
368
+ </style>
@@ -0,0 +1,23 @@
1
+ export type DrawerTriggerProps = {
2
+ onclick: () => void;
3
+ 'aria-haspopup': 'dialog';
4
+ };
5
+ import type { Snippet } from 'svelte';
6
+ type Props = {
7
+ /**
8
+ * Whether the drawer is open. Bindable. A sheet from the bottom on phones, a panel on the right from 768px up.
9
+ * Escape, the handle or close button, dragging it away and a click on the backdrop close it.
10
+ */
11
+ open?: boolean;
12
+ title: string;
13
+ description?: string;
14
+ /** Renders the trigger. Spread the given props onto it, e.g. `<Button {...props} />`. Omit to control `open` yourself. */
15
+ trigger?: Snippet<[DrawerTriggerProps]>;
16
+ /** The drawer's content; scrolls when it is taller than the screen. The first focusable element in it gets the focus on open. */
17
+ children?: Snippet;
18
+ /** Optional Buttons in the footer, stacked at full width with the primary one last; it ends up on top. Call `close` to close the drawer. */
19
+ actions?: Snippet<[close: () => void]>;
20
+ };
21
+ declare const Drawer: import("svelte").Component<Props, {}, "open">;
22
+ type Drawer = ReturnType<typeof Drawer>;
23
+ export default Drawer;
@@ -0,0 +1,86 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import type { LucideIcon } from '@lucide/svelte';
5
+
6
+ type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'title'> & {
7
+ /** Any Lucide icon (import from `svelte-ui/icons`). */
8
+ icon?: LucideIcon;
9
+ title: string;
10
+ description?: string;
11
+ /** `outline` has a dashed border, for empty areas that can be filled. */
12
+ variant?: 'secondary' | 'outline';
13
+ /** Optional actions below the text, e.g. a Button that creates the first entry. */
14
+ children?: Snippet;
15
+ };
16
+
17
+ let {
18
+ icon: Icon,
19
+ title,
20
+ description,
21
+ variant = 'secondary',
22
+ class: className,
23
+ children,
24
+ ...rest
25
+ }: Props = $props();
26
+ </script>
27
+
28
+ <div {...rest} class={['empty-state', `empty-state--${variant}`, className]}>
29
+ {#if Icon}<Icon class="empty-state-icon" aria-hidden="true" />{/if}
30
+ <p class="empty-state-title">{title}</p>
31
+ {#if description}<p class="empty-state-description">{description}</p>{/if}
32
+ {#if children}<div class="empty-state-actions">{@render children()}</div>{/if}
33
+ </div>
34
+
35
+ <style>
36
+ .empty-state {
37
+ display: flex;
38
+ flex-direction: column;
39
+ align-items: center;
40
+ gap: 6px;
41
+ box-sizing: border-box;
42
+ padding: 32px 16px;
43
+ font-size: 13px;
44
+ line-height: 1.5;
45
+ color: var(--color-text);
46
+ text-align: center;
47
+ border-radius: 14px;
48
+ }
49
+
50
+ .empty-state--secondary {
51
+ background-color: var(--color-surface);
52
+ border: 1px solid transparent;
53
+ }
54
+
55
+ .empty-state--outline {
56
+ border: 1px dashed var(--color-border-strong);
57
+ }
58
+
59
+ .empty-state :global(.empty-state-icon) {
60
+ width: 20px;
61
+ height: 20px;
62
+ margin-bottom: 4px;
63
+ color: var(--color-text-muted);
64
+ }
65
+
66
+ p {
67
+ max-width: 44ch;
68
+ margin: 0;
69
+ }
70
+
71
+ .empty-state-title {
72
+ font-weight: 500;
73
+ }
74
+
75
+ .empty-state-description {
76
+ color: var(--color-text-muted);
77
+ }
78
+
79
+ .empty-state-actions {
80
+ display: flex;
81
+ flex-wrap: wrap;
82
+ justify-content: center;
83
+ gap: 8px;
84
+ margin-top: 8px;
85
+ }
86
+ </style>
@@ -0,0 +1,16 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { LucideIcon } from '@lucide/svelte';
4
+ type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'title'> & {
5
+ /** Any Lucide icon (import from `svelte-ui/icons`). */
6
+ icon?: LucideIcon;
7
+ title: string;
8
+ description?: string;
9
+ /** `outline` has a dashed border, for empty areas that can be filled. */
10
+ variant?: 'secondary' | 'outline';
11
+ /** Optional actions below the text, e.g. a Button that creates the first entry. */
12
+ children?: Snippet;
13
+ };
14
+ declare const EmptyState: import("svelte").Component<Props, {}, "">;
15
+ type EmptyState = ReturnType<typeof EmptyState>;
16
+ export default EmptyState;
@@ -0,0 +1,83 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'title'> & {
6
+ /** Rendered as an `<h3>`. */
7
+ title: string;
8
+ /** Muted text next to the title, e.g. a period like "2021 - 2025". */
9
+ meta?: string;
10
+ /** The description. */
11
+ children?: Snippet;
12
+ /** Below the description, e.g. a Link. */
13
+ footer?: Snippet;
14
+ };
15
+
16
+ let { title, meta, class: className, children, footer, ...rest }: Props = $props();
17
+ </script>
18
+
19
+ <div {...rest} class={['entry', className]}>
20
+ <div class="entry-heading">
21
+ <h3 class="entry-title">{title}</h3>
22
+ {#if meta}
23
+ <span class="entry-meta">{meta}</span>
24
+ {/if}
25
+ </div>
26
+ {#if children}
27
+ <p class="entry-description">{@render children()}</p>
28
+ {/if}
29
+ {#if footer}
30
+ <div class="entry-footer">{@render footer()}</div>
31
+ {/if}
32
+ </div>
33
+
34
+ <style>
35
+ .entry {
36
+ display: flex;
37
+ flex-direction: column;
38
+ align-items: flex-start;
39
+ gap: 10px;
40
+ box-sizing: border-box;
41
+ padding: 24px;
42
+ color: var(--color-text);
43
+ border: 1px solid var(--color-border-strong);
44
+ border-radius: 14px;
45
+ }
46
+
47
+ .entry-heading {
48
+ display: flex;
49
+ flex-wrap: wrap;
50
+ align-items: baseline;
51
+ column-gap: 8px;
52
+ }
53
+
54
+ .entry-title {
55
+ margin: 0;
56
+ font-size: 20px;
57
+ font-weight: 500;
58
+ }
59
+
60
+ .entry-meta {
61
+ font-size: 14px;
62
+ color: var(--color-text-muted);
63
+ white-space: nowrap;
64
+ }
65
+
66
+ .entry-description {
67
+ margin: 0;
68
+ max-width: 640px;
69
+ }
70
+
71
+ .entry-footer {
72
+ display: flex;
73
+ flex-wrap: wrap;
74
+ align-items: center;
75
+ gap: 8px;
76
+ }
77
+
78
+ @media (min-width: 768px) {
79
+ .entry {
80
+ padding: 32px;
81
+ }
82
+ }
83
+ </style>
@@ -0,0 +1,15 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'title'> & {
4
+ /** Rendered as an `<h3>`. */
5
+ title: string;
6
+ /** Muted text next to the title, e.g. a period like "2021 - 2025". */
7
+ meta?: string;
8
+ /** The description. */
9
+ children?: Snippet;
10
+ /** Below the description, e.g. a Link. */
11
+ footer?: Snippet;
12
+ };
13
+ declare const Entry: import("svelte").Component<Props, {}, "">;
14
+ type Entry = ReturnType<typeof Entry>;
15
+ export default Entry;
@@ -0,0 +1,89 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+
5
+ type Props = Omit<HTMLAttributes<HTMLElement>, 'children' | 'title'> & {
6
+ /** Message in the panel, rendered as an `<h2>`; `\n` breaks the line. */
7
+ title?: string;
8
+ /** Owner shown below the panel as "© <current year> <copyright>". */
9
+ copyright?: string;
10
+ /** FooterColumns. */
11
+ children?: Snippet;
12
+ };
13
+
14
+ let { title, copyright, class: className, children, ...rest }: Props = $props();
15
+
16
+ const year = new Date().getFullYear();
17
+ </script>
18
+
19
+ <footer {...rest} class={['footer', className]}>
20
+ <div class="footer-panel">
21
+ {#if title}
22
+ <h2 class="footer-title">{title}</h2>
23
+ {/if}
24
+ {#if children}
25
+ <div class="footer-columns">{@render children()}</div>
26
+ {/if}
27
+ </div>
28
+ {#if copyright}
29
+ <p class="footer-copyright">© {year} {copyright}</p>
30
+ {/if}
31
+ </footer>
32
+
33
+ <style>
34
+ .footer {
35
+ display: flex;
36
+ flex-direction: column;
37
+ gap: 20px;
38
+ font-size: 14px;
39
+ }
40
+
41
+ .footer-panel {
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: 40px;
45
+ box-sizing: border-box;
46
+ min-height: 256px;
47
+ padding: 40px;
48
+ color: #fff;
49
+ background-color: #000;
50
+ border-radius: 14px;
51
+ }
52
+
53
+ .footer-title {
54
+ margin: 0;
55
+ font-size: 24px;
56
+ font-weight: 600;
57
+ line-height: 1.25;
58
+ white-space: pre-line;
59
+ user-select: none;
60
+ }
61
+
62
+ .footer-columns {
63
+ display: flex;
64
+ flex-direction: column;
65
+ gap: 40px;
66
+ }
67
+
68
+ .footer-copyright {
69
+ margin: 0;
70
+ text-align: center;
71
+ color: var(--color-text-muted);
72
+ user-select: none;
73
+ }
74
+
75
+ @media (min-width: 640px) {
76
+ .footer-panel {
77
+ flex-direction: row;
78
+ }
79
+
80
+ .footer-title {
81
+ flex: 1;
82
+ }
83
+
84
+ .footer-columns {
85
+ flex: 1;
86
+ flex-direction: row;
87
+ }
88
+ }
89
+ </style>
@@ -0,0 +1,13 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type Props = Omit<HTMLAttributes<HTMLElement>, 'children' | 'title'> & {
4
+ /** Message in the panel, rendered as an `<h2>`; `\n` breaks the line. */
5
+ title?: string;
6
+ /** Owner shown below the panel as "© <current year> <copyright>". */
7
+ copyright?: string;
8
+ /** FooterColumns. */
9
+ children?: Snippet;
10
+ };
11
+ declare const Footer: import("svelte").Component<Props, {}, "">;
12
+ type Footer = ReturnType<typeof Footer>;
13
+ export default Footer;