@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
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount, onDestroy } from 'svelte';
|
|
3
|
+
import type * as MonacoNs from 'monaco-editor';
|
|
4
|
+
import { cn } from '../utils.js';
|
|
5
|
+
import { loadMonaco, resolveMonacoTheme } from '../utils/monacoLoader.js';
|
|
6
|
+
import Skeleton from './Skeleton.svelte';
|
|
7
|
+
import type { CodeEditorTheme } from './CodeEditor.svelte';
|
|
8
|
+
|
|
9
|
+
type Props = {
|
|
10
|
+
/** Original (left) content. */
|
|
11
|
+
original?: string;
|
|
12
|
+
/** Modified (right) content. Bindable when not read-only. */
|
|
13
|
+
modified?: string;
|
|
14
|
+
/** Monaco language id. */
|
|
15
|
+
language?: string;
|
|
16
|
+
/** Theme mode. */
|
|
17
|
+
theme?: CodeEditorTheme;
|
|
18
|
+
/** Lock the right side. Defaults to true (most diff views are read-only). */
|
|
19
|
+
readOnly?: boolean;
|
|
20
|
+
/** Toggle inline vs side-by-side rendering. */
|
|
21
|
+
inline?: boolean;
|
|
22
|
+
/** Render whitespace differences. */
|
|
23
|
+
renderWhitespace?: boolean;
|
|
24
|
+
/** Hide unchanged regions to focus on actual changes. */
|
|
25
|
+
hideUnchangedRegions?: boolean;
|
|
26
|
+
/** CSS height. */
|
|
27
|
+
height?: string;
|
|
28
|
+
/** Container className. */
|
|
29
|
+
class?: string;
|
|
30
|
+
/** Called when modified content changes. */
|
|
31
|
+
onchange?: (modified: string) => void;
|
|
32
|
+
/** Aria label. */
|
|
33
|
+
ariaLabel?: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let {
|
|
37
|
+
original = '',
|
|
38
|
+
modified = $bindable(''),
|
|
39
|
+
language = 'plaintext',
|
|
40
|
+
theme = 'auto',
|
|
41
|
+
readOnly = true,
|
|
42
|
+
inline = false,
|
|
43
|
+
renderWhitespace = false,
|
|
44
|
+
hideUnchangedRegions = true,
|
|
45
|
+
height = '500px',
|
|
46
|
+
class: className,
|
|
47
|
+
onchange,
|
|
48
|
+
ariaLabel,
|
|
49
|
+
}: Props = $props();
|
|
50
|
+
|
|
51
|
+
let container: HTMLDivElement;
|
|
52
|
+
let diffEditor: MonacoNs.editor.IStandaloneDiffEditor | null = null;
|
|
53
|
+
let originalModel: MonacoNs.editor.ITextModel | null = null;
|
|
54
|
+
let modifiedModel: MonacoNs.editor.ITextModel | null = null;
|
|
55
|
+
let monacoRef: typeof MonacoNs | null = null;
|
|
56
|
+
let ready = $state(false);
|
|
57
|
+
let suppressNextChange = false;
|
|
58
|
+
|
|
59
|
+
onMount(() => {
|
|
60
|
+
const p = loadMonaco();
|
|
61
|
+
if (!p) return;
|
|
62
|
+
|
|
63
|
+
let disposed = false;
|
|
64
|
+
|
|
65
|
+
p.then((monaco) => {
|
|
66
|
+
if (disposed || !container) return;
|
|
67
|
+
monacoRef = monaco;
|
|
68
|
+
|
|
69
|
+
diffEditor = monaco.editor.createDiffEditor(container, {
|
|
70
|
+
theme: resolveMonacoTheme(theme),
|
|
71
|
+
readOnly,
|
|
72
|
+
renderSideBySide: !inline,
|
|
73
|
+
automaticLayout: true,
|
|
74
|
+
fontSize: 13,
|
|
75
|
+
fontFamily:
|
|
76
|
+
'"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
77
|
+
scrollBeyondLastLine: false,
|
|
78
|
+
renderWhitespace: renderWhitespace ? 'all' : 'none',
|
|
79
|
+
hideUnchangedRegions: { enabled: hideUnchangedRegions },
|
|
80
|
+
ariaLabel: ariaLabel ?? `Diff editor (${language})`,
|
|
81
|
+
originalEditable: false,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
originalModel = monaco.editor.createModel(original ?? '', language);
|
|
85
|
+
modifiedModel = monaco.editor.createModel(modified ?? '', language);
|
|
86
|
+
diffEditor.setModel({ original: originalModel, modified: modifiedModel });
|
|
87
|
+
|
|
88
|
+
modifiedModel.onDidChangeContent(() => {
|
|
89
|
+
if (!modifiedModel || suppressNextChange) {
|
|
90
|
+
suppressNextChange = false;
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const next = modifiedModel.getValue();
|
|
94
|
+
modified = next;
|
|
95
|
+
onchange?.(next);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
ready = true;
|
|
99
|
+
}).catch((err) => {
|
|
100
|
+
// eslint-disable-next-line no-console
|
|
101
|
+
console.error('[@nucel/ui] Monaco failed to load', err);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return () => {
|
|
105
|
+
disposed = true;
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
onDestroy(() => {
|
|
110
|
+
diffEditor?.dispose();
|
|
111
|
+
originalModel?.dispose();
|
|
112
|
+
modifiedModel?.dispose();
|
|
113
|
+
diffEditor = null;
|
|
114
|
+
originalModel = null;
|
|
115
|
+
modifiedModel = null;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
$effect(() => {
|
|
119
|
+
if (!originalModel) return;
|
|
120
|
+
if (originalModel.getValue() !== original) {
|
|
121
|
+
originalModel.setValue(original ?? '');
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
$effect(() => {
|
|
126
|
+
if (!modifiedModel) return;
|
|
127
|
+
if (modifiedModel.getValue() !== modified) {
|
|
128
|
+
suppressNextChange = true;
|
|
129
|
+
modifiedModel.setValue(modified ?? '');
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
$effect(() => {
|
|
134
|
+
if (!monacoRef || !originalModel || !modifiedModel) return;
|
|
135
|
+
if (originalModel.getLanguageId() !== language) {
|
|
136
|
+
monacoRef.editor.setModelLanguage(originalModel, language);
|
|
137
|
+
}
|
|
138
|
+
if (modifiedModel.getLanguageId() !== language) {
|
|
139
|
+
monacoRef.editor.setModelLanguage(modifiedModel, language);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
$effect(() => {
|
|
144
|
+
if (!monacoRef) return;
|
|
145
|
+
monacoRef.editor.setTheme(resolveMonacoTheme(theme));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
$effect(() => {
|
|
149
|
+
diffEditor?.updateOptions({
|
|
150
|
+
readOnly,
|
|
151
|
+
renderSideBySide: !inline,
|
|
152
|
+
renderWhitespace: renderWhitespace ? 'all' : 'none',
|
|
153
|
+
hideUnchangedRegions: { enabled: hideUnchangedRegions },
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
</script>
|
|
157
|
+
|
|
158
|
+
<div
|
|
159
|
+
bind:this={container}
|
|
160
|
+
data-slot="diff-editor"
|
|
161
|
+
class={cn(
|
|
162
|
+
'border-border bg-background relative overflow-hidden rounded-lg border',
|
|
163
|
+
className,
|
|
164
|
+
)}
|
|
165
|
+
style="height: {height};"
|
|
166
|
+
role="group"
|
|
167
|
+
aria-label={ariaLabel ?? `Diff editor (${language})`}
|
|
168
|
+
>
|
|
169
|
+
{#if !ready}
|
|
170
|
+
<div class="absolute inset-0 p-2">
|
|
171
|
+
<Skeleton class="h-full w-full" />
|
|
172
|
+
</div>
|
|
173
|
+
{/if}
|
|
174
|
+
</div>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import Sheet from './ui/sheet/sheet.svelte';
|
|
4
|
+
import SheetTrigger from './ui/sheet/sheet-trigger.svelte';
|
|
5
|
+
import SheetContent from './ui/sheet/sheet-content.svelte';
|
|
6
|
+
import SheetHeader from './ui/sheet/sheet-header.svelte';
|
|
7
|
+
import SheetTitle from './ui/sheet/sheet-title.svelte';
|
|
8
|
+
import SheetDescription from './ui/sheet/sheet-description.svelte';
|
|
9
|
+
import SheetFooter from './ui/sheet/sheet-footer.svelte';
|
|
10
|
+
import { cn } from '../utils.js';
|
|
11
|
+
|
|
12
|
+
type Side = 'top' | 'right' | 'bottom' | 'left';
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
open?: boolean;
|
|
16
|
+
side?: Side;
|
|
17
|
+
title?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
class?: string;
|
|
20
|
+
trigger?: Snippet;
|
|
21
|
+
header?: Snippet;
|
|
22
|
+
footer?: Snippet;
|
|
23
|
+
children?: Snippet;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
open = $bindable(false),
|
|
28
|
+
side = 'right',
|
|
29
|
+
title,
|
|
30
|
+
description,
|
|
31
|
+
class: className,
|
|
32
|
+
trigger,
|
|
33
|
+
header,
|
|
34
|
+
footer,
|
|
35
|
+
children,
|
|
36
|
+
}: Props = $props();
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<Sheet bind:open>
|
|
40
|
+
{#if trigger}
|
|
41
|
+
<SheetTrigger>
|
|
42
|
+
{@render trigger()}
|
|
43
|
+
</SheetTrigger>
|
|
44
|
+
{/if}
|
|
45
|
+
<SheetContent {side} class={cn('flex flex-col', className)}>
|
|
46
|
+
{#if header}
|
|
47
|
+
{@render header()}
|
|
48
|
+
{:else if title || description}
|
|
49
|
+
<SheetHeader>
|
|
50
|
+
{#if title}
|
|
51
|
+
<SheetTitle>{title}</SheetTitle>
|
|
52
|
+
{/if}
|
|
53
|
+
{#if description}
|
|
54
|
+
<SheetDescription>{description}</SheetDescription>
|
|
55
|
+
{/if}
|
|
56
|
+
</SheetHeader>
|
|
57
|
+
{/if}
|
|
58
|
+
|
|
59
|
+
<div class="flex-1 overflow-y-auto px-4 py-2">
|
|
60
|
+
{#if children}{@render children()}{/if}
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
{#if footer}
|
|
64
|
+
<SheetFooter>
|
|
65
|
+
{@render footer()}
|
|
66
|
+
</SheetFooter>
|
|
67
|
+
{/if}
|
|
68
|
+
</SheetContent>
|
|
69
|
+
</Sheet>
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Fab — Floating Action Button.
|
|
4
|
+
*
|
|
5
|
+
* Mobile-first primary-action affordance. Pinned bottom-right by
|
|
6
|
+
* default, respects `env(safe-area-inset-bottom)` so it clears the
|
|
7
|
+
* iOS home indicator. Size is fixed at 56×56 (Material spec, also
|
|
8
|
+
* comfortably above Apple's 44×44 HIG minimum).
|
|
9
|
+
*
|
|
10
|
+
* Hidden on `md:` and up by default — desktop should use a normal
|
|
11
|
+
* Button in the page header instead. Override with `alwaysVisible`
|
|
12
|
+
* if you need the FAB on all viewports.
|
|
13
|
+
*
|
|
14
|
+
* Use for: New mission, New issue, New comment — the single
|
|
15
|
+
* top-of-funnel action on a list/feed view.
|
|
16
|
+
*/
|
|
17
|
+
import type { Snippet } from 'svelte';
|
|
18
|
+
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
|
19
|
+
import { cn } from '../utils.js';
|
|
20
|
+
|
|
21
|
+
type Props = {
|
|
22
|
+
href?: string;
|
|
23
|
+
class?: string;
|
|
24
|
+
'aria-label': string;
|
|
25
|
+
/** Show on all viewports (default: hide >= md). */
|
|
26
|
+
alwaysVisible?: boolean;
|
|
27
|
+
/** Snippet for icon content (e.g. lucide icon). */
|
|
28
|
+
children?: Snippet;
|
|
29
|
+
} & Omit<HTMLButtonAttributes, 'class' | 'aria-label'> &
|
|
30
|
+
Omit<HTMLAnchorAttributes, 'class' | 'aria-label'>;
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
href,
|
|
34
|
+
class: className,
|
|
35
|
+
alwaysVisible = false,
|
|
36
|
+
children,
|
|
37
|
+
...restProps
|
|
38
|
+
}: Props = $props();
|
|
39
|
+
|
|
40
|
+
const base = cn(
|
|
41
|
+
'fixed right-4 z-40 grid size-14 place-items-center rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/30',
|
|
42
|
+
'transition-transform hover:scale-105 active:scale-95',
|
|
43
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
44
|
+
// Anchor 16px above the bottom safe area
|
|
45
|
+
'bottom-[calc(env(safe-area-inset-bottom,0px)+1rem)]',
|
|
46
|
+
alwaysVisible ? '' : 'md:hidden',
|
|
47
|
+
className,
|
|
48
|
+
);
|
|
49
|
+
</script>
|
|
50
|
+
|
|
51
|
+
{#if href}
|
|
52
|
+
<a {href} class={base} {...restProps}>
|
|
53
|
+
{#if children}{@render children()}{/if}
|
|
54
|
+
</a>
|
|
55
|
+
{:else}
|
|
56
|
+
<button type="button" class={base} {...restProps}>
|
|
57
|
+
{#if children}{@render children()}{/if}
|
|
58
|
+
</button>
|
|
59
|
+
{/if}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLFormAttributes } from 'svelte/elements';
|
|
3
|
+
import { cn, type WithElementRef } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
type Props = WithElementRef<HTMLFormAttributes, HTMLFormElement> & {
|
|
6
|
+
class?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Logical content type for the form payload. Defaults to `application/json`,
|
|
9
|
+
* which is what InertiaJS handlers expect. This is exposed as `data-content-type`
|
|
10
|
+
* on the rendered element so consumer code (e.g. an Inertia visit) can read it.
|
|
11
|
+
*
|
|
12
|
+
* Note: the native HTML `enctype` attribute does not accept `application/json`,
|
|
13
|
+
* so we deliberately don't set it. Consumers using Inertia / fetch should set
|
|
14
|
+
* the `Content-Type` header themselves.
|
|
15
|
+
*/
|
|
16
|
+
contentType?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
ref = $bindable(null),
|
|
21
|
+
method = 'post',
|
|
22
|
+
contentType = 'application/json',
|
|
23
|
+
class: className,
|
|
24
|
+
children,
|
|
25
|
+
...restProps
|
|
26
|
+
}: Props = $props();
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<form
|
|
30
|
+
bind:this={ref}
|
|
31
|
+
data-slot="form"
|
|
32
|
+
data-content-type={contentType}
|
|
33
|
+
{method}
|
|
34
|
+
class={cn('flex flex-col gap-4', className)}
|
|
35
|
+
{...restProps}
|
|
36
|
+
>
|
|
37
|
+
{@render children?.()}
|
|
38
|
+
</form>
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
let {
|
|
6
|
+
label,
|
|
7
|
+
error,
|
|
8
|
+
hint,
|
|
9
|
+
required = false,
|
|
10
|
+
htmlFor,
|
|
11
|
+
class: className,
|
|
12
|
+
children,
|
|
13
|
+
}: {
|
|
14
|
+
label?: string;
|
|
15
|
+
error?: string | null;
|
|
16
|
+
hint?: string;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
htmlFor?: string;
|
|
19
|
+
class?: string;
|
|
20
|
+
children: Snippet<[{ id: string; describedBy: string | undefined; invalid: boolean }]>;
|
|
21
|
+
} = $props();
|
|
22
|
+
|
|
23
|
+
const uid = $props.id();
|
|
24
|
+
const fieldId = $derived(htmlFor ?? `field-${uid}`);
|
|
25
|
+
const errorId = $derived(`${fieldId}-error`);
|
|
26
|
+
const hintId = $derived(`${fieldId}-hint`);
|
|
27
|
+
const describedBy = $derived(
|
|
28
|
+
error ? errorId : hint ? hintId : undefined,
|
|
29
|
+
);
|
|
30
|
+
const invalid = $derived(Boolean(error));
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<div data-slot="form-field" class={cn('flex flex-col gap-1.5', className)}>
|
|
34
|
+
{#if label}
|
|
35
|
+
<label
|
|
36
|
+
for={fieldId}
|
|
37
|
+
class="text-foreground/90 inline-flex items-center gap-1 text-sm leading-none font-medium select-none"
|
|
38
|
+
>
|
|
39
|
+
{label}
|
|
40
|
+
{#if required}
|
|
41
|
+
<span class="text-destructive" aria-hidden="true">*</span>
|
|
42
|
+
{/if}
|
|
43
|
+
</label>
|
|
44
|
+
{/if}
|
|
45
|
+
{@render children({ id: fieldId, describedBy, invalid })}
|
|
46
|
+
{#if error}
|
|
47
|
+
<p id={errorId} class="text-destructive text-xs leading-tight" role="alert">{error}</p>
|
|
48
|
+
{:else if hint}
|
|
49
|
+
<p id={hintId} class="text-muted-foreground text-xs leading-tight">{hint}</p>
|
|
50
|
+
{/if}
|
|
51
|
+
</div>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import { cn, type WithElementRef } from '../utils.js';
|
|
3
|
+
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
|
4
|
+
import { type VariantProps, tv } from 'tailwind-variants';
|
|
5
|
+
|
|
6
|
+
// Icon-only button/link. Distinct from `Button size="icon"` in that it
|
|
7
|
+
// defaults to the muted-foreground + hover:bg-accent "toolbar glyph" look
|
|
8
|
+
// used pervasively in TopBar / file trees / tooltip dismiss buttons, and
|
|
9
|
+
// offers a 44×44 `tap` size for mobile hit targets.
|
|
10
|
+
export const iconButtonVariants = tv({
|
|
11
|
+
base: "inline-grid place-items-center shrink-0 rounded-md transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
12
|
+
variants: {
|
|
13
|
+
variant: {
|
|
14
|
+
// Default toolbar glyph: muted until hovered.
|
|
15
|
+
ghost: 'text-muted-foreground hover:bg-accent hover:text-foreground',
|
|
16
|
+
outline:
|
|
17
|
+
'border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground',
|
|
18
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
|
|
19
|
+
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs',
|
|
20
|
+
destructive: 'text-destructive hover:bg-destructive/10',
|
|
21
|
+
},
|
|
22
|
+
size: {
|
|
23
|
+
sm: 'size-8',
|
|
24
|
+
default: 'size-9',
|
|
25
|
+
lg: 'size-10',
|
|
26
|
+
// 44×44 — meets the recommended mobile tap target.
|
|
27
|
+
tap: 'size-11',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
defaultVariants: {
|
|
31
|
+
variant: 'ghost',
|
|
32
|
+
size: 'default',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export type IconButtonVariant = VariantProps<typeof iconButtonVariants>['variant'];
|
|
37
|
+
export type IconButtonSize = VariantProps<typeof iconButtonVariants>['size'];
|
|
38
|
+
|
|
39
|
+
export type IconButtonProps = WithElementRef<HTMLButtonAttributes> &
|
|
40
|
+
WithElementRef<HTMLAnchorAttributes> & {
|
|
41
|
+
variant?: IconButtonVariant;
|
|
42
|
+
size?: IconButtonSize;
|
|
43
|
+
/** Required for an icon-only control. */
|
|
44
|
+
'aria-label': string;
|
|
45
|
+
};
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<script lang="ts">
|
|
49
|
+
let {
|
|
50
|
+
class: className,
|
|
51
|
+
variant = 'ghost',
|
|
52
|
+
size = 'default',
|
|
53
|
+
ref = $bindable(null),
|
|
54
|
+
href = undefined,
|
|
55
|
+
type = 'button',
|
|
56
|
+
disabled = false,
|
|
57
|
+
children,
|
|
58
|
+
...restProps
|
|
59
|
+
}: IconButtonProps = $props();
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
{#if href}
|
|
63
|
+
<a
|
|
64
|
+
bind:this={ref}
|
|
65
|
+
data-slot="icon-button"
|
|
66
|
+
class={cn(iconButtonVariants({ variant, size }), className)}
|
|
67
|
+
href={disabled ? undefined : href}
|
|
68
|
+
aria-disabled={disabled ? true : undefined}
|
|
69
|
+
role={disabled ? 'link' : undefined}
|
|
70
|
+
tabindex={disabled ? -1 : undefined}
|
|
71
|
+
{...restProps}
|
|
72
|
+
>
|
|
73
|
+
{@render children?.()}
|
|
74
|
+
</a>
|
|
75
|
+
{:else}
|
|
76
|
+
<button
|
|
77
|
+
bind:this={ref}
|
|
78
|
+
data-slot="icon-button"
|
|
79
|
+
class={cn(iconButtonVariants({ variant, size }), className)}
|
|
80
|
+
{type}
|
|
81
|
+
{disabled}
|
|
82
|
+
{...restProps}
|
|
83
|
+
>
|
|
84
|
+
{@render children?.()}
|
|
85
|
+
</button>
|
|
86
|
+
{/if}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { render } from '@testing-library/svelte';
|
|
3
|
+
import { createRawSnippet } from 'svelte';
|
|
4
|
+
import IconButton, { iconButtonVariants } from './IconButton.svelte';
|
|
5
|
+
|
|
6
|
+
/** A trivial snippet to pass as the icon child. */
|
|
7
|
+
const icon = createRawSnippet(() => ({
|
|
8
|
+
render: () => `<svg data-testid="icon"></svg>`,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
describe('IconButton', () => {
|
|
12
|
+
it('renders as a <button> by default', () => {
|
|
13
|
+
const { container } = render(IconButton, {
|
|
14
|
+
props: { 'aria-label': 'Notifications', children: icon },
|
|
15
|
+
});
|
|
16
|
+
const el = container.querySelector('[data-slot="icon-button"]');
|
|
17
|
+
expect(el).toBeInTheDocument();
|
|
18
|
+
expect(el?.tagName).toBe('BUTTON');
|
|
19
|
+
expect(el).toHaveAttribute('type', 'button');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('renders as an <a> when href is set', () => {
|
|
23
|
+
const { container } = render(IconButton, {
|
|
24
|
+
props: { 'aria-label': 'Go home', href: '/home', children: icon },
|
|
25
|
+
});
|
|
26
|
+
const el = container.querySelector('[data-slot="icon-button"]');
|
|
27
|
+
expect(el?.tagName).toBe('A');
|
|
28
|
+
expect(el).toHaveAttribute('href', '/home');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('exposes the required aria-label on the control', () => {
|
|
32
|
+
const { getByLabelText } = render(IconButton, {
|
|
33
|
+
props: { 'aria-label': 'Search', children: icon },
|
|
34
|
+
});
|
|
35
|
+
expect(getByLabelText('Search')).toBeInTheDocument();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('renders the icon child', () => {
|
|
39
|
+
const { getByTestId } = render(IconButton, {
|
|
40
|
+
props: { 'aria-label': 'Add', children: icon },
|
|
41
|
+
});
|
|
42
|
+
expect(getByTestId('icon')).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('applies the default variant (ghost) and default size classes', () => {
|
|
46
|
+
const { container } = render(IconButton, {
|
|
47
|
+
props: { 'aria-label': 'Default', children: icon },
|
|
48
|
+
});
|
|
49
|
+
const el = container.querySelector('[data-slot="icon-button"]')!;
|
|
50
|
+
// ghost variant => muted-foreground; default size => size-9
|
|
51
|
+
expect(el.className).toContain('text-muted-foreground');
|
|
52
|
+
expect(el.className).toContain('size-9');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('applies the tap size (44px / size-11) for mobile hit targets', () => {
|
|
56
|
+
const { container } = render(IconButton, {
|
|
57
|
+
props: { 'aria-label': 'Tap', size: 'tap', children: icon },
|
|
58
|
+
});
|
|
59
|
+
const el = container.querySelector('[data-slot="icon-button"]')!;
|
|
60
|
+
expect(el.className).toContain('size-11');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it.each([
|
|
64
|
+
['sm', 'size-8'],
|
|
65
|
+
['default', 'size-9'],
|
|
66
|
+
['lg', 'size-10'],
|
|
67
|
+
['tap', 'size-11'],
|
|
68
|
+
] as const)('applies the %s size class %s', (size, expectedClass) => {
|
|
69
|
+
const { container } = render(IconButton, {
|
|
70
|
+
props: { 'aria-label': 'Sized', size, children: icon },
|
|
71
|
+
});
|
|
72
|
+
const el = container.querySelector('[data-slot="icon-button"]')!;
|
|
73
|
+
expect(el.className).toContain(expectedClass);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it.each([
|
|
77
|
+
['ghost', 'text-muted-foreground'],
|
|
78
|
+
['outline', 'border'],
|
|
79
|
+
['default', 'bg-primary'],
|
|
80
|
+
['secondary', 'bg-secondary'],
|
|
81
|
+
['destructive', 'text-destructive'],
|
|
82
|
+
] as const)('applies the %s variant classes', (variant, expectedClass) => {
|
|
83
|
+
const { container } = render(IconButton, {
|
|
84
|
+
props: { 'aria-label': 'Variant', variant, children: icon },
|
|
85
|
+
});
|
|
86
|
+
const el = container.querySelector('[data-slot="icon-button"]')!;
|
|
87
|
+
expect(el.className).toContain(expectedClass);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('disables the native button when disabled', () => {
|
|
91
|
+
const { container } = render(IconButton, {
|
|
92
|
+
props: { 'aria-label': 'Disabled', disabled: true, children: icon },
|
|
93
|
+
});
|
|
94
|
+
const el = container.querySelector('button')!;
|
|
95
|
+
expect(el).toBeDisabled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('disables the anchor variant via aria-disabled and drops href', () => {
|
|
99
|
+
const { container } = render(IconButton, {
|
|
100
|
+
props: { 'aria-label': 'Disabled link', href: '/x', disabled: true, children: icon },
|
|
101
|
+
});
|
|
102
|
+
const el = container.querySelector('a')!;
|
|
103
|
+
expect(el).toHaveAttribute('aria-disabled', 'true');
|
|
104
|
+
expect(el).not.toHaveAttribute('href');
|
|
105
|
+
expect(el).toHaveAttribute('role', 'link');
|
|
106
|
+
expect(el).toHaveAttribute('tabindex', '-1');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('merges a custom class with the variant classes', () => {
|
|
110
|
+
const { container } = render(IconButton, {
|
|
111
|
+
props: { 'aria-label': 'Custom', class: 'my-custom-class', children: icon },
|
|
112
|
+
});
|
|
113
|
+
const el = container.querySelector('[data-slot="icon-button"]')!;
|
|
114
|
+
expect(el.className).toContain('my-custom-class');
|
|
115
|
+
expect(el.className).toContain('size-9');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('forwards arbitrary props (e.g. data-testid) to the element', () => {
|
|
119
|
+
const { getByTestId } = render(IconButton, {
|
|
120
|
+
props: { 'aria-label': 'Forwarded', 'data-testid': 'fwd', children: icon },
|
|
121
|
+
});
|
|
122
|
+
expect(getByTestId('fwd').tagName).toBe('BUTTON');
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('iconButtonVariants', () => {
|
|
127
|
+
it('returns base classes plus default variant/size', () => {
|
|
128
|
+
const cls = iconButtonVariants();
|
|
129
|
+
expect(cls).toContain('inline-grid');
|
|
130
|
+
expect(cls).toContain('text-muted-foreground'); // ghost default
|
|
131
|
+
expect(cls).toContain('size-9'); // default size
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('honours explicit variant + size args', () => {
|
|
135
|
+
const cls = iconButtonVariants({ variant: 'destructive', size: 'tap' });
|
|
136
|
+
expect(cls).toContain('text-destructive');
|
|
137
|
+
expect(cls).toContain('size-11');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import { cn } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
/** Code text. If `children` is provided, that wins. */
|
|
7
|
+
code?: string;
|
|
8
|
+
children?: Snippet;
|
|
9
|
+
class?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const { code, children, class: className }: Props = $props();
|
|
13
|
+
</script>
|
|
14
|
+
|
|
15
|
+
<!--
|
|
16
|
+
Inline `<code>` — single-line, no syntax highlighting (intentionally cheap;
|
|
17
|
+
pages with hundreds of inline code spans should not load Shiki). Pair with
|
|
18
|
+
`<CodeBlock>` for multi-line / highlighted samples.
|
|
19
|
+
-->
|
|
20
|
+
<code
|
|
21
|
+
data-slot="inline-code"
|
|
22
|
+
class={cn(
|
|
23
|
+
'bg-muted/60 text-foreground border-border/60 rounded border px-1.5 py-0.5 font-mono text-[0.875em]',
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
>
|
|
27
|
+
{#if children}{@render children()}{:else}{code}{/if}
|
|
28
|
+
</code>
|