@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.
Files changed (38) hide show
  1. package/dist/components/ErrorScreen.svelte +1 -1
  2. package/dist/components/compute/AppLayout.svelte +63 -26
  3. package/dist/components/layout/AppShell.svelte +10 -6
  4. package/dist/components/layout/PageHeader.svelte +11 -7
  5. package/dist/components/layout/SectionHeader.svelte +4 -2
  6. package/dist/components/layout/SideNav.svelte +19 -11
  7. package/dist/components/preview/ChartOutput.svelte +2 -2
  8. package/dist/components/preview/ImageOutput.svelte +1 -1
  9. package/dist/components/preview/OutputDisplay.svelte +1 -1
  10. package/dist/components/primitives/DataTable.svelte +38 -4
  11. package/dist/components/primitives/DataTable.svelte.d.ts +2 -0
  12. package/dist/components/primitives/alert-dialog/alert-dialog-content.svelte +1 -1
  13. package/dist/components/primitives/dialog/dialog-content.svelte +1 -1
  14. package/dist/components/primitives/drawer/Drawer.svelte +1 -1
  15. package/dist/components/viewer/MeshMetadataPanel.svelte +119 -0
  16. package/dist/components/viewer/MeshMetadataPanel.svelte.d.ts +9 -0
  17. package/dist/components/viewer/Viewer.svelte +59 -24
  18. package/dist/styles/base.css +21 -1
  19. package/package.json +5 -5
  20. package/src/lib/components/ErrorScreen.svelte +1 -1
  21. package/src/lib/components/compute/AppLayout.svelte +63 -26
  22. package/src/lib/components/layout/AppShell.svelte +10 -6
  23. package/src/lib/components/layout/PageHeader.svelte +11 -7
  24. package/src/lib/components/layout/SectionHeader.svelte +4 -2
  25. package/src/lib/components/layout/SideNav.svelte +19 -11
  26. package/src/lib/components/preview/ChartOutput.svelte +2 -2
  27. package/src/lib/components/preview/ImageOutput.svelte +1 -1
  28. package/src/lib/components/preview/OutputDisplay.svelte +1 -1
  29. package/src/lib/components/primitives/DataTable.svelte +38 -4
  30. package/src/lib/components/primitives/alert-dialog/alert-dialog-content.svelte +1 -1
  31. package/src/lib/components/primitives/dialog/dialog-content.svelte +1 -1
  32. package/src/lib/components/primitives/drawer/Drawer.svelte +1 -1
  33. package/src/lib/components/viewer/MeshMetadataPanel.svelte +119 -0
  34. package/src/lib/components/viewer/Viewer.svelte +59 -24
  35. package/src/lib/styles/base.css +21 -1
  36. package/dist/components/viewer/MeshMetadataDialog.svelte +0 -126
  37. package/dist/components/viewer/MeshMetadataDialog.svelte.d.ts +0 -10
  38. package/src/lib/components/viewer/MeshMetadataDialog.svelte +0 -126
@@ -34,7 +34,7 @@
34
34
  import type { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
35
35
  import { createSceneOutliner, type SceneOutliner } from '@selvajs/visualization/scene';
36
36
  import SceneManager from './SceneManager.svelte';
37
- import MeshMetadataDialog from './MeshMetadataDialog.svelte';
37
+ import MeshMetadataPanel from './MeshMetadataPanel.svelte';
38
38
  import * as Resizable from '../primitives/resizable/index.js';
39
39
  import * as DropdownMenu from '../primitives/dropdown-menu/index.js';
40
40
  import { Button } from '../primitives/button/index.js';
@@ -122,6 +122,7 @@
122
122
  let updateGridScale: (() => void) | null = null;
123
123
  let updateShadowBounds: (() => void) | null = null;
124
124
  let fitToView: (() => void) | null = null;
125
+ let clearSelection: (() => void) | null = null;
125
126
  let viewerInitialized = false;
126
127
  let sceneVersion = $state(0);
127
128
  let hideButton = $state(false);
@@ -194,6 +195,10 @@
194
195
  grid: { enabled: config.showToolsMenu && config.showGridToggle },
195
196
  measure: { enabled: config.showToolsMenu },
196
197
  events: {
198
+ // Fires before onMeshMetadataClicked, so picking an object without metadata closes the
199
+ // previous object's panel instead of leaving it describing the wrong thing.
200
+ onObjectSelected: closeMetadata,
201
+ onBackgroundClicked: closeMetadata,
197
202
  onMeshMetadataClicked: config.enableMeshClick
198
203
  ? (metadata: Record<string, unknown>) => {
199
204
  if (hasUsefulMetadata(metadata)) {
@@ -224,6 +229,7 @@
224
229
  updateGridScale = init.updateGridScale;
225
230
  updateShadowBounds = init.updateShadowBounds;
226
231
  fitToView = init.fitToView;
232
+ clearSelection = init.clearSelection;
227
233
  projection = init.cameraController.getProjection();
228
234
 
229
235
  const renderer = init.renderer;
@@ -375,6 +381,29 @@
375
381
  }
376
382
  });
377
383
 
384
+ function closeMetadata() {
385
+ selectedMeshMetadata = null;
386
+ selectedMeshName = null;
387
+ }
388
+
389
+ function dismissMetadata() {
390
+ closeMetadata();
391
+ clearSelection?.();
392
+ invalidate?.();
393
+ }
394
+
395
+ // The panel describes an object from the previous solve once new meshes arrive.
396
+ $effect(() => {
397
+ void meshes;
398
+ untrack(closeMetadata);
399
+ });
400
+
401
+ // Dialogs portal to <body>, so one opened over the fullscreen viewer needs lifting above it.
402
+ $effect(() => {
403
+ document.body.toggleAttribute('data-viewer-fullscreen', isFullscreen);
404
+ return () => document.body.removeAttribute('data-viewer-fullscreen');
405
+ });
406
+
378
407
  function toggleFullscreen() {
379
408
  isFullscreen = !isFullscreen;
380
409
  }
@@ -450,12 +479,9 @@
450
479
  {/if}
451
480
 
452
481
  {#if logoUrl}
453
- <!-- Bottom offset matches the tools menu so it clears the mobile drawer handle.
454
- Always pointer-events:none so it never intercepts canvas drags. -->
482
+ <!-- pointer-events:none so it never intercepts canvas drags. -->
455
483
  <div
456
- class="right-4 {isFullscreen
457
- ? 'bottom-4'
458
- : 'bottom-16 sm:bottom-4'} pointer-events-none absolute z-20 {hideButton
484
+ class="right-4 bottom-4 pointer-events-none absolute z-20 {hideButton
459
485
  ? 'opacity-0'
460
486
  : ''}"
461
487
  >
@@ -467,11 +493,18 @@
467
493
  </div>
468
494
  {/if}
469
495
 
496
+ {#if selectedMeshMetadata}
497
+ <MeshMetadataPanel
498
+ metadata={selectedMeshMetadata}
499
+ meshName={selectedMeshName}
500
+ onClose={dismissMetadata}
501
+ class="top-3 right-3 left-3 sm:right-auto sm:max-h-2/3 sm:w-72 absolute z-30 max-h-1/2"
502
+ />
503
+ {/if}
504
+
470
505
  {#if config.showToolsMenu}
471
506
  <div
472
- class="left-4 {isFullscreen
473
- ? 'bottom-4'
474
- : 'bottom-16 sm:bottom-4'} shadow-lg absolute z-50 flex items-center rounded-lg {hideButton
507
+ class="left-4 bottom-4 gap-2 *:shadow-lg absolute z-50 flex items-center *:rounded-md {hideButton
475
508
  ? 'pointer-events-none opacity-0'
476
509
  : ''}"
477
510
  >
@@ -594,6 +627,18 @@
594
627
  {/if}
595
628
  </DropdownMenu.Content>
596
629
  </DropdownMenu.Root>
630
+ <!-- Touch has no F key, and fit-to-view is the way back from a framed part. -->
631
+ <div class="hidden pointer-coarse:block">
632
+ <Button
633
+ variant="outline"
634
+ size="icon-lg"
635
+ title={t.fitToView}
636
+ aria-label={t.fitToView}
637
+ onclick={() => fitToView?.()}
638
+ >
639
+ <Frame />
640
+ </Button>
641
+ </div>
597
642
  </div>
598
643
  {/if}
599
644
  </div>
@@ -608,22 +653,12 @@
608
653
  </Resizable.PaneGroup>
609
654
  </div>
610
655
 
611
- <MeshMetadataDialog
612
- open={selectedMeshMetadata !== null}
613
- metadata={selectedMeshMetadata}
614
- meshName={selectedMeshName}
615
- {isFullscreen}
616
- onOpenChange={(open: boolean) => {
617
- if (!open) {
618
- setTimeout(() => {
619
- selectedMeshMetadata = null;
620
- selectedMeshName = null;
621
- }, 200);
622
- }
623
- }}
624
- />
625
-
626
656
  <style>
657
+ :global([data-viewer-fullscreen] [data-slot='dialog-content']),
658
+ :global([data-viewer-fullscreen] [data-slot='dialog-overlay']) {
659
+ z-index: 10001;
660
+ }
661
+
627
662
  .fullscreen-viewer {
628
663
  position: fixed;
629
664
  inset: 0;
@@ -3,7 +3,7 @@
3
3
  :root {
4
4
  --radius: 0.625rem;
5
5
  --header-h: 3.5rem;
6
- --page-px: 1.5rem;
6
+ --page-px: 1rem;
7
7
  --page-py: 1.75rem;
8
8
  --background: oklch(0.99 0 0);
9
9
  --foreground: oklch(0.22 0 0);
@@ -180,6 +180,26 @@
180
180
  border-radius: inherit;
181
181
  }
182
182
 
183
+ /* Phones keep a narrower gutter: at 390px every 8px of side padding is 2% of the width. */
184
+ @media (min-width: 40rem) {
185
+ :root {
186
+ --page-px: 1.5rem;
187
+ }
188
+ }
189
+
190
+ /* A native select draws its arrow inside the right padding, so with the `px-2`/`px-3` every
191
+ caller uses it sits on the border. A drawn chevron with its own gutter fixes them all. Unlayered
192
+ on purpose: the gutter has to beat the callers' `px-*` utilities. Mid-grey reads on both themes,
193
+ since a data URI can't take `currentColor`. */
194
+ select:not([multiple]):not([size]) {
195
+ appearance: none;
196
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
197
+ background-repeat: no-repeat;
198
+ background-position: right 0.625rem center;
199
+ background-size: 1.1em;
200
+ padding-inline-end: calc(1.1em + 1rem);
201
+ }
202
+
183
203
  @layer base {
184
204
  * {
185
205
  @apply border-border outline-ring;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/ui",
3
- "version": "6.6.0-beta.6",
3
+ "version": "6.6.0-beta.7",
4
4
  "description": "Shared UI components and utilities for Selva applications",
5
5
  "license": "MIT",
6
6
  "author": "VektorNode",
@@ -61,7 +61,7 @@
61
61
  "three": "^0.186.1",
62
62
  "@selvajs/compute": "^4.3.0-beta.1",
63
63
  "@selvajs/schemas": "^5.3.0-beta.0",
64
- "@selvajs/visualization": "^1.5.0",
64
+ "@selvajs/visualization": "^1.6.0-beta.0",
65
65
  "@selvajs/solve": "^1.3.0-beta.3"
66
66
  },
67
67
  "peerDependenciesMeta": {
@@ -92,9 +92,9 @@
92
92
  "vitest": "^5.0.2",
93
93
  "@selvajs/compute": "4.3.0-beta.1",
94
94
  "@selvajs/config": "0.0.4",
95
- "@selvajs/visualization": "1.5.0",
96
- "@selvajs/schemas": "5.3.0-beta.0",
97
- "@selvajs/solve": "1.3.0-beta.3"
95
+ "@selvajs/visualization": "1.6.0-beta.0",
96
+ "@selvajs/solve": "1.3.0-beta.3",
97
+ "@selvajs/schemas": "5.3.0-beta.0"
98
98
  },
99
99
  "scripts": {
100
100
  "predev": "node ../../scripts/sync-shared-assets.js",
@@ -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">
@@ -124,9 +124,14 @@
124
124
  const isTwoPanelMode = $derived(!hasViewer && hasLeftPanel && hasRightPanel);
125
125
 
126
126
  let isMobile = $state(false);
127
- 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'];
128
132
 
129
- 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);
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 _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
+ }
158
168
 
159
169
  function onDrawerTouchStart(e: TouchEvent) {
160
- _touchStartY = e.touches[0].clientY;
170
+ touchStartY = e.touches[0].clientY;
161
171
  }
162
172
 
163
173
  function onDrawerTouchEnd(e: TouchEvent) {
164
- const deltaY = e.changedTouches[0].clientY - _touchStartY;
174
+ const deltaY = e.changedTouches[0].clientY - touchStartY;
165
175
  const SWIPE_THRESHOLD = 40;
166
176
  if (Math.abs(deltaY) > SWIPE_THRESHOLD) {
167
- 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);
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
- <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'}>
286
307
  <Viewer
287
308
  {meshes}
288
309
  bind:isFullscreen={isViewerFullscreen}
289
310
  {isSolving}
290
- isBlurred={drawerOpen}
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={() => (drawerOpen = !drawerOpen)}
327
+ class="drawer-handle-bar touch-none"
328
+ onclick={onDrawerHandleClick}
311
329
  ontouchstart={onDrawerTouchStart}
312
330
  ontouchend={onDrawerTouchEnd}
313
- aria-expanded={drawerOpen}
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">{activeLeftTabLabel}</span>
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 {drawerOpen
320
- ? ''
321
- : 'rotate-180'}"
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={!drawerOpen}>
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(!drawerOpen)}
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: 60px;
584
+ height: var(--drawer-closed-h);
585
+ }
586
+ .drawer-half {
587
+ height: var(--drawer-half-h);
550
588
  }
551
- /* The pinned footer takes a fixed slice, so a short phone opens the drawer further to leave
552
- room for the fields; a tall one keeps it at 60% of the screen. */
553
- .drawer-open {
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
- ? `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}
@@ -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-screen' : 'h-95'}"></div>
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
  >
@@ -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>