@nucel/ui 0.2.0 → 0.3.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nucel/ui",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A comprehensive Svelte 5 UI component library for Nucel projects",
5
5
  "type": "module",
6
6
  "svelte": "./src/lib/index.ts",
@@ -13,6 +13,9 @@
13
13
  },
14
14
  "files": [
15
15
  "src/lib",
16
+ "!src/**/*.test.ts",
17
+ "!src/**/*.spec.ts",
18
+ "!src/tests",
16
19
  "src/styles.css"
17
20
  ],
18
21
  "scripts": {
@@ -71,6 +74,7 @@
71
74
  "clsx": "^2.1.1",
72
75
  "dompurify": "^3.3.3",
73
76
  "marked": "^17.0.5",
77
+ "shiki": "^4.0.2",
74
78
  "svelte-sonner": "^1.0.7",
75
79
  "tailwind-merge": "^3.5.0",
76
80
  "tailwind-variants": "^3.2.2",
@@ -0,0 +1,92 @@
1
+ <script lang="ts">
2
+ import { createHighlighter, type Highlighter } from 'shiki';
3
+ import CopyButton from './CopyButton.svelte';
4
+ import { cn } from '$lib/utils/cn.js';
5
+
6
+ let {
7
+ code,
8
+ language = 'plaintext',
9
+ showLanguage = true,
10
+ class: className,
11
+ }: {
12
+ code: string;
13
+ language?: string;
14
+ showLanguage?: boolean;
15
+ class?: string;
16
+ } = $props();
17
+
18
+ let html = $state('');
19
+ let highlighter: Highlighter | null = null;
20
+
21
+ const SUPPORTED_LANGS = [
22
+ 'typescript',
23
+ 'javascript',
24
+ 'svelte',
25
+ 'html',
26
+ 'css',
27
+ 'json',
28
+ 'bash',
29
+ 'shell',
30
+ 'python',
31
+ 'rust',
32
+ 'go',
33
+ 'plaintext',
34
+ 'sql',
35
+ 'yaml',
36
+ 'toml',
37
+ 'markdown',
38
+ ] as const;
39
+
40
+ async function highlight(src: string, lang: string) {
41
+ if (!highlighter) {
42
+ highlighter = await createHighlighter({
43
+ themes: ['github-dark'],
44
+ langs: SUPPORTED_LANGS as unknown as string[],
45
+ });
46
+ }
47
+ const safeLang = SUPPORTED_LANGS.includes(lang as (typeof SUPPORTED_LANGS)[number])
48
+ ? lang
49
+ : 'plaintext';
50
+ return highlighter.codeToHtml(src, { lang: safeLang, theme: 'github-dark' });
51
+ }
52
+
53
+ $effect(() => {
54
+ highlight(code, language)
55
+ .then((result) => {
56
+ html = result;
57
+ })
58
+ .catch(() => {
59
+ // Highlighting failed (e.g. in a test environment without WASM support).
60
+ // The component falls back to the plain <pre> display.
61
+ });
62
+ });
63
+ </script>
64
+
65
+ <div
66
+ class={cn(
67
+ 'relative overflow-hidden rounded-xl border border-border bg-[#0d1117] font-mono text-sm',
68
+ className,
69
+ )}
70
+ >
71
+ {#if showLanguage || true}
72
+ <div
73
+ class="flex items-center justify-between border-b border-white/8 px-4 py-2"
74
+ >
75
+ {#if showLanguage}
76
+ <span class="text-[11px] font-medium uppercase tracking-wide text-white/40">
77
+ {language}
78
+ </span>
79
+ {:else}
80
+ <span></span>
81
+ {/if}
82
+ <CopyButton text={code} size={14} class="text-white/40 hover:bg-white/8 hover:text-white/80" />
83
+ </div>
84
+ {/if}
85
+
86
+ {#if html}
87
+ <!-- eslint-disable-next-line svelte/no-at-html-tags -->
88
+ <div class="overflow-x-auto [&>pre]:p-4 [&>pre]:text-sm">{@html html}</div>
89
+ {:else}
90
+ <pre class="overflow-x-auto p-4 text-white/70">{code}</pre>
91
+ {/if}
92
+ </div>
@@ -0,0 +1,43 @@
1
+ <script lang="ts">
2
+ import { Copy, Check } from '@lucide/svelte';
3
+ import { cn } from '$lib/utils/cn.js';
4
+
5
+ let {
6
+ text,
7
+ size = 16,
8
+ class: className,
9
+ }: {
10
+ text: string;
11
+ size?: number;
12
+ class?: string;
13
+ } = $props();
14
+
15
+ let copied = $state(false);
16
+ let timer: ReturnType<typeof setTimeout> | null = null;
17
+
18
+ async function copy() {
19
+ await navigator.clipboard.writeText(text);
20
+ copied = true;
21
+ if (timer) clearTimeout(timer);
22
+ timer = setTimeout(() => {
23
+ copied = false;
24
+ timer = null;
25
+ }, 2000);
26
+ }
27
+ </script>
28
+
29
+ <button
30
+ type="button"
31
+ onclick={copy}
32
+ aria-label={copied ? 'Copied' : 'Copy to clipboard'}
33
+ class={cn(
34
+ 'inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
35
+ className,
36
+ )}
37
+ >
38
+ {#if copied}
39
+ <Check size={size} class="text-success" />
40
+ {:else}
41
+ <Copy size={size} />
42
+ {/if}
43
+ </button>
@@ -0,0 +1,63 @@
1
+ <script lang="ts">
2
+ import { Search, X } from '@lucide/svelte';
3
+ import { cn } from '$lib/utils/cn.js';
4
+
5
+ type Filter = {
6
+ key: string;
7
+ label: string;
8
+ value: string;
9
+ };
10
+
11
+ let {
12
+ value = '',
13
+ filters = [],
14
+ placeholder = 'Search…',
15
+ onchange,
16
+ onremove,
17
+ class: className,
18
+ }: {
19
+ value?: string;
20
+ filters?: Filter[];
21
+ placeholder?: string;
22
+ onchange?: (value: string) => void;
23
+ onremove?: (filter: Filter) => void;
24
+ class?: string;
25
+ } = $props();
26
+
27
+ function handleInput(e: Event) {
28
+ const target = e.currentTarget as HTMLInputElement;
29
+ onchange?.(target.value);
30
+ }
31
+ </script>
32
+
33
+ <div class={cn('flex flex-wrap items-center gap-2', className)}>
34
+ <!-- search input -->
35
+ <div class="relative flex min-w-[200px] items-center">
36
+ <Search size={14} class="absolute left-2.5 text-muted-foreground" />
37
+ <input
38
+ type="search"
39
+ {placeholder}
40
+ {value}
41
+ oninput={handleInput}
42
+ class="h-8 w-full rounded-md border border-border bg-background pl-8 pr-3 text-[13px] text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
43
+ />
44
+ </div>
45
+
46
+ <!-- filter chips -->
47
+ {#each filters as filter}
48
+ <span
49
+ class="inline-flex items-center gap-1 rounded-full border border-border bg-muted px-2.5 py-0.5 text-[12px] font-medium text-foreground"
50
+ >
51
+ <span class="text-muted-foreground">{filter.label}:</span>
52
+ <span>{filter.value}</span>
53
+ <button
54
+ type="button"
55
+ onclick={() => onremove?.(filter)}
56
+ aria-label="Remove {filter.label} filter"
57
+ class="ml-0.5 rounded-full p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
58
+ >
59
+ <X size={11} />
60
+ </button>
61
+ </span>
62
+ {/each}
63
+ </div>
@@ -0,0 +1,27 @@
1
+ <script lang="ts">
2
+ // Outer kanban container: a horizontally-scrollable row of columns plus
3
+ // an optional trailing slot for an "Add column" affordance.
4
+ //
5
+ // Composition: caller puts <KanbanColumn> elements (or anything else)
6
+ // into the default slot. DnD wiring stays out of this primitive — the
7
+ // consumer attaches dndzone to whichever container they want sortable.
8
+
9
+ import type { Snippet } from 'svelte';
10
+
11
+ let {
12
+ children,
13
+ trailing,
14
+ }: {
15
+ children: Snippet;
16
+ trailing?: Snippet;
17
+ } = $props();
18
+ </script>
19
+
20
+ <div class="flex items-start gap-3 overflow-x-auto pb-4">
21
+ {@render children()}
22
+ {#if trailing}
23
+ <div class="w-72 flex-shrink-0">
24
+ {@render trailing()}
25
+ </div>
26
+ {/if}
27
+ </div>
@@ -0,0 +1,43 @@
1
+ <script lang="ts">
2
+ // Minimal card for a kanban column. Title is required; body is optional
3
+ // single-paragraph supplementary text. `onDelete` adds a hover-revealed
4
+ // X button. `extra` is a snippet for arbitrary chrome (badges, avatars,
5
+ // etc.) under the body.
6
+
7
+ import { X } from '@lucide/svelte';
8
+ import type { Snippet } from 'svelte';
9
+
10
+ let {
11
+ title,
12
+ body = null,
13
+ onDelete,
14
+ extra,
15
+ }: {
16
+ title: string;
17
+ body?: string | null;
18
+ onDelete?: () => void;
19
+ extra?: Snippet;
20
+ } = $props();
21
+ </script>
22
+
23
+ <div class="group rounded-md border border-border bg-background p-2.5 text-sm shadow-sm">
24
+ <div class="flex items-start justify-between gap-1">
25
+ <span class="leading-snug">{title}</span>
26
+ {#if onDelete}
27
+ <button
28
+ type="button"
29
+ aria-label="Delete card"
30
+ class="opacity-0 transition-opacity group-hover:opacity-100"
31
+ onclick={onDelete}
32
+ >
33
+ <X class="h-3 w-3 text-muted-foreground hover:text-destructive" />
34
+ </button>
35
+ {/if}
36
+ </div>
37
+ {#if body}
38
+ <p class="mt-1 line-clamp-2 text-xs text-muted-foreground">{body}</p>
39
+ {/if}
40
+ {#if extra}
41
+ <div class="mt-1.5">{@render extra()}</div>
42
+ {/if}
43
+ </div>
@@ -0,0 +1,52 @@
1
+ <script lang="ts">
2
+ // A single kanban column: header (title + count + optional delete),
3
+ // a body snippet for cards (where the caller attaches a dndzone), and
4
+ // a footer snippet for the per-column "Add card" affordance.
5
+
6
+ import { X } from '@lucide/svelte';
7
+ import type { Snippet } from 'svelte';
8
+
9
+ let {
10
+ name,
11
+ count,
12
+ onDelete,
13
+ body,
14
+ footer,
15
+ }: {
16
+ name: string;
17
+ count: number;
18
+ /** When provided, renders a small delete-X in the header. */
19
+ onDelete?: () => void;
20
+ /** Sortable body — caller drops their dndzone-decorated container here. */
21
+ body: Snippet;
22
+ /** Optional footer (e.g. "+ Add card"). */
23
+ footer?: Snippet;
24
+ } = $props();
25
+ </script>
26
+
27
+ <div class="w-72 flex-shrink-0 rounded-lg bg-muted/50 p-3">
28
+ <div class="mb-2 flex items-center justify-between">
29
+ <h3 class="text-sm font-semibold">{name}</h3>
30
+ <div class="flex items-center gap-1">
31
+ <span class="rounded-full bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
32
+ {count}
33
+ </span>
34
+ {#if onDelete}
35
+ <button
36
+ type="button"
37
+ aria-label={`Delete column ${name}`}
38
+ class="rounded p-0.5 text-muted-foreground hover:bg-background hover:text-destructive"
39
+ onclick={onDelete}
40
+ >
41
+ <X class="h-3 w-3" />
42
+ </button>
43
+ {/if}
44
+ </div>
45
+ </div>
46
+
47
+ {@render body()}
48
+
49
+ {#if footer}
50
+ {@render footer()}
51
+ {/if}
52
+ </div>
@@ -0,0 +1,79 @@
1
+ <script lang="ts">
2
+ import { TrendingUp, TrendingDown, Minus } from '@lucide/svelte';
3
+ import Sparkline from './Sparkline.svelte';
4
+ import { cn } from '$lib/utils/cn.js';
5
+
6
+ type Trend = 'up' | 'down' | 'neutral';
7
+
8
+ let {
9
+ value,
10
+ label,
11
+ hint,
12
+ data = [],
13
+ trend,
14
+ class: className,
15
+ }: {
16
+ value: string | number;
17
+ label: string;
18
+ hint?: string;
19
+ data?: number[];
20
+ trend?: Trend;
21
+ class?: string;
22
+ } = $props();
23
+
24
+ const trendStyles: Record<Trend, string> = {
25
+ up: 'text-success',
26
+ down: 'text-destructive',
27
+ neutral: 'text-muted-foreground',
28
+ };
29
+
30
+ const trendSparkColor: Record<Trend, string> = {
31
+ up: 'stroke-success',
32
+ down: 'stroke-destructive',
33
+ neutral: 'stroke-muted-foreground',
34
+ };
35
+
36
+ const trendFillColor: Record<Trend, string> = {
37
+ up: 'fill-success/10',
38
+ down: 'fill-destructive/10',
39
+ neutral: 'fill-muted/30',
40
+ };
41
+ </script>
42
+
43
+ <div class={cn('rounded-xl border border-border bg-card p-4', className)}>
44
+ <div class="flex items-start justify-between gap-2">
45
+ <div class="min-w-0 flex-1">
46
+ <div class="text-xl font-semibold tracking-tight tabular-nums text-foreground">{value}</div>
47
+ <div class="mt-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
48
+ {label}
49
+ </div>
50
+ {#if hint}
51
+ <div
52
+ class={cn(
53
+ 'mt-1 flex items-center gap-1 text-[11px]',
54
+ trend ? trendStyles[trend] : 'text-muted-foreground/70',
55
+ )}
56
+ >
57
+ {#if trend === 'up'}
58
+ <TrendingUp size={11} />
59
+ {:else if trend === 'down'}
60
+ <TrendingDown size={11} />
61
+ {:else if trend === 'neutral'}
62
+ <Minus size={11} />
63
+ {/if}
64
+ {hint}
65
+ </div>
66
+ {/if}
67
+ </div>
68
+
69
+ {#if data.length > 0}
70
+ <Sparkline
71
+ {data}
72
+ color={trend ? trendSparkColor[trend] : 'stroke-primary'}
73
+ fillColor={trend ? trendFillColor[trend] : 'fill-primary/10'}
74
+ width={72}
75
+ height={28}
76
+ />
77
+ {/if}
78
+ </div>
79
+ </div>
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ import { ChevronLeft, ChevronRight } from '@lucide/svelte';
3
+ import { cn } from '$lib/utils/cn.js';
4
+
5
+ let {
6
+ page,
7
+ totalPages,
8
+ onchange,
9
+ class: className,
10
+ }: {
11
+ page: number;
12
+ totalPages: number;
13
+ onchange?: (page: number) => void;
14
+ class?: string;
15
+ } = $props();
16
+
17
+ function go(p: number) {
18
+ if (p < 1 || p > totalPages) return;
19
+ onchange?.(p);
20
+ }
21
+
22
+ const visiblePages = $derived.by(() => {
23
+ const pages: (number | '...')[] = [];
24
+ if (totalPages <= 7) {
25
+ for (let i = 1; i <= totalPages; i++) pages.push(i);
26
+ } else {
27
+ pages.push(1);
28
+ if (page > 3) pages.push('...');
29
+ for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) {
30
+ pages.push(i);
31
+ }
32
+ if (page < totalPages - 2) pages.push('...');
33
+ pages.push(totalPages);
34
+ }
35
+ return pages;
36
+ });
37
+ </script>
38
+
39
+ <nav
40
+ aria-label="Pagination"
41
+ class={cn('flex items-center gap-1', className)}
42
+ >
43
+ <button
44
+ type="button"
45
+ onclick={() => go(page - 1)}
46
+ disabled={page <= 1}
47
+ aria-label="Previous page"
48
+ class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-40"
49
+ >
50
+ <ChevronLeft size={15} />
51
+ </button>
52
+
53
+ {#each visiblePages as p}
54
+ {#if p === '...'}
55
+ <span class="flex h-8 w-8 items-center justify-center text-[13px] text-muted-foreground">
56
+
57
+ </span>
58
+ {:else}
59
+ <button
60
+ type="button"
61
+ onclick={() => go(p)}
62
+ aria-label="Page {p}"
63
+ aria-current={p === page ? 'page' : undefined}
64
+ class={cn(
65
+ 'inline-flex h-8 w-8 items-center justify-center rounded-md border text-[13px] font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
66
+ p === page
67
+ ? 'border-border bg-primary text-primary-foreground hover:bg-primary/90'
68
+ : 'border-transparent text-foreground',
69
+ )}
70
+ >
71
+ {p}
72
+ </button>
73
+ {/if}
74
+ {/each}
75
+
76
+ <button
77
+ type="button"
78
+ onclick={() => go(page + 1)}
79
+ disabled={page >= totalPages}
80
+ aria-label="Next page"
81
+ class="inline-flex h-8 w-8 items-center justify-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-40"
82
+ >
83
+ <ChevronRight size={15} />
84
+ </button>
85
+ </nav>
@@ -0,0 +1,85 @@
1
+ <script lang="ts">
2
+ import {
3
+ Check,
4
+ X,
5
+ AlertTriangle,
6
+ Clock,
7
+ Loader2,
8
+ } from '@lucide/svelte';
9
+ import { cn } from '$lib/utils/cn.js';
10
+
11
+ type Status = 'success' | 'error' | 'warning' | 'pending' | 'running';
12
+
13
+ type TimelineItem = {
14
+ time?: string;
15
+ title: string;
16
+ description?: string;
17
+ icon?: any;
18
+ status?: Status;
19
+ };
20
+
21
+ let {
22
+ items,
23
+ class: className,
24
+ }: {
25
+ items: TimelineItem[];
26
+ class?: string;
27
+ } = $props();
28
+
29
+ const statusStyles: Record<Status, string> = {
30
+ success: 'border-green-500/40 bg-green-500/10 text-green-500',
31
+ error: 'border-destructive/40 bg-destructive/10 text-destructive',
32
+ warning: 'border-amber-500/40 bg-amber-500/10 text-amber-500',
33
+ pending: 'border-border bg-muted text-muted-foreground',
34
+ running: 'border-blue-500/40 bg-blue-500/10 text-blue-400',
35
+ };
36
+ </script>
37
+
38
+ <ol class={cn('flex flex-col', className)}>
39
+ {#each items as item, i}
40
+ {@const isLast = i === items.length - 1}
41
+ {@const status = item.status}
42
+ <li class="relative flex gap-4">
43
+ <!-- connector line -->
44
+ {#if !isLast}
45
+ <div class="absolute left-[15px] top-8 h-full w-px bg-border"></div>
46
+ {/if}
47
+
48
+ <!-- dot / icon -->
49
+ <div
50
+ class={cn(
51
+ 'relative z-10 mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border',
52
+ status ? statusStyles[status] : 'border-border bg-muted text-muted-foreground',
53
+ )}
54
+ >
55
+ {#if item.icon}
56
+ {@const Icon = item.icon}
57
+ <Icon size={14} />
58
+ {:else if status === 'success'}
59
+ <Check size={14} />
60
+ {:else if status === 'error'}
61
+ <X size={14} />
62
+ {:else if status === 'warning'}
63
+ <AlertTriangle size={14} />
64
+ {:else if status === 'running'}
65
+ <Loader2 size={14} class="animate-spin" />
66
+ {:else}
67
+ <Clock size={14} />
68
+ {/if}
69
+ </div>
70
+
71
+ <!-- content -->
72
+ <div class={cn('min-w-0 flex-1 pb-6', isLast && 'pb-0')}>
73
+ <div class="flex items-baseline gap-2">
74
+ <span class="text-[13px] font-medium text-foreground">{item.title}</span>
75
+ {#if item.time}
76
+ <span class="shrink-0 text-[11px] text-muted-foreground">{item.time}</span>
77
+ {/if}
78
+ </div>
79
+ {#if item.description}
80
+ <p class="mt-0.5 text-[13px] text-muted-foreground">{item.description}</p>
81
+ {/if}
82
+ </div>
83
+ </li>
84
+ {/each}
85
+ </ol>
package/src/lib/index.ts CHANGED
@@ -287,6 +287,13 @@ export { default as PermissionChips } from './components/ui/PermissionChips.svel
287
287
  // AppCard — Nucel-App row card (marketplace, installed apps, owned apps)
288
288
  export { default as AppCard } from './components/ui/AppCard.svelte';
289
289
 
290
+ // Kanban primitives — generic Trello-style column/card chrome.
291
+ // DnD wiring stays out: the consumer attaches svelte-dnd-action's
292
+ // `dndzone` to whichever container they want sortable.
293
+ export { default as KanbanBoard } from './components/ui/KanbanBoard.svelte';
294
+ export { default as KanbanColumn } from './components/ui/KanbanColumn.svelte';
295
+ export { default as KanbanCard } from './components/ui/KanbanCard.svelte';
296
+
290
297
  // Section
291
298
  export { default as Section } from './components/ui/Section.svelte';
292
299
 
@@ -301,3 +308,21 @@ export { default as AppShell } from './components/ui/AppShell.svelte';
301
308
  export { default as AppSidebar } from './components/ui/AppSidebar.svelte';
302
309
  export { default as NavItem } from './components/ui/NavItem.svelte';
303
310
  export { default as NavSection } from './components/ui/NavSection.svelte';
311
+
312
+ // CopyButton
313
+ export { default as CopyButton } from './components/ui/CopyButton.svelte';
314
+
315
+ // Pagination
316
+ export { default as Pagination } from './components/ui/Pagination.svelte';
317
+
318
+ // CodeBlock
319
+ export { default as CodeBlock } from './components/ui/CodeBlock.svelte';
320
+
321
+ // Timeline
322
+ export { default as Timeline } from './components/ui/Timeline.svelte';
323
+
324
+ // FilterBar
325
+ export { default as FilterBar } from './components/ui/FilterBar.svelte';
326
+
327
+ // MetricCard
328
+ export { default as MetricCard } from './components/ui/MetricCard.svelte';