@selvajs/ui 6.6.0-beta.6 → 6.6.0-beta.7
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/dist/components/ErrorScreen.svelte +1 -1
- package/dist/components/compute/AppLayout.svelte +63 -26
- package/dist/components/layout/AppShell.svelte +10 -6
- package/dist/components/layout/PageHeader.svelte +11 -7
- package/dist/components/layout/SectionHeader.svelte +4 -2
- package/dist/components/layout/SideNav.svelte +19 -11
- package/dist/components/preview/ChartOutput.svelte +2 -2
- package/dist/components/preview/ImageOutput.svelte +1 -1
- package/dist/components/preview/OutputDisplay.svelte +1 -1
- package/dist/components/primitives/DataTable.svelte +38 -4
- package/dist/components/primitives/DataTable.svelte.d.ts +2 -0
- package/dist/components/primitives/alert-dialog/alert-dialog-content.svelte +1 -1
- package/dist/components/primitives/dialog/dialog-content.svelte +1 -1
- package/dist/components/primitives/drawer/Drawer.svelte +1 -1
- package/dist/components/viewer/MeshMetadataPanel.svelte +119 -0
- package/dist/components/viewer/MeshMetadataPanel.svelte.d.ts +9 -0
- package/dist/components/viewer/Viewer.svelte +59 -24
- package/dist/styles/base.css +21 -1
- package/package.json +5 -5
- package/src/lib/components/ErrorScreen.svelte +1 -1
- package/src/lib/components/compute/AppLayout.svelte +63 -26
- package/src/lib/components/layout/AppShell.svelte +10 -6
- package/src/lib/components/layout/PageHeader.svelte +11 -7
- package/src/lib/components/layout/SectionHeader.svelte +4 -2
- package/src/lib/components/layout/SideNav.svelte +19 -11
- package/src/lib/components/preview/ChartOutput.svelte +2 -2
- package/src/lib/components/preview/ImageOutput.svelte +1 -1
- package/src/lib/components/preview/OutputDisplay.svelte +1 -1
- package/src/lib/components/primitives/DataTable.svelte +38 -4
- package/src/lib/components/primitives/alert-dialog/alert-dialog-content.svelte +1 -1
- package/src/lib/components/primitives/dialog/dialog-content.svelte +1 -1
- package/src/lib/components/primitives/drawer/Drawer.svelte +1 -1
- package/src/lib/components/viewer/MeshMetadataPanel.svelte +119 -0
- package/src/lib/components/viewer/Viewer.svelte +59 -24
- package/src/lib/styles/base.css +21 -1
- package/dist/components/viewer/MeshMetadataDialog.svelte +0 -126
- package/dist/components/viewer/MeshMetadataDialog.svelte.d.ts +0 -10
- package/src/lib/components/viewer/MeshMetadataDialog.svelte +0 -126
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
const t = $derived(locale.messages);
|
|
19
19
|
</script>
|
|
20
20
|
|
|
21
|
-
<div class="p-4 flex min-h-
|
|
21
|
+
<div class="p-4 flex min-h-dvh items-center justify-center bg-background">
|
|
22
22
|
<Card.Root class="max-w-md w-full">
|
|
23
23
|
<Card.Header>
|
|
24
24
|
<div class="gap-4 flex flex-col items-center text-center">
|
|
@@ -124,9 +124,14 @@
|
|
|
124
124
|
const isTwoPanelMode = $derived(!hasViewer && hasLeftPanel && hasRightPanel);
|
|
125
125
|
|
|
126
126
|
let isMobile = $state(false);
|
|
127
|
-
|
|
127
|
+
// Half keeps the model visible above the fields so a change can be watched as it solves;
|
|
128
|
+
// full is for long forms.
|
|
129
|
+
type DrawerState = 'closed' | 'half' | 'full';
|
|
130
|
+
let drawer = $state<DrawerState>('closed');
|
|
131
|
+
const DRAWER_STATES: DrawerState[] = ['closed', 'half', 'full'];
|
|
128
132
|
|
|
129
|
-
|
|
133
|
+
// With several tabs the first tab's name would mislabel whichever one is showing.
|
|
134
|
+
const drawerLabel = $derived(leftTabs.length === 1 ? leftTabs[0].label : t.parametersTab);
|
|
130
135
|
|
|
131
136
|
$effect(() => {
|
|
132
137
|
const mqMobile = window.matchMedia('(max-width: 639px)');
|
|
@@ -154,19 +159,31 @@
|
|
|
154
159
|
await onLoadValues?.(loadedValues);
|
|
155
160
|
}
|
|
156
161
|
|
|
157
|
-
let
|
|
162
|
+
let touchStartY = 0;
|
|
163
|
+
|
|
164
|
+
function stepDrawer(by: number) {
|
|
165
|
+
const index = DRAWER_STATES.indexOf(drawer) + by;
|
|
166
|
+
drawer = DRAWER_STATES[Math.max(0, Math.min(DRAWER_STATES.length - 1, index))];
|
|
167
|
+
}
|
|
158
168
|
|
|
159
169
|
function onDrawerTouchStart(e: TouchEvent) {
|
|
160
|
-
|
|
170
|
+
touchStartY = e.touches[0].clientY;
|
|
161
171
|
}
|
|
162
172
|
|
|
163
173
|
function onDrawerTouchEnd(e: TouchEvent) {
|
|
164
|
-
const deltaY = e.changedTouches[0].clientY -
|
|
174
|
+
const deltaY = e.changedTouches[0].clientY - touchStartY;
|
|
165
175
|
const SWIPE_THRESHOLD = 40;
|
|
166
176
|
if (Math.abs(deltaY) > SWIPE_THRESHOLD) {
|
|
167
|
-
|
|
177
|
+
// Suppresses the click a short swipe can still end in, which would step the drawer twice.
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
stepDrawer(deltaY < 0 ? 1 : -1);
|
|
168
180
|
}
|
|
169
181
|
}
|
|
182
|
+
|
|
183
|
+
// A tap walks closed → half → full → closed.
|
|
184
|
+
function onDrawerHandleClick() {
|
|
185
|
+
drawer = drawer === 'full' ? 'closed' : DRAWER_STATES[DRAWER_STATES.indexOf(drawer) + 1];
|
|
186
|
+
}
|
|
170
187
|
</script>
|
|
171
188
|
|
|
172
189
|
<!-- Everything but Calculate: saving and loading inputs, then the host's own actions. -->
|
|
@@ -279,16 +296,19 @@
|
|
|
279
296
|
: 'sm:pl-(--page-px)'} {rightCollapsed ? 'sm:pr-0' : 'sm:pr-(--page-px)'}"
|
|
280
297
|
class:fullscreen-layout={isViewerFullscreen}
|
|
281
298
|
class:relative={isMobile}
|
|
299
|
+
class:mobile-root={isMobile}
|
|
282
300
|
>
|
|
283
301
|
{#if isMobile}
|
|
284
302
|
{#if hasViewer}
|
|
285
|
-
|
|
303
|
+
<!-- Ends above the drawer rather than behind it, so the model stays centred in what is
|
|
304
|
+
visible. It jumps straight to its new size: animating it would resize the canvas,
|
|
305
|
+
and its render targets, on every frame of the drawer's slide. -->
|
|
306
|
+
<div class="viewer-region" class:viewer-region-raised={drawer !== 'closed'}>
|
|
286
307
|
<Viewer
|
|
287
308
|
{meshes}
|
|
288
309
|
bind:isFullscreen={isViewerFullscreen}
|
|
289
310
|
{isSolving}
|
|
290
|
-
|
|
291
|
-
{drawerOpen}
|
|
311
|
+
drawerOpen={drawer === 'full'}
|
|
292
312
|
{logoUrl}
|
|
293
313
|
{onViewerReady}
|
|
294
314
|
viewerConfig={{
|
|
@@ -300,31 +320,30 @@
|
|
|
300
320
|
</div>
|
|
301
321
|
|
|
302
322
|
<div
|
|
303
|
-
class="drawer-container rounded-t-2xl z-40 border-t bg-background shadow-sheet"
|
|
304
|
-
class:drawer-open={drawerOpen}
|
|
305
|
-
class:drawer-closed={!drawerOpen}
|
|
323
|
+
class="drawer-container rounded-t-2xl z-40 border-t bg-background shadow-sheet drawer-{drawer}"
|
|
306
324
|
>
|
|
307
325
|
<!-- Named by its visible label; aria-expanded carries the open/closed state. -->
|
|
308
326
|
<button
|
|
309
|
-
class="drawer-handle-bar"
|
|
310
|
-
onclick={
|
|
327
|
+
class="drawer-handle-bar touch-none"
|
|
328
|
+
onclick={onDrawerHandleClick}
|
|
311
329
|
ontouchstart={onDrawerTouchStart}
|
|
312
330
|
ontouchend={onDrawerTouchEnd}
|
|
313
|
-
aria-expanded={
|
|
331
|
+
aria-expanded={drawer !== 'closed'}
|
|
314
332
|
>
|
|
315
333
|
<div class="drawer-pill"></div>
|
|
316
|
-
<span class="text-sm font-medium ml-2 text-foreground">{
|
|
334
|
+
<span class="text-sm font-medium ml-2 text-foreground">{drawerLabel}</span>
|
|
317
335
|
<ChevronUp
|
|
318
336
|
size={16}
|
|
319
|
-
class="ml-auto text-muted-foreground transition-transform duration-300 {
|
|
320
|
-
|
|
321
|
-
|
|
337
|
+
class="ml-auto text-muted-foreground transition-transform duration-300 {drawer ===
|
|
338
|
+
'full'
|
|
339
|
+
? 'rotate-180'
|
|
340
|
+
: ''}"
|
|
322
341
|
/>
|
|
323
342
|
</button>
|
|
324
343
|
|
|
325
344
|
<!-- Inert while closed: the fields are clipped out of view, and tabbing into them
|
|
326
345
|
would scroll the drawer's insides around behind the handle. -->
|
|
327
|
-
<div class="drawer-content" inert={
|
|
346
|
+
<div class="drawer-content" inert={drawer === 'closed'}>
|
|
328
347
|
<div class="px-3 pb-6">
|
|
329
348
|
{#if hasLeftPanel}
|
|
330
349
|
{@render panelContent(hasRightPanel ? 'left' : undefined, requestedLeftTabId, false)}
|
|
@@ -343,7 +362,7 @@
|
|
|
343
362
|
</div>
|
|
344
363
|
</div>
|
|
345
364
|
|
|
346
|
-
{@render drawerFooter(
|
|
365
|
+
{@render drawerFooter(drawer === 'closed')}
|
|
347
366
|
</div>
|
|
348
367
|
{:else}
|
|
349
368
|
<div class="flex flex-1 flex-col overflow-hidden">
|
|
@@ -534,6 +553,22 @@
|
|
|
534
553
|
gap: 0.5rem;
|
|
535
554
|
}
|
|
536
555
|
|
|
556
|
+
.mobile-root {
|
|
557
|
+
--drawer-closed-h: 60px;
|
|
558
|
+
--drawer-half-h: 50%;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
.viewer-region {
|
|
562
|
+
position: absolute;
|
|
563
|
+
inset: 0 0 var(--drawer-closed-h) 0;
|
|
564
|
+
display: flex;
|
|
565
|
+
flex-direction: column;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
.viewer-region-raised {
|
|
569
|
+
bottom: var(--drawer-half-h);
|
|
570
|
+
}
|
|
571
|
+
|
|
537
572
|
.drawer-container {
|
|
538
573
|
position: absolute;
|
|
539
574
|
bottom: 0;
|
|
@@ -546,12 +581,14 @@
|
|
|
546
581
|
}
|
|
547
582
|
|
|
548
583
|
.drawer-closed {
|
|
549
|
-
height:
|
|
584
|
+
height: var(--drawer-closed-h);
|
|
585
|
+
}
|
|
586
|
+
.drawer-half {
|
|
587
|
+
height: var(--drawer-half-h);
|
|
550
588
|
}
|
|
551
|
-
/*
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
height: max(60svh, min(30rem, 100svh - 10rem));
|
|
589
|
+
/* Leaves a strip of the model showing, so the sheet still reads as sitting over the viewer. */
|
|
590
|
+
.drawer-full {
|
|
591
|
+
height: calc(100% - 3rem);
|
|
555
592
|
}
|
|
556
593
|
|
|
557
594
|
.drawer-handle-bar {
|
|
@@ -74,14 +74,16 @@
|
|
|
74
74
|
|
|
75
75
|
const rootClass = $derived(
|
|
76
76
|
_mode === 'fixed'
|
|
77
|
-
?
|
|
78
|
-
|
|
77
|
+
? // dvh, not vh: iOS sizes 100vh to the viewport without its toolbar, which hides the
|
|
78
|
+
// footer and the phone drawer's handle behind the toolbar.
|
|
79
|
+
`flex flex-col h-dvh overflow-hidden bg-background ${className}`
|
|
80
|
+
: `flex flex-col min-h-dvh bg-background ${className}`
|
|
79
81
|
);
|
|
80
82
|
|
|
81
83
|
const bodyShellClass = $derived(
|
|
82
84
|
_mode === 'fixed'
|
|
83
|
-
? `flex flex-1 min-h-0 ${sidenav ? 'flex-row' : 'flex-col'} ${bodyClass}`.trim()
|
|
84
|
-
: `flex-1 ${sidenav ? 'flex flex-row' : ''} ${bodyClass}`.trim()
|
|
85
|
+
? `flex flex-1 min-h-0 ${sidenav ? 'flex-col sm:flex-row' : 'flex-col'} ${bodyClass}`.trim()
|
|
86
|
+
: `flex-1 ${sidenav ? 'flex flex-col sm:flex-row' : ''} ${bodyClass}`.trim()
|
|
85
87
|
);
|
|
86
88
|
|
|
87
89
|
const bodyShellStyle = '';
|
|
@@ -92,13 +94,15 @@
|
|
|
92
94
|
const sidenavWrapClass = $derived(
|
|
93
95
|
_mode === 'fixed'
|
|
94
96
|
? 'flex shrink-0'
|
|
95
|
-
: 'flex shrink-0 self-start sticky top-(--header-h) max-h-[calc(100svh-var(--header-h))]'
|
|
97
|
+
: 'flex shrink-0 sm:self-start sm:sticky sm:top-(--header-h) sm:max-h-[calc(100svh-var(--header-h))]'
|
|
96
98
|
);
|
|
97
99
|
|
|
98
100
|
// The sticky wrapper is only as tall as its content, so in scroll mode the divider comes
|
|
99
101
|
// from the main column's left edge — that one spans the full body height.
|
|
100
102
|
const mainClass = $derived(
|
|
101
|
-
_mode === 'fixed'
|
|
103
|
+
_mode === 'fixed'
|
|
104
|
+
? 'min-h-0 flex-1 overflow-y-auto'
|
|
105
|
+
: 'min-w-0 flex-1 overflow-y-auto border-border sm:border-l'
|
|
102
106
|
);
|
|
103
107
|
</script>
|
|
104
108
|
|
|
@@ -31,27 +31,31 @@
|
|
|
31
31
|
<header
|
|
32
32
|
class={`top-0 backdrop-blur-sm sticky z-40 border-b border-border bg-background/90 ${className}`}
|
|
33
33
|
>
|
|
34
|
-
<div class="gap-5 flex h-(--header-h) items-center px-(--page-px)">
|
|
34
|
+
<div class="gap-2 sm:gap-5 flex h-(--header-h) items-center px-(--page-px)">
|
|
35
35
|
<!-- Logo -->
|
|
36
|
-
<a href={homeUrl} class="gap-2 flex shrink-0 items-center">
|
|
36
|
+
<a href={homeUrl} class="gap-2 flex shrink-0 items-center" aria-label={brandName}>
|
|
37
37
|
<img src={logo} alt="" aria-hidden="true" class="h-5 w-5" />
|
|
38
|
-
|
|
38
|
+
<!-- On a phone the page title needs the room more than the brand does. -->
|
|
39
|
+
<span
|
|
40
|
+
class="font-semibold text-sm tracking-tight {title !== undefined ? 'sm:inline hidden' : ''}"
|
|
41
|
+
>{brandName}</span
|
|
42
|
+
>
|
|
39
43
|
</a>
|
|
40
44
|
|
|
41
45
|
{#if title !== undefined}
|
|
42
|
-
<span class="text-sm text-border">/</span>
|
|
43
|
-
<span class="text-sm font-medium text-muted-foreground">{title ?? ''}</span>
|
|
46
|
+
<span class="text-sm shrink-0 text-border">/</span>
|
|
47
|
+
<span class="text-sm font-medium min-w-0 truncate text-muted-foreground">{title ?? ''}</span>
|
|
44
48
|
{/if}
|
|
45
49
|
|
|
46
50
|
<!-- Primary nav next to logo -->
|
|
47
51
|
{#if navItems}
|
|
48
|
-
<nav class="ml-5 gap-1 flex">
|
|
52
|
+
<nav class="sm:ml-5 gap-1 flex shrink-0">
|
|
49
53
|
{@render navItems()}
|
|
50
54
|
</nav>
|
|
51
55
|
{/if}
|
|
52
56
|
|
|
53
57
|
<!-- Right cluster: identity + utilities, anchored flush-right -->
|
|
54
|
-
<div class="gap-2 ml-auto flex items-center">
|
|
58
|
+
<div class="gap-2 ml-auto flex shrink-0 items-center">
|
|
55
59
|
{#if rightContent}
|
|
56
60
|
{@render rightContent()}
|
|
57
61
|
{/if}
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
}: SectionHeaderProps = $props();
|
|
19
19
|
</script>
|
|
20
20
|
|
|
21
|
-
<div
|
|
21
|
+
<div
|
|
22
|
+
class={`mb-6 gap-3 sm:gap-5 sm:flex-row sm:items-start sm:justify-between flex flex-col ${className}`}
|
|
23
|
+
>
|
|
22
24
|
<div class="min-w-0">
|
|
23
25
|
{#if eyebrow}
|
|
24
26
|
<div class="mb-1.5 text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
|
@@ -31,7 +33,7 @@
|
|
|
31
33
|
{/if}
|
|
32
34
|
</div>
|
|
33
35
|
{#if actions}
|
|
34
|
-
<div class="gap-2 flex shrink-0 items-center">
|
|
36
|
+
<div class="gap-2 flex shrink-0 flex-wrap items-center">
|
|
35
37
|
{@render actions()}
|
|
36
38
|
</div>
|
|
37
39
|
{/if}
|
|
@@ -43,13 +43,15 @@
|
|
|
43
43
|
}
|
|
44
44
|
</script>
|
|
45
45
|
|
|
46
|
+
<!-- On a phone a 240px column would leave the page ~150px, so the nav becomes a scrolling strip
|
|
47
|
+
above the content and the eyebrow, header and footer drop out. -->
|
|
46
48
|
<aside
|
|
47
|
-
class={`w-60 flex
|
|
48
|
-
border ? 'border-r
|
|
49
|
+
class={`sm:h-full sm:w-60 sm:overflow-y-auto sm:border-b-0 flex w-full shrink-0 flex-col border-b border-border bg-background ${
|
|
50
|
+
border ? 'sm:border-r' : ''
|
|
49
51
|
} ${className}`}
|
|
50
52
|
>
|
|
51
53
|
{#if eyebrow}
|
|
52
|
-
<div class="px-4 pt-5 pb-3">
|
|
54
|
+
<div class="px-4 pt-5 pb-3 sm:block hidden">
|
|
53
55
|
<span class="text-xs font-medium tracking-wider text-muted-foreground uppercase">
|
|
54
56
|
{eyebrow}
|
|
55
57
|
</span>
|
|
@@ -57,30 +59,36 @@
|
|
|
57
59
|
{/if}
|
|
58
60
|
|
|
59
61
|
{#if header}
|
|
60
|
-
<div class="px-3 pb-3">
|
|
62
|
+
<div class="px-3 pb-3 sm:block hidden">
|
|
61
63
|
{@render header()}
|
|
62
64
|
</div>
|
|
63
65
|
{/if}
|
|
64
66
|
|
|
65
|
-
<nav
|
|
67
|
+
<nav
|
|
68
|
+
class="gap-1 py-2 sm:gap-0 sm:pt-0 sm:pb-4 sm:pr-2 sm:flex-1 sm:space-y-px sm:block sm:px-0 flex overflow-x-auto px-(--page-px)"
|
|
69
|
+
>
|
|
66
70
|
{#each items as item (item.href)}
|
|
67
71
|
{@const active = isActive(item)}
|
|
68
72
|
{@const Icon = item.icon}
|
|
69
73
|
<a
|
|
70
74
|
href={item.href}
|
|
71
|
-
class={`group gap-2 py-
|
|
75
|
+
class={`group gap-2 py-2 pr-2.5 sm:py-1.5 sm:w-full relative flex shrink-0 items-center text-left transition-colors ${
|
|
72
76
|
active
|
|
73
|
-
? 'pl-4 rounded-r-md bg-accent text-accent-foreground'
|
|
74
|
-
: 'mx-2 pl-2.5 rounded-md text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
|
77
|
+
? 'pl-2.5 sm:pl-4 sm:rounded-l-none sm:rounded-r-md rounded-md bg-accent text-accent-foreground'
|
|
78
|
+
: 'sm:mx-2 pl-2.5 rounded-md text-muted-foreground hover:bg-muted/60 hover:text-foreground'
|
|
75
79
|
}`}
|
|
76
80
|
>
|
|
77
81
|
{#if active}
|
|
78
|
-
<span
|
|
82
|
+
<span
|
|
83
|
+
class="left-0 top-0 bottom-0 w-0.75 sm:block absolute hidden bg-primary"
|
|
84
|
+
aria-hidden="true"
|
|
85
|
+
></span>
|
|
79
86
|
{/if}
|
|
80
87
|
{#if Icon}
|
|
81
88
|
<Icon class="h-3.5 w-3.5 shrink-0 opacity-70" />
|
|
82
89
|
{/if}
|
|
83
|
-
<span class="text-sm font-medium flex-1 truncate">{item.label}</span
|
|
90
|
+
<span class="text-sm font-medium sm:flex-1 sm:truncate whitespace-nowrap">{item.label}</span
|
|
91
|
+
>
|
|
84
92
|
{#if item.badge !== undefined}
|
|
85
93
|
<span class="font-mono shrink-0 text-2xs tabular-nums opacity-60">{item.badge}</span>
|
|
86
94
|
{/if}
|
|
@@ -89,7 +97,7 @@
|
|
|
89
97
|
</nav>
|
|
90
98
|
|
|
91
99
|
{#if footer}
|
|
92
|
-
<div class="px-3 py-3 border-t border-border/60">
|
|
100
|
+
<div class="px-3 py-3 sm:block hidden border-t border-border/60">
|
|
93
101
|
{@render footer()}
|
|
94
102
|
</div>
|
|
95
103
|
{/if}
|
|
@@ -196,10 +196,10 @@
|
|
|
196
196
|
<!-- The layout hides this output while it has no data, so this is data that doesn't parse yet. -->
|
|
197
197
|
<div class="px-4 py-8 text-sm text-center text-muted-foreground">{t.chartReceiving}</div>
|
|
198
198
|
{:else}
|
|
199
|
-
<div bind:this={containerEl} class="w-full {isFullscreen ? 'h-
|
|
199
|
+
<div bind:this={containerEl} class="w-full {isFullscreen ? 'h-dvh' : 'h-95'}"></div>
|
|
200
200
|
|
|
201
201
|
<div
|
|
202
|
-
class="gap-1 left-2 top-2 absolute z-10 flex items-center opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 {isFullscreen
|
|
202
|
+
class="gap-1 left-2 top-2 absolute z-10 flex items-center opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100 {isFullscreen
|
|
203
203
|
? 'opacity-100'
|
|
204
204
|
: ''}"
|
|
205
205
|
>
|
|
@@ -318,7 +318,7 @@
|
|
|
318
318
|
<div
|
|
319
319
|
class="right-2 top-2 absolute transition-opacity {copied
|
|
320
320
|
? 'opacity-100'
|
|
321
|
-
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100'}"
|
|
321
|
+
: 'opacity-0 group-hover:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100'}"
|
|
322
322
|
>
|
|
323
323
|
<Button onclick={copyToClipboard} size="sm">
|
|
324
324
|
{copied ? t.copied : t.copy}
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
label: string;
|
|
4
4
|
width?: string;
|
|
5
5
|
align?: 'left' | 'right';
|
|
6
|
+
/** Drop the column below 640px, where fixed widths would crush the flexible one. */
|
|
7
|
+
hideOnMobile?: boolean;
|
|
6
8
|
}
|
|
7
9
|
</script>
|
|
8
10
|
|
|
@@ -19,20 +21,52 @@
|
|
|
19
21
|
|
|
20
22
|
let { rows, columns, getKey, row, class: className = '' }: Props = $props();
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
// `minmax(0, …)` so a long name truncates rather than widening its row past the others.
|
|
25
|
+
const toTemplate = (cols: DataTableColumn[]) =>
|
|
26
|
+
cols.map((c) => c.width ?? 'minmax(0, 1fr)').join(' ');
|
|
27
|
+
const gridTemplate = $derived(toTemplate(columns));
|
|
28
|
+
const mobileTemplate = $derived(toTemplate(columns.filter((c) => !c.hideOnMobile)));
|
|
29
|
+
// Cells come from the caller's snippet, so they're hidden by position.
|
|
30
|
+
const hiddenCols = $derived(
|
|
31
|
+
columns
|
|
32
|
+
.map((c, i) => (c.hideOnMobile ? `hide-col-${i + 1}` : ''))
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.join(' ')
|
|
35
|
+
);
|
|
23
36
|
</script>
|
|
24
37
|
|
|
25
|
-
<div
|
|
38
|
+
<div
|
|
39
|
+
class={`divide-y rounded-lg border ${className}`}
|
|
40
|
+
style:--table-cols={gridTemplate}
|
|
41
|
+
style:--table-cols-sm={mobileTemplate}
|
|
42
|
+
>
|
|
26
43
|
<div
|
|
27
|
-
class="gap-4 px-4 py-2 text-xs font-medium tracking-wide grid grid-cols-(--table-cols) bg-muted/40 text-muted-foreground uppercase"
|
|
44
|
+
class="data-table-row gap-4 px-4 py-2 text-xs font-medium tracking-wide sm:grid-cols-(--table-cols) grid grid-cols-(--table-cols-sm) bg-muted/40 text-muted-foreground uppercase {hiddenCols}"
|
|
28
45
|
>
|
|
29
46
|
{#each columns as col (col.label)}
|
|
30
47
|
<span class={col.align === 'right' ? 'text-right' : ''}>{col.label}</span>
|
|
31
48
|
{/each}
|
|
32
49
|
</div>
|
|
33
50
|
{#each rows as item (getKey(item))}
|
|
34
|
-
<div
|
|
51
|
+
<div
|
|
52
|
+
class="data-table-row gap-4 px-4 py-3 sm:grid-cols-(--table-cols) grid grid-cols-(--table-cols-sm) items-center {hiddenCols}"
|
|
53
|
+
>
|
|
35
54
|
{@render row(item)}
|
|
36
55
|
</div>
|
|
37
56
|
{/each}
|
|
38
57
|
</div>
|
|
58
|
+
|
|
59
|
+
<style>
|
|
60
|
+
@media (max-width: 639px) {
|
|
61
|
+
:global(.data-table-row.hide-col-1 > :nth-child(1)),
|
|
62
|
+
:global(.data-table-row.hide-col-2 > :nth-child(2)),
|
|
63
|
+
:global(.data-table-row.hide-col-3 > :nth-child(3)),
|
|
64
|
+
:global(.data-table-row.hide-col-4 > :nth-child(4)),
|
|
65
|
+
:global(.data-table-row.hide-col-5 > :nth-child(5)),
|
|
66
|
+
:global(.data-table-row.hide-col-6 > :nth-child(6)),
|
|
67
|
+
:global(.data-table-row.hide-col-7 > :nth-child(7)),
|
|
68
|
+
:global(.data-table-row.hide-col-8 > :nth-child(8)) {
|
|
69
|
+
display: none;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
</style>
|
|
@@ -2,6 +2,8 @@ export interface DataTableColumn {
|
|
|
2
2
|
label: string;
|
|
3
3
|
width?: string;
|
|
4
4
|
align?: 'left' | 'right';
|
|
5
|
+
/** Drop the column below 640px, where fixed widths would crush the flexible one. */
|
|
6
|
+
hideOnMobile?: boolean;
|
|
5
7
|
}
|
|
6
8
|
import type { Snippet } from 'svelte';
|
|
7
9
|
declare function $$render<T>(): {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
bind:ref
|
|
20
20
|
data-slot="alert-dialog-content"
|
|
21
21
|
class={cn(
|
|
22
|
-
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 gap-4 p-6 shadow-lg sm:max-w-lg fixed start-1/2 top-1/2 z-50 grid
|
|
22
|
+
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 gap-4 p-6 shadow-lg sm:max-w-lg fixed start-1/2 top-1/2 z-50 grid max-h-dialog w-screen-inset -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border bg-background duration-200',
|
|
23
23
|
className
|
|
24
24
|
)}
|
|
25
25
|
{...restProps}
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
data-slot="dialog-content"
|
|
33
33
|
class={cn(
|
|
34
34
|
variant === 'flush' ? 'gap-0' : 'gap-4 p-6',
|
|
35
|
-
'shadow-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg fixed top-1/2 left-1/2 z-50 grid max-h-dialog w-
|
|
35
|
+
'shadow-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg fixed top-1/2 left-1/2 z-50 grid max-h-dialog w-screen-inset -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border bg-background duration-200',
|
|
36
36
|
className
|
|
37
37
|
)}
|
|
38
38
|
{...restProps}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
onkeydown={handleKey}
|
|
27
27
|
></div>
|
|
28
28
|
<div
|
|
29
|
-
class="animate-in slide-in-from-right-4 top-0 right-0 fixed z-50 flex h-full border-border bg-background {width} flex-col overflow-y-auto border-l duration-150"
|
|
29
|
+
class="animate-in slide-in-from-right-4 top-0 right-0 fixed z-50 flex h-full max-w-full border-border bg-background {width} flex-col overflow-y-auto border-l duration-150"
|
|
30
30
|
>
|
|
31
31
|
{@render children()}
|
|
32
32
|
</div>
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { X } from '@lucide/svelte';
|
|
3
|
+
import { Button } from '../primitives/button/index.js';
|
|
4
|
+
import { getLocaleContext } from '../../i18n/localeContext.svelte';
|
|
5
|
+
|
|
6
|
+
const locale = getLocaleContext();
|
|
7
|
+
const t = $derived(locale.messages);
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
metadata: Record<string, any>;
|
|
11
|
+
meshName: string | null;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const EXCLUDED_KEYS = new Set(['name', 'layer', 'id']);
|
|
17
|
+
|
|
18
|
+
let { metadata, meshName, onClose, class: className = '' }: Props = $props();
|
|
19
|
+
|
|
20
|
+
const filteredMetadata = $derived.by(() => {
|
|
21
|
+
const result: Record<string, unknown> = {};
|
|
22
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
23
|
+
if (EXCLUDED_KEYS.has(key)) continue;
|
|
24
|
+
if (
|
|
25
|
+
key === 'metadata' &&
|
|
26
|
+
typeof value === 'object' &&
|
|
27
|
+
value !== null &&
|
|
28
|
+
!Array.isArray(value)
|
|
29
|
+
) {
|
|
30
|
+
Object.assign(result, value);
|
|
31
|
+
} else {
|
|
32
|
+
result[key] = value;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
});
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<!-- Not a modal: a modal locks the page, and on touch the tap that should dismiss it can race the
|
|
40
|
+
dialog's own dismiss handler, leaving the viewer frozen. A card lets the canvas keep orbiting. -->
|
|
41
|
+
<section
|
|
42
|
+
aria-label={meshName ?? t.objectFallbackName}
|
|
43
|
+
class="min-h-0 shadow-lg backdrop-blur-sm flex flex-col overflow-hidden rounded-lg border bg-background/95 {className}"
|
|
44
|
+
>
|
|
45
|
+
<header class="gap-2 py-1 pr-1 pl-4 flex items-center border-b">
|
|
46
|
+
<h2 class="text-sm font-semibold min-w-0 flex-1 truncate">
|
|
47
|
+
{meshName ?? t.objectFallbackName}
|
|
48
|
+
</h2>
|
|
49
|
+
<Button
|
|
50
|
+
variant="ghost"
|
|
51
|
+
size="icon-sm"
|
|
52
|
+
class="pointer-coarse:size-10 shrink-0"
|
|
53
|
+
aria-label={t.close}
|
|
54
|
+
title={t.close}
|
|
55
|
+
onclick={onClose}
|
|
56
|
+
>
|
|
57
|
+
<X />
|
|
58
|
+
</Button>
|
|
59
|
+
</header>
|
|
60
|
+
|
|
61
|
+
<div class="min-h-0 overflow-y-auto overscroll-contain">
|
|
62
|
+
{#if Object.keys(filteredMetadata).length === 0}
|
|
63
|
+
<p class="px-4 py-3 text-xs text-muted-foreground">{t.noMetadata}</p>
|
|
64
|
+
{:else}
|
|
65
|
+
<table class="text-xs w-full border-collapse">
|
|
66
|
+
<tbody>
|
|
67
|
+
{#each Object.entries(filteredMetadata) as [key, value] (key)}
|
|
68
|
+
<tr class="border-b border-border/60 last:border-b-0">
|
|
69
|
+
<th
|
|
70
|
+
scope="row"
|
|
71
|
+
class="px-4 py-2 font-normal text-left align-top whitespace-nowrap text-muted-foreground"
|
|
72
|
+
>{key}</th
|
|
73
|
+
>
|
|
74
|
+
<td class="px-4 py-2 text-right align-top text-foreground">
|
|
75
|
+
{#if typeof value === 'object' && value !== null}
|
|
76
|
+
{#if Array.isArray(value)}
|
|
77
|
+
<div class="space-y-1 text-left">
|
|
78
|
+
{#each value as item, index (index)}
|
|
79
|
+
{#if typeof item === 'object'}
|
|
80
|
+
<div class="space-y-0.5">
|
|
81
|
+
{#each Object.entries(item) as [k, v] (k)}
|
|
82
|
+
<div class="gap-2 flex">
|
|
83
|
+
<span class="shrink-0 text-muted-foreground">{k}:</span>
|
|
84
|
+
<span>{v}</span>
|
|
85
|
+
</div>
|
|
86
|
+
{/each}
|
|
87
|
+
</div>
|
|
88
|
+
{:else}
|
|
89
|
+
<div>{item}</div>
|
|
90
|
+
{/if}
|
|
91
|
+
{/each}
|
|
92
|
+
</div>
|
|
93
|
+
{:else}
|
|
94
|
+
<div class="space-y-0.5 text-left">
|
|
95
|
+
{#each Object.entries(value) as [k, v] (k)}
|
|
96
|
+
<div class="gap-2 flex">
|
|
97
|
+
<span class="shrink-0 text-muted-foreground">{k}:</span>
|
|
98
|
+
<span>{v}</span>
|
|
99
|
+
</div>
|
|
100
|
+
{/each}
|
|
101
|
+
</div>
|
|
102
|
+
{/if}
|
|
103
|
+
{:else if typeof value === 'number'}
|
|
104
|
+
<span class="font-mono">{value}</span>
|
|
105
|
+
{:else if typeof value === 'boolean'}
|
|
106
|
+
<span class={value ? 'text-success' : 'text-muted-foreground'}
|
|
107
|
+
>{value ? 'true' : 'false'}</span
|
|
108
|
+
>
|
|
109
|
+
{:else}
|
|
110
|
+
<span class="break-all">{value}</span>
|
|
111
|
+
{/if}
|
|
112
|
+
</td>
|
|
113
|
+
</tr>
|
|
114
|
+
{/each}
|
|
115
|
+
</tbody>
|
|
116
|
+
</table>
|
|
117
|
+
{/if}
|
|
118
|
+
</div>
|
|
119
|
+
</section>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
metadata: Record<string, any>;
|
|
3
|
+
meshName: string | null;
|
|
4
|
+
onClose: () => void;
|
|
5
|
+
class?: string;
|
|
6
|
+
}
|
|
7
|
+
declare const MeshMetadataPanel: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type MeshMetadataPanel = ReturnType<typeof MeshMetadataPanel>;
|
|
9
|
+
export default MeshMetadataPanel;
|