@nucel/ui 0.3.0 → 0.10.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.
Files changed (78) hide show
  1. package/package.json +8 -36
  2. package/src/lib/components/BottomSheet.svelte +96 -0
  3. package/src/lib/components/Breadcrumbs.svelte +57 -0
  4. package/src/lib/components/Checkbox.svelte +64 -0
  5. package/src/lib/components/CodeBlock.svelte +264 -0
  6. package/src/lib/components/CodeEditor.svelte +175 -0
  7. package/src/lib/components/ColorInput.svelte +41 -0
  8. package/src/lib/components/ColorInput.test.ts +126 -0
  9. package/src/lib/components/Combobox.svelte +103 -0
  10. package/src/lib/components/CommandPalette.svelte +135 -0
  11. package/src/lib/components/CopyButton.svelte +95 -0
  12. package/src/lib/components/CopyButton.test.ts +213 -0
  13. package/src/lib/components/DataTable.svelte +202 -0
  14. package/src/lib/components/DateRangePicker.svelte +185 -0
  15. package/src/lib/components/DiffEditor.svelte +174 -0
  16. package/src/lib/components/Drawer.svelte +69 -0
  17. package/src/lib/components/Fab.svelte +59 -0
  18. package/src/lib/components/Form.svelte +38 -0
  19. package/src/lib/components/FormField.svelte +51 -0
  20. package/src/lib/components/IconButton.svelte +86 -0
  21. package/src/lib/components/IconButton.test.ts +139 -0
  22. package/src/lib/components/InlineCode.svelte +28 -0
  23. package/src/lib/components/Pagination.svelte +65 -0
  24. package/src/lib/components/Radio.svelte +60 -0
  25. package/src/lib/components/RadioGroup.svelte +26 -0
  26. package/src/lib/components/SearchInput.svelte +77 -0
  27. package/src/lib/components/Skeleton.svelte +76 -0
  28. package/src/lib/components/StatCard.svelte +97 -0
  29. package/src/lib/components/ThemeProvider.svelte +157 -0
  30. package/src/lib/components/ThemeToggle.svelte +68 -0
  31. package/src/lib/components/ThreeWayMerge.svelte +185 -0
  32. package/src/lib/components/ui/MarkdownRenderer.svelte +126 -8
  33. package/src/lib/components/ui/Sparkline.svelte +1 -1
  34. package/src/lib/components/ui/StatusBadge.svelte +6 -3
  35. package/src/lib/components/ui/StatusDot.svelte +3 -3
  36. package/src/lib/index.ts +113 -63
  37. package/src/lib/utils/cn.test.ts +993 -0
  38. package/src/lib/utils/detectLanguage.ts +187 -0
  39. package/src/lib/utils/monaco-workers.d.ts +32 -0
  40. package/src/lib/utils/monacoLoader.ts +167 -0
  41. package/src/lib/utils/shikiHighlighter.ts +78 -0
  42. package/src/styles.css +100 -32
  43. package/src/lib/components/ui/Alert.svelte +0 -47
  44. package/src/lib/components/ui/AppCard.svelte +0 -76
  45. package/src/lib/components/ui/AppShell.svelte +0 -14
  46. package/src/lib/components/ui/AppSidebar.svelte +0 -45
  47. package/src/lib/components/ui/BranchPill.svelte +0 -19
  48. package/src/lib/components/ui/CodeBlock.svelte +0 -92
  49. package/src/lib/components/ui/CommentPill.svelte +0 -12
  50. package/src/lib/components/ui/CopyButton.svelte +0 -43
  51. package/src/lib/components/ui/CostDisplay.svelte +0 -26
  52. package/src/lib/components/ui/FilterBar.svelte +0 -63
  53. package/src/lib/components/ui/FormField.svelte +0 -34
  54. package/src/lib/components/ui/KanbanBoard.svelte +0 -27
  55. package/src/lib/components/ui/KanbanCard.svelte +0 -43
  56. package/src/lib/components/ui/KanbanColumn.svelte +0 -52
  57. package/src/lib/components/ui/ListCard.svelte +0 -9
  58. package/src/lib/components/ui/MetricCard.svelte +0 -79
  59. package/src/lib/components/ui/NavItem.svelte +0 -42
  60. package/src/lib/components/ui/NavSection.svelte +0 -17
  61. package/src/lib/components/ui/PageHeader.svelte +0 -25
  62. package/src/lib/components/ui/Pagination.svelte +0 -85
  63. package/src/lib/components/ui/PermissionChips.svelte +0 -49
  64. package/src/lib/components/ui/Section.svelte +0 -21
  65. package/src/lib/components/ui/SectionTitle.svelte +0 -16
  66. package/src/lib/components/ui/StatCard.svelte +0 -19
  67. package/src/lib/components/ui/StatusPill.svelte +0 -54
  68. package/src/lib/components/ui/Timeline.svelte +0 -85
  69. package/src/lib/components/ui/editor/RichEditor.svelte +0 -580
  70. package/src/lib/components/ui/editor/mention-suggestion.ts +0 -144
  71. package/src/lib/components/ui/table/Table.svelte +0 -12
  72. package/src/lib/components/ui/table/TableBody.svelte +0 -10
  73. package/src/lib/components/ui/table/TableCaption.svelte +0 -10
  74. package/src/lib/components/ui/table/TableCell.svelte +0 -10
  75. package/src/lib/components/ui/table/TableHead.svelte +0 -10
  76. package/src/lib/components/ui/table/TableHeader.svelte +0 -10
  77. package/src/lib/components/ui/table/TableRow.svelte +0 -10
  78. package/src/lib/components/ui/table/index.ts +0 -7
@@ -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>