@r2digisolutions/components 0.6.9 → 0.7.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/README.md +47 -0
- package/dist/components/atoms/Input/Input.svelte +61 -44
- package/dist/components/molecules/InfiniteScroll/InfiniteScroll.svelte +16 -25
- package/dist/components/molecules/InfiniteScroll/InfiniteScroll.svelte.d.ts +1 -1
- package/dist/components/molecules/Select/Select.svelte +3 -4
- package/dist/components/molecules/TreePanel/TreePanel.stories.d.ts +26 -0
- package/dist/components/molecules/TreePanel/TreePanel.stories.js +18 -0
- package/dist/components/molecules/TreePanel/TreePanel.svelte +278 -0
- package/dist/components/molecules/TreePanel/TreePanel.svelte.d.ts +37 -0
- package/dist/components/molecules/TreePanel/TreePanelStory.svelte +54 -0
- package/dist/components/molecules/TreePanel/TreePanelStory.svelte.d.ts +7 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/styles.css +247 -202
- package/dist/utils/theme.svelte.d.ts +16 -2
- package/dist/utils/theme.svelte.js +44 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,6 +85,53 @@ Los componentes usan clases de Tailwind 4. En la app consumidora basta con impor
|
|
|
85
85
|
|
|
86
86
|
Ese import registra tokens (`border-border`, `bg-surface`, …) y hace que Tailwind escanee los `.svelte` del paquete. Sin él, las utilidades pueden faltar o `--border` no estar definido.
|
|
87
87
|
|
|
88
|
+
## Tema: modo + paleta
|
|
89
|
+
|
|
90
|
+
Hay dos ejes independientes en `<html>`:
|
|
91
|
+
|
|
92
|
+
| Atributo | Valores | Qué cambia |
|
|
93
|
+
| -------------- | ----------------------------- | ----------------- |
|
|
94
|
+
| `class="dark"` | presente / ausente | Claro / oscuro |
|
|
95
|
+
| `data-theme` | `slate` (defecto) · `neutral` | Familia de grises |
|
|
96
|
+
|
|
97
|
+
- **slate** — gris frío (hue 265, el look actual).
|
|
98
|
+
- **neutral** — croma 0, sin castaño azul. Misma familia que Tailwind `neutral` (`#171717`, `#fafafa`).
|
|
99
|
+
|
|
100
|
+
Para evitar FOUC, fíjalo en `app.html` antes de pintar:
|
|
101
|
+
|
|
102
|
+
```html
|
|
103
|
+
<html lang="es" data-theme="neutral">
|
|
104
|
+
<head>
|
|
105
|
+
<script>
|
|
106
|
+
(function () {
|
|
107
|
+
var palette = localStorage.getItem('r2-theme-palette') || 'neutral';
|
|
108
|
+
document.documentElement.setAttribute('data-theme', palette);
|
|
109
|
+
if (
|
|
110
|
+
localStorage.theme === 'dark' ||
|
|
111
|
+
localStorage.getItem('r2-theme') === 'dark' ||
|
|
112
|
+
(!('theme' in localStorage) &&
|
|
113
|
+
!localStorage.getItem('r2-theme') &&
|
|
114
|
+
window.matchMedia('(prefers-color-scheme: dark)').matches)
|
|
115
|
+
) {
|
|
116
|
+
document.documentElement.classList.add('dark');
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
</script>
|
|
120
|
+
</head>
|
|
121
|
+
</html>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
En runtime (Svelte 5):
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { themeStore } from '@r2digisolutions/components';
|
|
128
|
+
|
|
129
|
+
themeStore.set('dark');
|
|
130
|
+
themeStore.setPalette('neutral');
|
|
131
|
+
// Solo esta página, sin persistir:
|
|
132
|
+
themeStore.setPalette('slate', { persist: false });
|
|
133
|
+
```
|
|
134
|
+
|
|
88
135
|
## Licencia
|
|
89
136
|
|
|
90
137
|
MIT
|
|
@@ -111,12 +111,9 @@
|
|
|
111
111
|
}
|
|
112
112
|
</script>
|
|
113
113
|
|
|
114
|
-
<div class={['
|
|
114
|
+
<div class={['gap-1.5 flex w-full flex-col', className]}>
|
|
115
115
|
{#if label}
|
|
116
|
-
<label
|
|
117
|
-
for={inputId}
|
|
118
|
-
class="text-sm font-medium text-primary"
|
|
119
|
-
>
|
|
116
|
+
<label for={inputId} class="text-sm font-medium text-primary">
|
|
120
117
|
{label}
|
|
121
118
|
{#if required}
|
|
122
119
|
<span class="text-red-500 ml-0.5" aria-hidden="true">*</span>
|
|
@@ -126,51 +123,53 @@
|
|
|
126
123
|
|
|
127
124
|
<div
|
|
128
125
|
class={[
|
|
129
|
-
'
|
|
126
|
+
'gap-2 rounded-lg flex w-full items-center border bg-transparent transition-all duration-200',
|
|
130
127
|
paddingSizeClasses[size],
|
|
131
128
|
wrapperSizeClasses[size],
|
|
132
129
|
statusRingClasses[status],
|
|
133
|
-
disabled
|
|
130
|
+
disabled ? 'bg-surface cursor-not-allowed opacity-50' : 'cursor-text'
|
|
134
131
|
]}
|
|
135
132
|
>
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
{
|
|
139
|
-
|
|
140
|
-
|
|
133
|
+
<label class="min-h-0 min-w-0 gap-2 flex flex-1 cursor-text items-center self-stretch">
|
|
134
|
+
{#if leadIcon}
|
|
135
|
+
<span class={['text-muted pointer-events-none shrink-0', statusIconClasses[status]]}>
|
|
136
|
+
{@render leadIcon()}
|
|
137
|
+
</span>
|
|
138
|
+
{/if}
|
|
141
139
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
140
|
+
<input
|
|
141
|
+
id={inputId}
|
|
142
|
+
{name}
|
|
143
|
+
{type}
|
|
144
|
+
{placeholder}
|
|
145
|
+
{disabled}
|
|
146
|
+
{readonly}
|
|
147
|
+
{required}
|
|
148
|
+
{autofocus}
|
|
149
|
+
{min}
|
|
150
|
+
{max}
|
|
151
|
+
{step}
|
|
152
|
+
bind:value
|
|
153
|
+
aria-describedby={helperText ? helperId : undefined}
|
|
154
|
+
aria-invalid={status === 'error'}
|
|
155
|
+
class={[
|
|
156
|
+
'min-w-0 text-primary placeholder:text-muted h-full flex-1 self-stretch bg-transparent outline-none disabled:cursor-not-allowed',
|
|
157
|
+
type === 'search' &&
|
|
158
|
+
'[&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none'
|
|
159
|
+
]}
|
|
160
|
+
{oninput}
|
|
161
|
+
{onchange}
|
|
162
|
+
{onfocus}
|
|
163
|
+
{onblur}
|
|
164
|
+
{onkeydown}
|
|
165
|
+
/>
|
|
166
|
+
</label>
|
|
168
167
|
|
|
169
168
|
{#if hasClear}
|
|
170
169
|
<button
|
|
171
170
|
type="button"
|
|
172
171
|
onclick={clearValue}
|
|
173
|
-
class="
|
|
172
|
+
class="text-muted hover:text-primary rounded shrink-0 transition-colors duration-150"
|
|
174
173
|
aria-label="Clear input"
|
|
175
174
|
>
|
|
176
175
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
@@ -178,16 +177,34 @@
|
|
|
178
177
|
</svg>
|
|
179
178
|
</button>
|
|
180
179
|
{:else if trailIcon}
|
|
181
|
-
<span class={['shrink-0
|
|
180
|
+
<span class={['text-muted shrink-0', statusIconClasses[status]]}>
|
|
182
181
|
{@render trailIcon()}
|
|
183
182
|
</span>
|
|
184
183
|
{:else if status === 'error'}
|
|
185
|
-
<svg
|
|
186
|
-
|
|
184
|
+
<svg
|
|
185
|
+
class="h-4 w-4 text-red-500 shrink-0"
|
|
186
|
+
viewBox="0 0 24 24"
|
|
187
|
+
fill="currentColor"
|
|
188
|
+
aria-hidden="true"
|
|
189
|
+
>
|
|
190
|
+
<path
|
|
191
|
+
fill-rule="evenodd"
|
|
192
|
+
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm-1 5a1 1 0 112 0v4a1 1 0 11-2 0V7zm1 8a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"
|
|
193
|
+
clip-rule="evenodd"
|
|
194
|
+
/>
|
|
187
195
|
</svg>
|
|
188
196
|
{:else if status === 'success'}
|
|
189
|
-
<svg
|
|
190
|
-
|
|
197
|
+
<svg
|
|
198
|
+
class="h-4 w-4 text-green-500 shrink-0"
|
|
199
|
+
viewBox="0 0 24 24"
|
|
200
|
+
fill="currentColor"
|
|
201
|
+
aria-hidden="true"
|
|
202
|
+
>
|
|
203
|
+
<path
|
|
204
|
+
fill-rule="evenodd"
|
|
205
|
+
d="M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm4.707 7.293a1 1 0 00-1.414 0L10 14.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l6-6a1 1 0 000-1.414z"
|
|
206
|
+
clip-rule="evenodd"
|
|
207
|
+
/>
|
|
191
208
|
</svg>
|
|
192
209
|
{/if}
|
|
193
210
|
</div>
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
end?: Snippet;
|
|
38
38
|
/** Replaces default error + retry */
|
|
39
39
|
errorSnippet?: Snippet;
|
|
40
|
-
onloadmore?: () => void
|
|
40
|
+
onloadmore?: () => void | Promise<void>;
|
|
41
41
|
onretry?: () => void;
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -67,41 +67,35 @@
|
|
|
67
67
|
let locked = $state(false);
|
|
68
68
|
|
|
69
69
|
const canAutoLoad = $derived(
|
|
70
|
-
(mode === 'auto' || mode === 'both') &&
|
|
71
|
-
!disabled &&
|
|
72
|
-
!loading &&
|
|
73
|
-
!error &&
|
|
74
|
-
hasMore &&
|
|
75
|
-
!locked
|
|
70
|
+
(mode === 'auto' || mode === 'both') && !disabled && !loading && !error && hasMore && !locked
|
|
76
71
|
);
|
|
77
72
|
|
|
78
73
|
const showManual = $derived(
|
|
79
|
-
(mode === 'manual' || mode === 'both') && hasMore && !disabled && !error
|
|
74
|
+
(mode === 'manual' || mode === 'both') && hasMore && !disabled && !error && !loading && !locked
|
|
80
75
|
);
|
|
81
76
|
|
|
82
|
-
function requestMore() {
|
|
77
|
+
async function requestMore() {
|
|
83
78
|
if (disabled || loading || !hasMore || locked || error) return;
|
|
84
79
|
locked = true;
|
|
85
|
-
|
|
80
|
+
try {
|
|
81
|
+
await onloadmore?.();
|
|
82
|
+
} finally {
|
|
83
|
+
locked = false;
|
|
84
|
+
}
|
|
86
85
|
}
|
|
87
86
|
|
|
88
87
|
function retry() {
|
|
89
88
|
if (loading) return;
|
|
90
|
-
onretry?.() ??
|
|
89
|
+
void (onretry?.() ?? requestMore());
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
// Unlock when parent finishes loading (or errors / ends).
|
|
94
|
-
$effect(() => {
|
|
95
|
-
if (!loading) locked = false;
|
|
96
|
-
});
|
|
97
|
-
|
|
98
92
|
$effect(() => {
|
|
99
93
|
if (!sentinel || !canAutoLoad) return;
|
|
100
94
|
|
|
101
95
|
const el = sentinel;
|
|
102
96
|
const obs = new IntersectionObserver(
|
|
103
97
|
(entries) => {
|
|
104
|
-
if (entries.some((e) => e.isIntersecting)) requestMore();
|
|
98
|
+
if (entries.some((e) => e.isIntersecting)) void requestMore();
|
|
105
99
|
},
|
|
106
100
|
{
|
|
107
101
|
root: scrollRoot ?? null,
|
|
@@ -114,10 +108,7 @@
|
|
|
114
108
|
});
|
|
115
109
|
</script>
|
|
116
110
|
|
|
117
|
-
<div
|
|
118
|
-
class={['w-full', className]}
|
|
119
|
-
aria-busy={loading}
|
|
120
|
-
>
|
|
111
|
+
<div class={['w-full', className]} aria-busy={loading}>
|
|
121
112
|
{#if sentinelPos === 'start'}
|
|
122
113
|
{@render footer()}
|
|
123
114
|
{/if}
|
|
@@ -134,14 +125,14 @@
|
|
|
134
125
|
{#snippet footer()}
|
|
135
126
|
<div
|
|
136
127
|
bind:this={sentinel}
|
|
137
|
-
class="flex flex-col items-center justify-center
|
|
128
|
+
class="gap-2 py-4 flex flex-col items-center justify-center"
|
|
138
129
|
aria-live="polite"
|
|
139
130
|
>
|
|
140
131
|
{#if error}
|
|
141
132
|
{#if errorSnippet}
|
|
142
133
|
{@render errorSnippet()}
|
|
143
134
|
{:else}
|
|
144
|
-
<div class="flex flex-col items-center
|
|
135
|
+
<div class="gap-2 px-3 flex flex-col items-center text-center">
|
|
145
136
|
<p class="text-xs text-red-600 dark:text-red-400">{error}</p>
|
|
146
137
|
<Button size="sm" variant="secondary" onclick={retry} disabled={loading}>
|
|
147
138
|
{retryLabel}
|
|
@@ -152,7 +143,7 @@
|
|
|
152
143
|
{#if loader}
|
|
153
144
|
{@render loader()}
|
|
154
145
|
{:else}
|
|
155
|
-
<div class="
|
|
146
|
+
<div class="gap-2 text-muted flex items-center">
|
|
156
147
|
<Spinner size="sm" />
|
|
157
148
|
<span class="text-xs">Loading…</span>
|
|
158
149
|
</div>
|
|
@@ -167,7 +158,7 @@
|
|
|
167
158
|
<Button
|
|
168
159
|
size="sm"
|
|
169
160
|
variant={mode === 'manual' ? 'secondary' : 'ghost'}
|
|
170
|
-
onclick={requestMore}
|
|
161
|
+
onclick={() => void requestMore()}
|
|
171
162
|
disabled={loading}
|
|
172
163
|
>
|
|
173
164
|
{loadMoreLabel}
|
|
@@ -29,7 +29,7 @@ interface InfiniteScrollProps {
|
|
|
29
29
|
end?: Snippet;
|
|
30
30
|
/** Replaces default error + retry */
|
|
31
31
|
errorSnippet?: Snippet;
|
|
32
|
-
onloadmore?: () => void
|
|
32
|
+
onloadmore?: () => void | Promise<void>;
|
|
33
33
|
onretry?: () => void;
|
|
34
34
|
}
|
|
35
35
|
declare const InfiniteScroll: import("svelte").Component<InfiniteScrollProps, {}, "">;
|
|
@@ -139,8 +139,7 @@
|
|
|
139
139
|
const q = searchQuery.toLowerCase();
|
|
140
140
|
return leafOptions.filter(
|
|
141
141
|
(o) =>
|
|
142
|
-
o.label.toLowerCase().includes(q) ||
|
|
143
|
-
(o.breadcrumb?.toLowerCase().includes(q) ?? false)
|
|
142
|
+
o.label.toLowerCase().includes(q) || (o.breadcrumb?.toLowerCase().includes(q) ?? false)
|
|
144
143
|
);
|
|
145
144
|
}
|
|
146
145
|
return currentLevel.options;
|
|
@@ -480,7 +479,7 @@
|
|
|
480
479
|
popovertargetaction="toggle"
|
|
481
480
|
onkeydown={handleTriggerKeydown}
|
|
482
481
|
class={[
|
|
483
|
-
'gap-2
|
|
482
|
+
'gap-2 rounded-lg flex w-full items-center justify-between border bg-transparent text-left transition-colors duration-150 outline-none select-none',
|
|
484
483
|
sizeClasses[size],
|
|
485
484
|
statusRingClasses[status],
|
|
486
485
|
isOpen && 'border-brand-500 ring-brand-500/20 ring-2',
|
|
@@ -562,7 +561,7 @@
|
|
|
562
561
|
<button
|
|
563
562
|
type="button"
|
|
564
563
|
onclick={goBack}
|
|
565
|
-
class="gap-2 text-primary hover:bg-surface-overlay mx-1.5 mt-1.5
|
|
564
|
+
class="gap-2 text-primary hover:bg-surface-overlay mx-1.5 mt-1.5 rounded-lg px-2.5 py-2 text-sm font-medium flex shrink-0 items-center text-left transition-colors"
|
|
566
565
|
>
|
|
567
566
|
<svg
|
|
568
567
|
class="text-secondary h-4 w-4 shrink-0"
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { StoryObj } from '@storybook/svelte';
|
|
2
|
+
declare const meta: {
|
|
3
|
+
title: string;
|
|
4
|
+
component: import("svelte").Component<{
|
|
5
|
+
showMeta?: boolean;
|
|
6
|
+
empty?: boolean;
|
|
7
|
+
}, {}, "">;
|
|
8
|
+
tags: string[];
|
|
9
|
+
argTypes: {
|
|
10
|
+
showMeta: {
|
|
11
|
+
control: "boolean";
|
|
12
|
+
};
|
|
13
|
+
empty: {
|
|
14
|
+
control: "boolean";
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
args: {
|
|
18
|
+
showMeta: false;
|
|
19
|
+
empty: false;
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
export default meta;
|
|
23
|
+
type Story = StoryObj<typeof meta>;
|
|
24
|
+
export declare const Default: Story;
|
|
25
|
+
export declare const WithOrder: Story;
|
|
26
|
+
export declare const Empty: Story;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import TreePanelStory from './TreePanelStory.svelte';
|
|
2
|
+
const meta = {
|
|
3
|
+
title: 'Molecules/TreePanel',
|
|
4
|
+
component: TreePanelStory,
|
|
5
|
+
tags: ['autodocs'],
|
|
6
|
+
argTypes: {
|
|
7
|
+
showMeta: { control: 'boolean' },
|
|
8
|
+
empty: { control: 'boolean' }
|
|
9
|
+
},
|
|
10
|
+
args: {
|
|
11
|
+
showMeta: false,
|
|
12
|
+
empty: false
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
export default meta;
|
|
16
|
+
export const Default = {};
|
|
17
|
+
export const WithOrder = { args: { showMeta: true } };
|
|
18
|
+
export const Empty = { args: { empty: true } };
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte';
|
|
3
|
+
import Card from '../Card/Card.svelte';
|
|
4
|
+
import IconButton from '../../atoms/IconButton/IconButton.svelte';
|
|
5
|
+
import IconBox from '../../atoms/IconBox/IconBox.svelte';
|
|
6
|
+
|
|
7
|
+
export interface TreePanelNode {
|
|
8
|
+
id: string;
|
|
9
|
+
label: string;
|
|
10
|
+
/** Emoji or short glyph shown instead of the default folder / branch icon. */
|
|
11
|
+
icon?: string | null;
|
|
12
|
+
/** Extra column value (sort order, count, …). */
|
|
13
|
+
meta?: string | number | null;
|
|
14
|
+
children?: TreePanelNode[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface TreePanelProps {
|
|
18
|
+
items?: TreePanelNode[];
|
|
19
|
+
collapsedIds?: string[];
|
|
20
|
+
emptyMessage?: string;
|
|
21
|
+
hierarchyLabel?: string;
|
|
22
|
+
metaLabel?: string;
|
|
23
|
+
actionsLabel?: string;
|
|
24
|
+
showMeta?: boolean;
|
|
25
|
+
addChildLabel?: string;
|
|
26
|
+
editLabel?: string;
|
|
27
|
+
deleteLabel?: string;
|
|
28
|
+
expandLabel?: string;
|
|
29
|
+
collapseLabel?: string;
|
|
30
|
+
onAddChild?: (node: TreePanelNode) => void;
|
|
31
|
+
onEdit?: (node: TreePanelNode) => void;
|
|
32
|
+
onDelete?: (node: TreePanelNode) => void;
|
|
33
|
+
/** Extra content after the label (badges, counts). */
|
|
34
|
+
meta?: Snippet<[TreePanelNode, number]>;
|
|
35
|
+
/** Replaces the default add / edit / delete buttons. */
|
|
36
|
+
trailing?: Snippet<[TreePanelNode, number]>;
|
|
37
|
+
leading?: Snippet<[TreePanelNode, number]>;
|
|
38
|
+
empty?: Snippet;
|
|
39
|
+
class?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let {
|
|
43
|
+
items = [],
|
|
44
|
+
collapsedIds = $bindable([]),
|
|
45
|
+
emptyMessage = '',
|
|
46
|
+
hierarchyLabel = 'Hierarchy',
|
|
47
|
+
metaLabel = 'Order',
|
|
48
|
+
actionsLabel = 'Actions',
|
|
49
|
+
showMeta = false,
|
|
50
|
+
addChildLabel = 'Add child',
|
|
51
|
+
editLabel = 'Edit',
|
|
52
|
+
deleteLabel = 'Delete',
|
|
53
|
+
expandLabel = 'Expand',
|
|
54
|
+
collapseLabel = 'Collapse',
|
|
55
|
+
onAddChild,
|
|
56
|
+
onEdit,
|
|
57
|
+
onDelete,
|
|
58
|
+
meta,
|
|
59
|
+
trailing,
|
|
60
|
+
leading,
|
|
61
|
+
empty,
|
|
62
|
+
class: className = ''
|
|
63
|
+
}: TreePanelProps = $props();
|
|
64
|
+
|
|
65
|
+
const collapsed = $derived(new Set(collapsedIds));
|
|
66
|
+
const showActions = $derived(!!trailing || !!onAddChild || !!onEdit || !!onDelete);
|
|
67
|
+
|
|
68
|
+
function toggleCollapse(id: string) {
|
|
69
|
+
collapsedIds = collapsed.has(id)
|
|
70
|
+
? collapsedIds.filter((item) => item !== id)
|
|
71
|
+
: [...collapsedIds, id];
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<Card variant="default" padding="none" class={`rounded-3xl ${className}`.trim()}>
|
|
76
|
+
{#snippet header()}
|
|
77
|
+
<div
|
|
78
|
+
class="px-6 py-4 text-xs font-semibold tracking-wider text-secondary flex items-center uppercase"
|
|
79
|
+
>
|
|
80
|
+
<div class="min-w-0 flex-1">{hierarchyLabel}</div>
|
|
81
|
+
{#if showMeta}
|
|
82
|
+
<div class="mr-2 w-14 text-center">{metaLabel}</div>
|
|
83
|
+
{/if}
|
|
84
|
+
{#if showActions}
|
|
85
|
+
<div class="w-28 text-right">{actionsLabel}</div>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
88
|
+
{/snippet}
|
|
89
|
+
|
|
90
|
+
<div class="p-2">
|
|
91
|
+
{#if items.length === 0}
|
|
92
|
+
{#if empty}
|
|
93
|
+
{@render empty()}
|
|
94
|
+
{:else}
|
|
95
|
+
<p class="px-4 py-8 text-sm text-secondary text-center">{emptyMessage}</p>
|
|
96
|
+
{/if}
|
|
97
|
+
{/if}
|
|
98
|
+
|
|
99
|
+
{#snippet treeItem(node: TreePanelNode, level: number)}
|
|
100
|
+
{@const children = node.children ?? []}
|
|
101
|
+
{@const hasChildren = children.length > 0}
|
|
102
|
+
{@const isCollapsed = collapsed.has(node.id)}
|
|
103
|
+
<div
|
|
104
|
+
class="group rounded-xl p-2 hover:bg-surface-overlay flex items-center transition-colors"
|
|
105
|
+
>
|
|
106
|
+
<div class="min-w-0 flex flex-1 items-center" style:padding-left={`${level * 1.5}rem`}>
|
|
107
|
+
{#if hasChildren}
|
|
108
|
+
<IconButton
|
|
109
|
+
size="xs"
|
|
110
|
+
variant="ghost"
|
|
111
|
+
label={isCollapsed ? expandLabel : collapseLabel}
|
|
112
|
+
class={!isCollapsed ? 'mr-1 [&_svg]:rotate-90' : 'mr-1'}
|
|
113
|
+
onclick={() => toggleCollapse(node.id)}
|
|
114
|
+
>
|
|
115
|
+
<svg
|
|
116
|
+
class="transition-transform"
|
|
117
|
+
viewBox="0 0 24 24"
|
|
118
|
+
fill="none"
|
|
119
|
+
stroke="currentColor"
|
|
120
|
+
stroke-width="2"
|
|
121
|
+
stroke-linecap="round"
|
|
122
|
+
stroke-linejoin="round"
|
|
123
|
+
>
|
|
124
|
+
<path d="m9 18 6-6-6-6" />
|
|
125
|
+
</svg>
|
|
126
|
+
</IconButton>
|
|
127
|
+
{:else}
|
|
128
|
+
<span class="mr-1 h-7 w-7 flex shrink-0 items-center justify-center" aria-hidden="true">
|
|
129
|
+
<span class="h-1 w-1 bg-border-strong rounded-full"></span>
|
|
130
|
+
</span>
|
|
131
|
+
{/if}
|
|
132
|
+
|
|
133
|
+
{#if leading}
|
|
134
|
+
{@render leading(node, level)}
|
|
135
|
+
{:else if node.icon}
|
|
136
|
+
<span class="mr-2 text-lg shrink-0" aria-hidden="true">{node.icon}</span>
|
|
137
|
+
{:else if level === 0}
|
|
138
|
+
<IconBox tone="brand" size="sm" rounded="lg" class="mr-2">
|
|
139
|
+
<svg
|
|
140
|
+
viewBox="0 0 24 24"
|
|
141
|
+
fill="none"
|
|
142
|
+
stroke="currentColor"
|
|
143
|
+
stroke-width="2"
|
|
144
|
+
stroke-linecap="round"
|
|
145
|
+
stroke-linejoin="round"
|
|
146
|
+
>
|
|
147
|
+
<path
|
|
148
|
+
d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"
|
|
149
|
+
/>
|
|
150
|
+
<path d="M2 10h20" />
|
|
151
|
+
</svg>
|
|
152
|
+
</IconBox>
|
|
153
|
+
{:else}
|
|
154
|
+
<svg
|
|
155
|
+
class="mr-2 h-4 w-4 text-secondary shrink-0"
|
|
156
|
+
viewBox="0 0 24 24"
|
|
157
|
+
fill="none"
|
|
158
|
+
stroke="currentColor"
|
|
159
|
+
stroke-width="2"
|
|
160
|
+
stroke-linecap="round"
|
|
161
|
+
stroke-linejoin="round"
|
|
162
|
+
aria-hidden="true"
|
|
163
|
+
>
|
|
164
|
+
<polyline points="15 10 20 15 15 20" />
|
|
165
|
+
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
|
166
|
+
</svg>
|
|
167
|
+
{/if}
|
|
168
|
+
|
|
169
|
+
<span
|
|
170
|
+
class={[
|
|
171
|
+
'font-medium truncate',
|
|
172
|
+
level === 0 ? 'text-base font-semibold text-primary' : 'text-sm text-secondary'
|
|
173
|
+
]}
|
|
174
|
+
>
|
|
175
|
+
{node.label}
|
|
176
|
+
</span>
|
|
177
|
+
{#if meta}
|
|
178
|
+
<div class="ml-2 shrink-0">
|
|
179
|
+
{@render meta(node, level)}
|
|
180
|
+
</div>
|
|
181
|
+
{/if}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{#if showMeta}
|
|
185
|
+
<span class="mr-2 w-14 font-mono text-xs text-secondary text-center">
|
|
186
|
+
{node.meta ?? ''}
|
|
187
|
+
</span>
|
|
188
|
+
{/if}
|
|
189
|
+
|
|
190
|
+
{#if showActions}
|
|
191
|
+
<div
|
|
192
|
+
class="w-28 gap-0.5 sm:opacity-0 sm:group-hover:opacity-100 flex items-center justify-end opacity-100 transition-opacity"
|
|
193
|
+
>
|
|
194
|
+
{#if trailing}
|
|
195
|
+
{@render trailing(node, level)}
|
|
196
|
+
{:else}
|
|
197
|
+
{#if onAddChild}
|
|
198
|
+
<IconButton
|
|
199
|
+
size="xs"
|
|
200
|
+
variant="ghost"
|
|
201
|
+
label={addChildLabel}
|
|
202
|
+
class="text-secondary hover:bg-emerald-50 hover:text-emerald-600 dark:hover:bg-emerald-500/10 dark:hover:text-emerald-400"
|
|
203
|
+
onclick={() => onAddChild(node)}
|
|
204
|
+
>
|
|
205
|
+
<svg
|
|
206
|
+
viewBox="0 0 24 24"
|
|
207
|
+
fill="none"
|
|
208
|
+
stroke="currentColor"
|
|
209
|
+
stroke-width="2"
|
|
210
|
+
stroke-linecap="round"
|
|
211
|
+
stroke-linejoin="round"
|
|
212
|
+
>
|
|
213
|
+
<path d="M5 12h14" />
|
|
214
|
+
<path d="M12 5v14" />
|
|
215
|
+
</svg>
|
|
216
|
+
</IconButton>
|
|
217
|
+
{/if}
|
|
218
|
+
{#if onEdit}
|
|
219
|
+
<IconButton
|
|
220
|
+
size="xs"
|
|
221
|
+
variant="ghost"
|
|
222
|
+
label={editLabel}
|
|
223
|
+
class="text-secondary hover:bg-sky-50 hover:text-sky-600 dark:hover:bg-sky-500/10 dark:hover:text-sky-400"
|
|
224
|
+
onclick={() => onEdit(node)}
|
|
225
|
+
>
|
|
226
|
+
<svg
|
|
227
|
+
viewBox="0 0 24 24"
|
|
228
|
+
fill="none"
|
|
229
|
+
stroke="currentColor"
|
|
230
|
+
stroke-width="2"
|
|
231
|
+
stroke-linecap="round"
|
|
232
|
+
stroke-linejoin="round"
|
|
233
|
+
>
|
|
234
|
+
<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z" />
|
|
235
|
+
<path d="m15 5 4 4" />
|
|
236
|
+
</svg>
|
|
237
|
+
</IconButton>
|
|
238
|
+
{/if}
|
|
239
|
+
{#if onDelete}
|
|
240
|
+
<IconButton
|
|
241
|
+
size="xs"
|
|
242
|
+
variant="ghost"
|
|
243
|
+
label={deleteLabel}
|
|
244
|
+
class="text-secondary hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-500/10 dark:hover:text-red-400"
|
|
245
|
+
onclick={() => onDelete(node)}
|
|
246
|
+
>
|
|
247
|
+
<svg
|
|
248
|
+
viewBox="0 0 24 24"
|
|
249
|
+
fill="none"
|
|
250
|
+
stroke="currentColor"
|
|
251
|
+
stroke-width="2"
|
|
252
|
+
stroke-linecap="round"
|
|
253
|
+
stroke-linejoin="round"
|
|
254
|
+
>
|
|
255
|
+
<path d="M3 6h18" />
|
|
256
|
+
<path d="M8 6V4h8v2" />
|
|
257
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
|
258
|
+
</svg>
|
|
259
|
+
</IconButton>
|
|
260
|
+
{/if}
|
|
261
|
+
{/if}
|
|
262
|
+
</div>
|
|
263
|
+
{/if}
|
|
264
|
+
</div>
|
|
265
|
+
{#if hasChildren && !isCollapsed}
|
|
266
|
+
{#each children as child (child.id)}
|
|
267
|
+
{@render treeItem(child, level + 1)}
|
|
268
|
+
{/each}
|
|
269
|
+
{/if}
|
|
270
|
+
{/snippet}
|
|
271
|
+
|
|
272
|
+
<div class="space-y-0.5">
|
|
273
|
+
{#each items as node (node.id)}
|
|
274
|
+
{@render treeItem(node, 0)}
|
|
275
|
+
{/each}
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
</Card>
|