@ruckc/truss-ui 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +6 -1
  2. package/dist/commands.svelte.d.ts +41 -0
  3. package/dist/commands.svelte.js +59 -0
  4. package/dist/components/Accordion.svelte +1 -1
  5. package/dist/components/Button.svelte +65 -8
  6. package/dist/components/Button.svelte.d.ts +2 -0
  7. package/dist/components/Calendar.svelte +2 -2
  8. package/dist/components/Card.svelte +1 -0
  9. package/dist/components/Card.svelte.d.ts +1 -0
  10. package/dist/components/Checkbox.svelte +1 -1
  11. package/dist/components/CommandPalette.svelte +207 -0
  12. package/dist/components/CommandPalette.svelte.d.ts +39 -0
  13. package/dist/components/DataTable.svelte +201 -27
  14. package/dist/components/DataTable.svelte.d.ts +50 -4
  15. package/dist/components/Dialog.svelte +2 -1
  16. package/dist/components/Drawer.svelte +1 -1
  17. package/dist/components/EditableText.svelte +2 -2
  18. package/dist/components/Eyebrow.svelte +1 -1
  19. package/dist/components/FileUpload.svelte +1 -1
  20. package/dist/components/Footer.svelte +29 -2
  21. package/dist/components/Footer.svelte.d.ts +8 -0
  22. package/dist/components/GridLayout.svelte +242 -0
  23. package/dist/components/GridLayout.svelte.d.ts +30 -0
  24. package/dist/components/HoverCard.svelte +158 -0
  25. package/dist/components/HoverCard.svelte.d.ts +46 -0
  26. package/dist/components/Kbd.svelte +36 -0
  27. package/dist/components/Kbd.svelte.d.ts +8 -0
  28. package/dist/components/Link.svelte +1 -1
  29. package/dist/components/List.svelte +113 -0
  30. package/dist/components/List.svelte.d.ts +17 -0
  31. package/dist/components/ListItem.svelte +13 -0
  32. package/dist/components/ListItem.svelte.d.ts +9 -0
  33. package/dist/components/Menu.svelte +4 -3
  34. package/dist/components/MultiSelect.svelte +2 -2
  35. package/dist/components/Navbar.svelte +13 -3
  36. package/dist/components/Navbar.svelte.d.ts +5 -1
  37. package/dist/components/Panel.svelte +2 -2
  38. package/dist/components/Popover.svelte +22 -1
  39. package/dist/components/Popover.svelte.d.ts +2 -0
  40. package/dist/components/RadioGroup.svelte +1 -1
  41. package/dist/components/Select.svelte +1 -1
  42. package/dist/components/Shell.svelte +12 -1
  43. package/dist/components/Shell.svelte.d.ts +2 -0
  44. package/dist/components/ShortcutSheet.svelte +62 -0
  45. package/dist/components/ShortcutSheet.svelte.d.ts +9 -0
  46. package/dist/components/Sidebar.svelte +2 -2
  47. package/dist/components/Skeleton.svelte +80 -6
  48. package/dist/components/Skeleton.svelte.d.ts +8 -1
  49. package/dist/components/Slider.svelte +1 -1
  50. package/dist/components/Switch.svelte +1 -1
  51. package/dist/components/Tabs.svelte +252 -32
  52. package/dist/components/Tabs.svelte.d.ts +21 -9
  53. package/dist/components/Tag.svelte +7 -7
  54. package/dist/components/TagInput.svelte +1 -1
  55. package/dist/components/Text.svelte +1 -1
  56. package/dist/components/Toaster.svelte +33 -2
  57. package/dist/components/ToggleChip.svelte +1 -1
  58. package/dist/components/Toolbar.svelte +27 -0
  59. package/dist/components/Toolbar.svelte.d.ts +10 -0
  60. package/dist/components/ToolbarSeparator.svelte +15 -0
  61. package/dist/components/ToolbarSeparator.svelte.d.ts +3 -0
  62. package/dist/components/Tooltip.svelte +19 -28
  63. package/dist/components/Tooltip.svelte.d.ts +6 -1
  64. package/dist/components/TooltipTip.svelte +59 -0
  65. package/dist/components/TooltipTip.svelte.d.ts +12 -0
  66. package/dist/components/Tree.svelte +343 -44
  67. package/dist/components/Tree.svelte.d.ts +29 -2
  68. package/dist/grid-layout.d.ts +42 -0
  69. package/dist/grid-layout.js +100 -0
  70. package/dist/hotkeys.svelte.d.ts +45 -0
  71. package/dist/hotkeys.svelte.js +108 -0
  72. package/dist/index.d.ts +21 -2
  73. package/dist/index.js +13 -0
  74. package/dist/keys.d.ts +25 -0
  75. package/dist/keys.js +88 -0
  76. package/dist/roving.d.ts +25 -0
  77. package/dist/roving.js +129 -0
  78. package/dist/styles/base.css +2 -0
  79. package/dist/styles/tokens.css +49 -0
  80. package/dist/table-export.d.ts +24 -0
  81. package/dist/table-export.js +130 -0
  82. package/dist/toast.svelte.d.ts +25 -1
  83. package/dist/toast.svelte.js +42 -4
  84. package/dist/types.d.ts +6 -0
  85. package/package.json +7 -3
@@ -0,0 +1,17 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ type $$ComponentProps = {
4
+ /** Render an `ol` with numbered items instead of a `ul`. */
5
+ ordered?: boolean;
6
+ /** Marker style. Ordered lists number their items, so only `none` changes them. */
7
+ marker?: 'disc' | 'check' | 'dash' | 'none';
8
+ /** Space between items, from the `--tr-space-*` scale. */
9
+ gap?: 1 | 2 | 3 | 4 | 5 | 6;
10
+ size?: 'sm' | 'md';
11
+ class?: string;
12
+ /** `ListItem`s, or plain `li` elements. */
13
+ children?: Snippet;
14
+ } & HTMLAttributes<HTMLUListElement | HTMLOListElement>;
15
+ declare const List: import("svelte").Component<$$ComponentProps, {}, "">;
16
+ type List = ReturnType<typeof List>;
17
+ export default List;
@@ -0,0 +1,13 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLLiAttributes } from 'svelte/elements';
4
+
5
+ /** An item of a `List`. Plain `li` elements inside a `List` work too. */
6
+ let { class: klass = '', children, ...rest }: { class?: string; children?: Snippet } & HTMLLiAttributes = $props();
7
+ </script>
8
+
9
+ <li class="item {klass}" {...rest}>{@render children?.()}</li>
10
+
11
+ <style>
12
+ .item { min-width: 0; overflow-wrap: anywhere; }
13
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLLiAttributes } from 'svelte/elements';
3
+ type $$ComponentProps = {
4
+ class?: string;
5
+ children?: Snippet;
6
+ } & HTMLLiAttributes;
7
+ declare const ListItem: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type ListItem = ReturnType<typeof ListItem>;
9
+ export default ListItem;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import { tick } from 'svelte';
3
3
  import Icon from '../icons/Icon.svelte';
4
+ import Kbd from './Kbd.svelte';
4
5
  import Menu from './Menu.svelte';
5
6
  import type { MenuEntry, MenuItem } from '../types.js';
6
7
 
@@ -69,7 +70,7 @@
69
70
  <a href={it.href} data-i={i} role="menuitem" data-danger={it.danger} onclick={(e) => follow(e, it)}>
70
71
  <span class="lead">{#if it.icon}<Icon name={it.icon} />{/if}</span>
71
72
  <span class="lbl">{it.label}</span>
72
- {#if it.shortcut}<kbd>{it.shortcut}</kbd>{/if}
73
+ {#if it.shortcut}<Kbd keys={it.shortcut} />{/if}
73
74
  </a>
74
75
  {:else}
75
76
  <button
@@ -79,7 +80,7 @@
79
80
  >
80
81
  <span class="lead">{#if it.checked}<Icon name="tick" />{:else if it.icon}<Icon name={it.icon} />{/if}</span>
81
82
  <span class="lbl">{it.label}</span>
82
- {#if it.shortcut}<kbd>{it.shortcut}</kbd>{/if}
83
+ {#if it.shortcut}<Kbd keys={it.shortcut} />{/if}
83
84
  {#if it.items}<Icon name="chevron-right" />{/if}
84
85
  </button>
85
86
  {/if}
@@ -102,7 +103,7 @@
102
103
  button[data-danger='true'], a[data-danger='true'] { color: var(--tr-danger); }
103
104
  .lead { display: inline-flex; width: 16px; flex: none; }
104
105
  .lbl { flex: 1; }
105
- kbd { font: var(--tr-text-xs) var(--tr-font-mono); color: var(--tr-on-surface-variant); }
106
+ .menu :global(.tr-kbd) { color: var(--tr-on-surface-variant); }
106
107
  .sep { height: 1px; margin: 4px 0; background: var(--tr-outline-variant); }
107
108
  .heading { padding: 6px 8px 2px; font-size: var(--tr-text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--tr-on-surface-variant); }
108
109
  .sub { position: absolute; top: -5px; left: calc(100% + 2px); z-index: 60; background: var(--tr-surface-container-low); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); box-shadow: var(--tr-elevation-2); }
@@ -121,8 +121,8 @@
121
121
 
122
122
  <style>
123
123
  .multi { flex-wrap: wrap; padding: 3px 6px; gap: 4px; cursor: text; }
124
- .multi .tr-bare { flex: 1 1 80px; height: 24px; }
125
- .chip { display: inline-flex; align-items: center; gap: 2px; height: 24px; padding: 0 2px 0 8px; border-radius: var(--tr-radius-pill); background: var(--tr-primary-container); color: var(--tr-on-primary-container); font-size: var(--tr-text-sm); font-weight: 600; }
124
+ .multi .tr-bare { flex: 1 1 80px; height: var(--tr-control-h-sm); }
125
+ .chip { display: inline-flex; align-items: center; gap: 2px; height: var(--tr-control-h-sm); padding: 0 2px 0 8px; border-radius: var(--tr-radius-pill); background: var(--tr-primary-container); color: var(--tr-on-primary-container); font-size: var(--tr-text-sm); font-weight: 600; }
126
126
  .chip button { display: grid; place-items: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 50%; background: none; color: inherit; cursor: pointer; }
127
127
  .chip button:hover { background: color-mix(in srgb, currentColor 15%, transparent); }
128
128
  .chip button:focus-visible { outline: none; box-shadow: var(--tr-focus-ring); }
@@ -13,6 +13,8 @@
13
13
  elevated = true,
14
14
  collapseBelow,
15
15
  menu,
16
+ menuAlign = 'center',
17
+ contained = false,
16
18
  menuLabel = 'Menu',
17
19
  class: klass,
18
20
  ...rest
@@ -27,8 +29,12 @@
27
29
  elevated?: boolean;
28
30
  /** Viewport width (px, or any CSS length such as `48rem`) at or below which `menu` moves into a drawer behind a menu button. */
29
31
  collapseBelow?: string | number;
30
- /** Primary navigation (usually a `<NavList>`). Shown in the centre region when wide, in a drawer when collapsed. */
32
+ /** Primary navigation (usually a `<NavList>`). Shown in the centre region (or at the end, see `menuAlign`) when wide, in a drawer when collapsed. */
31
33
  menu?: Snippet;
34
+ /** Where `menu` sits when the bar is wide: the centre region, or at the trailing edge just before the `end` content. */
35
+ menuAlign?: 'center' | 'end';
36
+ /** Align the content to the `Container` max-width and gutters while the background stays full-bleed. `true` is `lg`; sizes match `Container`. */
37
+ contained?: boolean | 'sm' | 'md' | 'lg';
32
38
  /** Accessible name of the menu button and the drawer title. */
33
39
  menuLabel?: string;
34
40
  class?: string;
@@ -63,10 +69,11 @@
63
69
  });
64
70
  </script>
65
71
 
66
- <header {...rest} class={['nb', klass]} data-sticky={sticky} data-variant={variant} data-elevated={elevated}>
72
+ <header {...rest} class={['nb', klass]} data-sticky={sticky} data-variant={variant} data-elevated={elevated} data-contained={contained === true ? 'lg' : contained || undefined}>
67
73
  <div class="s">{#if start}{@render start()}{/if}</div>
68
- <div class="c">{#if menu && !collapsed}{@render menu()}{/if}{#if center}{@render center()}{/if}</div>
74
+ <div class="c">{#if menu && !collapsed && menuAlign !== 'end'}{@render menu()}{/if}{#if center}{@render center()}{/if}</div>
69
75
  <div class="e">
76
+ {#if menu && !collapsed && menuAlign === 'end'}{@render menu()}{/if}
70
77
  {#if end}{@render end()}{/if}
71
78
  {#if menu && collapsed}
72
79
  <span class="mb" bind:this={trigger}>
@@ -85,6 +92,9 @@
85
92
 
86
93
  <style>
87
94
  .nb { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px; height: var(--tr-navbar-h, 48px); padding: 0 12px; background: var(--tr-surface-container-low); border-bottom: 1px solid var(--tr-outline-variant); box-shadow: var(--tr-elevation-1); z-index: 20; font-family: var(--tr-font-display, var(--tr-font-sans)); letter-spacing: var(--tr-display-tracking, normal); text-transform: var(--tr-display-transform, none); }
95
+ .nb[data-contained] { --_w: var(--tr-container-max); padding-inline: max(var(--tr-gutter), calc((100% - var(--_w)) / 2 + var(--tr-gutter))); }
96
+ .nb[data-contained='sm'] { --_w: 40rem; }
97
+ .nb[data-contained='md'] { --_w: 56rem; }
88
98
  .nb[data-sticky='true'] { position: sticky; top: 0; }
89
99
  .nb[data-elevated='false'] { box-shadow: none; }
90
100
  /* Without backdrop-filter the surface stays nearly opaque so content beneath cannot hurt legibility. */
@@ -11,8 +11,12 @@ type $$ComponentProps = {
11
11
  elevated?: boolean;
12
12
  /** Viewport width (px, or any CSS length such as `48rem`) at or below which `menu` moves into a drawer behind a menu button. */
13
13
  collapseBelow?: string | number;
14
- /** Primary navigation (usually a `<NavList>`). Shown in the centre region when wide, in a drawer when collapsed. */
14
+ /** Primary navigation (usually a `<NavList>`). Shown in the centre region (or at the end, see `menuAlign`) when wide, in a drawer when collapsed. */
15
15
  menu?: Snippet;
16
+ /** Where `menu` sits when the bar is wide: the centre region, or at the trailing edge just before the `end` content. */
17
+ menuAlign?: 'center' | 'end';
18
+ /** Align the content to the `Container` max-width and gutters while the background stays full-bleed. `true` is `lg`; sizes match `Container`. */
19
+ contained?: boolean | 'sm' | 'md' | 'lg';
16
20
  /** Accessible name of the menu button and the drawer title. */
17
21
  menuLabel?: string;
18
22
  class?: string;
@@ -42,9 +42,9 @@
42
42
 
43
43
  <style>
44
44
  .pnl { background: var(--tr-surface-container-low); border: 1px solid var(--tr-outline-variant); border-radius: var(--tr-radius-md); box-shadow: var(--tr-elevation-1); overflow: hidden; }
45
- header { display: flex; align-items: center; gap: 8px; min-height: 40px; padding: 0 8px 0 12px; background: var(--tr-surface-container); }
45
+ header { display: flex; align-items: center; gap: 8px; min-height: calc(var(--tr-control-h) + 8px); padding: 0 8px 0 12px; background: var(--tr-surface-container); }
46
46
  .pnl:has(.body) header { border-bottom: 1px solid var(--tr-outline-variant); }
47
- .tog { display: flex; align-items: center; gap: 8px; flex: 1; min-height: 40px; margin-left: -4px; padding: 0 4px; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
47
+ .tog { display: flex; align-items: center; gap: 8px; flex: 1; min-height: calc(var(--tr-control-h) + 8px); margin-left: -4px; padding: 0 4px; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }
48
48
  .tog:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--tr-primary); }
49
49
  .tog :global(svg) { transition: transform var(--tr-dur) var(--tr-ease); }
50
50
  .tog[aria-expanded='true'] :global(svg) { transform: rotate(90deg); }
@@ -13,6 +13,7 @@
13
13
  align = 'start',
14
14
  fixed = false,
15
15
  anchor,
16
+ placement,
16
17
  children
17
18
  }: {
18
19
  open?: boolean;
@@ -22,11 +23,14 @@
22
23
  /** Viewport-fixed positioning against `anchor`. */
23
24
  fixed?: boolean;
24
25
  anchor?: HTMLElement | null;
26
+ /** Side of the anchor to open on (flips to the opposite side when there is no room). Applies to `fixed` popovers only; unset keeps the default below-the-anchor placement with `align`. */
27
+ placement?: 'top' | 'bottom' | 'left' | 'right';
25
28
  children: Snippet;
26
29
  } = $props();
27
30
 
28
31
  let el = $state<HTMLDivElement>();
29
32
  let flip = $state(false);
33
+ let side = $state<string>();
30
34
  let pos = $state({ top: 0, left: 0, minWidth: 0 });
31
35
 
32
36
  function close(refocus: boolean) {
@@ -41,6 +45,19 @@
41
45
  const r = host.getBoundingClientRect();
42
46
  const h = el!.offsetHeight, w = el!.offsetWidth;
43
47
  const above = r.bottom + h + 8 > window.innerHeight && r.top > h + 8;
48
+ if (fixed && placement) {
49
+ let p = placement;
50
+ if (p === 'top' && r.top < h + 8 && r.bottom + h + 8 <= window.innerHeight) p = 'bottom';
51
+ else if (p === 'bottom' && r.bottom + h + 8 > window.innerHeight && r.top >= h + 8) p = 'top';
52
+ else if (p === 'left' && r.left < w + 8 && r.right + w + 8 <= window.innerWidth) p = 'right';
53
+ else if (p === 'right' && r.right + w + 8 > window.innerWidth && r.left >= w + 8) p = 'left';
54
+ const horizontal = p === 'left' || p === 'right';
55
+ const left = horizontal ? (p === 'left' ? r.left - w - 4 : r.right + 4) : align === 'end' ? r.right - w : r.left;
56
+ const top = horizontal ? r.top : p === 'top' ? r.top - h - 4 : r.bottom + 4;
57
+ pos = { top: Math.min(Math.max(4, top), Math.max(4, window.innerHeight - h - 4)), left: Math.min(Math.max(4, left), Math.max(4, window.innerWidth - w - 4)), minWidth: horizontal ? 0 : r.width };
58
+ side = p;
59
+ return;
60
+ }
44
61
  if (fixed) {
45
62
  const left = align === 'end' ? r.right - w : r.left;
46
63
  pos = { top: above ? r.top - h - 4 : r.bottom + 4, left: Math.min(Math.max(4, left), window.innerWidth - w - 4), minWidth: r.width };
@@ -58,7 +75,11 @@
58
75
  const keyTarget: EventTarget = fixed ? window : host;
59
76
  keyTarget.addEventListener('keydown', key as EventListener);
60
77
  if (fixed) { window.addEventListener('scroll', place, true); window.addEventListener('resize', place); }
78
+ // Content that changes size while open (e.g. loaded asynchronously) moves the edge it grows from
79
+ const ro = fixed && placement && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : undefined;
80
+ ro?.observe(el);
61
81
  return () => {
82
+ ro?.disconnect();
62
83
  window.removeEventListener('pointerdown', down);
63
84
  keyTarget.removeEventListener('keydown', key as EventListener);
64
85
  if (fixed) { window.removeEventListener('scroll', place, true); window.removeEventListener('resize', place); }
@@ -68,7 +89,7 @@
68
89
 
69
90
  {#if open}
70
91
  <div
71
- class="tr-popover" bind:this={el} data-flip={flip} data-align={align} data-fixed={fixed}
92
+ class="tr-popover" bind:this={el} data-flip={flip} data-align={align} data-fixed={fixed} data-placement={placement ? side : undefined}
72
93
  style:top={fixed ? `${pos.top}px` : undefined} style:left={fixed ? `${pos.left}px` : undefined} style:min-width={fixed ? `${pos.minWidth}px` : undefined}
73
94
  >{@render children()}</div>
74
95
  {/if}
@@ -7,6 +7,8 @@ type $$ComponentProps = {
7
7
  /** Viewport-fixed positioning against `anchor`. */
8
8
  fixed?: boolean;
9
9
  anchor?: HTMLElement | null;
10
+ /** Side of the anchor to open on (flips to the opposite side when there is no room). Applies to `fixed` popovers only; unset keeps the default below-the-anchor placement with `align`. */
11
+ placement?: 'top' | 'bottom' | 'left' | 'right';
10
12
  children: Snippet;
11
13
  };
12
14
  declare const Popover: import("svelte").Component<$$ComponentProps, {}, "open">;
@@ -29,7 +29,7 @@
29
29
  .rg { margin: 0; padding: 0; border: 0; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
30
30
  .rg[data-orientation='horizontal'] { flex-flow: row wrap; gap: 4px 16px; }
31
31
  .rg legend { padding: 0; }
32
- .opt { display: inline-flex; align-items: center; gap: 8px; min-height: 24px; cursor: pointer; }
32
+ .opt { display: inline-flex; align-items: center; gap: 8px; min-height: var(--tr-control-h-sm); cursor: pointer; }
33
33
  .opt[data-disabled='true'] { opacity: 0.5; cursor: not-allowed; }
34
34
  .dot { flex: none; width: 18px; height: 18px; border: 1px solid var(--tr-outline); border-radius: 50%; background: var(--tr-surface-container-low); display: grid; place-items: center; transition: box-shadow var(--tr-dur) var(--tr-ease); }
35
35
  .dot::after { content: ''; width: 8px; height: 8px; border-radius: 50%; background: var(--tr-primary); transform: scale(0); transition: transform var(--tr-dur-fast) var(--tr-ease); }
@@ -198,7 +198,7 @@
198
198
  li { display: flex; align-items: center; gap: 8px; height: var(--tr-control-h); padding: 0 8px; border-radius: var(--tr-radius-sm); cursor: pointer; }
199
199
  li span { flex: 1; }
200
200
  li[data-active='true'] { background: var(--tr-surface-container-highest); }
201
- li[aria-selected='true'] { color: var(--tr-primary); font-weight: 600; }
201
+ li[aria-selected='true'] { color: var(--tr-primary-text, var(--tr-primary)); font-weight: 600; }
202
202
  li[aria-disabled='true'] { opacity: 0.45; cursor: not-allowed; }
203
203
  li.empty { color: var(--tr-on-surface-variant); cursor: default; }
204
204
  </style>
@@ -22,6 +22,7 @@
22
22
  asideOpen = $bindable(true),
23
23
  asideWidth = $bindable(320),
24
24
  theme = $bindable('system'),
25
+ density,
25
26
  breakpoint = 840,
26
27
  navLabel = 'Navigation',
27
28
  asideLabel = 'Details'
@@ -43,6 +44,8 @@
43
44
  asideWidth?: number;
44
45
  /** 'system' follows the OS; otherwise forces data-theme on <html>. */
45
46
  theme?: 'system' | 'light' | 'dark';
47
+ /** Global density: sets `data-density` on <html>. Leave unset to keep the default (or your own attribute). */
48
+ density?: 'compact' | 'default' | 'comfortable';
46
49
  /** px below which the sidebar becomes a drawer. */
47
50
  breakpoint?: number;
48
51
  navLabel?: string;
@@ -63,6 +66,14 @@
63
66
  else el.dataset.theme = theme;
64
67
  });
65
68
 
69
+ $effect(() => {
70
+ if (!density) return;
71
+ const el = document.documentElement;
72
+ const prev = el.dataset.density;
73
+ el.dataset.density = density;
74
+ return () => { if (prev === undefined) delete el.dataset.density; else el.dataset.density = prev; };
75
+ });
76
+
66
77
  const toggleSidebar = () => (narrow ? (sidebarOpen = !sidebarOpen) : (sidebarCollapsed = !sidebarCollapsed));
67
78
  const toggleTheme = () => {
68
79
  const dark = theme === 'dark' || (theme === 'system' && matchMedia('(prefers-color-scheme: dark)').matches);
@@ -133,7 +144,7 @@
133
144
  main { grid-area: main; min-width: 0; min-height: 0; overflow: auto; outline: none; }
134
145
  .as { grid-area: as; position: relative; width: var(--aside-w); min-height: 0; background: var(--tr-surface-container-low); border-left: 1px solid var(--tr-outline-variant); }
135
146
  .as-body { height: 100%; overflow: auto; padding: 12px; }
136
- .ft { grid-area: ft; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: 24px; padding: 0 12px; background: var(--tr-surface-container); border-top: 1px solid var(--tr-outline-variant); font-size: var(--tr-text-sm); color: var(--tr-on-surface-variant); }
147
+ .ft { grid-area: ft; min-width: 0; display: flex; align-items: center; gap: 12px; min-height: var(--tr-control-h-sm); padding: 0 var(--tr-space-3); background: var(--tr-surface-container); border-top: 1px solid var(--tr-outline-variant); font-size: var(--tr-text-sm); color: var(--tr-on-surface-variant); }
137
148
  .grip { position: absolute; left: -4px; top: 0; bottom: 0; width: 8px; cursor: col-resize; z-index: 5; touch-action: none; }
138
149
  .grip::after { content: ''; position: absolute; left: 3px; top: 0; bottom: 0; width: 2px; background: transparent; transition: background-color var(--tr-dur) var(--tr-ease); }
139
150
  .grip:hover::after, .grip:focus-visible::after, .shell[data-resizing='true'] .grip::after { background: var(--tr-primary); }
@@ -26,6 +26,8 @@ type $$ComponentProps = {
26
26
  asideWidth?: number;
27
27
  /** 'system' follows the OS; otherwise forces data-theme on <html>. */
28
28
  theme?: 'system' | 'light' | 'dark';
29
+ /** Global density: sets `data-density` on <html>. Leave unset to keep the default (or your own attribute). */
30
+ density?: 'compact' | 'default' | 'comfortable';
29
31
  /** px below which the sidebar becomes a drawer. */
30
32
  breakpoint?: number;
31
33
  navLabel?: string;
@@ -0,0 +1,62 @@
1
+ <script lang="ts">
2
+ import Dialog from './Dialog.svelte';
3
+ import Kbd from './Kbd.svelte';
4
+ import { hotkeys } from '../hotkeys.svelte.js';
5
+
6
+ let {
7
+ open = $bindable(false),
8
+ shortcut = '?',
9
+ title = 'Keyboard shortcuts'
10
+ }: {
11
+ open?: boolean;
12
+ /** Key combination that toggles the sheet; `null` disables the built-in binding. */
13
+ shortcut?: string | null;
14
+ title?: string;
15
+ } = $props();
16
+
17
+ $effect(() => {
18
+ if (!shortcut) return;
19
+ const off = [
20
+ hotkeys.register(shortcut, () => (open = !open), { description: 'Show keyboard shortcuts', group: 'General' }),
21
+ hotkeys.register(shortcut, () => (open = false), { scope: 'shortcuts' })
22
+ ];
23
+ return () => off.forEach((f) => f());
24
+ });
25
+ $effect(() => { if (open) return hotkeys.pushScope('shortcuts'); });
26
+
27
+ const groups = $derived.by(() => {
28
+ if (!open) return [];
29
+ const m = new Map<string, { shortcut: string; description: string }[]>();
30
+ for (const b of hotkeys.described) {
31
+ if (b.scope !== 'global' || (b.when && !b.when())) continue;
32
+ const g = b.group ?? 'General';
33
+ (m.get(g) ?? m.set(g, []).get(g)!).push({ shortcut: b.shortcut, description: b.description! });
34
+ }
35
+ return [...m].map(([name, items]) => ({ name, items }));
36
+ });
37
+ </script>
38
+
39
+ <Dialog bind:open {title}>
40
+ {#each groups as g (g.name)}
41
+ <section>
42
+ <h3>{g.name}</h3>
43
+ <dl>
44
+ {#each g.items as it}
45
+ <dt>{it.description}</dt>
46
+ <dd><Kbd keys={it.shortcut} /></dd>
47
+ {/each}
48
+ </dl>
49
+ </section>
50
+ {:else}
51
+ <p class="none">No shortcuts are registered.</p>
52
+ {/each}
53
+ </Dialog>
54
+
55
+ <style>
56
+ section + section { margin-top: 16px; }
57
+ h3 { margin: 0 0 4px; font-size: var(--tr-text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--tr-on-surface-variant); }
58
+ dl { display: grid; grid-template-columns: 1fr auto; gap: 0; margin: 0; }
59
+ dt, dd { margin: 0; padding: 6px 0; border-bottom: 1px solid var(--tr-outline-variant); }
60
+ dd { justify-self: end; }
61
+ .none { margin: 0; color: var(--tr-on-surface-variant); }
62
+ </style>
@@ -0,0 +1,9 @@
1
+ type $$ComponentProps = {
2
+ open?: boolean;
3
+ /** Key combination that toggles the sheet; `null` disables the built-in binding. */
4
+ shortcut?: string | null;
5
+ title?: string;
6
+ };
7
+ declare const ShortcutSheet: import("svelte").Component<$$ComponentProps, {}, "open">;
8
+ type ShortcutSheet = ReturnType<typeof ShortcutSheet>;
9
+ export default ShortcutSheet;
@@ -69,7 +69,7 @@
69
69
  .sb > ul { flex: 1; overflow-y: auto; }
70
70
  .head { padding: 12px 10px 4px; font-size: var(--tr-text-xs); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--tr-on-surface-variant); }
71
71
  .rule { display: block; height: 1px; background: var(--tr-outline-variant); margin: 4px 4px; }
72
- .item { display: flex; align-items: center; gap: 10px; width: 100%; height: 36px; padding: 0 10px; border: 0; border-radius: var(--tr-radius-pill); background: none; color: var(--tr-on-surface-variant); font: 500 var(--tr-text-md) var(--tr-font-sans); text-decoration: none; text-align: left; cursor: pointer; }
72
+ .item { display: flex; align-items: center; gap: 10px; width: 100%; height: calc(var(--tr-control-h) + 4px); padding: 0 10px; border: 0; border-radius: var(--tr-radius-pill); background: none; color: var(--tr-on-surface-variant); font: 500 var(--tr-text-md) var(--tr-font-sans); text-decoration: none; text-align: left; cursor: pointer; }
73
73
  .sb[data-collapsed='true'] .item { justify-content: center; padding: 0; width: 40px; }
74
74
  .sb[data-collapsed='true'] li { display: flex; justify-content: center; }
75
75
  .item:hover { background: var(--tr-surface-container-high); color: var(--tr-on-surface); }
@@ -78,7 +78,7 @@
78
78
  .lbl { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
79
79
  .sub { margin: 2px 0 2px 20px; padding-left: 8px; border-left: 1px solid var(--tr-outline-variant); }
80
80
  .child { height: 30px; font-size: var(--tr-text-sm); }
81
- .collapse { display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 10px; border: 0; border-top: 1px solid var(--tr-outline-variant); border-radius: 0; background: none; color: var(--tr-on-surface-variant); font: 500 var(--tr-text-sm) var(--tr-font-sans); cursor: pointer; }
81
+ .collapse { display: flex; align-items: center; gap: 10px; height: calc(var(--tr-control-h) + 4px); padding: 0 10px; border: 0; border-top: 1px solid var(--tr-outline-variant); border-radius: 0; background: none; color: var(--tr-on-surface-variant); font: 500 var(--tr-text-sm) var(--tr-font-sans); cursor: pointer; }
82
82
  .collapse:hover { color: var(--tr-on-surface); }
83
83
  .collapse:focus-visible { outline: none; box-shadow: inset 0 0 0 2px var(--tr-primary); }
84
84
  </style>
@@ -3,23 +3,97 @@
3
3
  variant = 'text',
4
4
  lines = 1,
5
5
  width,
6
- height
7
- }: { variant?: 'text' | 'rect' | 'circle'; lines?: number; width?: string | number; height?: string | number } = $props();
6
+ height,
7
+ rows,
8
+ cols,
9
+ label = 'Loading'
10
+ }: {
11
+ /** `text`, `rect` and `circle` are single bars; `table`, `tree`, `card` and `header` are layout presets sized from the density tokens. */
12
+ variant?: 'text' | 'rect' | 'circle' | 'table' | 'tree' | 'card' | 'header';
13
+ lines?: number;
14
+ width?: string | number;
15
+ height?: string | number;
16
+ /** Preset rows: table body rows (default 5), tree rows (6) or card text lines (3). */
17
+ rows?: number;
18
+ /** Table columns (default 4). */
19
+ cols?: number;
20
+ /** Visually hidden text announced to assistive tech while loading. */
21
+ label?: string;
22
+ } = $props();
8
23
  const px = (v?: string | number) => (typeof v === 'number' ? `${v}px` : v);
24
+ const count = (n: number | undefined, d: number) => Math.max(1, Math.floor(n ?? d));
25
+ const preset = $derived(['table', 'tree', 'card', 'header'].includes(variant));
26
+ const nRows = $derived(count(rows, variant === 'tree' ? 6 : variant === 'card' ? 3 : 5));
27
+ const nCols = $derived(count(cols, 4));
28
+ // Tree depth cycles so the indent reads like a real outline; 18px per level matches Tree.
29
+ const depths = [0, 1, 2, 2, 1, 0, 1, 2];
30
+ const barW = (r: number, c: number) => `${55 + ((r * 7 + c * 13) % 5) * 9}%`;
31
+ const treeW = (r: number) => `${40 + ((r * 11) % 4) * 14}%`;
9
32
  </script>
10
33
 
11
- <span class="sk-wrap" aria-hidden="true">
12
- {#each Array.from({ length: variant === 'text' ? lines : 1 }) as _, i}
13
- <span class="sk" data-variant={variant} style:width={px(width) ?? (variant === 'text' && lines > 1 && i === lines - 1 ? '60%' : undefined)} style:height={px(height)}></span>
14
- {/each}
34
+ <span class="sk-root" role="status" aria-busy="true" style:width={preset && variant !== 'header' ? px(width) : undefined}>
35
+ <span class="vh">{label}</span>
36
+ <span class="sk-wrap" data-variant={preset ? variant : undefined} aria-hidden="true">
37
+ {#if variant === 'table'}
38
+ <span class="row head" style:grid-template-columns="repeat({nCols}, minmax(0, 1fr))">
39
+ {#each Array.from({ length: nCols }) as _, c}<span class="sk" data-variant="text" style:width="{45 + (c % 3) * 10}%"></span>{/each}
40
+ </span>
41
+ {#each Array.from({ length: nRows }) as _, r}
42
+ <span class="row" style:grid-template-columns="repeat({nCols}, minmax(0, 1fr))">
43
+ {#each Array.from({ length: nCols }) as _, c}<span class="sk" data-variant="text" style:width={barW(r, c)}></span>{/each}
44
+ </span>
45
+ {/each}
46
+ {:else if variant === 'tree'}
47
+ {#each Array.from({ length: nRows }) as _, r}
48
+ <span class="trow" style:padding-left="{depths[r % depths.length] * 18}px">
49
+ <span class="sk" data-variant="icon"></span>
50
+ <span class="sk" data-variant="text" style:width={treeW(r)}></span>
51
+ </span>
52
+ {/each}
53
+ {:else if variant === 'card'}
54
+ <span class="sk" data-variant="rect" style:height={px(height)}></span>
55
+ <span class="card-body">
56
+ <span class="sk" data-variant="title"></span>
57
+ {#each Array.from({ length: nRows }) as _, i}<span class="sk" data-variant="text" style:width={i === nRows - 1 ? '60%' : undefined}></span>{/each}
58
+ </span>
59
+ {:else if variant === 'header'}
60
+ <span class="hd">
61
+ <span class="hd-text">
62
+ <span class="sk" data-variant="title" style:width="40%"></span>
63
+ <span class="sk" data-variant="text" style:width="65%"></span>
64
+ </span>
65
+ <span class="hd-actions">
66
+ <span class="sk" data-variant="action"></span>
67
+ <span class="sk" data-variant="action"></span>
68
+ </span>
69
+ </span>
70
+ {:else}
71
+ {#each Array.from({ length: variant === 'text' ? lines : 1 }) as _, i}
72
+ <span class="sk" data-variant={variant} style:width={px(width) ?? (variant === 'text' && lines > 1 && i === lines - 1 ? '60%' : undefined)} style:height={px(height)}></span>
73
+ {/each}
74
+ {/if}
75
+ </span>
15
76
  </span>
16
77
 
17
78
  <style>
79
+ .sk-root { display: block; }
80
+ .vh { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
18
81
  .sk-wrap { display: flex; flex-direction: column; gap: 8px; }
82
+ .sk-wrap[data-variant='table'], .sk-wrap[data-variant='tree'] { gap: 0; }
19
83
  .sk { display: block; background: linear-gradient(90deg, var(--tr-surface-container-high) 25%, var(--tr-surface-container-highest) 50%, var(--tr-surface-container-high) 75%); background-size: 200% 100%; animation: shimmer 1.4s linear infinite; border-radius: var(--tr-radius-sm); }
20
84
  .sk[data-variant='text'] { height: 12px; }
21
85
  .sk[data-variant='rect'] { height: 80px; border-radius: var(--tr-radius-md); }
22
86
  .sk[data-variant='circle'] { width: 40px; height: 40px; border-radius: 50%; }
87
+ .sk[data-variant='title'] { height: 20px; }
88
+ .sk[data-variant='icon'] { flex: none; width: var(--tr-icon-sm); height: var(--tr-icon-sm); }
89
+ .sk[data-variant='action'] { width: calc(var(--tr-control-h) * 2.5); height: var(--tr-control-h); }
90
+ .row { display: grid; align-items: center; column-gap: var(--tr-space-3); height: var(--tr-row-h); padding: 0 var(--tr-space-3); border-bottom: 1px solid var(--tr-outline-variant); }
91
+ .row.head { height: var(--tr-row-h-head); }
92
+ .trow { display: flex; align-items: center; gap: var(--tr-space-2); height: var(--tr-tree-row-h); }
93
+ .card-body { display: flex; flex-direction: column; gap: var(--tr-space-2); }
94
+ .hd { display: flex; align-items: center; justify-content: space-between; gap: var(--tr-space-4); }
95
+ .hd-text { display: flex; flex-direction: column; gap: var(--tr-space-2); flex: 1; min-width: 0; }
96
+ .hd-actions { display: flex; gap: var(--tr-gap); flex: none; }
23
97
  @keyframes shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
24
98
  @media (prefers-reduced-motion: reduce) { .sk { animation: none; } }
25
99
  </style>
@@ -1,8 +1,15 @@
1
1
  type $$ComponentProps = {
2
- variant?: 'text' | 'rect' | 'circle';
2
+ /** `text`, `rect` and `circle` are single bars; `table`, `tree`, `card` and `header` are layout presets sized from the density tokens. */
3
+ variant?: 'text' | 'rect' | 'circle' | 'table' | 'tree' | 'card' | 'header';
3
4
  lines?: number;
4
5
  width?: string | number;
5
6
  height?: string | number;
7
+ /** Preset rows: table body rows (default 5), tree rows (6) or card text lines (3). */
8
+ rows?: number;
9
+ /** Table columns (default 4). */
10
+ cols?: number;
11
+ /** Visually hidden text announced to assistive tech while loading. */
12
+ label?: string;
6
13
  };
7
14
  declare const Skeleton: import("svelte").Component<$$ComponentProps, {}, "">;
8
15
  type Skeleton = ReturnType<typeof Skeleton>;
@@ -106,7 +106,7 @@
106
106
  .sl[data-disabled='true'] { opacity: 0.5; }
107
107
  .head { display: flex; justify-content: space-between; margin: 0 -8px 4px; }
108
108
  .head .tr-label { margin: 0; }
109
- .val { font: 600 var(--tr-text-sm) var(--tr-font-sans); font-variant-numeric: tabular-nums; color: var(--tr-primary); }
109
+ .val { font: 600 var(--tr-text-sm) var(--tr-font-sans); font-variant-numeric: tabular-nums; color: var(--tr-primary-text, var(--tr-primary)); }
110
110
  .track { position: relative; height: 24px; cursor: pointer; touch-action: none; }
111
111
  .sl[data-disabled='true'] .track { cursor: not-allowed; }
112
112
  .rail, .fill { position: absolute; top: 10px; height: 4px; border-radius: 2px; }
@@ -11,7 +11,7 @@
11
11
  </label>
12
12
 
13
13
  <style>
14
- .sw { display: inline-flex; align-items: center; gap: 8px; min-height: 24px; cursor: pointer; }
14
+ .sw { display: inline-flex; align-items: center; gap: 8px; min-height: var(--tr-control-h-sm); cursor: pointer; }
15
15
  .sw[data-disabled='true'] { opacity: 0.5; cursor: not-allowed; }
16
16
  .track { position: relative; flex: none; width: 32px; height: 18px; border: 1px solid var(--tr-outline); border-radius: var(--tr-radius-pill); background: var(--tr-surface-container-highest); transition: background-color var(--tr-dur) var(--tr-ease); }
17
17
  .thumb { position: absolute; top: 2px; left: 2px; width: 12px; height: 12px; border-radius: 50%; background: var(--tr-outline); transition: transform var(--tr-dur) var(--tr-ease), background-color var(--tr-dur) var(--tr-ease); }