@nucel/ui 0.2.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.
- package/package.json +8 -32
- 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/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/index.ts +120 -45
- 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/Alert.svelte +0 -47
- package/src/lib/components/ui/Alert.test.ts +0 -206
- package/src/lib/components/ui/AppCard.svelte +0 -76
- package/src/lib/components/ui/AppShell.svelte +0 -14
- package/src/lib/components/ui/AppSidebar.svelte +0 -45
- package/src/lib/components/ui/BranchPill.svelte +0 -19
- package/src/lib/components/ui/BranchPill.test.ts +0 -121
- package/src/lib/components/ui/CommentPill.svelte +0 -12
- package/src/lib/components/ui/CostDisplay.svelte +0 -26
- package/src/lib/components/ui/CostDisplay.test.ts +0 -1115
- package/src/lib/components/ui/FormField.svelte +0 -34
- package/src/lib/components/ui/FormField.test.ts +0 -41
- package/src/lib/components/ui/ListCard.svelte +0 -9
- package/src/lib/components/ui/NavItem.svelte +0 -42
- package/src/lib/components/ui/NavSection.svelte +0 -17
- package/src/lib/components/ui/PageHeader.svelte +0 -25
- package/src/lib/components/ui/PageHeader.test.ts +0 -72
- package/src/lib/components/ui/PermissionChips.svelte +0 -49
- package/src/lib/components/ui/ProgressRing.test.ts +0 -239
- package/src/lib/components/ui/Section.svelte +0 -21
- package/src/lib/components/ui/Section.test.ts +0 -44
- package/src/lib/components/ui/SectionTitle.svelte +0 -16
- package/src/lib/components/ui/StatCard.svelte +0 -19
- package/src/lib/components/ui/StatusBadge.test.ts +0 -150
- package/src/lib/components/ui/StatusPill.svelte +0 -54
- package/src/lib/components/ui/StatusPill.test.ts +0 -125
- package/src/lib/components/ui/editor/RichEditor.svelte +0 -580
- package/src/lib/components/ui/editor/mention-suggestion.ts +0 -144
- package/src/lib/components/ui/table/Table.svelte +0 -12
- package/src/lib/components/ui/table/Table.test.ts +0 -317
- package/src/lib/components/ui/table/TableBody.svelte +0 -10
- package/src/lib/components/ui/table/TableCaption.svelte +0 -10
- package/src/lib/components/ui/table/TableCell.svelte +0 -10
- package/src/lib/components/ui/table/TableHead.svelte +0 -10
- package/src/lib/components/ui/table/TableHeader.svelte +0 -10
- package/src/lib/components/ui/table/TableRow.svelte +0 -10
- package/src/lib/components/ui/table/index.ts +0 -7
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { Snippet } from 'svelte';
|
|
3
|
-
import { cn } from '../../utils/cn.js';
|
|
4
|
-
import { Label } from './label/index.js';
|
|
5
|
-
|
|
6
|
-
let {
|
|
7
|
-
label,
|
|
8
|
-
for: forId,
|
|
9
|
-
hint,
|
|
10
|
-
error,
|
|
11
|
-
children,
|
|
12
|
-
class: className,
|
|
13
|
-
}: {
|
|
14
|
-
label?: string;
|
|
15
|
-
for?: string;
|
|
16
|
-
hint?: string;
|
|
17
|
-
error?: string;
|
|
18
|
-
children?: Snippet;
|
|
19
|
-
class?: string;
|
|
20
|
-
} = $props();
|
|
21
|
-
</script>
|
|
22
|
-
|
|
23
|
-
<div class={cn('space-y-1.5', className)}>
|
|
24
|
-
{#if label}
|
|
25
|
-
<Label for={forId}>{label}</Label>
|
|
26
|
-
{/if}
|
|
27
|
-
{@render children?.()}
|
|
28
|
-
{#if hint && !error}
|
|
29
|
-
<p class="text-[11px] text-muted-foreground">{hint}</p>
|
|
30
|
-
{/if}
|
|
31
|
-
{#if error}
|
|
32
|
-
<p class="text-sm text-destructive">{error}</p>
|
|
33
|
-
{/if}
|
|
34
|
-
</div>
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/svelte";
|
|
3
|
-
import FormField from "./FormField.svelte";
|
|
4
|
-
|
|
5
|
-
describe("FormField", () => {
|
|
6
|
-
it("renders children (wrapper div is present)", () => {
|
|
7
|
-
const { container } = render(FormField, { props: {} });
|
|
8
|
-
expect(container.querySelector("div")).not.toBeNull();
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it("renders label when label prop is given", () => {
|
|
12
|
-
render(FormField, { props: { label: "Username" } });
|
|
13
|
-
expect(screen.getByText("Username")).toBeInTheDocument();
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it("does NOT render a label element when label prop is absent", () => {
|
|
17
|
-
const { container } = render(FormField, { props: {} });
|
|
18
|
-
expect(container.querySelector("label")).toBeNull();
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("renders error text when error prop is given", () => {
|
|
22
|
-
render(FormField, { props: { error: "This field is required" } });
|
|
23
|
-
expect(screen.getByText("This field is required")).toBeInTheDocument();
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
it("renders hint text when hint prop is given and error is absent", () => {
|
|
27
|
-
render(FormField, { props: { hint: "Must be between 3–20 characters" } });
|
|
28
|
-
expect(screen.getByText("Must be between 3–20 characters")).toBeInTheDocument();
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("does NOT render hint when error is also given", () => {
|
|
32
|
-
render(FormField, {
|
|
33
|
-
props: {
|
|
34
|
-
hint: "Some hint",
|
|
35
|
-
error: "Some error",
|
|
36
|
-
},
|
|
37
|
-
});
|
|
38
|
-
expect(screen.queryByText("Some hint")).toBeNull();
|
|
39
|
-
expect(screen.getByText("Some error")).toBeInTheDocument();
|
|
40
|
-
});
|
|
41
|
-
});
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
|
3
|
-
import { cn } from '$lib/utils/cn.js';
|
|
4
|
-
|
|
5
|
-
type Props = HTMLAnchorAttributes & {
|
|
6
|
-
href: string;
|
|
7
|
-
label: string;
|
|
8
|
-
icon?: any;
|
|
9
|
-
active?: boolean;
|
|
10
|
-
collapsed?: boolean;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
let {
|
|
14
|
-
href,
|
|
15
|
-
label,
|
|
16
|
-
icon: Icon,
|
|
17
|
-
active = false,
|
|
18
|
-
collapsed = false,
|
|
19
|
-
class: className,
|
|
20
|
-
...rest
|
|
21
|
-
}: Props = $props();
|
|
22
|
-
</script>
|
|
23
|
-
|
|
24
|
-
<a
|
|
25
|
-
{href}
|
|
26
|
-
class={cn(
|
|
27
|
-
'flex items-center gap-2 rounded-md px-2 py-1.5 text-[13px] font-medium text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground',
|
|
28
|
-
active && 'bg-sidebar-accent text-sidebar-primary',
|
|
29
|
-
className,
|
|
30
|
-
)}
|
|
31
|
-
aria-label={collapsed ? label : undefined}
|
|
32
|
-
{...rest}
|
|
33
|
-
>
|
|
34
|
-
{#if Icon}
|
|
35
|
-
<span class="flex size-5 shrink-0 items-center justify-center">
|
|
36
|
-
<Icon size={16} />
|
|
37
|
-
</span>
|
|
38
|
-
{/if}
|
|
39
|
-
{#if !collapsed}
|
|
40
|
-
<span class="truncate">{label}</span>
|
|
41
|
-
{/if}
|
|
42
|
-
</a>
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
let {
|
|
3
|
-
label,
|
|
4
|
-
collapsed = false,
|
|
5
|
-
}: {
|
|
6
|
-
label: string;
|
|
7
|
-
collapsed?: boolean;
|
|
8
|
-
} = $props();
|
|
9
|
-
</script>
|
|
10
|
-
|
|
11
|
-
{#if !collapsed}
|
|
12
|
-
<p class="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/80">
|
|
13
|
-
{label}
|
|
14
|
-
</p>
|
|
15
|
-
{:else}
|
|
16
|
-
<div class="pt-3"></div>
|
|
17
|
-
{/if}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { Snippet } from 'svelte';
|
|
3
|
-
|
|
4
|
-
let {
|
|
5
|
-
title,
|
|
6
|
-
subtitle,
|
|
7
|
-
actions,
|
|
8
|
-
}: {
|
|
9
|
-
title: string;
|
|
10
|
-
subtitle?: string;
|
|
11
|
-
actions?: Snippet;
|
|
12
|
-
} = $props();
|
|
13
|
-
</script>
|
|
14
|
-
|
|
15
|
-
<div class="mb-5 flex items-start justify-between gap-4">
|
|
16
|
-
<div class="min-w-0">
|
|
17
|
-
<h1 class="text-lg font-semibold tracking-tight text-foreground">{title}</h1>
|
|
18
|
-
{#if subtitle}
|
|
19
|
-
<p class="mt-0.5 text-xs text-muted-foreground">{subtitle}</p>
|
|
20
|
-
{/if}
|
|
21
|
-
</div>
|
|
22
|
-
{#if actions}
|
|
23
|
-
<div class="flex shrink-0 items-center gap-2">{@render actions()}</div>
|
|
24
|
-
{/if}
|
|
25
|
-
</div>
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/svelte";
|
|
3
|
-
import PageHeader from "./PageHeader.svelte";
|
|
4
|
-
|
|
5
|
-
describe("PageHeader", () => {
|
|
6
|
-
it("renders the title", () => {
|
|
7
|
-
render(PageHeader, { props: { title: "Repositories" } });
|
|
8
|
-
expect(screen.getByText("Repositories")).toBeInTheDocument();
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it("title is rendered as an h1 element", () => {
|
|
12
|
-
render(PageHeader, { props: { title: "Repositories" } });
|
|
13
|
-
const h1 = screen.getByRole("heading", { level: 1 });
|
|
14
|
-
expect(h1).toBeInTheDocument();
|
|
15
|
-
expect(h1.textContent).toBe("Repositories");
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("renders the subtitle when provided", () => {
|
|
19
|
-
render(PageHeader, { props: { title: "Repositories", subtitle: "All repositories" } });
|
|
20
|
-
expect(screen.getByText("All repositories")).toBeInTheDocument();
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("does not render a subtitle element when subtitle is not given", () => {
|
|
24
|
-
render(PageHeader, { props: { title: "Repositories" } });
|
|
25
|
-
expect(screen.queryByText("All repositories")).not.toBeInTheDocument();
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("subtitle is rendered as a paragraph element", () => {
|
|
29
|
-
const { container } = render(PageHeader, {
|
|
30
|
-
props: { title: "Repositories", subtitle: "Some subtitle" },
|
|
31
|
-
});
|
|
32
|
-
const p = container.querySelector("p");
|
|
33
|
-
expect(p).not.toBeNull();
|
|
34
|
-
expect(p!.textContent).toBe("Some subtitle");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("does not render a paragraph element when subtitle is absent", () => {
|
|
38
|
-
const { container } = render(PageHeader, { props: { title: "Only Title" } });
|
|
39
|
-
expect(container.querySelector("p")).toBeNull();
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("renders different title text correctly", () => {
|
|
43
|
-
render(PageHeader, { props: { title: "Issues" } });
|
|
44
|
-
expect(screen.getByText("Issues")).toBeInTheDocument();
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it("renders without crashing when only title is provided", () => {
|
|
48
|
-
expect(() =>
|
|
49
|
-
render(PageHeader, { props: { title: "Test" } }),
|
|
50
|
-
).not.toThrow();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("renders without crashing when title and subtitle are provided", () => {
|
|
54
|
-
expect(() =>
|
|
55
|
-
render(PageHeader, { props: { title: "Test", subtitle: "A subtitle" } }),
|
|
56
|
-
).not.toThrow();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("h1 has tracking-tight class", () => {
|
|
60
|
-
const { container } = render(PageHeader, { props: { title: "Test" } });
|
|
61
|
-
const h1 = container.querySelector("h1");
|
|
62
|
-
expect(h1!.className).toContain("tracking-tight");
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("subtitle paragraph has text-muted-foreground class", () => {
|
|
66
|
-
const { container } = render(PageHeader, {
|
|
67
|
-
props: { title: "Test", subtitle: "Sub" },
|
|
68
|
-
});
|
|
69
|
-
const p = container.querySelector("p");
|
|
70
|
-
expect(p!.className).toContain("text-muted-foreground");
|
|
71
|
-
});
|
|
72
|
-
});
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
// Compact permission chips for Nucel-App permission sets.
|
|
3
|
-
//
|
|
4
|
-
// Used by the marketplace card, installed-apps card, and the
|
|
5
|
-
// developer's per-app Show page. Filters out `none` automatically and
|
|
6
|
-
// sorts alphabetically so the visual order is stable across calls.
|
|
7
|
-
//
|
|
8
|
-
// `compact=true` (default) renders single-line `key:level` chips
|
|
9
|
-
// (used inside cards). `compact=false` renders a per-row list with
|
|
10
|
-
// primary-tinted level badges (used on the Show page).
|
|
11
|
-
|
|
12
|
-
let { permissions, compact = true }: {
|
|
13
|
-
permissions: Record<string, string> | null | undefined;
|
|
14
|
-
compact?: boolean;
|
|
15
|
-
} = $props();
|
|
16
|
-
|
|
17
|
-
const entries = $derived(
|
|
18
|
-
Object.entries(permissions ?? {})
|
|
19
|
-
.filter(([_, v]) => typeof v === 'string' && v !== 'none')
|
|
20
|
-
.sort(([a], [b]) => a.localeCompare(b)),
|
|
21
|
-
);
|
|
22
|
-
</script>
|
|
23
|
-
|
|
24
|
-
{#if entries.length === 0}
|
|
25
|
-
<span class="text-[11px] italic text-muted-foreground">No write access requested.</span>
|
|
26
|
-
{:else if compact}
|
|
27
|
-
<div class="flex flex-wrap gap-1">
|
|
28
|
-
{#each entries as [key, level]}
|
|
29
|
-
<span class="rounded-md border border-border/60 bg-muted/40 px-1.5 py-0.5 text-[11px]">
|
|
30
|
-
<code class="font-mono">{key}</code>:{level}
|
|
31
|
-
</span>
|
|
32
|
-
{/each}
|
|
33
|
-
</div>
|
|
34
|
-
{:else}
|
|
35
|
-
<ul class="space-y-1.5">
|
|
36
|
-
{#each entries as [key, level]}
|
|
37
|
-
<li class="flex items-center justify-between gap-3 rounded-md border border-border/60 bg-muted/30 px-3 py-1.5 text-xs">
|
|
38
|
-
<code class="font-mono">{key}</code>
|
|
39
|
-
<span
|
|
40
|
-
class="rounded-md border px-1.5 py-0.5 text-[11px] {level === 'write' || level === 'admin'
|
|
41
|
-
? 'border-primary/40 bg-primary/10 text-primary'
|
|
42
|
-
: 'border-border/60 bg-muted text-muted-foreground'}"
|
|
43
|
-
>
|
|
44
|
-
{level}
|
|
45
|
-
</span>
|
|
46
|
-
</li>
|
|
47
|
-
{/each}
|
|
48
|
-
</ul>
|
|
49
|
-
{/if}
|
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { render } from "@testing-library/svelte";
|
|
3
|
-
import ProgressRing from "./ProgressRing.svelte";
|
|
4
|
-
|
|
5
|
-
describe("ProgressRing — rendering", () => {
|
|
6
|
-
it("renders nothing when limit === 0", () => {
|
|
7
|
-
const { container } = render(ProgressRing, { props: { spent: 50, limit: 0 } });
|
|
8
|
-
expect(container.querySelector("svg")).toBeNull();
|
|
9
|
-
expect(container.querySelector("span")).toBeNull();
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it("renders an SVG when limit > 0", () => {
|
|
13
|
-
const { container } = render(ProgressRing, { props: { spent: 25, limit: 100 } });
|
|
14
|
-
expect(container.querySelector("svg")).not.toBeNull();
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("renders a span wrapper when limit > 0", () => {
|
|
18
|
-
const { container } = render(ProgressRing, { props: { spent: 10, limit: 100 } });
|
|
19
|
-
expect(container.querySelector("span")).not.toBeNull();
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("renders two circle elements inside the SVG", () => {
|
|
23
|
-
const { container } = render(ProgressRing, { props: { spent: 50, limit: 100 } });
|
|
24
|
-
expect(container.querySelectorAll("circle").length).toBe(2);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("renders nothing when both spent and limit are 0", () => {
|
|
28
|
-
const { container } = render(ProgressRing, { props: { spent: 0, limit: 0 } });
|
|
29
|
-
expect(container.querySelector("svg")).toBeNull();
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it("renders when spent > limit (clamped to 1)", () => {
|
|
33
|
-
const { container } = render(ProgressRing, { props: { spent: 200, limit: 100 } });
|
|
34
|
-
expect(container.querySelector("svg")).not.toBeNull();
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("renders without crashing for fractional values", () => {
|
|
38
|
-
expect(() =>
|
|
39
|
-
render(ProgressRing, { props: { spent: 33.33, limit: 100 } }),
|
|
40
|
-
).not.toThrow();
|
|
41
|
-
});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe("ProgressRing — title attribute", () => {
|
|
45
|
-
it("title contains the percentage for 25%", () => {
|
|
46
|
-
const { container } = render(ProgressRing, { props: { spent: 25, limit: 100 } });
|
|
47
|
-
const span = container.querySelector("span");
|
|
48
|
-
expect(span?.getAttribute("title")).toContain("25%");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("title contains the percentage for 50%", () => {
|
|
52
|
-
const { container } = render(ProgressRing, { props: { spent: 50, limit: 100 } });
|
|
53
|
-
const span = container.querySelector("span");
|
|
54
|
-
expect(span?.getAttribute("title")).toContain("50%");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
it("title contains the percentage for 80%", () => {
|
|
58
|
-
const { container } = render(ProgressRing, { props: { spent: 80, limit: 100 } });
|
|
59
|
-
const span = container.querySelector("span");
|
|
60
|
-
expect(span?.getAttribute("title")).toContain("80%");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("title contains the percentage for 100% when spent === limit", () => {
|
|
64
|
-
const { container } = render(ProgressRing, { props: { spent: 100, limit: 100 } });
|
|
65
|
-
const span = container.querySelector("span");
|
|
66
|
-
expect(span?.getAttribute("title")).toContain("100%");
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("title contains the spent value formatted with 2 decimal places", () => {
|
|
70
|
-
const { container } = render(ProgressRing, { props: { spent: 25, limit: 100 } });
|
|
71
|
-
const span = container.querySelector("span");
|
|
72
|
-
expect(span?.getAttribute("title")).toContain("25.00");
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("title contains the limit value formatted with 2 decimal places", () => {
|
|
76
|
-
const { container } = render(ProgressRing, { props: { spent: 25, limit: 100 } });
|
|
77
|
-
const span = container.querySelector("span");
|
|
78
|
-
expect(span?.getAttribute("title")).toContain("100.00");
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it("title contains '$' signs for spent and limit", () => {
|
|
82
|
-
const { container } = render(ProgressRing, { props: { spent: 10, limit: 50 } });
|
|
83
|
-
const span = container.querySelector("span");
|
|
84
|
-
const title = span?.getAttribute("title") ?? "";
|
|
85
|
-
expect(title.match(/\$/g)?.length).toBeGreaterThanOrEqual(2);
|
|
86
|
-
});
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
describe("ProgressRing — color derivation", () => {
|
|
90
|
-
it("uses stroke-success class when pct < 0.5 (0%)", () => {
|
|
91
|
-
const { container } = render(ProgressRing, { props: { spent: 0, limit: 100 } });
|
|
92
|
-
const circles = container.querySelectorAll("circle");
|
|
93
|
-
const hasSuccess = Array.from(circles).some((c) =>
|
|
94
|
-
c.getAttribute("class")?.includes("stroke-success"),
|
|
95
|
-
);
|
|
96
|
-
expect(hasSuccess).toBe(true);
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it("uses stroke-success class when pct < 0.5 (25%)", () => {
|
|
100
|
-
const { container } = render(ProgressRing, { props: { spent: 25, limit: 100 } });
|
|
101
|
-
const circles = container.querySelectorAll("circle");
|
|
102
|
-
const hasSuccess = Array.from(circles).some((c) =>
|
|
103
|
-
c.getAttribute("class")?.includes("stroke-success"),
|
|
104
|
-
);
|
|
105
|
-
expect(hasSuccess).toBe(true);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("uses stroke-warning class when pct is 0.5 (exactly 50%)", () => {
|
|
109
|
-
const { container } = render(ProgressRing, { props: { spent: 50, limit: 100 } });
|
|
110
|
-
const circles = container.querySelectorAll("circle");
|
|
111
|
-
const hasWarning = Array.from(circles).some((c) =>
|
|
112
|
-
c.getAttribute("class")?.includes("stroke-warning"),
|
|
113
|
-
);
|
|
114
|
-
expect(hasWarning).toBe(true);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
it("uses stroke-warning class when pct is 0.79 (79%)", () => {
|
|
118
|
-
const { container } = render(ProgressRing, { props: { spent: 79, limit: 100 } });
|
|
119
|
-
const circles = container.querySelectorAll("circle");
|
|
120
|
-
const hasWarning = Array.from(circles).some((c) =>
|
|
121
|
-
c.getAttribute("class")?.includes("stroke-warning"),
|
|
122
|
-
);
|
|
123
|
-
expect(hasWarning).toBe(true);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
it("uses stroke-destructive class when pct is 0.8 (exactly 80%)", () => {
|
|
127
|
-
const { container } = render(ProgressRing, { props: { spent: 80, limit: 100 } });
|
|
128
|
-
const circles = container.querySelectorAll("circle");
|
|
129
|
-
const hasDestructive = Array.from(circles).some((c) =>
|
|
130
|
-
c.getAttribute("class")?.includes("stroke-destructive"),
|
|
131
|
-
);
|
|
132
|
-
expect(hasDestructive).toBe(true);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it("uses stroke-destructive class when pct is 1.0 (100%)", () => {
|
|
136
|
-
const { container } = render(ProgressRing, { props: { spent: 100, limit: 100 } });
|
|
137
|
-
const circles = container.querySelectorAll("circle");
|
|
138
|
-
const hasDestructive = Array.from(circles).some((c) =>
|
|
139
|
-
c.getAttribute("class")?.includes("stroke-destructive"),
|
|
140
|
-
);
|
|
141
|
-
expect(hasDestructive).toBe(true);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("uses stroke-destructive class when spent > limit (clamped to 100%)", () => {
|
|
145
|
-
const { container } = render(ProgressRing, { props: { spent: 150, limit: 100 } });
|
|
146
|
-
const circles = container.querySelectorAll("circle");
|
|
147
|
-
const hasDestructive = Array.from(circles).some((c) =>
|
|
148
|
-
c.getAttribute("class")?.includes("stroke-destructive"),
|
|
149
|
-
);
|
|
150
|
-
expect(hasDestructive).toBe(true);
|
|
151
|
-
});
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
describe("ProgressRing — bgColor derivation", () => {
|
|
155
|
-
it("uses stroke-success/20 bg class when pct < 0.5", () => {
|
|
156
|
-
const { container } = render(ProgressRing, { props: { spent: 10, limit: 100 } });
|
|
157
|
-
const circles = container.querySelectorAll("circle");
|
|
158
|
-
const hasBg = Array.from(circles).some((c) =>
|
|
159
|
-
c.getAttribute("class")?.includes("stroke-success/20"),
|
|
160
|
-
);
|
|
161
|
-
expect(hasBg).toBe(true);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("uses stroke-warning/20 bg class when pct is in warning range", () => {
|
|
165
|
-
const { container } = render(ProgressRing, { props: { spent: 65, limit: 100 } });
|
|
166
|
-
const circles = container.querySelectorAll("circle");
|
|
167
|
-
const hasBg = Array.from(circles).some((c) =>
|
|
168
|
-
c.getAttribute("class")?.includes("stroke-warning/20"),
|
|
169
|
-
);
|
|
170
|
-
expect(hasBg).toBe(true);
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("uses stroke-destructive/20 bg class when pct >= 0.8", () => {
|
|
174
|
-
const { container } = render(ProgressRing, { props: { spent: 90, limit: 100 } });
|
|
175
|
-
const circles = container.querySelectorAll("circle");
|
|
176
|
-
const hasBg = Array.from(circles).some((c) =>
|
|
177
|
-
c.getAttribute("class")?.includes("stroke-destructive/20"),
|
|
178
|
-
);
|
|
179
|
-
expect(hasBg).toBe(true);
|
|
180
|
-
});
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
describe("ProgressRing — size and SVG attributes", () => {
|
|
184
|
-
it("SVG has correct width and height for default size (16)", () => {
|
|
185
|
-
const { container } = render(ProgressRing, { props: { spent: 50, limit: 100 } });
|
|
186
|
-
const svg = container.querySelector("svg");
|
|
187
|
-
expect(svg?.getAttribute("width")).toBe("16");
|
|
188
|
-
expect(svg?.getAttribute("height")).toBe("16");
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
it("SVG has correct width for custom size", () => {
|
|
192
|
-
const { container } = render(ProgressRing, { props: { spent: 50, limit: 100, size: 40 } });
|
|
193
|
-
const svg = container.querySelector("svg");
|
|
194
|
-
expect(svg?.getAttribute("width")).toBe("40");
|
|
195
|
-
expect(svg?.getAttribute("height")).toBe("40");
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("SVG has -rotate-90 class", () => {
|
|
199
|
-
const { container } = render(ProgressRing, { props: { spent: 50, limit: 100 } });
|
|
200
|
-
const svg = container.querySelector("svg");
|
|
201
|
-
// SVG elements use SVGAnimatedString for className — use getAttribute instead
|
|
202
|
-
expect(svg?.getAttribute("class")).toContain("-rotate-90");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("applies custom class to span wrapper", () => {
|
|
206
|
-
const { container } = render(ProgressRing, {
|
|
207
|
-
props: { spent: 50, limit: 100, class: "my-ring-class" },
|
|
208
|
-
});
|
|
209
|
-
const span = container.querySelector("span");
|
|
210
|
-
expect(span?.className).toContain("my-ring-class");
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
describe("ProgressRing — pct calculation", () => {
|
|
215
|
-
it("pct is 0 when limit === 0 (no SVG rendered)", () => {
|
|
216
|
-
// The component renders nothing when limit === 0 because pct would be 0
|
|
217
|
-
const { container } = render(ProgressRing, { props: { spent: 100, limit: 0 } });
|
|
218
|
-
expect(container.querySelector("svg")).toBeNull();
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it("pct is clamped to 1 when spent > limit", () => {
|
|
222
|
-
// At 100% the destructive color should be used
|
|
223
|
-
const { container } = render(ProgressRing, { props: { spent: 999, limit: 100 } });
|
|
224
|
-
const circles = container.querySelectorAll("circle");
|
|
225
|
-
const hasDestructive = Array.from(circles).some((c) =>
|
|
226
|
-
c.getAttribute("class")?.includes("stroke-destructive"),
|
|
227
|
-
);
|
|
228
|
-
expect(hasDestructive).toBe(true);
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
it("pct is 0 when spent === 0 (success color)", () => {
|
|
232
|
-
const { container } = render(ProgressRing, { props: { spent: 0, limit: 100 } });
|
|
233
|
-
const circles = container.querySelectorAll("circle");
|
|
234
|
-
const hasSuccess = Array.from(circles).some((c) =>
|
|
235
|
-
c.getAttribute("class")?.includes("stroke-success"),
|
|
236
|
-
);
|
|
237
|
-
expect(hasSuccess).toBe(true);
|
|
238
|
-
});
|
|
239
|
-
});
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { Snippet } from 'svelte';
|
|
3
|
-
import { cn } from '../../utils/cn.js';
|
|
4
|
-
|
|
5
|
-
let {
|
|
6
|
-
title,
|
|
7
|
-
children,
|
|
8
|
-
class: className,
|
|
9
|
-
}: {
|
|
10
|
-
title?: string;
|
|
11
|
-
children?: Snippet;
|
|
12
|
-
class?: string;
|
|
13
|
-
} = $props();
|
|
14
|
-
</script>
|
|
15
|
-
|
|
16
|
-
<div class={cn('rounded-xl border border-border bg-card p-5', className)}>
|
|
17
|
-
{#if title}
|
|
18
|
-
<h3 class="mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</h3>
|
|
19
|
-
{/if}
|
|
20
|
-
{@render children?.()}
|
|
21
|
-
</div>
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { render } from "@testing-library/svelte";
|
|
3
|
-
import Section from "./Section.svelte";
|
|
4
|
-
|
|
5
|
-
describe("Section", () => {
|
|
6
|
-
it("renders a container div", () => {
|
|
7
|
-
const { container } = render(Section, { props: {} });
|
|
8
|
-
expect(container.querySelector("div")).not.toBeNull();
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it("renders the title when given", () => {
|
|
12
|
-
const { container } = render(Section, {
|
|
13
|
-
props: { title: "My Section" },
|
|
14
|
-
});
|
|
15
|
-
const h3 = container.querySelector("h3");
|
|
16
|
-
expect(h3).not.toBeNull();
|
|
17
|
-
expect(h3!.textContent).toBe("My Section");
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("does NOT render an h3 when no title is given", () => {
|
|
21
|
-
const { container } = render(Section, {
|
|
22
|
-
props: {},
|
|
23
|
-
});
|
|
24
|
-
expect(container.querySelector("h3")).toBeNull();
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("the h3 has the uppercase class", () => {
|
|
28
|
-
const { container } = render(Section, {
|
|
29
|
-
props: { title: "Settings" },
|
|
30
|
-
});
|
|
31
|
-
const h3 = container.querySelector("h3");
|
|
32
|
-
expect(h3).not.toBeNull();
|
|
33
|
-
expect(h3!.className).toContain("uppercase");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it("extra class prop is merged into the container div", () => {
|
|
37
|
-
const { container } = render(Section, {
|
|
38
|
-
props: { class: "my-extra-class" },
|
|
39
|
-
});
|
|
40
|
-
const div = container.querySelector("div");
|
|
41
|
-
expect(div).not.toBeNull();
|
|
42
|
-
expect(div!.className).toContain("my-extra-class");
|
|
43
|
-
});
|
|
44
|
-
});
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import type { Snippet } from 'svelte';
|
|
3
|
-
import { cn } from '../../utils/cn.js';
|
|
4
|
-
|
|
5
|
-
let {
|
|
6
|
-
children,
|
|
7
|
-
class: className,
|
|
8
|
-
}: {
|
|
9
|
-
children: Snippet;
|
|
10
|
-
class?: string;
|
|
11
|
-
} = $props();
|
|
12
|
-
</script>
|
|
13
|
-
|
|
14
|
-
<h3 class={cn('mb-3 text-xs font-semibold uppercase tracking-wide text-muted-foreground', className)}>
|
|
15
|
-
{@render children()}
|
|
16
|
-
</h3>
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
let {
|
|
3
|
-
label,
|
|
4
|
-
value,
|
|
5
|
-
hint,
|
|
6
|
-
}: {
|
|
7
|
-
label: string;
|
|
8
|
-
value: string | number;
|
|
9
|
-
hint?: string;
|
|
10
|
-
} = $props();
|
|
11
|
-
</script>
|
|
12
|
-
|
|
13
|
-
<div class="rounded-xl border border-border bg-card p-4">
|
|
14
|
-
<div class="text-xl font-semibold tracking-tight tabular-nums text-foreground">{value}</div>
|
|
15
|
-
<div class="mt-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</div>
|
|
16
|
-
{#if hint}
|
|
17
|
-
<div class="mt-1 text-[11px] text-muted-foreground/70">{hint}</div>
|
|
18
|
-
{/if}
|
|
19
|
-
</div>
|