@noahbaumgartner/svelte-ui 0.0.7 → 0.0.9
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/dist/components/button/Button.svelte +0 -10
- package/dist/components/lightbox/Lightbox.svelte +136 -0
- package/dist/components/lightbox/Lightbox.svelte.d.ts +14 -0
- package/dist/components/media-preview/MediaPreview.svelte +110 -0
- package/dist/components/media-preview/MediaPreview.svelte.d.ts +11 -0
- package/dist/components/mesh-gradient/MeshGradient.svelte +19 -2
- package/dist/components/page-header/PageHeader.svelte +83 -0
- package/dist/components/page-header/PageHeader.svelte.d.ts +13 -0
- package/dist/components/section/Section.svelte +9 -5
- package/dist/components/section/Section.svelte.d.ts +6 -3
- package/dist/components/select/Select.svelte.d.ts +1 -1
- package/dist/components/table-of-contents/TableOfContents.svelte +296 -0
- package/dist/components/table-of-contents/TableOfContents.svelte.d.ts +7 -0
- package/dist/components/tabs/Tabs.svelte +72 -2
- package/dist/components/tabs/Tabs.svelte.d.ts +3 -1
- package/dist/components/theme-toggle/ThemeToggle.svelte +40 -0
- package/dist/components/theme-toggle/ThemeToggle.svelte.d.ts +7 -0
- package/dist/components/timeline/Timeline.svelte +122 -0
- package/dist/components/timeline/Timeline.svelte.d.ts +21 -0
- package/dist/icon.d.ts +7 -7
- package/dist/icons/GithubIcon.svelte +25 -0
- package/dist/icons/GithubIcon.svelte.d.ts +4 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.js +7 -1
- package/dist/internal/icons/Monitor.svelte +15 -0
- package/dist/internal/icons/Monitor.svelte.d.ts +4 -0
- package/dist/internal/icons/Moon.svelte +18 -0
- package/dist/internal/icons/Moon.svelte.d.ts +4 -0
- package/dist/internal/icons/Pause.svelte +14 -0
- package/dist/internal/icons/Pause.svelte.d.ts +4 -0
- package/dist/internal/icons/Play.svelte +16 -0
- package/dist/internal/icons/Play.svelte.d.ts +4 -0
- package/dist/internal/icons/Sun.svelte +21 -0
- package/dist/internal/icons/Sun.svelte.d.ts +4 -0
- package/dist/styles/tokens.css +7 -0
- package/package.json +4 -2
- package/dist/components/entry/Entry.svelte +0 -94
- package/dist/components/entry/Entry.svelte.d.ts +0 -16
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { tick } from 'svelte';
|
|
3
|
+
import { SvelteSet } from 'svelte/reactivity';
|
|
4
|
+
import ChevronDown from '../../internal/icons/ChevronDown.svelte';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
container,
|
|
8
|
+
variant = 'sidebar'
|
|
9
|
+
}: {
|
|
10
|
+
container: HTMLElement | undefined;
|
|
11
|
+
variant?: 'sidebar' | 'mobile';
|
|
12
|
+
} = $props();
|
|
13
|
+
|
|
14
|
+
type TocItem = { id: string; text: string; level: number };
|
|
15
|
+
|
|
16
|
+
let items: TocItem[] = $state([]);
|
|
17
|
+
let activeId: string | null = $state(null);
|
|
18
|
+
let expanded = $state(false);
|
|
19
|
+
|
|
20
|
+
let activeItem = $derived(items.find((item) => item.id === activeId) ?? items[0]);
|
|
21
|
+
|
|
22
|
+
function slugify(text: string) {
|
|
23
|
+
return text
|
|
24
|
+
.toLowerCase()
|
|
25
|
+
.trim()
|
|
26
|
+
.replace(/[äáàâ]/g, 'a')
|
|
27
|
+
.replace(/[öóòô]/g, 'o')
|
|
28
|
+
.replace(/[üúùû]/g, 'u')
|
|
29
|
+
.replace(/ß/g, 'ss')
|
|
30
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
31
|
+
.replace(/^-+|-+$/g, '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
$effect(() => {
|
|
35
|
+
if (!container) return;
|
|
36
|
+
|
|
37
|
+
const headings = Array.from(container.querySelectorAll('h1, h2')) as HTMLElement[];
|
|
38
|
+
const usedIds = new SvelteSet<string>();
|
|
39
|
+
|
|
40
|
+
items = headings.map((heading) => {
|
|
41
|
+
if (!heading.id) {
|
|
42
|
+
let id = slugify(heading.textContent ?? '');
|
|
43
|
+
let unique = id;
|
|
44
|
+
let i = 2;
|
|
45
|
+
while (usedIds.has(unique)) {
|
|
46
|
+
unique = `${id}-${i}`;
|
|
47
|
+
i++;
|
|
48
|
+
}
|
|
49
|
+
heading.id = unique;
|
|
50
|
+
}
|
|
51
|
+
usedIds.add(heading.id);
|
|
52
|
+
return {
|
|
53
|
+
id: heading.id,
|
|
54
|
+
text: heading.textContent ?? '',
|
|
55
|
+
level: heading.tagName === 'H1' ? 1 : 2
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const observer = new IntersectionObserver(
|
|
60
|
+
(entries) => {
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (entry.isIntersecting) {
|
|
63
|
+
activeId = (entry.target as HTMLElement).id;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{ rootMargin: '0px 0px -70% 0px', threshold: 0 }
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
headings.forEach((heading) => observer.observe(heading));
|
|
72
|
+
|
|
73
|
+
return () => observer.disconnect();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const scrollOffset = $derived(variant === 'mobile' ? 130 : 64);
|
|
77
|
+
|
|
78
|
+
async function handleClick(event: MouseEvent, id: string) {
|
|
79
|
+
event.preventDefault();
|
|
80
|
+
expanded = false;
|
|
81
|
+
// Wait for the mobile panel's collapse to actually reach the DOM before
|
|
82
|
+
// measuring the heading position, otherwise the still-expanded panel's
|
|
83
|
+
// extra height gets baked into the scroll target and it overshoots.
|
|
84
|
+
await tick();
|
|
85
|
+
const target = document.getElementById(id);
|
|
86
|
+
if (!target) return;
|
|
87
|
+
const top = target.getBoundingClientRect().top + window.scrollY - scrollOffset;
|
|
88
|
+
|
|
89
|
+
// Mobile Safari can silently drop a new smooth scroll if one is still
|
|
90
|
+
// animating, so snap to the current position first to clear it before
|
|
91
|
+
// requesting the next smooth scroll on the following frame.
|
|
92
|
+
window.scrollTo({ top: window.scrollY, behavior: 'instant' });
|
|
93
|
+
requestAnimationFrame(() => {
|
|
94
|
+
window.scrollTo({ top, behavior: 'smooth' });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
history.replaceState(null, '', `#${id}`);
|
|
98
|
+
}
|
|
99
|
+
</script>
|
|
100
|
+
|
|
101
|
+
{#if items.length > 0}
|
|
102
|
+
{#if variant === 'mobile'}
|
|
103
|
+
<div class="toc-mobile">
|
|
104
|
+
{#if expanded}
|
|
105
|
+
<div class="toc-mobile-trigger">
|
|
106
|
+
<a
|
|
107
|
+
href="#{items[0].id}"
|
|
108
|
+
class="toc-item toc-mobile-title"
|
|
109
|
+
class:toc-item--sub={items[0].level === 2}
|
|
110
|
+
class:toc-item--active={activeId === items[0].id}
|
|
111
|
+
onclick={(event) => handleClick(event, items[0].id)}
|
|
112
|
+
>
|
|
113
|
+
{items[0].text}
|
|
114
|
+
</a>
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
class="toc-mobile-chevron-btn"
|
|
118
|
+
onclick={() => (expanded = !expanded)}
|
|
119
|
+
aria-expanded={expanded}
|
|
120
|
+
aria-label="Inhaltsverzeichnis einklappen"
|
|
121
|
+
>
|
|
122
|
+
<ChevronDown class="toc-mobile-chevron" style="transform: rotate(180deg)" />
|
|
123
|
+
</button>
|
|
124
|
+
</div>
|
|
125
|
+
{:else}
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
class="toc-mobile-trigger"
|
|
129
|
+
onclick={() => (expanded = !expanded)}
|
|
130
|
+
aria-expanded={expanded}
|
|
131
|
+
aria-label="Inhaltsverzeichnis ausklappen"
|
|
132
|
+
>
|
|
133
|
+
<span class="toc-mobile-title">{activeItem?.text}</span>
|
|
134
|
+
<ChevronDown class="toc-mobile-chevron" />
|
|
135
|
+
</button>
|
|
136
|
+
{/if}
|
|
137
|
+
|
|
138
|
+
{#if expanded}
|
|
139
|
+
<nav class="toc-mobile-panel" aria-label="Inhaltsverzeichnis">
|
|
140
|
+
{#each items.slice(1) as item (item.id)}
|
|
141
|
+
<a
|
|
142
|
+
href="#{item.id}"
|
|
143
|
+
class="toc-item"
|
|
144
|
+
class:toc-item--sub={item.level === 2}
|
|
145
|
+
class:toc-item--active={activeId === item.id}
|
|
146
|
+
onclick={(event) => handleClick(event, item.id)}
|
|
147
|
+
>
|
|
148
|
+
{item.text}
|
|
149
|
+
</a>
|
|
150
|
+
{/each}
|
|
151
|
+
</nav>
|
|
152
|
+
{/if}
|
|
153
|
+
</div>
|
|
154
|
+
{:else}
|
|
155
|
+
<nav class="toc" aria-label="Inhaltsverzeichnis">
|
|
156
|
+
{#each items as item (item.id)}
|
|
157
|
+
<a
|
|
158
|
+
href="#{item.id}"
|
|
159
|
+
class="toc-item"
|
|
160
|
+
class:toc-item--sub={item.level === 2}
|
|
161
|
+
class:toc-item--active={activeId === item.id}
|
|
162
|
+
onclick={(event) => handleClick(event, item.id)}
|
|
163
|
+
>
|
|
164
|
+
{item.text}
|
|
165
|
+
</a>
|
|
166
|
+
{/each}
|
|
167
|
+
</nav>
|
|
168
|
+
{/if}
|
|
169
|
+
{/if}
|
|
170
|
+
|
|
171
|
+
<style>
|
|
172
|
+
.toc {
|
|
173
|
+
display: flex;
|
|
174
|
+
flex-direction: column;
|
|
175
|
+
gap: 10px;
|
|
176
|
+
user-select: none;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.toc-item {
|
|
180
|
+
font-size: 12px;
|
|
181
|
+
color: var(--color-text-muted);
|
|
182
|
+
text-decoration: none;
|
|
183
|
+
line-height: 1.4;
|
|
184
|
+
border-radius: 4px;
|
|
185
|
+
outline: none;
|
|
186
|
+
transition: color 150ms ease;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.toc-item:hover {
|
|
190
|
+
color: var(--color-text);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.toc-item:focus-visible {
|
|
194
|
+
outline: 2px solid var(--color-ink);
|
|
195
|
+
outline-offset: 2px;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.toc-item--sub {
|
|
199
|
+
padding-left: 16px;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
.toc-item--active {
|
|
203
|
+
color: var(--color-text);
|
|
204
|
+
font-weight: 600;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
.toc-mobile {
|
|
208
|
+
background-color: var(--color-bg);
|
|
209
|
+
border-bottom: 1px solid var(--color-border);
|
|
210
|
+
user-select: none;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.toc-mobile-trigger {
|
|
214
|
+
display: flex;
|
|
215
|
+
align-items: center;
|
|
216
|
+
justify-content: space-between;
|
|
217
|
+
gap: 12px;
|
|
218
|
+
width: 100%;
|
|
219
|
+
padding: 14px 24px;
|
|
220
|
+
font-size: 14px;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
button.toc-mobile-trigger {
|
|
224
|
+
background-color: transparent;
|
|
225
|
+
border: none;
|
|
226
|
+
font: inherit;
|
|
227
|
+
font-size: 14px;
|
|
228
|
+
color: var(--color-text);
|
|
229
|
+
text-align: left;
|
|
230
|
+
outline: none;
|
|
231
|
+
cursor: pointer;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
button.toc-mobile-trigger:focus-visible {
|
|
235
|
+
outline: 2px solid var(--color-ink);
|
|
236
|
+
outline-offset: -2px;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.toc-mobile-title {
|
|
240
|
+
flex: 1;
|
|
241
|
+
overflow: hidden;
|
|
242
|
+
text-overflow: ellipsis;
|
|
243
|
+
white-space: nowrap;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
a.toc-mobile-title {
|
|
247
|
+
font-size: 14px;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
span.toc-mobile-title {
|
|
251
|
+
color: var(--color-text);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.toc-mobile-chevron-btn {
|
|
255
|
+
display: flex;
|
|
256
|
+
align-items: center;
|
|
257
|
+
justify-content: center;
|
|
258
|
+
flex-shrink: 0;
|
|
259
|
+
padding: 0;
|
|
260
|
+
background-color: transparent;
|
|
261
|
+
border: none;
|
|
262
|
+
border-radius: 4px;
|
|
263
|
+
color: var(--color-text);
|
|
264
|
+
outline: none;
|
|
265
|
+
cursor: pointer;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.toc-mobile-chevron-btn:focus-visible {
|
|
269
|
+
outline: 2px solid var(--color-ink);
|
|
270
|
+
outline-offset: 2px;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.toc-mobile-trigger :global(.toc-mobile-chevron) {
|
|
274
|
+
width: 16px;
|
|
275
|
+
height: 16px;
|
|
276
|
+
flex-shrink: 0;
|
|
277
|
+
transition: transform 200ms ease;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.toc-mobile-panel {
|
|
281
|
+
display: flex;
|
|
282
|
+
flex-direction: column;
|
|
283
|
+
gap: 16px;
|
|
284
|
+
padding: 4px 24px 20px;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
.toc-mobile-panel .toc-item {
|
|
288
|
+
font-size: 14px;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
@media (min-width: 640px) {
|
|
292
|
+
.toc-mobile {
|
|
293
|
+
display: none;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
</style>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
container: HTMLElement | undefined;
|
|
3
|
+
variant?: 'sidebar' | 'mobile';
|
|
4
|
+
};
|
|
5
|
+
declare const TableOfContents: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
6
|
+
type TableOfContents = ReturnType<typeof TableOfContents>;
|
|
7
|
+
export default TableOfContents;
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
options: TabsOption<T>[];
|
|
24
24
|
/** Accessible name for the tab list. */
|
|
25
25
|
label?: string;
|
|
26
|
-
size?: 'sm' | 'md';
|
|
26
|
+
size?: 'sm' | 'md' | 'lg';
|
|
27
|
+
/** `segmented` sits in a pill-shaped track; `text` is plain text where only the selected tab is emphasised. */
|
|
28
|
+
variant?: 'segmented' | 'text';
|
|
27
29
|
ref?: HTMLDivElement | null;
|
|
28
30
|
/** Optional panel below the tabs; receives the selected value. Omit to render the content yourself. */
|
|
29
31
|
children?: Snippet<[value: T]>;
|
|
@@ -37,6 +39,7 @@
|
|
|
37
39
|
options,
|
|
38
40
|
label,
|
|
39
41
|
size = 'md',
|
|
42
|
+
variant = 'segmented',
|
|
40
43
|
ref = $bindable(null),
|
|
41
44
|
class: className,
|
|
42
45
|
children,
|
|
@@ -100,7 +103,7 @@
|
|
|
100
103
|
}
|
|
101
104
|
</script>
|
|
102
105
|
|
|
103
|
-
<div {...rest} bind:this={ref} class={['tabs', `tabs--${size}`, className]}>
|
|
106
|
+
<div {...rest} bind:this={ref} class={['tabs', `tabs--${size}`, `tabs--${variant}`, className]}>
|
|
104
107
|
<div
|
|
105
108
|
class={[
|
|
106
109
|
'tabs-scroller',
|
|
@@ -280,6 +283,63 @@
|
|
|
280
283
|
border-radius: 5px;
|
|
281
284
|
}
|
|
282
285
|
|
|
286
|
+
.tabs--lg .tabs-list {
|
|
287
|
+
border-radius: 12px;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.tabs--lg .tabs-tab {
|
|
291
|
+
height: 32px;
|
|
292
|
+
padding: 0 14px;
|
|
293
|
+
font-size: 14px;
|
|
294
|
+
border-radius: 9px;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* Text: no track and no pill, the selected tab is only darker. Padding stays vertical so the first label lines up with the content below. */
|
|
298
|
+
.tabs--text .tabs-list {
|
|
299
|
+
gap: 20px;
|
|
300
|
+
padding: 0;
|
|
301
|
+
background-color: transparent;
|
|
302
|
+
border-radius: 0;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.tabs--text .tabs-tab {
|
|
306
|
+
height: auto;
|
|
307
|
+
padding: 4px 0;
|
|
308
|
+
font-size: 16px;
|
|
309
|
+
border-radius: 0;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
.tabs--text.tabs--sm .tabs-list {
|
|
313
|
+
gap: 16px;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.tabs--text.tabs--sm .tabs-tab {
|
|
317
|
+
padding: 2px 0;
|
|
318
|
+
font-size: 14px;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.tabs--text.tabs--lg .tabs-list {
|
|
322
|
+
gap: 24px;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.tabs--text.tabs--lg .tabs-tab {
|
|
326
|
+
font-size: 18px;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.tabs--text .tabs-tab--selected {
|
|
330
|
+
background-color: transparent;
|
|
331
|
+
box-shadow: none;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/* An outline would be clipped by the scroller, so the focus ring is an underline. */
|
|
335
|
+
.tabs--text .tabs-tab:focus-visible {
|
|
336
|
+
outline: none;
|
|
337
|
+
text-decoration: underline;
|
|
338
|
+
text-decoration-color: var(--color-accent);
|
|
339
|
+
text-decoration-thickness: 2px;
|
|
340
|
+
text-underline-offset: 4px;
|
|
341
|
+
}
|
|
342
|
+
|
|
283
343
|
.tabs-panel {
|
|
284
344
|
font-size: 13px;
|
|
285
345
|
color: var(--color-text);
|
|
@@ -315,6 +375,16 @@
|
|
|
315
375
|
box-shadow: none;
|
|
316
376
|
}
|
|
317
377
|
|
|
378
|
+
/* Text has no track and no filled tab, so the selected tab is told apart by weight */
|
|
379
|
+
.tabs--text .tabs-list {
|
|
380
|
+
border: none;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.tabs--text .tabs-tab--selected {
|
|
384
|
+
color: ButtonText;
|
|
385
|
+
font-weight: 700;
|
|
386
|
+
}
|
|
387
|
+
|
|
318
388
|
.tabs-tab:disabled {
|
|
319
389
|
color: GrayText;
|
|
320
390
|
}
|
|
@@ -18,7 +18,9 @@ declare function $$render<T extends string>(): {
|
|
|
18
18
|
options: TabsOption<T>[];
|
|
19
19
|
/** Accessible name for the tab list. */
|
|
20
20
|
label?: string;
|
|
21
|
-
size?: "sm" | "md";
|
|
21
|
+
size?: "sm" | "md" | "lg";
|
|
22
|
+
/** `segmented` sits in a pill-shaped track; `text` is plain text where only the selected tab is emphasised. */
|
|
23
|
+
variant?: "segmented" | "text";
|
|
22
24
|
ref?: HTMLDivElement | null;
|
|
23
25
|
/** Optional panel below the tabs; receives the selected value. Omit to render the content yourself. */
|
|
24
26
|
children?: Snippet<[value: T]>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import Sun from '../../internal/icons/Sun.svelte';
|
|
4
|
+
import Moon from '../../internal/icons/Moon.svelte';
|
|
5
|
+
import Button from '../button/Button.svelte';
|
|
6
|
+
|
|
7
|
+
type Theme = 'light' | 'dark';
|
|
8
|
+
|
|
9
|
+
/** Accessible name of the button for each current theme; the click switches to the other one. */
|
|
10
|
+
let {
|
|
11
|
+
labels = { light: 'Theme: Light', dark: 'Theme: Dark' }
|
|
12
|
+
}: { labels?: Record<Theme, string> } = $props();
|
|
13
|
+
|
|
14
|
+
let theme: Theme = $state('light');
|
|
15
|
+
|
|
16
|
+
const icons = { light: Sun, dark: Moon };
|
|
17
|
+
|
|
18
|
+
onMount(() => {
|
|
19
|
+
const query = window.matchMedia('(prefers-color-scheme: dark)');
|
|
20
|
+
const systemTheme = (): Theme => (query.matches ? 'dark' : 'light');
|
|
21
|
+
|
|
22
|
+
const stored = localStorage.getItem('theme');
|
|
23
|
+
theme = stored === 'light' || stored === 'dark' ? stored : systemTheme();
|
|
24
|
+
|
|
25
|
+
// Without an explicit choice the toggle keeps following the system.
|
|
26
|
+
const onSystemChange = () => {
|
|
27
|
+
if (!localStorage.getItem('theme')) theme = systemTheme();
|
|
28
|
+
};
|
|
29
|
+
query.addEventListener('change', onSystemChange);
|
|
30
|
+
return () => query.removeEventListener('change', onSystemChange);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function toggle() {
|
|
34
|
+
theme = theme === 'light' ? 'dark' : 'light';
|
|
35
|
+
document.documentElement.dataset.theme = theme;
|
|
36
|
+
localStorage.setItem('theme', theme);
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<Button variant="ghost" size="lg" icon={icons[theme]} label={labels[theme]} onclick={toggle} />
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
export type TimelineItem = {
|
|
3
|
+
/** Muted text left of the item, e.g. "2021 - 2025". */
|
|
4
|
+
period: string;
|
|
5
|
+
/** Rendered as an `<h3>`. */
|
|
6
|
+
title: string;
|
|
7
|
+
/** Muted line below the title, e.g. a company or institution. */
|
|
8
|
+
subtitle?: string;
|
|
9
|
+
/** Turns the subtitle into a Link. */
|
|
10
|
+
href?: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
};
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<script lang="ts">
|
|
16
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
17
|
+
import Link from '../link/Link.svelte';
|
|
18
|
+
|
|
19
|
+
type Props = Omit<HTMLAttributes<HTMLElement>, 'title'> & {
|
|
20
|
+
/** Rendered as an `<h2>` next to the list. */
|
|
21
|
+
title?: string;
|
|
22
|
+
items: TimelineItem[];
|
|
23
|
+
ref?: HTMLElement | null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let { title, items, class: className, ref = $bindable(null), ...rest }: Props = $props();
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<section {...rest} bind:this={ref} class={['timeline', className]}>
|
|
30
|
+
{#if title}
|
|
31
|
+
<h2 class="timeline-title">{title}</h2>
|
|
32
|
+
{/if}
|
|
33
|
+
<ol class="timeline-list">
|
|
34
|
+
{#each items as item (item.title + item.period)}
|
|
35
|
+
<li class="timeline-item">
|
|
36
|
+
<span class="timeline-period">{item.period}</span>
|
|
37
|
+
<div class="timeline-body">
|
|
38
|
+
<h3 class="timeline-item-title">{item.title}</h3>
|
|
39
|
+
{#if item.subtitle}
|
|
40
|
+
<p class="timeline-subtitle">
|
|
41
|
+
{#if item.href}
|
|
42
|
+
<Link href={item.href}>{item.subtitle}</Link>
|
|
43
|
+
{:else}
|
|
44
|
+
{item.subtitle}
|
|
45
|
+
{/if}
|
|
46
|
+
</p>
|
|
47
|
+
{/if}
|
|
48
|
+
{#if item.description}
|
|
49
|
+
<p class="timeline-description">{item.description}</p>
|
|
50
|
+
{/if}
|
|
51
|
+
</div>
|
|
52
|
+
</li>
|
|
53
|
+
{/each}
|
|
54
|
+
</ol>
|
|
55
|
+
</section>
|
|
56
|
+
|
|
57
|
+
<style>
|
|
58
|
+
.timeline {
|
|
59
|
+
display: grid;
|
|
60
|
+
gap: 32px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.timeline-title {
|
|
64
|
+
font-size: clamp(24px, 2vw + 16px, 32px);
|
|
65
|
+
font-weight: 500;
|
|
66
|
+
letter-spacing: -0.01em;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.timeline-list {
|
|
70
|
+
border-bottom: 1px solid var(--color-border);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.timeline-item {
|
|
74
|
+
display: grid;
|
|
75
|
+
gap: 8px;
|
|
76
|
+
padding-block: 24px;
|
|
77
|
+
border-top: 1px solid var(--color-border);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.timeline-period {
|
|
81
|
+
font-size: 14px;
|
|
82
|
+
color: var(--color-text-muted);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.timeline-body {
|
|
86
|
+
display: flex;
|
|
87
|
+
flex-direction: column;
|
|
88
|
+
gap: 4px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.timeline-item-title {
|
|
92
|
+
font-size: 20px;
|
|
93
|
+
font-weight: 500;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.timeline-subtitle {
|
|
97
|
+
font-size: 15px;
|
|
98
|
+
color: var(--color-text-muted);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.timeline-description {
|
|
102
|
+
margin-top: 8px;
|
|
103
|
+
max-width: 640px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@media (min-width: 768px) {
|
|
107
|
+
.timeline {
|
|
108
|
+
grid-template-columns: 1fr 2fr;
|
|
109
|
+
gap: 64px;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.timeline-item {
|
|
113
|
+
grid-template-columns: 180px 1fr;
|
|
114
|
+
gap: 32px;
|
|
115
|
+
padding-block: 32px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.timeline-period {
|
|
119
|
+
padding-top: 4px;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
</style>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type TimelineItem = {
|
|
2
|
+
/** Muted text left of the item, e.g. "2021 - 2025". */
|
|
3
|
+
period: string;
|
|
4
|
+
/** Rendered as an `<h3>`. */
|
|
5
|
+
title: string;
|
|
6
|
+
/** Muted line below the title, e.g. a company or institution. */
|
|
7
|
+
subtitle?: string;
|
|
8
|
+
/** Turns the subtitle into a Link. */
|
|
9
|
+
href?: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
};
|
|
12
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
13
|
+
type Props = Omit<HTMLAttributes<HTMLElement>, 'title'> & {
|
|
14
|
+
/** Rendered as an `<h2>` next to the list. */
|
|
15
|
+
title?: string;
|
|
16
|
+
items: TimelineItem[];
|
|
17
|
+
ref?: HTMLElement | null;
|
|
18
|
+
};
|
|
19
|
+
declare const Timeline: import("svelte").Component<Props, {}, "ref">;
|
|
20
|
+
type Timeline = ReturnType<typeof Timeline>;
|
|
21
|
+
export default Timeline;
|
package/dist/icon.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { LucideIcon, LucideProps } from '@lucide/svelte';
|
|
2
|
+
export type { LucideProps as IconProps };
|
|
2
3
|
/**
|
|
3
|
-
* A Svelte component that renders an icon
|
|
4
|
-
* (
|
|
4
|
+
* A Svelte component that renders an icon and takes the props of a Lucide icon
|
|
5
|
+
* (`size`, `strokeWidth`, `class`, `aria-hidden`, …). Any `@lucide/svelte` icon
|
|
6
|
+
* qualifies, as does an inline SVG component typed with `IconProps` (see
|
|
7
|
+
* `icons/`).
|
|
5
8
|
*
|
|
6
9
|
* Components only pass `class` and `aria-hidden`; the size comes from CSS, so
|
|
7
10
|
* the icon should fill its box (`width`/`height: 1em` or a fixed size is fine).
|
|
8
11
|
*/
|
|
9
|
-
export type IconComponent =
|
|
10
|
-
class?: string;
|
|
11
|
-
'aria-hidden'?: boolean | 'true' | 'false';
|
|
12
|
-
}>;
|
|
12
|
+
export type IconComponent = LucideIcon;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { IconProps } from '../icon.js';
|
|
3
|
+
|
|
4
|
+
// Lucide dropped its brand icons in v1, so the GitHub mark lives here. It
|
|
5
|
+
// takes the same props as a Lucide icon so it can be passed as `icon`.
|
|
6
|
+
let { size = 24, strokeWidth = 2, class: className, ...rest }: IconProps = $props();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<svg
|
|
10
|
+
{...rest}
|
|
11
|
+
class={className}
|
|
12
|
+
width={size}
|
|
13
|
+
height={size}
|
|
14
|
+
viewBox="0 0 24 24"
|
|
15
|
+
fill="none"
|
|
16
|
+
stroke="currentColor"
|
|
17
|
+
stroke-width={strokeWidth}
|
|
18
|
+
stroke-linecap="round"
|
|
19
|
+
stroke-linejoin="round"
|
|
20
|
+
>
|
|
21
|
+
<path
|
|
22
|
+
d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"
|
|
23
|
+
/>
|
|
24
|
+
<path d="M9 18c-4.51 2-5-2-7-2" />
|
|
25
|
+
</svg>
|