@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,175 @@
|
|
|
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
|
+
|
|
8
|
+
export type CodeEditorTheme = 'light' | 'dark' | 'auto';
|
|
9
|
+
|
|
10
|
+
type Props = {
|
|
11
|
+
/** Editor content (bindable). */
|
|
12
|
+
value?: string;
|
|
13
|
+
/** Monaco language id, e.g. "typescript", "yaml", "markdown". */
|
|
14
|
+
language?: string;
|
|
15
|
+
/** Theme mode. "auto" follows the .dark class on <html>. */
|
|
16
|
+
theme?: CodeEditorTheme;
|
|
17
|
+
/** Read-only mode (disables editing but keeps copy/select). */
|
|
18
|
+
readOnly?: boolean;
|
|
19
|
+
/** CSS height for the editor container. */
|
|
20
|
+
height?: string;
|
|
21
|
+
/** Show Monaco's minimap. Off by default to keep things calm. */
|
|
22
|
+
minimap?: boolean;
|
|
23
|
+
/** Show line numbers. */
|
|
24
|
+
lineNumbers?: boolean;
|
|
25
|
+
/** Soft word wrap. */
|
|
26
|
+
wordWrap?: boolean;
|
|
27
|
+
/** Tab size (spaces). */
|
|
28
|
+
tabSize?: number;
|
|
29
|
+
/** Container className. */
|
|
30
|
+
class?: string;
|
|
31
|
+
/** Optional placeholder shown when value is empty. */
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
/** Called on every value change. */
|
|
34
|
+
onchange?: (value: string) => void;
|
|
35
|
+
/** Aria label for the editor (accessibility). */
|
|
36
|
+
ariaLabel?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
let {
|
|
40
|
+
value = $bindable(''),
|
|
41
|
+
language = 'plaintext',
|
|
42
|
+
theme = 'auto',
|
|
43
|
+
readOnly = false,
|
|
44
|
+
height = '400px',
|
|
45
|
+
minimap = false,
|
|
46
|
+
lineNumbers = true,
|
|
47
|
+
wordWrap = true,
|
|
48
|
+
tabSize = 2,
|
|
49
|
+
class: className,
|
|
50
|
+
placeholder,
|
|
51
|
+
onchange,
|
|
52
|
+
ariaLabel,
|
|
53
|
+
}: Props = $props();
|
|
54
|
+
|
|
55
|
+
let container: HTMLDivElement;
|
|
56
|
+
let editor: MonacoNs.editor.IStandaloneCodeEditor | null = null;
|
|
57
|
+
let monacoRef: typeof MonacoNs | null = null;
|
|
58
|
+
let ready = $state(false);
|
|
59
|
+
let suppressNextChange = false;
|
|
60
|
+
|
|
61
|
+
onMount(() => {
|
|
62
|
+
const p = loadMonaco();
|
|
63
|
+
if (!p) return;
|
|
64
|
+
|
|
65
|
+
let disposed = false;
|
|
66
|
+
|
|
67
|
+
p.then((monaco) => {
|
|
68
|
+
if (disposed || !container) return;
|
|
69
|
+
monacoRef = monaco;
|
|
70
|
+
|
|
71
|
+
editor = monaco.editor.create(container, {
|
|
72
|
+
value: value ?? '',
|
|
73
|
+
language,
|
|
74
|
+
theme: resolveMonacoTheme(theme),
|
|
75
|
+
readOnly,
|
|
76
|
+
minimap: { enabled: minimap },
|
|
77
|
+
lineNumbers: lineNumbers ? 'on' : 'off',
|
|
78
|
+
wordWrap: wordWrap ? 'on' : 'off',
|
|
79
|
+
tabSize,
|
|
80
|
+
automaticLayout: true,
|
|
81
|
+
scrollBeyondLastLine: false,
|
|
82
|
+
fontSize: 13,
|
|
83
|
+
fontFamily:
|
|
84
|
+
'"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
|
|
85
|
+
smoothScrolling: true,
|
|
86
|
+
cursorBlinking: 'smooth',
|
|
87
|
+
padding: { top: 12, bottom: 12 },
|
|
88
|
+
renderLineHighlight: 'all',
|
|
89
|
+
ariaLabel: ariaLabel ?? `Code editor (${language})`,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
editor.onDidChangeModelContent(() => {
|
|
93
|
+
if (!editor || suppressNextChange) {
|
|
94
|
+
suppressNextChange = false;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const next = editor.getValue();
|
|
98
|
+
value = next;
|
|
99
|
+
onchange?.(next);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
ready = true;
|
|
103
|
+
}).catch((err) => {
|
|
104
|
+
// eslint-disable-next-line no-console
|
|
105
|
+
console.error('[@nucel/ui] Monaco failed to load', err);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
disposed = true;
|
|
110
|
+
};
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
onDestroy(() => {
|
|
114
|
+
editor?.dispose();
|
|
115
|
+
editor = null;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// React to prop changes after mount.
|
|
119
|
+
$effect(() => {
|
|
120
|
+
if (!editor) return;
|
|
121
|
+
const current = editor.getValue();
|
|
122
|
+
if (value !== current) {
|
|
123
|
+
suppressNextChange = true;
|
|
124
|
+
editor.setValue(value ?? '');
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
$effect(() => {
|
|
129
|
+
if (!editor || !monacoRef) return;
|
|
130
|
+
const model = editor.getModel();
|
|
131
|
+
if (model && model.getLanguageId() !== language) {
|
|
132
|
+
monacoRef.editor.setModelLanguage(model, language);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
$effect(() => {
|
|
137
|
+
if (!monacoRef) return;
|
|
138
|
+
monacoRef.editor.setTheme(resolveMonacoTheme(theme));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
$effect(() => {
|
|
142
|
+
editor?.updateOptions({
|
|
143
|
+
readOnly,
|
|
144
|
+
minimap: { enabled: minimap },
|
|
145
|
+
lineNumbers: lineNumbers ? 'on' : 'off',
|
|
146
|
+
wordWrap: wordWrap ? 'on' : 'off',
|
|
147
|
+
tabSize,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
</script>
|
|
151
|
+
|
|
152
|
+
<div
|
|
153
|
+
bind:this={container}
|
|
154
|
+
data-slot="code-editor"
|
|
155
|
+
class={cn(
|
|
156
|
+
'border-border bg-background relative overflow-hidden rounded-lg border',
|
|
157
|
+
className,
|
|
158
|
+
)}
|
|
159
|
+
style="height: {height};"
|
|
160
|
+
role="group"
|
|
161
|
+
aria-label={ariaLabel ?? `Code editor (${language})`}
|
|
162
|
+
>
|
|
163
|
+
{#if !ready}
|
|
164
|
+
<div class="absolute inset-0 p-2">
|
|
165
|
+
<Skeleton class="h-full w-full" />
|
|
166
|
+
</div>
|
|
167
|
+
{/if}
|
|
168
|
+
{#if ready && placeholder && (!value || value.length === 0)}
|
|
169
|
+
<div
|
|
170
|
+
class="text-muted-foreground pointer-events-none absolute left-14 top-3 font-mono text-[13px] opacity-60"
|
|
171
|
+
>
|
|
172
|
+
{placeholder}
|
|
173
|
+
</div>
|
|
174
|
+
{/if}
|
|
175
|
+
</div>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { HTMLInputAttributes } from 'svelte/elements';
|
|
3
|
+
import { cn, type WithElementRef } from '../utils.js';
|
|
4
|
+
|
|
5
|
+
// Native <input type="color"> wrapper styled to match @nucel/ui form
|
|
6
|
+
// controls (matching border/ring/focus tokens). Used for label colours,
|
|
7
|
+
// theme pickers, etc. Optionally renders the current hex value alongside.
|
|
8
|
+
type Props = Omit<WithElementRef<HTMLInputAttributes>, 'type'> & {
|
|
9
|
+
value?: string;
|
|
10
|
+
/** Show the current hex value next to the swatch. */
|
|
11
|
+
showValue?: boolean;
|
|
12
|
+
class?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
ref = $bindable(null),
|
|
17
|
+
value = $bindable('#000000'),
|
|
18
|
+
showValue = false,
|
|
19
|
+
class: className,
|
|
20
|
+
'data-slot': dataSlot = 'color-input',
|
|
21
|
+
...restProps
|
|
22
|
+
}: Props = $props();
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<span data-slot={dataSlot} class={cn('inline-flex items-center gap-2', showValue && 'min-w-0')}>
|
|
26
|
+
<input
|
|
27
|
+
bind:this={ref}
|
|
28
|
+
type="color"
|
|
29
|
+
bind:value
|
|
30
|
+
class={cn(
|
|
31
|
+
'h-9 w-12 cursor-pointer rounded-md border border-input bg-background p-1 shadow-xs transition-[color,box-shadow] outline-none',
|
|
32
|
+
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
|
33
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
34
|
+
className,
|
|
35
|
+
)}
|
|
36
|
+
{...restProps}
|
|
37
|
+
/>
|
|
38
|
+
{#if showValue}
|
|
39
|
+
<span class="text-muted-foreground font-mono text-xs uppercase">{value}</span>
|
|
40
|
+
{/if}
|
|
41
|
+
</span>
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render } from '@testing-library/svelte';
|
|
3
|
+
import { tick } from 'svelte';
|
|
4
|
+
import ColorInput from './ColorInput.svelte';
|
|
5
|
+
|
|
6
|
+
/** Grab the native color input from a rendered ColorInput. */
|
|
7
|
+
function getColorInput(container: HTMLElement): HTMLInputElement {
|
|
8
|
+
return container.querySelector('input[type="color"]') as HTMLInputElement;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('ColorInput', () => {
|
|
12
|
+
it('renders a native <input type="color"> inside the color-input slot', () => {
|
|
13
|
+
const { container } = render(ColorInput, { props: { value: '#3b82f6' } });
|
|
14
|
+
const wrapper = container.querySelector('[data-slot="color-input"]');
|
|
15
|
+
expect(wrapper).toBeInTheDocument();
|
|
16
|
+
const input = getColorInput(container);
|
|
17
|
+
expect(input).toBeInTheDocument();
|
|
18
|
+
expect(input.type).toBe('color');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('reflects the initial value on the input', () => {
|
|
22
|
+
const { container } = render(ColorInput, { props: { value: '#22c55e' } });
|
|
23
|
+
expect(getColorInput(container).value).toBe('#22c55e');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('defaults to #000000 when no value is supplied', () => {
|
|
27
|
+
const { container } = render(ColorInput, { props: {} });
|
|
28
|
+
expect(getColorInput(container).value).toBe('#000000');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('does not show the hex value by default', () => {
|
|
32
|
+
const { container } = render(ColorInput, { props: { value: '#abcdef' } });
|
|
33
|
+
const wrapper = container.querySelector('[data-slot="color-input"]')!;
|
|
34
|
+
// Only the <input> child, no trailing text span.
|
|
35
|
+
expect(wrapper.querySelectorAll('span').length).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('shows the hex value (uppercased via class) when showValue is set', () => {
|
|
39
|
+
const { container } = render(ColorInput, {
|
|
40
|
+
props: { value: '#abcdef', showValue: true },
|
|
41
|
+
});
|
|
42
|
+
const wrapper = container.querySelector('[data-slot="color-input"]')!;
|
|
43
|
+
const valueSpan = wrapper.querySelector('span');
|
|
44
|
+
expect(valueSpan).toBeInTheDocument();
|
|
45
|
+
expect(valueSpan).toHaveTextContent('#abcdef');
|
|
46
|
+
expect(valueSpan?.className).toContain('uppercase');
|
|
47
|
+
expect(valueSpan?.className).toContain('font-mono');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('updates the displayed hex value when the input changes', async () => {
|
|
51
|
+
const { container } = render(ColorInput, {
|
|
52
|
+
props: { value: '#000000', showValue: true },
|
|
53
|
+
});
|
|
54
|
+
const input = getColorInput(container);
|
|
55
|
+
|
|
56
|
+
input.value = '#ff8800';
|
|
57
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
58
|
+
await tick();
|
|
59
|
+
|
|
60
|
+
const valueSpan = container.querySelector('[data-slot="color-input"] span');
|
|
61
|
+
expect(valueSpan).toHaveTextContent('#ff8800');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('propagates an input change through the two-way bound value', async () => {
|
|
65
|
+
// `bind:value` drives the `showValue` span; if the bound value updates
|
|
66
|
+
// on input, the rendered hex text reflects the new colour.
|
|
67
|
+
const { container } = render(ColorInput, {
|
|
68
|
+
props: { value: '#111111', showValue: true },
|
|
69
|
+
});
|
|
70
|
+
const input = getColorInput(container);
|
|
71
|
+
|
|
72
|
+
input.value = '#00ff00';
|
|
73
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
74
|
+
await tick();
|
|
75
|
+
|
|
76
|
+
expect(input.value).toBe('#00ff00');
|
|
77
|
+
expect(container.querySelector('[data-slot="color-input"] span')).toHaveTextContent(
|
|
78
|
+
'#00ff00',
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('emits a change event consumers can listen to', async () => {
|
|
83
|
+
const onchange = vi.fn();
|
|
84
|
+
const { container } = render(ColorInput, {
|
|
85
|
+
props: { value: '#000000', onchange },
|
|
86
|
+
});
|
|
87
|
+
const input = getColorInput(container);
|
|
88
|
+
|
|
89
|
+
input.value = '#123456';
|
|
90
|
+
input.dispatchEvent(new Event('change', { bubbles: true }));
|
|
91
|
+
await tick();
|
|
92
|
+
|
|
93
|
+
expect(onchange).toHaveBeenCalledOnce();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('forwards the disabled attribute to the native input', () => {
|
|
97
|
+
const { container } = render(ColorInput, {
|
|
98
|
+
props: { value: '#ef4444', disabled: true },
|
|
99
|
+
});
|
|
100
|
+
expect(getColorInput(container)).toBeDisabled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('merges a custom class onto the input', () => {
|
|
104
|
+
const { container } = render(ColorInput, {
|
|
105
|
+
props: { value: '#000000', class: 'my-swatch' },
|
|
106
|
+
});
|
|
107
|
+
const input = getColorInput(container);
|
|
108
|
+
expect(input.className).toContain('my-swatch');
|
|
109
|
+
// Base styling is preserved.
|
|
110
|
+
expect(input.className).toContain('rounded-md');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('honours a custom data-slot', () => {
|
|
114
|
+
const { container } = render(ColorInput, {
|
|
115
|
+
props: { value: '#000000', 'data-slot': 'theme-color' },
|
|
116
|
+
});
|
|
117
|
+
expect(container.querySelector('[data-slot="theme-color"]')).toBeInTheDocument();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('forwards arbitrary props (e.g. aria-label) to the input', () => {
|
|
121
|
+
const { getByLabelText } = render(ColorInput, {
|
|
122
|
+
props: { value: '#000000', 'aria-label': 'Pick a label colour' },
|
|
123
|
+
});
|
|
124
|
+
expect(getByLabelText('Pick a label colour')).toBeInTheDocument();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type ComboboxOption = {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
};
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<script lang="ts">
|
|
10
|
+
import { Combobox as ComboboxPrimitive } from 'bits-ui';
|
|
11
|
+
import { CheckIcon, ChevronsUpDownIcon } from '@lucide/svelte';
|
|
12
|
+
import { cn } from '../utils.js';
|
|
13
|
+
|
|
14
|
+
type Props = {
|
|
15
|
+
options: ComboboxOption[];
|
|
16
|
+
value?: string;
|
|
17
|
+
placeholder?: string;
|
|
18
|
+
emptyMessage?: string;
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
class?: string;
|
|
21
|
+
inputClass?: string;
|
|
22
|
+
contentClass?: string;
|
|
23
|
+
onValueChange?: (value: string) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
options,
|
|
28
|
+
value = $bindable(''),
|
|
29
|
+
placeholder = 'Select…',
|
|
30
|
+
emptyMessage = 'No results',
|
|
31
|
+
disabled = false,
|
|
32
|
+
class: className,
|
|
33
|
+
inputClass,
|
|
34
|
+
contentClass,
|
|
35
|
+
onValueChange,
|
|
36
|
+
}: Props = $props();
|
|
37
|
+
|
|
38
|
+
let search = $state('');
|
|
39
|
+
|
|
40
|
+
const filtered = $derived.by(() => {
|
|
41
|
+
const q = search.trim().toLowerCase();
|
|
42
|
+
if (!q) return options;
|
|
43
|
+
return options.filter((o) => o.label.toLowerCase().includes(q));
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const inputValue = $derived(options.find((o) => o.value === value)?.label ?? '');
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<ComboboxPrimitive.Root
|
|
50
|
+
type="single"
|
|
51
|
+
bind:value
|
|
52
|
+
{inputValue}
|
|
53
|
+
{disabled}
|
|
54
|
+
{onValueChange}
|
|
55
|
+
>
|
|
56
|
+
<div data-slot="combobox" class={cn('relative w-full', className)}>
|
|
57
|
+
<ComboboxPrimitive.Input
|
|
58
|
+
oninput={(e: Event) => (search = (e.currentTarget as HTMLInputElement).value)}
|
|
59
|
+
class={cn(
|
|
60
|
+
'border-input bg-background placeholder:text-muted-foreground flex h-9 w-full rounded-md border px-3 py-1 pr-8 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
|
61
|
+
inputClass,
|
|
62
|
+
)}
|
|
63
|
+
{placeholder}
|
|
64
|
+
/>
|
|
65
|
+
<ComboboxPrimitive.Trigger
|
|
66
|
+
class="text-muted-foreground absolute top-1/2 right-2 -translate-y-1/2 outline-none"
|
|
67
|
+
aria-label="Open options"
|
|
68
|
+
>
|
|
69
|
+
<ChevronsUpDownIcon class="size-4" />
|
|
70
|
+
</ComboboxPrimitive.Trigger>
|
|
71
|
+
</div>
|
|
72
|
+
<ComboboxPrimitive.Portal>
|
|
73
|
+
<ComboboxPrimitive.Content
|
|
74
|
+
sideOffset={4}
|
|
75
|
+
class={cn(
|
|
76
|
+
'bg-popover text-popover-foreground border-border data-[state=open]:animate-in data-[state=closed]:animate-out z-50 max-h-72 min-w-[var(--bits-combobox-anchor-width)] overflow-hidden rounded-md border p-1 shadow-md',
|
|
77
|
+
contentClass,
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<ComboboxPrimitive.Viewport class="max-h-64 overflow-y-auto p-1">
|
|
81
|
+
{#each filtered as opt (opt.value)}
|
|
82
|
+
<ComboboxPrimitive.Item
|
|
83
|
+
value={opt.value}
|
|
84
|
+
label={opt.label}
|
|
85
|
+
disabled={opt.disabled}
|
|
86
|
+
class="data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
|
87
|
+
>
|
|
88
|
+
{#snippet children({ selected })}
|
|
89
|
+
<span class="flex-1">{opt.label}</span>
|
|
90
|
+
{#if selected}
|
|
91
|
+
<CheckIcon class="size-4" />
|
|
92
|
+
{/if}
|
|
93
|
+
{/snippet}
|
|
94
|
+
</ComboboxPrimitive.Item>
|
|
95
|
+
{:else}
|
|
96
|
+
<div class="text-muted-foreground px-2 py-3 text-center text-sm">
|
|
97
|
+
{emptyMessage}
|
|
98
|
+
</div>
|
|
99
|
+
{/each}
|
|
100
|
+
</ComboboxPrimitive.Viewport>
|
|
101
|
+
</ComboboxPrimitive.Content>
|
|
102
|
+
</ComboboxPrimitive.Portal>
|
|
103
|
+
</ComboboxPrimitive.Root>
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
import type { Component } from 'svelte';
|
|
3
|
+
|
|
4
|
+
export type CommandPaletteItem = {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
group?: string;
|
|
9
|
+
keywords?: string[];
|
|
10
|
+
shortcut?: string;
|
|
11
|
+
icon?: Component<{ class?: string }>;
|
|
12
|
+
onSelect?: () => void;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
};
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<script lang="ts">
|
|
18
|
+
import { Command as CommandPrimitive, Dialog as DialogPrimitive } from 'bits-ui';
|
|
19
|
+
import { SearchIcon } from '@lucide/svelte';
|
|
20
|
+
import { cn } from '../utils.js';
|
|
21
|
+
|
|
22
|
+
type Props = {
|
|
23
|
+
open?: boolean;
|
|
24
|
+
items: CommandPaletteItem[];
|
|
25
|
+
placeholder?: string;
|
|
26
|
+
emptyMessage?: string;
|
|
27
|
+
label?: string;
|
|
28
|
+
class?: string;
|
|
29
|
+
/** Close palette on item select (defaults true) */
|
|
30
|
+
closeOnSelect?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let {
|
|
34
|
+
open = $bindable(false),
|
|
35
|
+
items,
|
|
36
|
+
placeholder = 'Type a command or search…',
|
|
37
|
+
emptyMessage = 'No results found.',
|
|
38
|
+
label = 'Command palette',
|
|
39
|
+
class: className,
|
|
40
|
+
closeOnSelect = true,
|
|
41
|
+
}: Props = $props();
|
|
42
|
+
|
|
43
|
+
// Group items by `group` (default group = "")
|
|
44
|
+
const grouped = $derived.by(() => {
|
|
45
|
+
const map = new Map<string, CommandPaletteItem[]>();
|
|
46
|
+
for (const item of items) {
|
|
47
|
+
const g = item.group ?? '';
|
|
48
|
+
if (!map.has(g)) map.set(g, []);
|
|
49
|
+
map.get(g)!.push(item);
|
|
50
|
+
}
|
|
51
|
+
return Array.from(map.entries());
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function handleSelect(item: CommandPaletteItem) {
|
|
55
|
+
if (item.disabled) return;
|
|
56
|
+
item.onSelect?.();
|
|
57
|
+
if (closeOnSelect) open = false;
|
|
58
|
+
}
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<DialogPrimitive.Root bind:open>
|
|
62
|
+
<DialogPrimitive.Portal>
|
|
63
|
+
<DialogPrimitive.Overlay
|
|
64
|
+
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50"
|
|
65
|
+
/>
|
|
66
|
+
<DialogPrimitive.Content
|
|
67
|
+
class={cn(
|
|
68
|
+
'bg-popover text-popover-foreground border-border data-[state=open]:animate-in data-[state=closed]:animate-out fixed top-[20%] left-1/2 z-50 w-full max-w-lg -translate-x-1/2 overflow-hidden rounded-lg border shadow-lg',
|
|
69
|
+
className,
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
<DialogPrimitive.Title class="sr-only">{label}</DialogPrimitive.Title>
|
|
73
|
+
<DialogPrimitive.Description class="sr-only">{placeholder}</DialogPrimitive.Description>
|
|
74
|
+
|
|
75
|
+
<CommandPrimitive.Root {label} class="flex flex-col" loop>
|
|
76
|
+
<div class="border-border flex items-center gap-2 border-b px-3">
|
|
77
|
+
<SearchIcon class="text-muted-foreground size-4 shrink-0" />
|
|
78
|
+
<CommandPrimitive.Input
|
|
79
|
+
{placeholder}
|
|
80
|
+
class="placeholder:text-muted-foreground flex h-11 w-full bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
<CommandPrimitive.List class="max-h-80 overflow-y-auto p-1">
|
|
84
|
+
<CommandPrimitive.Empty
|
|
85
|
+
class="text-muted-foreground py-6 text-center text-sm"
|
|
86
|
+
>
|
|
87
|
+
{emptyMessage}
|
|
88
|
+
</CommandPrimitive.Empty>
|
|
89
|
+
<CommandPrimitive.Viewport>
|
|
90
|
+
{#each grouped as [group, groupItems] (group)}
|
|
91
|
+
<CommandPrimitive.Group value={group || 'default'} class="overflow-hidden p-1">
|
|
92
|
+
{#if group}
|
|
93
|
+
<CommandPrimitive.GroupHeading
|
|
94
|
+
class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
|
|
95
|
+
>
|
|
96
|
+
{group}
|
|
97
|
+
</CommandPrimitive.GroupHeading>
|
|
98
|
+
{/if}
|
|
99
|
+
<CommandPrimitive.GroupItems>
|
|
100
|
+
{#each groupItems as item (item.id)}
|
|
101
|
+
<CommandPrimitive.Item
|
|
102
|
+
value={item.id}
|
|
103
|
+
keywords={item.keywords}
|
|
104
|
+
disabled={item.disabled}
|
|
105
|
+
onSelect={() => handleSelect(item)}
|
|
106
|
+
class="data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50"
|
|
107
|
+
>
|
|
108
|
+
{#if item.icon}
|
|
109
|
+
{@const Icon = item.icon}
|
|
110
|
+
<Icon class="size-4 shrink-0" />
|
|
111
|
+
{/if}
|
|
112
|
+
<div class="flex flex-1 flex-col">
|
|
113
|
+
<span>{item.label}</span>
|
|
114
|
+
{#if item.description}
|
|
115
|
+
<span class="text-muted-foreground text-xs">{item.description}</span>
|
|
116
|
+
{/if}
|
|
117
|
+
</div>
|
|
118
|
+
{#if item.shortcut}
|
|
119
|
+
<kbd
|
|
120
|
+
class="bg-muted text-muted-foreground ms-auto rounded px-1.5 py-0.5 font-mono text-[10px]"
|
|
121
|
+
>
|
|
122
|
+
{item.shortcut}
|
|
123
|
+
</kbd>
|
|
124
|
+
{/if}
|
|
125
|
+
</CommandPrimitive.Item>
|
|
126
|
+
{/each}
|
|
127
|
+
</CommandPrimitive.GroupItems>
|
|
128
|
+
</CommandPrimitive.Group>
|
|
129
|
+
{/each}
|
|
130
|
+
</CommandPrimitive.Viewport>
|
|
131
|
+
</CommandPrimitive.List>
|
|
132
|
+
</CommandPrimitive.Root>
|
|
133
|
+
</DialogPrimitive.Content>
|
|
134
|
+
</DialogPrimitive.Portal>
|
|
135
|
+
</DialogPrimitive.Root>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type CopyButtonVariant = ButtonVariant;
|
|
3
|
+
export type CopyButtonSize = ButtonSize;
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { CheckIcon, CopyIcon } from '@lucide/svelte';
|
|
8
|
+
import Button, { type ButtonVariant, type ButtonSize } from './ui/button/button.svelte';
|
|
9
|
+
import { cn } from '../utils.js';
|
|
10
|
+
|
|
11
|
+
type Props = {
|
|
12
|
+
/** The text to write to the clipboard. */
|
|
13
|
+
value: string;
|
|
14
|
+
/** Visible label. Pass `false`/empty for an icon-only button. */
|
|
15
|
+
label?: string | false;
|
|
16
|
+
/** Label swapped in after a successful copy. */
|
|
17
|
+
copiedLabel?: string;
|
|
18
|
+
/** How long (ms) the "copied" state lasts before reverting. */
|
|
19
|
+
timeout?: number;
|
|
20
|
+
variant?: CopyButtonVariant;
|
|
21
|
+
size?: CopyButtonSize;
|
|
22
|
+
/** Hide the leading icon (e.g. when you only want a text toggle). */
|
|
23
|
+
hideIcon?: boolean;
|
|
24
|
+
class?: string;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** Fired after a successful copy. */
|
|
27
|
+
onCopy?: (value: string) => void;
|
|
28
|
+
/** Fired when the clipboard write throws (e.g. permission denied). */
|
|
29
|
+
onError?: (err: unknown) => void;
|
|
30
|
+
'aria-label'?: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
let {
|
|
34
|
+
value,
|
|
35
|
+
label = 'Copy',
|
|
36
|
+
copiedLabel = 'Copied',
|
|
37
|
+
timeout = 2000,
|
|
38
|
+
variant = 'outline',
|
|
39
|
+
size = 'sm',
|
|
40
|
+
hideIcon = false,
|
|
41
|
+
class: className,
|
|
42
|
+
disabled = false,
|
|
43
|
+
onCopy,
|
|
44
|
+
onError,
|
|
45
|
+
'aria-label': ariaLabel,
|
|
46
|
+
...restProps
|
|
47
|
+
}: Props = $props();
|
|
48
|
+
|
|
49
|
+
let copied = $state(false);
|
|
50
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
51
|
+
|
|
52
|
+
const iconOnly = $derived(label === false || label === '');
|
|
53
|
+
|
|
54
|
+
async function copy() {
|
|
55
|
+
try {
|
|
56
|
+
await navigator.clipboard.writeText(value);
|
|
57
|
+
copied = true;
|
|
58
|
+
onCopy?.(value);
|
|
59
|
+
if (timer) clearTimeout(timer);
|
|
60
|
+
timer = setTimeout(() => {
|
|
61
|
+
copied = false;
|
|
62
|
+
timer = null;
|
|
63
|
+
}, timeout);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
onError?.(err);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
$effect(() => () => {
|
|
70
|
+
if (timer) clearTimeout(timer);
|
|
71
|
+
});
|
|
72
|
+
</script>
|
|
73
|
+
|
|
74
|
+
<Button
|
|
75
|
+
{variant}
|
|
76
|
+
size={iconOnly && size === 'sm' ? 'icon-sm' : size}
|
|
77
|
+
{disabled}
|
|
78
|
+
onclick={copy}
|
|
79
|
+
data-slot="copy-button"
|
|
80
|
+
data-copied={copied ? '' : undefined}
|
|
81
|
+
aria-label={ariaLabel ?? (iconOnly ? (copied ? copiedLabel : (label || 'Copy')) : undefined)}
|
|
82
|
+
class={cn(className)}
|
|
83
|
+
{...restProps}
|
|
84
|
+
>
|
|
85
|
+
{#if !hideIcon}
|
|
86
|
+
{#if copied}
|
|
87
|
+
<CheckIcon class="text-success" />
|
|
88
|
+
{:else}
|
|
89
|
+
<CopyIcon />
|
|
90
|
+
{/if}
|
|
91
|
+
{/if}
|
|
92
|
+
{#if !iconOnly}
|
|
93
|
+
<span>{copied ? copiedLabel : label}</span>
|
|
94
|
+
{/if}
|
|
95
|
+
</Button>
|