@connve/ui 0.0.0-stage → 0.1.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.
Files changed (62) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +92 -2
  3. package/dist/AppShell.svelte +258 -0
  4. package/dist/AppShell.svelte.d.ts +29 -0
  5. package/dist/Badge.svelte +33 -0
  6. package/dist/Badge.svelte.d.ts +9 -0
  7. package/dist/ContextBridge.svelte +18 -0
  8. package/dist/ContextBridge.svelte.d.ts +8 -0
  9. package/dist/CopyButton.svelte +57 -0
  10. package/dist/CopyButton.svelte.d.ts +11 -0
  11. package/dist/EditButton.svelte +37 -0
  12. package/dist/EditButton.svelte.d.ts +9 -0
  13. package/dist/FolderTree.svelte +116 -0
  14. package/dist/FolderTree.svelte.d.ts +42 -0
  15. package/dist/IconButton.svelte +33 -0
  16. package/dist/IconButton.svelte.d.ts +16 -0
  17. package/dist/Inspector.svelte +115 -0
  18. package/dist/Inspector.svelte.d.ts +20 -0
  19. package/dist/InspectorSection.svelte +18 -0
  20. package/dist/InspectorSection.svelte.d.ts +9 -0
  21. package/dist/ListHeader.svelte +73 -0
  22. package/dist/ListHeader.svelte.d.ts +18 -0
  23. package/dist/Loading.svelte +25 -0
  24. package/dist/Loading.svelte.d.ts +9 -0
  25. package/dist/Logo.svelte +93 -0
  26. package/dist/Logo.svelte.d.ts +9 -0
  27. package/dist/NavList.svelte +74 -0
  28. package/dist/NavList.svelte.d.ts +24 -0
  29. package/dist/PaneResizer.svelte +36 -0
  30. package/dist/PaneResizer.svelte.d.ts +7 -0
  31. package/dist/Segmented.svelte +38 -0
  32. package/dist/Segmented.svelte.d.ts +33 -0
  33. package/dist/SidePane.svelte +148 -0
  34. package/dist/SidePane.svelte.d.ts +17 -0
  35. package/dist/StateMessage.svelte +110 -0
  36. package/dist/StateMessage.svelte.d.ts +15 -0
  37. package/dist/StatusDot.svelte +32 -0
  38. package/dist/StatusDot.svelte.d.ts +10 -0
  39. package/dist/ThemeToggle.svelte +37 -0
  40. package/dist/ThemeToggle.svelte.d.ts +3 -0
  41. package/dist/focus.d.ts +2 -0
  42. package/dist/focus.js +39 -0
  43. package/dist/icons.d.ts +19 -0
  44. package/dist/icons.js +90 -0
  45. package/dist/index.d.ts +24 -0
  46. package/dist/index.js +23 -0
  47. package/dist/labels.svelte.d.ts +28 -0
  48. package/dist/labels.svelte.js +27 -0
  49. package/dist/mobile.d.ts +5 -0
  50. package/dist/mobile.js +7 -0
  51. package/dist/paneSize.svelte.d.ts +26 -0
  52. package/dist/paneSize.svelte.js +112 -0
  53. package/dist/shell.svelte.d.ts +18 -0
  54. package/dist/shell.svelte.js +20 -0
  55. package/dist/theme.css +169 -0
  56. package/dist/theme.svelte.d.ts +13 -0
  57. package/dist/theme.svelte.js +46 -0
  58. package/dist/tooltip.d.ts +3 -0
  59. package/dist/tooltip.js +7 -0
  60. package/dist/tree.d.ts +17 -0
  61. package/dist/tree.js +44 -0
  62. package/package.json +82 -5
@@ -0,0 +1,258 @@
1
+ <script lang="ts" module>
2
+ import type { IconifyIcon } from '@iconify/svelte';
3
+
4
+ export interface NavItem {
5
+ href: string;
6
+ /** An Iconify name such as `tabler:home`, or icon data, which needs no request. */
7
+ icon: string | IconifyIcon;
8
+ label: string;
9
+ active: boolean;
10
+ /** A count shown on the item, such as unread conversations. */
11
+ badge?: number;
12
+ /** Names the item while it shows `badge`, e.g. "Agents, 2 unread"; `labels.navBadge` otherwise. */
13
+ badgeLabel?: string;
14
+ }
15
+ </script>
16
+
17
+ <script lang="ts">
18
+ import Icon from '@iconify/svelte';
19
+ import * as icons from './icons.js';
20
+ import { onMount, untrack, type Snippet } from 'svelte';
21
+ import { MediaQuery } from 'svelte/reactivity';
22
+ import { keepFocusWithin } from './focus.js';
23
+ import { labels } from './labels.svelte.js';
24
+ import { MOBILE_QUERY } from './mobile.js';
25
+ import { ShellState, setShell } from './shell.svelte.js';
26
+
27
+ interface Props {
28
+ /** The rail's links; each `href` must be unique. */
29
+ nav: NavItem[];
30
+ /** The logo at the top of the rail; it gives way to the expand button on hover while compact. */
31
+ brand?: Snippet;
32
+ /** The bottom of the rail, such as a version and a settings link. */
33
+ railFooter?: Snippet<[{ compact: boolean }]>;
34
+ /** The right side of the top bar. */
35
+ actions?: Snippet;
36
+ children: Snippet;
37
+ }
38
+
39
+ let { nav, brand, railFooter, actions, children }: Props = $props();
40
+
41
+ const shell = setShell(new ShellState());
42
+
43
+ const COLLAPSED_KEY = 'connve-nav-collapsed';
44
+
45
+ let collapsed = $state(true);
46
+ // On a phone the rail is a drawer, opened from the top bar and always full width. Open, it
47
+ // covers the page, so it acts as a dialog. Crossing the phone breakpoint closes it.
48
+ const phone = new MediaQuery(MOBILE_QUERY);
49
+ let menuOpen = $derived.by(() => {
50
+ void phone.current;
51
+ return false;
52
+ });
53
+ let compact = $derived(collapsed && !menuOpen);
54
+ let rail = $state<HTMLElement | null>(null);
55
+ let menuButton = $state<HTMLButtonElement | null>(null);
56
+
57
+ $effect(() => {
58
+ if (menuOpen) untrack(() => rail?.querySelector<HTMLElement>('nav a[href]')?.focus());
59
+ });
60
+
61
+ onMount(() => {
62
+ try {
63
+ const saved = localStorage.getItem(COLLAPSED_KEY);
64
+ if (saved !== null) collapsed = saved === '1';
65
+ } catch {
66
+ // The rail starts collapsed.
67
+ }
68
+ });
69
+
70
+ function toggle() {
71
+ collapsed = !collapsed;
72
+ try {
73
+ localStorage.setItem(COLLAPSED_KEY, collapsed ? '1' : '0');
74
+ } catch {
75
+ // Not remembered, but the rail still changes for this visit.
76
+ }
77
+ }
78
+
79
+ // Any link in the drawer navigates away from it, including the app's own in `brand` and `railFooter`.
80
+ function closeOnLink(event: MouseEvent) {
81
+ if (event.target instanceof Element && event.target.closest('a[href]')) menuOpen = false;
82
+ }
83
+
84
+ function closeMenu() {
85
+ menuOpen = false;
86
+ menuButton?.focus();
87
+ }
88
+
89
+ function onRailKeydown(event: KeyboardEvent) {
90
+ if (!menuOpen || !rail) return;
91
+ if (event.key === 'Escape') closeMenu();
92
+ else keepFocusWithin(event, rail);
93
+ }
94
+
95
+ const BUTTON =
96
+ 'flex h-8 w-8 items-center justify-center rounded-md text-base-content/60 transition-colors hover:bg-base-200 hover:text-base-content';
97
+ </script>
98
+
99
+ <svelte:window onpopstate={() => (menuOpen = false)} />
100
+
101
+ <div class="min-h-screen overflow-x-hidden bg-base-100 text-base-content">
102
+ <div class="flex min-h-screen w-full min-w-0">
103
+ {#if menuOpen}
104
+ <div
105
+ class="fixed inset-0 z-30 bg-black/30 md:hidden"
106
+ aria-hidden="true"
107
+ onclick={closeMenu}
108
+ ></div>
109
+ {/if}
110
+ <div
111
+ bind:this={rail}
112
+ role={menuOpen ? 'dialog' : undefined}
113
+ aria-modal={menuOpen ? 'true' : undefined}
114
+ aria-label={menuOpen ? labels.menu : undefined}
115
+ onclick={closeOnLink}
116
+ onkeydown={onRailKeydown}
117
+ class={[
118
+ 'flex shrink-0 flex-col border-r border-base-200 bg-base-100 transition-[width] duration-200 ease-out motion-reduce:transition-none',
119
+ menuOpen
120
+ ? 'max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-40 max-md:w-64 max-md:shadow-xl'
121
+ : 'max-md:hidden',
122
+ compact ? 'w-16' : 'w-56'
123
+ ]}
124
+ >
125
+ <div
126
+ class={[
127
+ 'group relative flex h-16 shrink-0 items-center',
128
+ compact ? 'justify-center' : 'justify-between pr-3 pl-6'
129
+ ]}
130
+ >
131
+ {#if brand}
132
+ <div
133
+ class={[
134
+ 'flex h-10 items-center transition-opacity',
135
+ compact && 'md:group-hover:opacity-0 md:group-has-[button:focus-visible]:opacity-0'
136
+ ]}
137
+ >
138
+ {@render brand()}
139
+ </div>
140
+ {/if}
141
+ <div
142
+ class={[
143
+ 'tooltip tooltip-right max-md:hidden',
144
+ compact &&
145
+ brand &&
146
+ 'absolute inset-0 m-auto h-8 w-8 opacity-0 transition-opacity group-hover:opacity-100 has-[button:focus-visible]:opacity-100'
147
+ ]}
148
+ data-tip={collapsed ? labels.navExpand : labels.navCollapse}
149
+ >
150
+ <button
151
+ type="button"
152
+ aria-label={collapsed ? labels.navExpand : labels.navCollapse}
153
+ aria-expanded={!collapsed}
154
+ class={BUTTON}
155
+ onclick={toggle}
156
+ >
157
+ <Icon
158
+ icon={collapsed ? icons.layoutSidebarLeftExpand : icons.layoutSidebarLeftCollapse}
159
+ class="h-5 w-5"
160
+ />
161
+ </button>
162
+ </div>
163
+ </div>
164
+
165
+ <nav
166
+ aria-label={labels.navigation}
167
+ class={[
168
+ 'flex-1 space-y-px pb-2 max-md:pt-2',
169
+ compact ? 'flex flex-col items-center' : 'px-3'
170
+ ]}
171
+ >
172
+ {#each nav as item (item.href)}
173
+ {@const label = item.badge
174
+ ? (item.badgeLabel ?? labels.navBadge(item.label, item.badge))
175
+ : item.label}
176
+ <!-- The tooltip wraps the link, so it shows on keyboard focus too. -->
177
+ <div class={[compact && 'tooltip tooltip-right']} data-tip={compact ? label : undefined}>
178
+ <a
179
+ href={item.href}
180
+ class={[
181
+ 'relative flex h-9 items-center rounded-md text-sm transition-colors',
182
+ item.active
183
+ ? 'bg-base-200 font-semibold text-base-content'
184
+ : 'font-medium text-base-content/70 hover:bg-base-200/60 hover:text-base-content',
185
+ compact ? 'w-9 justify-center' : 'gap-3 px-3'
186
+ ]}
187
+ aria-label={label}
188
+ aria-current={item.active ? 'page' : undefined}
189
+ >
190
+ <Icon icon={item.icon} class="h-5 w-5 shrink-0" />
191
+ {#if !compact}
192
+ <span>{item.label}</span>
193
+ {/if}
194
+ {#if item.badge}
195
+ <span
196
+ class={[
197
+ 'badge badge-primary',
198
+ compact ? 'absolute -top-1 -right-1 min-w-4 px-1 badge-xs' : 'ml-auto badge-sm'
199
+ ]}
200
+ >
201
+ {item.badge}
202
+ </span>
203
+ {/if}
204
+ </a>
205
+ </div>
206
+ {/each}
207
+ </nav>
208
+
209
+ {#if railFooter}
210
+ <div class={['flex h-12 shrink-0 items-center', compact ? 'justify-center' : 'px-6']}>
211
+ {@render railFooter({ compact })}
212
+ </div>
213
+ {/if}
214
+ </div>
215
+
216
+ {#if shell.pane}
217
+ <div class="relative flex h-screen shrink-0">
218
+ {@render shell.pane()}
219
+ </div>
220
+ {/if}
221
+
222
+ <div class="flex min-w-0 flex-1 flex-col bg-base-100">
223
+ <header class="flex h-16 items-center justify-between gap-3 px-4 md:px-6">
224
+ <div class="flex items-center">
225
+ <button
226
+ bind:this={menuButton}
227
+ type="button"
228
+ class="{BUTTON} md:hidden"
229
+ aria-label={labels.menu}
230
+ aria-expanded={menuOpen}
231
+ onclick={() => (menuOpen = true)}
232
+ >
233
+ <Icon icon={icons.menu2} class="h-5 w-5" />
234
+ </button>
235
+ {#if shell.expand}
236
+ <button
237
+ bind:this={shell.expandButton}
238
+ type="button"
239
+ class="{BUTTON} md:hidden"
240
+ aria-label={shell.expand.label}
241
+ aria-expanded="false"
242
+ onclick={shell.expand.onclick}
243
+ >
244
+ <Icon icon={icons.layoutSidebarLeftExpand} class="h-5 w-5" />
245
+ </button>
246
+ {/if}
247
+ </div>
248
+ <div class="flex items-center justify-end gap-3">
249
+ {@render actions?.()}
250
+ </div>
251
+ </header>
252
+
253
+ <main class="min-w-0 flex-1 overflow-hidden bg-base-100">
254
+ {@render children()}
255
+ </main>
256
+ </div>
257
+ </div>
258
+ </div>
@@ -0,0 +1,29 @@
1
+ import type { IconifyIcon } from '@iconify/svelte';
2
+ export interface NavItem {
3
+ href: string;
4
+ /** An Iconify name such as `tabler:home`, or icon data, which needs no request. */
5
+ icon: string | IconifyIcon;
6
+ label: string;
7
+ active: boolean;
8
+ /** A count shown on the item, such as unread conversations. */
9
+ badge?: number;
10
+ /** Names the item while it shows `badge`, e.g. "Agents, 2 unread"; `labels.navBadge` otherwise. */
11
+ badgeLabel?: string;
12
+ }
13
+ import { type Snippet } from 'svelte';
14
+ interface Props {
15
+ /** The rail's links; each `href` must be unique. */
16
+ nav: NavItem[];
17
+ /** The logo at the top of the rail; it gives way to the expand button on hover while compact. */
18
+ brand?: Snippet;
19
+ /** The bottom of the rail, such as a version and a settings link. */
20
+ railFooter?: Snippet<[{
21
+ compact: boolean;
22
+ }]>;
23
+ /** The right side of the top bar. */
24
+ actions?: Snippet;
25
+ children: Snippet;
26
+ }
27
+ declare const AppShell: import("svelte").Component<Props, {}, "">;
28
+ type AppShell = ReturnType<typeof AppShell>;
29
+ export default AppShell;
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ // Single badge style for the whole UI: uppercase, small.
3
+ // - neutral: outlined, monochrome, faded (used for type tags: flow, sql, json).
4
+ // - success/error/warning: filled soft (bg-*/10 + text-*) — same look as the
5
+ // Copied confirmation, reads strongly against row background.
6
+ import type { Snippet } from 'svelte';
7
+ import type { HTMLAttributes } from 'svelte/elements';
8
+
9
+ interface Props extends HTMLAttributes<HTMLSpanElement> {
10
+ variant?: 'neutral' | 'success' | 'error' | 'warning';
11
+ children: Snippet;
12
+ }
13
+
14
+ let { variant = 'neutral', class: className, children, ...rest }: Props = $props();
15
+
16
+ const VARIANT = {
17
+ neutral: 'border-current opacity-60',
18
+ success: 'border-transparent bg-primary/10 text-(--color-chip-success)',
19
+ error: 'border-transparent bg-error/10 text-(--color-chip-error)',
20
+ warning: 'border-transparent bg-warning/10 text-attention'
21
+ };
22
+ </script>
23
+
24
+ <span
25
+ {...rest}
26
+ class={[
27
+ 'inline-block rounded border px-1.5 py-0.5 text-[10px] leading-none font-semibold tracking-wider whitespace-nowrap uppercase',
28
+ VARIANT[variant],
29
+ className
30
+ ]}
31
+ >
32
+ {@render children()}
33
+ </span>
@@ -0,0 +1,9 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ interface Props extends HTMLAttributes<HTMLSpanElement> {
4
+ variant?: 'neutral' | 'success' | 'error' | 'warning';
5
+ children: Snippet;
6
+ }
7
+ declare const Badge: import("svelte").Component<Props, {}, "">;
8
+ type Badge = ReturnType<typeof Badge>;
9
+ export default Badge;
@@ -0,0 +1,18 @@
1
+ <script lang="ts">
2
+ // Renders `children` with contexts from elsewhere in the tree: content a component
3
+ // hands to the shell renders in the shell, and would otherwise lose the page's contexts.
4
+ import { setContext, type Snippet } from 'svelte';
5
+
6
+ interface Props {
7
+ contexts: Map<unknown, unknown>;
8
+ children: Snippet;
9
+ }
10
+
11
+ let { contexts, children }: Props = $props();
12
+
13
+ // Contexts can only be set while the component starts, so the first `contexts` is the one used.
14
+ // svelte-ignore state_referenced_locally
15
+ for (const [key, value] of contexts) setContext(key, value);
16
+ </script>
17
+
18
+ {@render children()}
@@ -0,0 +1,8 @@
1
+ import { type Snippet } from 'svelte';
2
+ interface Props {
3
+ contexts: Map<unknown, unknown>;
4
+ children: Snippet;
5
+ }
6
+ declare const ContextBridge: import("svelte").Component<Props, {}, "">;
7
+ type ContextBridge = ReturnType<typeof ContextBridge>;
8
+ export default ContextBridge;
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ import Icon from '@iconify/svelte';
3
+ import * as icons from './icons.js';
4
+ import { onDestroy } from 'svelte';
5
+ import { labels } from './labels.svelte.js';
6
+ import { TOOLTIP_SIDE, type TooltipSide } from './tooltip.js';
7
+
8
+ interface Props {
9
+ text: string | null | undefined;
10
+ /** Names the button, and shows as its tooltip; `labels.copy` when unset. */
11
+ label?: string;
12
+ size?: 'xs' | 'sm';
13
+ tooltip?: TooltipSide;
14
+ }
15
+
16
+ let { text, label, size = 'sm', tooltip = 'left' }: Props = $props();
17
+
18
+ let copied = $state(false);
19
+ let timeout: ReturnType<typeof setTimeout> | undefined;
20
+
21
+ onDestroy(() => clearTimeout(timeout));
22
+
23
+ async function onclick() {
24
+ if (!text) return;
25
+ try {
26
+ await navigator.clipboard.writeText(text);
27
+ copied = true;
28
+ clearTimeout(timeout);
29
+ timeout = setTimeout(() => (copied = false), 1500);
30
+ } catch {
31
+ // Clipboard refused — silent no-op.
32
+ }
33
+ }
34
+ </script>
35
+
36
+ <div
37
+ class={['tooltip', TOOLTIP_SIDE[tooltip]]}
38
+ data-tip={copied ? labels.copied : (label ?? labels.copy)}
39
+ >
40
+ <button
41
+ type="button"
42
+ class={[
43
+ 'flex items-center justify-center rounded-md text-base-content/60 transition-colors hover:bg-base-200 hover:text-base-content disabled:opacity-40',
44
+ size === 'xs' ? 'h-6 w-6' : 'h-8 w-8'
45
+ ]}
46
+ aria-label={label ?? labels.copy}
47
+ {onclick}
48
+ disabled={!text}
49
+ >
50
+ <Icon
51
+ icon={copied ? icons.check : icons.copy}
52
+ class="{size === 'xs' ? 'h-4 w-4' : 'h-5 w-5'} {copied ? 'text-primary' : ''}"
53
+ />
54
+ </button>
55
+ <!-- Always present, so screen readers announce the confirmation when it appears. -->
56
+ <span class="sr-only" role="status">{copied ? labels.copied : ''}</span>
57
+ </div>
@@ -0,0 +1,11 @@
1
+ import { type TooltipSide } from './tooltip.js';
2
+ interface Props {
3
+ text: string | null | undefined;
4
+ /** Names the button, and shows as its tooltip; `labels.copy` when unset. */
5
+ label?: string;
6
+ size?: 'xs' | 'sm';
7
+ tooltip?: TooltipSide;
8
+ }
9
+ declare const CopyButton: import("svelte").Component<Props, {}, "">;
10
+ type CopyButton = ReturnType<typeof CopyButton>;
11
+ export default CopyButton;
@@ -0,0 +1,37 @@
1
+ <script lang="ts">
2
+ import Icon from '@iconify/svelte';
3
+ import * as icons from './icons.js';
4
+ import { labels } from './labels.svelte.js';
5
+ import { TOOLTIP_SIDE, type TooltipSide } from './tooltip.js';
6
+
7
+ interface Props {
8
+ /** Editor link; without one the button is read-only and says why. */
9
+ href: string | null;
10
+ tooltip?: TooltipSide;
11
+ }
12
+
13
+ let { href, tooltip = 'bottom' }: Props = $props();
14
+
15
+ const reasonId = $props.id();
16
+ </script>
17
+
18
+ {#if href}
19
+ <a {href} class="btn gap-1.5 btn-ghost btn-sm">
20
+ <Icon icon={icons.pencil} class="h-4 w-4" />
21
+ {labels.edit}
22
+ </a>
23
+ {:else}
24
+ <!-- `aria-disabled` rather than `disabled`, so keyboard users can reach the button and its reason. -->
25
+ <div class={['tooltip', TOOLTIP_SIDE[tooltip]]} data-tip={labels.editReadOnly}>
26
+ <button
27
+ type="button"
28
+ class="btn gap-1.5 btn-ghost btn-sm"
29
+ aria-disabled="true"
30
+ aria-describedby={reasonId}
31
+ >
32
+ <Icon icon={icons.pencil} class="h-4 w-4" />
33
+ {labels.edit}
34
+ </button>
35
+ <span id={reasonId} class="sr-only">{labels.editReadOnly}</span>
36
+ </div>
37
+ {/if}
@@ -0,0 +1,9 @@
1
+ import { type TooltipSide } from './tooltip.js';
2
+ interface Props {
3
+ /** Editor link; without one the button is read-only and says why. */
4
+ href: string | null;
5
+ tooltip?: TooltipSide;
6
+ }
7
+ declare const EditButton: import("svelte").Component<Props, {}, "">;
8
+ type EditButton = ReturnType<typeof EditButton>;
9
+ export default EditButton;
@@ -0,0 +1,116 @@
1
+ <script lang="ts" generics="T">
2
+ import Icon, { type IconifyIcon } from '@iconify/svelte';
3
+ import * as icons from './icons.js';
4
+ import type { Snippet } from 'svelte';
5
+ import type { TreeNode } from './tree.js';
6
+
7
+ interface Props {
8
+ tree: TreeNode<T>[];
9
+ /** The selected folder, `null` for all. */
10
+ selected: string | null;
11
+ expanded: ReadonlySet<string>;
12
+ allLabel: string;
13
+ allIcon: string | IconifyIcon;
14
+ /** Shown after the "all" row's label. */
15
+ allCount?: number;
16
+ expandLabel: (folder: string) => string;
17
+ collapseLabel: (folder: string) => string;
18
+ onselect: (folder: string | null) => void;
19
+ ontoggle: (folder: string) => void;
20
+ /** Shown after a folder's name; its item count when unset. */
21
+ trailing?: Snippet<[TreeNode<T>]>;
22
+ }
23
+
24
+ let {
25
+ tree,
26
+ selected,
27
+ expanded,
28
+ allLabel,
29
+ allIcon,
30
+ allCount,
31
+ expandLabel,
32
+ collapseLabel,
33
+ onselect,
34
+ ontoggle,
35
+ trailing
36
+ }: Props = $props();
37
+
38
+ function row(isSelected: boolean) {
39
+ return [
40
+ 'flex h-9 w-full min-w-0 items-center gap-2 rounded-md pr-2 text-left text-sm transition-colors',
41
+ isSelected ? 'bg-base-200 font-medium' : 'hover:bg-base-200/60'
42
+ ];
43
+ }
44
+ </script>
45
+
46
+ <ul class="space-y-px">
47
+ <li>
48
+ <button
49
+ type="button"
50
+ class={row(selected === null)}
51
+ style="padding-left: 1.75rem"
52
+ aria-current={selected === null ? 'true' : undefined}
53
+ onclick={() => onselect(null)}
54
+ >
55
+ <Icon icon={allIcon} class="h-4 w-4 shrink-0 opacity-60" />
56
+ <span class="min-w-0 flex-1 truncate">{allLabel}</span>
57
+ {#if allCount !== undefined}
58
+ <span class="shrink-0 text-xs text-base-content/60 tabular-nums">{allCount}</span>
59
+ {/if}
60
+ </button>
61
+ </li>
62
+ {#snippet folders(nodes: TreeNode<T>[])}
63
+ {#each nodes.filter((node) => node.isFolder) as node (node.fullPath)}
64
+ {@const isOpen = expanded.has(node.fullPath)}
65
+ {@const canExpand = (node.children ?? []).some((child) => child.isFolder)}
66
+ {@const indent = node.depth * 0.75}
67
+ <li>
68
+ <div class="relative">
69
+ <button
70
+ type="button"
71
+ class={row(selected === node.fullPath)}
72
+ style="padding-left: {indent + 1.75}rem"
73
+ aria-current={selected === node.fullPath ? 'true' : undefined}
74
+ onclick={() => onselect(node.fullPath)}
75
+ >
76
+ <Icon
77
+ icon={isOpen && canExpand ? icons.folderOpen : icons.folder}
78
+ class="h-4 w-4 shrink-0 opacity-60"
79
+ />
80
+ <span class="min-w-0 flex-1 truncate">{node.name}</span>
81
+ {#if trailing}
82
+ {@render trailing(node)}
83
+ {:else if node.fileCount !== undefined}
84
+ <span class="shrink-0 text-xs text-base-content/60 tabular-nums"
85
+ >{node.fileCount}</span
86
+ >
87
+ {/if}
88
+ </button>
89
+ {#if canExpand}
90
+ <button
91
+ type="button"
92
+ class="absolute top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded text-base-content/60 hover:bg-base-300/50 hover:text-base-content"
93
+ style="left: {indent + 0.25}rem"
94
+ aria-label={isOpen ? collapseLabel(node.name) : expandLabel(node.name)}
95
+ aria-expanded={isOpen}
96
+ onclick={() => ontoggle(node.fullPath)}
97
+ >
98
+ <Icon
99
+ icon={icons.chevronRight}
100
+ class="h-3.5 w-3.5 transition-transform motion-reduce:transition-none {isOpen
101
+ ? 'rotate-90'
102
+ : ''}"
103
+ />
104
+ </button>
105
+ {/if}
106
+ </div>
107
+ {#if isOpen && canExpand && node.children}
108
+ <ul class="space-y-px">
109
+ {@render folders(node.children)}
110
+ </ul>
111
+ {/if}
112
+ </li>
113
+ {/each}
114
+ {/snippet}
115
+ {@render folders(tree)}
116
+ </ul>
@@ -0,0 +1,42 @@
1
+ import { type IconifyIcon } from '@iconify/svelte';
2
+ import type { Snippet } from 'svelte';
3
+ import type { TreeNode } from './tree.js';
4
+ declare function $$render<T>(): {
5
+ props: {
6
+ tree: TreeNode<T>[];
7
+ /** The selected folder, `null` for all. */
8
+ selected: string | null;
9
+ expanded: ReadonlySet<string>;
10
+ allLabel: string;
11
+ allIcon: string | IconifyIcon;
12
+ /** Shown after the "all" row's label. */
13
+ allCount?: number;
14
+ expandLabel: (folder: string) => string;
15
+ collapseLabel: (folder: string) => string;
16
+ onselect: (folder: string | null) => void;
17
+ ontoggle: (folder: string) => void;
18
+ /** Shown after a folder's name; its item count when unset. */
19
+ trailing?: Snippet<[TreeNode<T>]>;
20
+ };
21
+ exports: {};
22
+ bindings: "";
23
+ slots: {};
24
+ events: {};
25
+ };
26
+ declare class __sveltets_Render<T> {
27
+ props(): ReturnType<typeof $$render<T>>['props'];
28
+ events(): ReturnType<typeof $$render<T>>['events'];
29
+ slots(): ReturnType<typeof $$render<T>>['slots'];
30
+ bindings(): "";
31
+ exports(): {};
32
+ }
33
+ interface $$IsomorphicComponent {
34
+ new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
35
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
36
+ } & ReturnType<__sveltets_Render<T>['exports']>;
37
+ <T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
38
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
39
+ }
40
+ declare const FolderTree: $$IsomorphicComponent;
41
+ type FolderTree<T> = InstanceType<typeof FolderTree<T>>;
42
+ export default FolderTree;
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import Icon, { type IconifyIcon } from '@iconify/svelte';
3
+ import { TOOLTIP_SIDE, type TooltipSide } from './tooltip.js';
4
+
5
+ interface Props {
6
+ /** An Iconify name, or icon data, which needs no request. */
7
+ icon: string | IconifyIcon;
8
+ /** Names the button, and shows as its tooltip. */
9
+ label: string;
10
+ /** A link when set, otherwise a button. */
11
+ href?: string;
12
+ onclick?: (event: MouseEvent) => void;
13
+ tooltip?: TooltipSide;
14
+ ref?: HTMLAnchorElement | HTMLButtonElement | null;
15
+ }
16
+
17
+ let { icon, label, href, onclick, tooltip = 'bottom', ref = $bindable(null) }: Props = $props();
18
+
19
+ const CLASS =
20
+ 'flex h-8 w-8 items-center justify-center rounded-md text-base-content/60 transition-colors hover:bg-base-200 hover:text-base-content';
21
+ </script>
22
+
23
+ <div class={['tooltip', TOOLTIP_SIDE[tooltip]]} data-tip={label}>
24
+ {#if href}
25
+ <a bind:this={ref} {href} class={CLASS} aria-label={label} {onclick}>
26
+ <Icon {icon} class="h-5 w-5" />
27
+ </a>
28
+ {:else}
29
+ <button bind:this={ref} type="button" class={CLASS} aria-label={label} {onclick}>
30
+ <Icon {icon} class="h-5 w-5" />
31
+ </button>
32
+ {/if}
33
+ </div>