@nucel/ui 0.3.0 → 0.11.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/package.json +27 -34
- package/src/lib/components/BottomSheet.svelte +96 -0
- package/src/lib/components/Breadcrumbs.svelte +57 -0
- package/src/lib/components/Checkbox.svelte +64 -0
- package/src/lib/components/CodeBlock.svelte +264 -0
- package/src/lib/components/CodeEditor.svelte +175 -0
- package/src/lib/components/ColorInput.svelte +41 -0
- package/src/lib/components/ColorInput.test.ts +126 -0
- package/src/lib/components/Combobox.svelte +103 -0
- package/src/lib/components/CommandPalette.svelte +135 -0
- package/src/lib/components/CopyButton.svelte +95 -0
- package/src/lib/components/CopyButton.test.ts +213 -0
- package/src/lib/components/DataTable.svelte +202 -0
- package/src/lib/components/DateRangePicker.svelte +185 -0
- package/src/lib/components/DiffEditor.svelte +174 -0
- package/src/lib/components/Drawer.svelte +69 -0
- package/src/lib/components/Fab.svelte +59 -0
- package/src/lib/components/Form.svelte +38 -0
- package/src/lib/components/FormField.svelte +51 -0
- package/src/lib/components/IconButton.svelte +86 -0
- package/src/lib/components/IconButton.test.ts +139 -0
- package/src/lib/components/InlineCode.svelte +28 -0
- package/src/lib/components/Pagination.svelte +65 -0
- package/src/lib/components/Radio.svelte +60 -0
- package/src/lib/components/RadioGroup.svelte +26 -0
- package/src/lib/components/SearchInput.svelte +77 -0
- package/src/lib/components/Skeleton.svelte +76 -0
- package/src/lib/components/StatCard.svelte +97 -0
- package/src/lib/components/ThemeProvider.svelte +157 -0
- package/src/lib/components/ThemeToggle.svelte +68 -0
- package/src/lib/components/ThreeWayMerge.svelte +185 -0
- package/src/lib/components/ui/Alert.svelte +1 -1
- package/src/lib/components/ui/MarkdownRenderer.svelte +126 -8
- package/src/lib/components/ui/Sparkline.svelte +1 -1
- package/src/lib/components/ui/StatusBadge.svelte +6 -3
- package/src/lib/components/ui/StatusDot.svelte +3 -3
- package/src/lib/components/ui/table/Table.svelte +1 -1
- package/src/lib/components/ui/table/TableBody.svelte +1 -1
- package/src/lib/components/ui/table/TableCaption.svelte +1 -1
- package/src/lib/components/ui/table/TableCell.svelte +1 -1
- package/src/lib/components/ui/table/TableHead.svelte +1 -1
- package/src/lib/components/ui/table/TableHeader.svelte +1 -1
- package/src/lib/components/ui/table/TableRow.svelte +1 -1
- package/src/lib/index.ts +161 -61
- package/src/lib/utils/cn.test.ts +993 -0
- package/src/lib/utils/detectLanguage.ts +187 -0
- package/src/lib/utils/monaco-workers.d.ts +32 -0
- package/src/lib/utils/monacoLoader.ts +167 -0
- package/src/lib/utils/shikiHighlighter.ts +78 -0
- package/src/styles.css +100 -32
- package/src/lib/components/ui/AppShell.svelte +0 -14
- package/src/lib/components/ui/AppSidebar.svelte +0 -45
- package/src/lib/components/ui/CodeBlock.svelte +0 -92
- package/src/lib/components/ui/CopyButton.svelte +0 -43
- package/src/lib/components/ui/CostDisplay.svelte +0 -26
- package/src/lib/components/ui/FilterBar.svelte +0 -63
- package/src/lib/components/ui/FormField.svelte +0 -34
- package/src/lib/components/ui/MetricCard.svelte +0 -79
- package/src/lib/components/ui/NavItem.svelte +0 -42
- package/src/lib/components/ui/NavSection.svelte +0 -17
- package/src/lib/components/ui/Pagination.svelte +0 -85
- package/src/lib/components/ui/StatCard.svelte +0 -19
- package/src/lib/components/ui/Timeline.svelte +0 -85
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Pagination as PaginationPrimitive } from 'bits-ui';
|
|
3
|
+
import { ChevronLeftIcon, ChevronRightIcon } from '@lucide/svelte';
|
|
4
|
+
import { cn } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
let {
|
|
7
|
+
total,
|
|
8
|
+
page = $bindable(1),
|
|
9
|
+
pageSize = 10,
|
|
10
|
+
siblingCount = 1,
|
|
11
|
+
class: className,
|
|
12
|
+
ariaLabel = 'pagination',
|
|
13
|
+
}: {
|
|
14
|
+
total: number;
|
|
15
|
+
page?: number;
|
|
16
|
+
pageSize?: number;
|
|
17
|
+
siblingCount?: number;
|
|
18
|
+
class?: string;
|
|
19
|
+
ariaLabel?: string;
|
|
20
|
+
} = $props();
|
|
21
|
+
|
|
22
|
+
const buttonBase =
|
|
23
|
+
'inline-flex h-9 min-w-9 items-center justify-center gap-1.5 rounded-md border border-transparent px-3 text-sm font-medium transition-colors outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50';
|
|
24
|
+
const navClass = `${buttonBase} hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50`;
|
|
25
|
+
const pageClass = `${buttonBase} hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50`;
|
|
26
|
+
const pageSelectedClass = `${buttonBase} bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs`;
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<PaginationPrimitive.Root
|
|
30
|
+
count={total}
|
|
31
|
+
perPage={pageSize}
|
|
32
|
+
bind:page
|
|
33
|
+
{siblingCount}
|
|
34
|
+
aria-label={ariaLabel}
|
|
35
|
+
data-slot="pagination"
|
|
36
|
+
class={cn('flex items-center justify-center gap-1', className)}
|
|
37
|
+
>
|
|
38
|
+
{#snippet children({ pages, currentPage })}
|
|
39
|
+
<PaginationPrimitive.PrevButton class={navClass} aria-label="Previous page">
|
|
40
|
+
<ChevronLeftIcon class="size-4" />
|
|
41
|
+
<span class="hidden sm:inline">Prev</span>
|
|
42
|
+
</PaginationPrimitive.PrevButton>
|
|
43
|
+
{#each pages as p (p.key)}
|
|
44
|
+
{#if p.type === 'ellipsis'}
|
|
45
|
+
<span
|
|
46
|
+
class="text-muted-foreground inline-flex h-9 min-w-9 items-center justify-center text-sm"
|
|
47
|
+
aria-hidden="true">…</span
|
|
48
|
+
>
|
|
49
|
+
{:else}
|
|
50
|
+
<PaginationPrimitive.Page
|
|
51
|
+
page={p}
|
|
52
|
+
class={p.value === currentPage ? pageSelectedClass : pageClass}
|
|
53
|
+
aria-label={`Page ${p.value}`}
|
|
54
|
+
aria-current={p.value === currentPage ? 'page' : undefined}
|
|
55
|
+
>
|
|
56
|
+
{p.value}
|
|
57
|
+
</PaginationPrimitive.Page>
|
|
58
|
+
{/if}
|
|
59
|
+
{/each}
|
|
60
|
+
<PaginationPrimitive.NextButton class={navClass} aria-label="Next page">
|
|
61
|
+
<span class="hidden sm:inline">Next</span>
|
|
62
|
+
<ChevronRightIcon class="size-4" />
|
|
63
|
+
</PaginationPrimitive.NextButton>
|
|
64
|
+
{/snippet}
|
|
65
|
+
</PaginationPrimitive.Root>
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
|
|
3
|
+
import { CircleIcon } from '@lucide/svelte';
|
|
4
|
+
import { cn, type WithoutChildrenOrChild } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
type Props = WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> & {
|
|
7
|
+
label?: string;
|
|
8
|
+
class?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
let {
|
|
12
|
+
ref = $bindable(null),
|
|
13
|
+
value,
|
|
14
|
+
disabled,
|
|
15
|
+
label,
|
|
16
|
+
class: className,
|
|
17
|
+
id,
|
|
18
|
+
...restProps
|
|
19
|
+
}: Props = $props();
|
|
20
|
+
|
|
21
|
+
const uid = $props.id();
|
|
22
|
+
const itemId = $derived(id ?? `radio-${uid}`);
|
|
23
|
+
|
|
24
|
+
const dotClass =
|
|
25
|
+
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 inline-flex aspect-square size-4 shrink-0 items-center justify-center rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50';
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
{#snippet dot()}
|
|
29
|
+
<RadioGroupPrimitive.Item
|
|
30
|
+
bind:ref
|
|
31
|
+
id={itemId}
|
|
32
|
+
{value}
|
|
33
|
+
{disabled}
|
|
34
|
+
data-slot="radio-group-item"
|
|
35
|
+
class={cn(dotClass, !label && className)}
|
|
36
|
+
{...restProps}
|
|
37
|
+
>
|
|
38
|
+
{#snippet children({ checked })}
|
|
39
|
+
{#if checked}
|
|
40
|
+
<CircleIcon class="fill-primary text-primary size-2" />
|
|
41
|
+
{/if}
|
|
42
|
+
{/snippet}
|
|
43
|
+
</RadioGroupPrimitive.Item>
|
|
44
|
+
{/snippet}
|
|
45
|
+
|
|
46
|
+
{#if label}
|
|
47
|
+
<label
|
|
48
|
+
for={itemId}
|
|
49
|
+
class={cn(
|
|
50
|
+
'inline-flex items-center gap-2 text-sm leading-none font-medium select-none',
|
|
51
|
+
disabled && 'cursor-not-allowed opacity-50',
|
|
52
|
+
className,
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
{@render dot()}
|
|
56
|
+
<span>{label}</span>
|
|
57
|
+
</label>
|
|
58
|
+
{:else}
|
|
59
|
+
{@render dot()}
|
|
60
|
+
{/if}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { RadioGroup as RadioGroupPrimitive } from 'bits-ui';
|
|
3
|
+
import { cn, type WithoutChild } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
type Props = WithoutChild<RadioGroupPrimitive.RootProps> & {
|
|
6
|
+
class?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
let {
|
|
10
|
+
ref = $bindable(null),
|
|
11
|
+
value = $bindable(''),
|
|
12
|
+
class: className,
|
|
13
|
+
children,
|
|
14
|
+
...restProps
|
|
15
|
+
}: Props = $props();
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<RadioGroupPrimitive.Root
|
|
19
|
+
bind:ref
|
|
20
|
+
bind:value
|
|
21
|
+
data-slot="radio-group"
|
|
22
|
+
class={cn('grid gap-2', className)}
|
|
23
|
+
{...restProps}
|
|
24
|
+
>
|
|
25
|
+
{@render children?.()}
|
|
26
|
+
</RadioGroupPrimitive.Root>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
3
|
+
import { SearchIcon, XIcon } from '@lucide/svelte';
|
|
4
|
+
import Input from './ui/input/input.svelte';
|
|
5
|
+
import { cn } from '../utils.js';
|
|
6
|
+
|
|
7
|
+
type Props = Omit<HTMLInputAttributes, 'type' | 'value'> & {
|
|
8
|
+
value?: string;
|
|
9
|
+
debounce?: number;
|
|
10
|
+
clearable?: boolean;
|
|
11
|
+
class?: string;
|
|
12
|
+
onDebounced?: (value: string) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
value = $bindable(''),
|
|
17
|
+
debounce = 300,
|
|
18
|
+
clearable = true,
|
|
19
|
+
class: className,
|
|
20
|
+
placeholder = 'Search…',
|
|
21
|
+
onDebounced,
|
|
22
|
+
...restProps
|
|
23
|
+
}: Props = $props();
|
|
24
|
+
|
|
25
|
+
let inner = $state(value ?? '');
|
|
26
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
27
|
+
|
|
28
|
+
// Keep inner in sync if value is updated externally
|
|
29
|
+
$effect(() => {
|
|
30
|
+
if (value !== inner) inner = value ?? '';
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
function commit(next: string) {
|
|
34
|
+
value = next;
|
|
35
|
+
onDebounced?.(next);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function onInput(e: Event) {
|
|
39
|
+
const next = (e.currentTarget as HTMLInputElement).value;
|
|
40
|
+
inner = next;
|
|
41
|
+
if (timer) clearTimeout(timer);
|
|
42
|
+
timer = setTimeout(() => commit(next), debounce);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function clear() {
|
|
46
|
+
inner = '';
|
|
47
|
+
if (timer) clearTimeout(timer);
|
|
48
|
+
commit('');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Avoid forwarding the `files` typing onto a text input
|
|
52
|
+
const inputProps = $derived(restProps as Record<string, unknown>);
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<div data-slot="search-input" class={cn('relative w-full', className)}>
|
|
56
|
+
<SearchIcon
|
|
57
|
+
class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
|
|
58
|
+
/>
|
|
59
|
+
<Input
|
|
60
|
+
type="text"
|
|
61
|
+
value={inner}
|
|
62
|
+
oninput={onInput}
|
|
63
|
+
{placeholder}
|
|
64
|
+
class="ps-9 pe-9"
|
|
65
|
+
{...inputProps}
|
|
66
|
+
/>
|
|
67
|
+
{#if clearable && inner}
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onclick={clear}
|
|
71
|
+
aria-label="Clear search"
|
|
72
|
+
class="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2 rounded-md p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
|
73
|
+
>
|
|
74
|
+
<XIcon class="size-3.5" />
|
|
75
|
+
</button>
|
|
76
|
+
{/if}
|
|
77
|
+
</div>
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
|
+
import { cn, type WithElementRef } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
type Props = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
6
|
+
width?: string | number;
|
|
7
|
+
height?: string | number;
|
|
8
|
+
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'full';
|
|
9
|
+
shimmer?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let {
|
|
13
|
+
ref = $bindable(null),
|
|
14
|
+
width,
|
|
15
|
+
height,
|
|
16
|
+
rounded = 'md',
|
|
17
|
+
shimmer = true,
|
|
18
|
+
class: className,
|
|
19
|
+
style: styleProp,
|
|
20
|
+
...restProps
|
|
21
|
+
}: Props = $props();
|
|
22
|
+
|
|
23
|
+
function toCssLength(v: string | number | undefined): string | undefined {
|
|
24
|
+
if (v === undefined) return undefined;
|
|
25
|
+
return typeof v === 'number' ? `${v}px` : v;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const inlineStyle = $derived.by(() => {
|
|
29
|
+
const parts: string[] = [];
|
|
30
|
+
const w = toCssLength(width);
|
|
31
|
+
const h = toCssLength(height);
|
|
32
|
+
if (w) parts.push(`width:${w}`);
|
|
33
|
+
if (h) parts.push(`height:${h}`);
|
|
34
|
+
if (styleProp) parts.push(String(styleProp));
|
|
35
|
+
return parts.length ? parts.join(';') : undefined;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const roundedClass = $derived.by(() => {
|
|
39
|
+
switch (rounded) {
|
|
40
|
+
case 'none':
|
|
41
|
+
return 'rounded-none';
|
|
42
|
+
case 'sm':
|
|
43
|
+
return 'rounded-sm';
|
|
44
|
+
case 'lg':
|
|
45
|
+
return 'rounded-lg';
|
|
46
|
+
case 'full':
|
|
47
|
+
return 'rounded-full';
|
|
48
|
+
case 'md':
|
|
49
|
+
default:
|
|
50
|
+
return 'rounded-md';
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<div
|
|
56
|
+
bind:this={ref}
|
|
57
|
+
data-slot="skeleton"
|
|
58
|
+
style={inlineStyle}
|
|
59
|
+
class={cn(
|
|
60
|
+
'bg-accent relative overflow-hidden',
|
|
61
|
+
shimmer ? 'animate-pulse' : '',
|
|
62
|
+
roundedClass,
|
|
63
|
+
shimmer &&
|
|
64
|
+
"after:absolute after:inset-0 after:-translate-x-full after:animate-[shimmer_1.8s_infinite] after:bg-gradient-to-r after:from-transparent after:via-black/5 after:to-transparent dark:after:via-white/10 after:content-['']",
|
|
65
|
+
className,
|
|
66
|
+
)}
|
|
67
|
+
{...restProps}
|
|
68
|
+
></div>
|
|
69
|
+
|
|
70
|
+
<style>
|
|
71
|
+
@keyframes shimmer {
|
|
72
|
+
100% {
|
|
73
|
+
transform: translateX(100%);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
</style>
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Component, Snippet } from 'svelte';
|
|
3
|
+
import { ArrowDownIcon, ArrowUpIcon, MinusIcon } from '@lucide/svelte';
|
|
4
|
+
import { cn } from '../utils.js';
|
|
5
|
+
|
|
6
|
+
type Trend = 'up' | 'down' | 'flat';
|
|
7
|
+
|
|
8
|
+
let {
|
|
9
|
+
label,
|
|
10
|
+
value,
|
|
11
|
+
delta,
|
|
12
|
+
trend,
|
|
13
|
+
icon,
|
|
14
|
+
hint,
|
|
15
|
+
invertTrend = false,
|
|
16
|
+
class: className,
|
|
17
|
+
children,
|
|
18
|
+
}: {
|
|
19
|
+
label: string;
|
|
20
|
+
value: string | number;
|
|
21
|
+
delta?: number;
|
|
22
|
+
trend?: Trend;
|
|
23
|
+
icon?: Component<{ class?: string }>;
|
|
24
|
+
hint?: string;
|
|
25
|
+
invertTrend?: boolean;
|
|
26
|
+
class?: string;
|
|
27
|
+
children?: Snippet;
|
|
28
|
+
} = $props();
|
|
29
|
+
|
|
30
|
+
const computedTrend = $derived<Trend>(
|
|
31
|
+
trend ?? (delta === undefined ? 'flat' : delta > 0 ? 'up' : delta < 0 ? 'down' : 'flat'),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const trendIsGood = $derived(
|
|
35
|
+
computedTrend === 'flat'
|
|
36
|
+
? null
|
|
37
|
+
: invertTrend
|
|
38
|
+
? computedTrend === 'down'
|
|
39
|
+
: computedTrend === 'up',
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const trendColor = $derived.by(() => {
|
|
43
|
+
if (computedTrend === 'flat') return 'text-muted-foreground';
|
|
44
|
+
return trendIsGood
|
|
45
|
+
? 'text-emerald-600 dark:text-emerald-400'
|
|
46
|
+
: 'text-destructive';
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const formattedDelta = $derived.by(() => {
|
|
50
|
+
if (delta === undefined) return null;
|
|
51
|
+
const sign = delta > 0 ? '+' : '';
|
|
52
|
+
return `${sign}${delta}%`;
|
|
53
|
+
});
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<div
|
|
57
|
+
data-slot="stat-card"
|
|
58
|
+
class={cn(
|
|
59
|
+
'bg-card text-card-foreground border-border flex flex-col gap-2 rounded-xl border p-4 shadow-xs',
|
|
60
|
+
className,
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
<div class="flex items-start justify-between gap-2">
|
|
64
|
+
<p class="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
|
65
|
+
{label}
|
|
66
|
+
</p>
|
|
67
|
+
{#if icon}
|
|
68
|
+
{@const Icon = icon}
|
|
69
|
+
<div
|
|
70
|
+
class="bg-secondary text-muted-foreground flex h-7 w-7 items-center justify-center rounded-md"
|
|
71
|
+
>
|
|
72
|
+
<Icon class="size-3.5" />
|
|
73
|
+
</div>
|
|
74
|
+
{/if}
|
|
75
|
+
</div>
|
|
76
|
+
<div class="flex items-baseline gap-2">
|
|
77
|
+
<span class="text-foreground text-2xl font-semibold tabular-nums">{value}</span>
|
|
78
|
+
{#if delta !== undefined || trend}
|
|
79
|
+
<span class={cn('inline-flex items-center gap-0.5 text-xs font-medium', trendColor)}>
|
|
80
|
+
{#if computedTrend === 'up'}
|
|
81
|
+
<ArrowUpIcon class="size-3" />
|
|
82
|
+
{:else if computedTrend === 'down'}
|
|
83
|
+
<ArrowDownIcon class="size-3" />
|
|
84
|
+
{:else}
|
|
85
|
+
<MinusIcon class="size-3" />
|
|
86
|
+
{/if}
|
|
87
|
+
{#if formattedDelta}{formattedDelta}{/if}
|
|
88
|
+
</span>
|
|
89
|
+
{/if}
|
|
90
|
+
</div>
|
|
91
|
+
{#if hint}
|
|
92
|
+
<p class="text-muted-foreground text-xs">{hint}</p>
|
|
93
|
+
{/if}
|
|
94
|
+
{#if children}
|
|
95
|
+
{@render children()}
|
|
96
|
+
{/if}
|
|
97
|
+
</div>
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import { getContext, setContext } from 'svelte';
|
|
3
|
+
|
|
4
|
+
export type Theme = 'light' | 'dark' | 'system';
|
|
5
|
+
export type ResolvedTheme = 'light' | 'dark';
|
|
6
|
+
|
|
7
|
+
export type ThemeContext = {
|
|
8
|
+
/** The user's preference: 'light' | 'dark' | 'system'. */
|
|
9
|
+
readonly theme: Theme;
|
|
10
|
+
/** The currently applied theme after resolving 'system'. Always 'light' or 'dark'. */
|
|
11
|
+
readonly resolved: ResolvedTheme;
|
|
12
|
+
/** Update the user's preference. Persists to localStorage. */
|
|
13
|
+
setTheme: (t: Theme) => void;
|
|
14
|
+
/** Cycle system -> light -> dark -> system. */
|
|
15
|
+
cycleTheme: () => void;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const THEME_CONTEXT_KEY = Symbol('@nucel/ui:theme');
|
|
19
|
+
|
|
20
|
+
/** Read the ThemeProvider context from a descendant component. */
|
|
21
|
+
export function getThemeContext(): ThemeContext {
|
|
22
|
+
const ctx = getContext<ThemeContext | undefined>(THEME_CONTEXT_KEY);
|
|
23
|
+
if (!ctx) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
'[@nucel/ui] getThemeContext() called outside a <ThemeProvider>. Wrap your app in <ThemeProvider> first.',
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
return ctx;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function _setThemeContext(ctx: ThemeContext): void {
|
|
32
|
+
setContext(THEME_CONTEXT_KEY, ctx);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export { _setThemeContext };
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<script lang="ts">
|
|
39
|
+
import type { Snippet } from 'svelte';
|
|
40
|
+
import { onMount, untrack } from 'svelte';
|
|
41
|
+
|
|
42
|
+
type Props = {
|
|
43
|
+
/** Initial theme preference. Defaults to 'system'. */
|
|
44
|
+
defaultTheme?: Theme;
|
|
45
|
+
/** localStorage key used for persistence. */
|
|
46
|
+
storageKey?: string;
|
|
47
|
+
/** Class to apply to the html element for dark mode. Default 'dark'. */
|
|
48
|
+
darkClass?: string;
|
|
49
|
+
/** Element/attribute target. Default 'html'. */
|
|
50
|
+
attribute?: 'html' | 'body';
|
|
51
|
+
children?: Snippet;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let {
|
|
55
|
+
defaultTheme = 'system',
|
|
56
|
+
storageKey = 'nucel-ui-theme',
|
|
57
|
+
darkClass = 'dark',
|
|
58
|
+
attribute = 'html',
|
|
59
|
+
children,
|
|
60
|
+
}: Props = $props();
|
|
61
|
+
|
|
62
|
+
function isTheme(v: unknown): v is Theme {
|
|
63
|
+
return v === 'light' || v === 'dark' || v === 'system';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readStoredTheme(): Theme {
|
|
67
|
+
if (typeof localStorage === 'undefined') return defaultTheme;
|
|
68
|
+
try {
|
|
69
|
+
const raw = localStorage.getItem(storageKey);
|
|
70
|
+
return isTheme(raw) ? raw : defaultTheme;
|
|
71
|
+
} catch {
|
|
72
|
+
return defaultTheme;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getSystemPreference(): ResolvedTheme {
|
|
77
|
+
if (typeof window === 'undefined' || !window.matchMedia) return 'light';
|
|
78
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Initial value: capture the prop once. Subsequent changes flow through `setTheme`.
|
|
82
|
+
// eslint-disable-next-line svelte/no-reactive-reassign
|
|
83
|
+
let theme = $state<Theme>(untrack(() => defaultTheme));
|
|
84
|
+
let systemPref = $state<ResolvedTheme>('light');
|
|
85
|
+
|
|
86
|
+
const resolved = $derived<ResolvedTheme>(theme === 'system' ? systemPref : theme);
|
|
87
|
+
|
|
88
|
+
onMount(() => {
|
|
89
|
+
// Hydrate from localStorage on mount (avoids SSR mismatch).
|
|
90
|
+
theme = readStoredTheme();
|
|
91
|
+
systemPref = getSystemPreference();
|
|
92
|
+
|
|
93
|
+
const mql = window.matchMedia?.('(prefers-color-scheme: dark)');
|
|
94
|
+
if (!mql) return;
|
|
95
|
+
|
|
96
|
+
const onChange = (e: MediaQueryListEvent) => {
|
|
97
|
+
systemPref = e.matches ? 'dark' : 'light';
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Modern API + Safari fallback.
|
|
101
|
+
if (typeof mql.addEventListener === 'function') {
|
|
102
|
+
mql.addEventListener('change', onChange);
|
|
103
|
+
return () => mql.removeEventListener('change', onChange);
|
|
104
|
+
} else if (typeof (mql as MediaQueryList).addListener === 'function') {
|
|
105
|
+
(mql as MediaQueryList).addListener(onChange);
|
|
106
|
+
return () => (mql as MediaQueryList).removeListener(onChange);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Apply the resolved theme to the DOM whenever it changes.
|
|
111
|
+
$effect(() => {
|
|
112
|
+
if (typeof document === 'undefined') return;
|
|
113
|
+
const target = attribute === 'body' ? document.body : document.documentElement;
|
|
114
|
+
if (!target) return;
|
|
115
|
+
if (resolved === 'dark') {
|
|
116
|
+
target.classList.add(darkClass);
|
|
117
|
+
target.style.colorScheme = 'dark';
|
|
118
|
+
} else {
|
|
119
|
+
target.classList.remove(darkClass);
|
|
120
|
+
target.style.colorScheme = 'light';
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
function setTheme(next: Theme) {
|
|
125
|
+
theme = next;
|
|
126
|
+
try {
|
|
127
|
+
if (typeof localStorage !== 'undefined') {
|
|
128
|
+
localStorage.setItem(storageKey, next);
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// localStorage unavailable (private mode / quota); silently ignore.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function cycleTheme() {
|
|
136
|
+
// system -> light -> dark -> system
|
|
137
|
+
const next: Theme = theme === 'system' ? 'light' : theme === 'light' ? 'dark' : 'system';
|
|
138
|
+
setTheme(next);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Publish a stable context object whose getters always read the latest reactive state.
|
|
142
|
+
_setThemeContext({
|
|
143
|
+
get theme() {
|
|
144
|
+
return theme;
|
|
145
|
+
},
|
|
146
|
+
get resolved() {
|
|
147
|
+
return resolved;
|
|
148
|
+
},
|
|
149
|
+
setTheme,
|
|
150
|
+
cycleTheme,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
</script>
|
|
154
|
+
|
|
155
|
+
{#if children}
|
|
156
|
+
{@render children()}
|
|
157
|
+
{/if}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLButtonAttributes } from 'svelte/elements';
|
|
3
|
+
import { MoonIcon, SunIcon, MonitorIcon } from '@lucide/svelte';
|
|
4
|
+
import { cn } from '../utils.js';
|
|
5
|
+
import { getThemeContext } from './ThemeProvider.svelte';
|
|
6
|
+
|
|
7
|
+
type Props = HTMLButtonAttributes & {
|
|
8
|
+
/** Visual size variant. */
|
|
9
|
+
size?: 'sm' | 'md' | 'lg';
|
|
10
|
+
/** Override accessible label. */
|
|
11
|
+
label?: string;
|
|
12
|
+
class?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
size = 'md',
|
|
17
|
+
label,
|
|
18
|
+
class: className,
|
|
19
|
+
onclick,
|
|
20
|
+
...restProps
|
|
21
|
+
}: Props = $props();
|
|
22
|
+
|
|
23
|
+
const ctx = getThemeContext();
|
|
24
|
+
|
|
25
|
+
const sizeClass = $derived(
|
|
26
|
+
size === 'sm' ? 'h-7 w-7' : size === 'lg' ? 'h-10 w-10' : 'h-9 w-9',
|
|
27
|
+
);
|
|
28
|
+
const iconClass = $derived(
|
|
29
|
+
size === 'sm' ? 'size-3.5' : size === 'lg' ? 'size-5' : 'size-4',
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const ariaLabel = $derived(
|
|
33
|
+
label ??
|
|
34
|
+
(ctx.theme === 'system'
|
|
35
|
+
? 'Theme: system. Click to switch to light.'
|
|
36
|
+
: ctx.theme === 'light'
|
|
37
|
+
? 'Theme: light. Click to switch to dark.'
|
|
38
|
+
: 'Theme: dark. Click to switch to system.'),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
function handleClick(e: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) {
|
|
42
|
+
ctx.cycleTheme();
|
|
43
|
+
onclick?.(e);
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
aria-label={ariaLabel}
|
|
50
|
+
title={ariaLabel}
|
|
51
|
+
data-theme={ctx.theme}
|
|
52
|
+
data-resolved-theme={ctx.resolved}
|
|
53
|
+
onclick={handleClick}
|
|
54
|
+
class={cn(
|
|
55
|
+
'border-border bg-background hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring/50 inline-flex shrink-0 items-center justify-center rounded-md border text-sm transition-colors outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
|
|
56
|
+
sizeClass,
|
|
57
|
+
className,
|
|
58
|
+
)}
|
|
59
|
+
{...restProps}
|
|
60
|
+
>
|
|
61
|
+
{#if ctx.theme === 'system'}
|
|
62
|
+
<MonitorIcon class={iconClass} aria-hidden="true" />
|
|
63
|
+
{:else if ctx.theme === 'light'}
|
|
64
|
+
<SunIcon class={iconClass} aria-hidden="true" />
|
|
65
|
+
{:else}
|
|
66
|
+
<MoonIcon class={iconClass} aria-hidden="true" />
|
|
67
|
+
{/if}
|
|
68
|
+
</button>
|