@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 +5 -1
- package/src/lib/components/ui/CodeBlock.svelte +92 -0
- package/src/lib/components/ui/CopyButton.svelte +43 -0
- package/src/lib/components/ui/FilterBar.svelte +63 -0
- package/src/lib/components/ui/KanbanBoard.svelte +27 -0
- package/src/lib/components/ui/KanbanCard.svelte +43 -0
- package/src/lib/components/ui/KanbanColumn.svelte +52 -0
- package/src/lib/components/ui/MetricCard.svelte +79 -0
- package/src/lib/components/ui/Pagination.svelte +85 -0
- package/src/lib/components/ui/Timeline.svelte +85 -0
- package/src/lib/index.ts +25 -0
- package/src/lib/components/ui/Alert.test.ts +0 -206
- package/src/lib/components/ui/BranchPill.test.ts +0 -121
- package/src/lib/components/ui/CostDisplay.test.ts +0 -1115
- package/src/lib/components/ui/FormField.test.ts +0 -41
- package/src/lib/components/ui/PageHeader.test.ts +0 -72
- package/src/lib/components/ui/ProgressRing.test.ts +0 -239
- package/src/lib/components/ui/Section.test.ts +0 -44
- package/src/lib/components/ui/StatusBadge.test.ts +0 -150
- package/src/lib/components/ui/StatusPill.test.ts +0 -125
- package/src/lib/components/ui/table/Table.test.ts +0 -317
- package/src/lib/utils/cn.test.ts +0 -993
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nucel/ui",
|
|
3
|
-
"version": "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';
|