@selvajs/ui 6.6.0-beta.5 → 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.
Files changed (44) hide show
  1. package/dist/components/ErrorScreen.svelte +1 -1
  2. package/dist/components/compute/AppLayout.svelte +69 -29
  3. package/dist/components/compute/AppLayout.svelte.d.ts +5 -0
  4. package/dist/components/compute/ComputeApp.svelte +1 -0
  5. package/dist/components/layout/AppShell.svelte +10 -6
  6. package/dist/components/layout/PageHeader.svelte +11 -7
  7. package/dist/components/layout/SectionHeader.svelte +4 -2
  8. package/dist/components/layout/SideNav.svelte +19 -11
  9. package/dist/components/preview/ChartOutput.svelte +7 -3
  10. package/dist/components/preview/ImageOutput.svelte +1 -1
  11. package/dist/components/preview/OutputDisplay.svelte +1 -1
  12. package/dist/components/primitives/DataTable.svelte +38 -4
  13. package/dist/components/primitives/DataTable.svelte.d.ts +2 -0
  14. package/dist/components/primitives/alert-dialog/alert-dialog-content.svelte +1 -1
  15. package/dist/components/primitives/dialog/dialog-content.svelte +1 -1
  16. package/dist/components/primitives/drawer/Drawer.svelte +1 -1
  17. package/dist/components/viewer/MeshMetadataPanel.svelte +119 -0
  18. package/dist/components/viewer/MeshMetadataPanel.svelte.d.ts +9 -0
  19. package/dist/components/viewer/Viewer.svelte +59 -24
  20. package/dist/styles/base.css +21 -1
  21. package/dist/utils/loadScript.d.ts +2 -0
  22. package/dist/utils/loadScript.js +2 -0
  23. package/package.json +9 -9
  24. package/src/lib/components/ErrorScreen.svelte +1 -1
  25. package/src/lib/components/compute/AppLayout.svelte +69 -29
  26. package/src/lib/components/compute/ComputeApp.svelte +1 -0
  27. package/src/lib/components/layout/AppShell.svelte +10 -6
  28. package/src/lib/components/layout/PageHeader.svelte +11 -7
  29. package/src/lib/components/layout/SectionHeader.svelte +4 -2
  30. package/src/lib/components/layout/SideNav.svelte +19 -11
  31. package/src/lib/components/preview/ChartOutput.svelte +7 -3
  32. package/src/lib/components/preview/ImageOutput.svelte +1 -1
  33. package/src/lib/components/preview/OutputDisplay.svelte +1 -1
  34. package/src/lib/components/primitives/DataTable.svelte +38 -4
  35. package/src/lib/components/primitives/alert-dialog/alert-dialog-content.svelte +1 -1
  36. package/src/lib/components/primitives/dialog/dialog-content.svelte +1 -1
  37. package/src/lib/components/primitives/drawer/Drawer.svelte +1 -1
  38. package/src/lib/components/viewer/MeshMetadataPanel.svelte +119 -0
  39. package/src/lib/components/viewer/Viewer.svelte +59 -24
  40. package/src/lib/styles/base.css +21 -1
  41. package/src/lib/utils/loadScript.ts +3 -0
  42. package/dist/components/viewer/MeshMetadataDialog.svelte +0 -126
  43. package/dist/components/viewer/MeshMetadataDialog.svelte.d.ts +0 -10
  44. 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-screen items-center justify-center bg-background">
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">
@@ -26,6 +26,11 @@
26
26
 
27
27
  interface Props {
28
28
  schema: UISchema;
29
+ /**
30
+ * Whether to draw the 3D viewer. Each host reads its own switch: the plugin's preview
31
+ * `viewerOptions.enableLocal`, a Rhino.Compute host `viewerOptions.enableRemote`.
32
+ */
33
+ showViewer: boolean;
29
34
  meshes?: any[];
30
35
  isSolving: boolean;
31
36
  showSolvingIndicator?: boolean;
@@ -54,6 +59,7 @@
54
59
 
55
60
  let {
56
61
  schema,
62
+ showViewer: hasViewer,
57
63
  meshes = [],
58
64
  isSolving,
59
65
  showSolvingIndicator = false,
@@ -81,9 +87,6 @@
81
87
  const hasMenu = $derived(showSaveButton || showLoadButton || panelActions.length > 0);
82
88
  const resultIsStale = $derived(hasPendingChanges || hasNeverSolved);
83
89
 
84
- const hasViewer = $derived(
85
- !!(schema?.viewerOptions?.enableLocal || schema?.viewerOptions?.enableRemote)
86
- );
87
90
  const shownTabs = $derived(
88
91
  schema.layout.type === 'tabbed'
89
92
  ? schema.layout.tabs.filter((tab) => isTabVisible(tab, values))
@@ -121,9 +124,14 @@
121
124
  const isTwoPanelMode = $derived(!hasViewer && hasLeftPanel && hasRightPanel);
122
125
 
123
126
  let isMobile = $state(false);
124
- let drawerOpen = $state(false);
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'];
125
132
 
126
- const activeLeftTabLabel = $derived(leftTabs[0]?.label ?? t.parametersTab);
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);
127
135
 
128
136
  $effect(() => {
129
137
  const mqMobile = window.matchMedia('(max-width: 639px)');
@@ -151,19 +159,31 @@
151
159
  await onLoadValues?.(loadedValues);
152
160
  }
153
161
 
154
- let _touchStartY = 0;
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
+ }
155
168
 
156
169
  function onDrawerTouchStart(e: TouchEvent) {
157
- _touchStartY = e.touches[0].clientY;
170
+ touchStartY = e.touches[0].clientY;
158
171
  }
159
172
 
160
173
  function onDrawerTouchEnd(e: TouchEvent) {
161
- const deltaY = e.changedTouches[0].clientY - _touchStartY;
174
+ const deltaY = e.changedTouches[0].clientY - touchStartY;
162
175
  const SWIPE_THRESHOLD = 40;
163
176
  if (Math.abs(deltaY) > SWIPE_THRESHOLD) {
164
- drawerOpen = deltaY < 0; // swipe up → open, swipe down → close
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);
165
180
  }
166
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
+ }
167
187
  </script>
168
188
 
169
189
  <!-- Everything but Calculate: saving and loading inputs, then the host's own actions. -->
@@ -276,16 +296,19 @@
276
296
  : 'sm:pl-(--page-px)'} {rightCollapsed ? 'sm:pr-0' : 'sm:pr-(--page-px)'}"
277
297
  class:fullscreen-layout={isViewerFullscreen}
278
298
  class:relative={isMobile}
299
+ class:mobile-root={isMobile}
279
300
  >
280
301
  {#if isMobile}
281
302
  {#if hasViewer}
282
- <div class="min-h-0 flex flex-1 flex-col">
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'}>
283
307
  <Viewer
284
308
  {meshes}
285
309
  bind:isFullscreen={isViewerFullscreen}
286
310
  {isSolving}
287
- isBlurred={drawerOpen}
288
- {drawerOpen}
311
+ drawerOpen={drawer === 'full'}
289
312
  {logoUrl}
290
313
  {onViewerReady}
291
314
  viewerConfig={{
@@ -297,31 +320,30 @@
297
320
  </div>
298
321
 
299
322
  <div
300
- class="drawer-container rounded-t-2xl z-40 border-t bg-background shadow-sheet"
301
- class:drawer-open={drawerOpen}
302
- class:drawer-closed={!drawerOpen}
323
+ class="drawer-container rounded-t-2xl z-40 border-t bg-background shadow-sheet drawer-{drawer}"
303
324
  >
304
325
  <!-- Named by its visible label; aria-expanded carries the open/closed state. -->
305
326
  <button
306
- class="drawer-handle-bar"
307
- onclick={() => (drawerOpen = !drawerOpen)}
327
+ class="drawer-handle-bar touch-none"
328
+ onclick={onDrawerHandleClick}
308
329
  ontouchstart={onDrawerTouchStart}
309
330
  ontouchend={onDrawerTouchEnd}
310
- aria-expanded={drawerOpen}
331
+ aria-expanded={drawer !== 'closed'}
311
332
  >
312
333
  <div class="drawer-pill"></div>
313
- <span class="text-sm font-medium ml-2 text-foreground">{activeLeftTabLabel}</span>
334
+ <span class="text-sm font-medium ml-2 text-foreground">{drawerLabel}</span>
314
335
  <ChevronUp
315
336
  size={16}
316
- class="ml-auto text-muted-foreground transition-transform duration-300 {drawerOpen
317
- ? ''
318
- : 'rotate-180'}"
337
+ class="ml-auto text-muted-foreground transition-transform duration-300 {drawer ===
338
+ 'full'
339
+ ? 'rotate-180'
340
+ : ''}"
319
341
  />
320
342
  </button>
321
343
 
322
344
  <!-- Inert while closed: the fields are clipped out of view, and tabbing into them
323
345
  would scroll the drawer's insides around behind the handle. -->
324
- <div class="drawer-content" inert={!drawerOpen}>
346
+ <div class="drawer-content" inert={drawer === 'closed'}>
325
347
  <div class="px-3 pb-6">
326
348
  {#if hasLeftPanel}
327
349
  {@render panelContent(hasRightPanel ? 'left' : undefined, requestedLeftTabId, false)}
@@ -340,7 +362,7 @@
340
362
  </div>
341
363
  </div>
342
364
 
343
- {@render drawerFooter(!drawerOpen)}
365
+ {@render drawerFooter(drawer === 'closed')}
344
366
  </div>
345
367
  {:else}
346
368
  <div class="flex flex-1 flex-col overflow-hidden">
@@ -531,6 +553,22 @@
531
553
  gap: 0.5rem;
532
554
  }
533
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
+
534
572
  .drawer-container {
535
573
  position: absolute;
536
574
  bottom: 0;
@@ -543,12 +581,14 @@
543
581
  }
544
582
 
545
583
  .drawer-closed {
546
- height: 60px;
584
+ height: var(--drawer-closed-h);
585
+ }
586
+ .drawer-half {
587
+ height: var(--drawer-half-h);
547
588
  }
548
- /* The pinned footer takes a fixed slice, so a short phone opens the drawer further to leave
549
- room for the fields; a tall one keeps it at 60% of the screen. */
550
- .drawer-open {
551
- 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);
552
592
  }
553
593
 
554
594
  .drawer-handle-bar {
@@ -6,6 +6,11 @@ import type { ThreeViewer } from '@selvajs/visualization/render';
6
6
  import { type ViewerConfig } from '../viewer/Viewer.svelte';
7
7
  interface Props {
8
8
  schema: UISchema;
9
+ /**
10
+ * Whether to draw the 3D viewer. Each host reads its own switch: the plugin's preview
11
+ * `viewerOptions.enableLocal`, a Rhino.Compute host `viewerOptions.enableRemote`.
12
+ */
13
+ showViewer: boolean;
9
14
  meshes?: any[];
10
15
  isSolving: boolean;
11
16
  showSolvingIndicator?: boolean;
@@ -283,6 +283,7 @@
283
283
  {#key definitionKey}
284
284
  <AppLayout
285
285
  {schema}
286
+ showViewer={!!schema.viewerOptions?.enableRemote}
286
287
  meshes={session.meshes}
287
288
  isSolving={session.isSolving}
288
289
  showSolvingIndicator={schema.instanceSolve !== false && solvingIndicator.show}
@@ -74,14 +74,16 @@
74
74
 
75
75
  const rootClass = $derived(
76
76
  _mode === 'fixed'
77
- ? `flex flex-col h-screen overflow-hidden bg-background ${className}`
78
- : `flex flex-col min-h-screen bg-background ${className}`
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' ? 'flex-1 overflow-y-auto' : 'flex-1 overflow-y-auto border-l border-border'
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
- <span class="font-semibold text-sm tracking-tight">{brandName}</span>
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 class={`mb-6 gap-5 flex items-start justify-between ${className}`}>
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 h-full shrink-0 flex-col overflow-y-auto bg-background ${
48
- border ? 'border-r border-border' : ''
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 class="pb-4 pr-2 flex-1 space-y-px">
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-1.5 pr-2.5 relative flex w-full items-center text-left transition-colors ${
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 class="left-0 top-0 bottom-0 w-0.75 absolute bg-primary" aria-hidden="true"></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}
@@ -45,7 +45,11 @@
45
45
  // @ts-expect-error — Plotly is loaded dynamically onto window
46
46
  if (window.Plotly) return window.Plotly;
47
47
 
48
- await loadScript('https://cdn.plot.ly/plotly-3.4.0.min.js', { crossOrigin: 'anonymous' });
48
+ // Pinned by hash: the plugin's web UI runs this on the origin that holds its bridge secret.
49
+ await loadScript('https://cdn.plot.ly/plotly-3.4.0.min.js', {
50
+ crossOrigin: 'anonymous',
51
+ integrity: 'sha384-pFOQ7loGBChmUyS4Tszy+he8OWeYkwTFjc2xii4aJUWWiGwf2u2OEc04T5iDRpMp'
52
+ });
49
53
  // @ts-expect-error — Plotly is loaded dynamically onto window
50
54
  return window.Plotly;
51
55
  }
@@ -192,10 +196,10 @@
192
196
  <!-- The layout hides this output while it has no data, so this is data that doesn't parse yet. -->
193
197
  <div class="px-4 py-8 text-sm text-center text-muted-foreground">{t.chartReceiving}</div>
194
198
  {:else}
195
- <div bind:this={containerEl} class="w-full {isFullscreen ? 'h-screen' : 'h-95'}"></div>
199
+ <div bind:this={containerEl} class="w-full {isFullscreen ? 'h-dvh' : 'h-95'}"></div>
196
200
 
197
201
  <div
198
- 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
199
203
  ? 'opacity-100'
200
204
  : ''}"
201
205
  >
@@ -302,7 +302,7 @@
302
302
  <div
303
303
  bind:this={wrapperEl}
304
304
  class="rounded flex flex-col overflow-hidden border border-border bg-background {isFullscreen
305
- ? 'h-screen'
305
+ ? 'h-dvh'
306
306
  : ''}"
307
307
  >
308
308
  {#if error}
@@ -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
- const gridTemplate = $derived(columns.map((c) => c.width ?? '1fr').join(' '));
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 class={`divide-y rounded-lg border ${className}`} style:--table-cols={gridTemplate}>
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 class="gap-4 px-4 py-3 grid grid-cols-(--table-cols) items-center">
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 w-full max-w-screen-inset -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background duration-200',
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-full max-w-screen-inset -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-lg border bg-background duration-200',
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>