@w5-ui/svelte 0.2.1 → 0.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 (139) hide show
  1. package/README.md +116 -4
  2. package/package.json +2 -2
  3. package/src/lib/actions/portal.ts +55 -0
  4. package/src/lib/components/Accordion.svelte +1 -1
  5. package/src/lib/components/AccordionItem.svelte +6 -7
  6. package/src/lib/components/ActionMenu.svelte +17 -9
  7. package/src/lib/components/ActivityFeed.svelte +20 -14
  8. package/src/lib/components/Alert.svelte +22 -4
  9. package/src/lib/components/AppLayout.svelte +34 -0
  10. package/src/lib/components/Avatar.svelte +28 -11
  11. package/src/lib/components/AvatarGroup.svelte +21 -4
  12. package/src/lib/components/Badge.svelte +22 -6
  13. package/src/lib/components/Banner.svelte +36 -7
  14. package/src/lib/components/Body.svelte +29 -0
  15. package/src/lib/components/BodyLg.svelte +26 -0
  16. package/src/lib/components/Breadcrumb.svelte +19 -12
  17. package/src/lib/components/Button.svelte +30 -1
  18. package/src/lib/components/CIDRInput.svelte +17 -9
  19. package/src/lib/components/Callout.svelte +29 -8
  20. package/src/lib/components/Caption.svelte +26 -0
  21. package/src/lib/components/Card.svelte +11 -3
  22. package/src/lib/components/CardMore.svelte +20 -0
  23. package/src/lib/components/CardTitle.svelte +20 -0
  24. package/src/lib/components/Carousel.svelte +27 -20
  25. package/src/lib/components/Checkbox.svelte +24 -3
  26. package/src/lib/components/Code.svelte +24 -0
  27. package/src/lib/components/CodeBlock.svelte +8 -7
  28. package/src/lib/components/ColorPicker.svelte +13 -13
  29. package/src/lib/components/ColumnToggle.svelte +10 -9
  30. package/src/lib/components/Combobox.svelte +31 -17
  31. package/src/lib/components/CommandPalette.svelte +42 -26
  32. package/src/lib/components/ConfirmDialog.svelte +21 -15
  33. package/src/lib/components/ContextMenu.svelte +12 -12
  34. package/src/lib/components/ContextualHelp.svelte +11 -6
  35. package/src/lib/components/CopyButton.svelte +2 -1
  36. package/src/lib/components/DatePicker.svelte +27 -23
  37. package/src/lib/components/DateRangePicker.svelte +46 -39
  38. package/src/lib/components/Delta.svelte +25 -0
  39. package/src/lib/components/Display1.svelte +29 -0
  40. package/src/lib/components/Display2.svelte +29 -0
  41. package/src/lib/components/Drawer.svelte +6 -5
  42. package/src/lib/components/DurationInput.svelte +16 -8
  43. package/src/lib/components/EmptyState.svelte +16 -7
  44. package/src/lib/components/ExpandableRow.svelte +8 -15
  45. package/src/lib/components/Eyebrow.svelte +25 -0
  46. package/src/lib/components/Field.svelte +7 -7
  47. package/src/lib/components/FieldStack.svelte +18 -0
  48. package/src/lib/components/FileUpload.svelte +8 -10
  49. package/src/lib/components/FilterBuilder.svelte +52 -28
  50. package/src/lib/components/FormActions.svelte +18 -0
  51. package/src/lib/components/FormRow.svelte +17 -0
  52. package/src/lib/components/Grid.svelte +17 -0
  53. package/src/lib/components/GroupedList.svelte +35 -21
  54. package/src/lib/components/H1.svelte +29 -0
  55. package/src/lib/components/H2.svelte +29 -0
  56. package/src/lib/components/H3.svelte +29 -0
  57. package/src/lib/components/H4.svelte +29 -0
  58. package/src/lib/components/HealthBar.svelte +6 -3
  59. package/src/lib/components/HoverCard.svelte +16 -5
  60. package/src/lib/components/IPInput.svelte +14 -8
  61. package/src/lib/components/IconButton.svelte +1 -2
  62. package/src/lib/components/InlineEdit.svelte +25 -14
  63. package/src/lib/components/Input.svelte +13 -4
  64. package/src/lib/components/InputGroup.svelte +14 -3
  65. package/src/lib/components/JsonViewer.svelte +7 -2
  66. package/src/lib/components/JsonViewerNode.svelte +38 -32
  67. package/src/lib/components/KVTable.svelte +10 -4
  68. package/src/lib/components/KanbanBoard.svelte +29 -27
  69. package/src/lib/components/Kbd.svelte +8 -5
  70. package/src/lib/components/Label.svelte +28 -0
  71. package/src/lib/components/LogViewer.svelte +24 -20
  72. package/src/lib/components/MACInput.svelte +14 -8
  73. package/src/lib/components/Mac.svelte +23 -0
  74. package/src/lib/components/Menubar.svelte +30 -13
  75. package/src/lib/components/Modal.svelte +15 -6
  76. package/src/lib/components/Mono.svelte +28 -0
  77. package/src/lib/components/MultiSelect.svelte +43 -23
  78. package/src/lib/components/Muted.svelte +24 -0
  79. package/src/lib/components/NameCell.svelte +35 -0
  80. package/src/lib/components/NotifDot.svelte +23 -0
  81. package/src/lib/components/NotificationPanel.svelte +44 -31
  82. package/src/lib/components/NumberInput.svelte +13 -12
  83. package/src/lib/components/OTPInput.svelte +14 -8
  84. package/src/lib/components/PageHeader.svelte +25 -0
  85. package/src/lib/components/Pagination.svelte +16 -12
  86. package/src/lib/components/PasswordInput.svelte +8 -7
  87. package/src/lib/components/Pill.svelte +34 -6
  88. package/src/lib/components/Popover.svelte +101 -17
  89. package/src/lib/components/ProgressBar.svelte +10 -7
  90. package/src/lib/components/RadioGroup.svelte +22 -7
  91. package/src/lib/components/RangeSlider.svelte +17 -12
  92. package/src/lib/components/RankedList.svelte +12 -18
  93. package/src/lib/components/ResizablePanel.svelte +25 -14
  94. package/src/lib/components/RowToggle.svelte +6 -4
  95. package/src/lib/components/SearchBox.svelte +6 -2
  96. package/src/lib/components/SegmentedControl.svelte +16 -2
  97. package/src/lib/components/Select.svelte +20 -8
  98. package/src/lib/components/SelectionToolbar.svelte +13 -9
  99. package/src/lib/components/Sidebar.svelte +32 -11
  100. package/src/lib/components/Signal.svelte +3 -2
  101. package/src/lib/components/Skeleton.svelte +21 -9
  102. package/src/lib/components/SkipLink.svelte +5 -2
  103. package/src/lib/components/Slider.svelte +16 -9
  104. package/src/lib/components/SortHeader.svelte +13 -4
  105. package/src/lib/components/SortableList.svelte +17 -18
  106. package/src/lib/components/Sparkline.svelte +20 -14
  107. package/src/lib/components/Spinner.svelte +16 -1
  108. package/src/lib/components/SplitButton.svelte +27 -8
  109. package/src/lib/components/Spoiler.svelte +8 -6
  110. package/src/lib/components/SrOnly.svelte +23 -0
  111. package/src/lib/components/StackedProgress.svelte +17 -9
  112. package/src/lib/components/StarRating.svelte +35 -10
  113. package/src/lib/components/Stat.svelte +19 -5
  114. package/src/lib/components/StatNumber.svelte +22 -0
  115. package/src/lib/components/StatusIndicator.svelte +9 -2
  116. package/src/lib/components/StatusRing.svelte +36 -0
  117. package/src/lib/components/Stepper.svelte +30 -8
  118. package/src/lib/components/Submeta.svelte +19 -0
  119. package/src/lib/components/SwitchPortGrid.svelte +20 -7
  120. package/src/lib/components/TablePager.svelte +22 -0
  121. package/src/lib/components/TableShell.svelte +19 -0
  122. package/src/lib/components/Tabs.svelte +16 -5
  123. package/src/lib/components/Tag.svelte +5 -3
  124. package/src/lib/components/TagInput.svelte +9 -10
  125. package/src/lib/components/Textarea.svelte +7 -1
  126. package/src/lib/components/TimePicker.svelte +14 -8
  127. package/src/lib/components/TimeRange.svelte +20 -22
  128. package/src/lib/components/Timeline.svelte +20 -9
  129. package/src/lib/components/Toast.svelte +24 -5
  130. package/src/lib/components/Toggle.svelte +10 -4
  131. package/src/lib/components/ToggleGroup.svelte +15 -5
  132. package/src/lib/components/Tooltip.svelte +20 -5
  133. package/src/lib/components/Topbar.svelte +161 -28
  134. package/src/lib/components/TopbarActions.svelte +52 -0
  135. package/src/lib/components/TransferList.svelte +30 -22
  136. package/src/lib/components/TreeItem.svelte +11 -13
  137. package/src/lib/components/Unit.svelte +22 -0
  138. package/src/lib/components/VirtualList.svelte +12 -9
  139. package/src/lib/index.ts +37 -0
@@ -1,10 +1,27 @@
1
1
  <script module lang="ts">
2
+ import type { AppLogoKey } from '@w5-ui/assets';
3
+
4
+ /** Logo identifier. Either a built-in `AppLogoKey` (resolved via
5
+ * `@w5-ui/assets`'s `appLogos` map) or a raw URL string — use the
6
+ * latter when the consuming app wants to brand an entry with its
7
+ * own image without having to upstream a new asset into the
8
+ * design system. */
9
+ export type AppLogo = AppLogoKey | (string & {})
2
10
  export interface AppDef { id: string; label: string; logo: AppLogo }
11
+ export type TopbarStatus = 'ok' | 'warn' | 'danger' | 'neutral';
3
12
  </script>
4
13
 
5
14
  <script lang="ts">
6
-
7
- import { appLogos, type AppLogoKey as AppLogo } from '@w5-ui/assets';
15
+ import type { Snippet } from 'svelte';
16
+ import { appLogos, type AppLogoKey } from '@w5-ui/assets';
17
+
18
+ /** Resolve an `AppLogo` to an `<img src>` URL. Known keys go
19
+ * through the bundled `appLogos` map; anything else is treated as
20
+ * a URL the consumer supplied directly. */
21
+ function resolveLogo(logo: string): string {
22
+ if (logo in appLogos) return appLogos[logo as AppLogoKey]
23
+ return logo
24
+ }
8
25
  import IconButton from './IconButton.svelte';
9
26
  import Avatar from './Avatar.svelte';
10
27
  import CaretIcon from '../icons/CaretIcon.svelte';
@@ -25,6 +42,21 @@
25
42
  apps?: AppDef[];
26
43
  initials?: string;
27
44
  notificationCount?: number;
45
+ /** Health colour of the site-name status ring. Defaults to 'ok' (green). */
46
+ status?: TopbarStatus;
47
+ /** Optional URL of a square logo (or w5-ui `AppLogoKey`) to render in
48
+ * place of the status dot, next to `siteName`. The status colour
49
+ * becomes a thin ring AROUND the logo so the health signal is
50
+ * preserved without claiming a dedicated dot. Falls back to the
51
+ * classic status-dot layout when undefined. */
52
+ siteLogo?: AppLogo;
53
+ /** When false, the site label renders as a static element instead of a
54
+ * site-switcher dropdown trigger. Use for single-site dashboards. */
55
+ siteSwitchable?: boolean;
56
+ /** Custom right-side actions snippet. When provided, replaces the default
57
+ * Search/Updates/Notifications/Help/Avatar block in full. Use to slot in
58
+ * app-specific controls (theme toggle, status pill, etc.). */
59
+ actions?: Snippet;
28
60
  onappchange?: (payload: string) => void;
29
61
  }
30
62
 
@@ -34,44 +66,145 @@
34
66
  apps = DEFAULT_APPS,
35
67
  initials = 'MS',
36
68
  notificationCount = 1,
69
+ status = 'ok',
70
+ siteLogo,
71
+ siteSwitchable = true,
72
+ actions,
37
73
  onappchange,
38
74
  }: Props = $props();
75
+
76
+ // Status-ring colour mapping. We pre-compose the Tailwind class strings here
77
+ // (rather than inline) so the consumer's Tailwind compiler picks them up
78
+ // unambiguously via static-string scanning.
79
+ // Ring background opacities chosen to match dashboard.css exactly
80
+ // (`.status-ring { background: rgba(<colour>, 0.18) }`).
81
+ const RING_BG: Record<TopbarStatus, string> = {
82
+ ok: 'bg-status-success/18',
83
+ warn: 'bg-status-warning/18',
84
+ danger: 'bg-status-danger/18',
85
+ neutral: 'bg-status-neutral/18',
86
+ };
87
+ const RING_DOT: Record<TopbarStatus, string> = {
88
+ ok: 'bg-status-success',
89
+ warn: 'bg-status-warning',
90
+ danger: 'bg-status-danger',
91
+ neutral: 'bg-status-neutral',
92
+ };
93
+ /** Solid-colour ring drawn AROUND the site logo (when `siteLogo` is
94
+ * set) so the health signal stays visible without the dot+halo
95
+ * treatment. Pre-composed for the Tailwind static scanner. */
96
+ const LOGO_RING: Record<TopbarStatus, string> = {
97
+ ok: 'ring-status-success',
98
+ warn: 'ring-status-warning',
99
+ danger: 'ring-status-danger',
100
+ neutral: 'ring-status-neutral',
101
+ };
102
+
103
+ let ringWrapClass = $derived(
104
+ `inline-flex h-[18px] w-[18px] items-center justify-center rounded-full ${RING_BG[status]}`,
105
+ );
106
+ let ringDotClass = $derived(`h-2 w-2 rounded-full ${RING_DOT[status]}`);
107
+ let logoRingClass = $derived(
108
+ `inline-flex h-6 w-6 items-center justify-center overflow-hidden rounded-full ring-2 ring-inset ${LOGO_RING[status]}`,
109
+ );
39
110
  </script>
40
111
 
41
- <header class="topbar">
42
- <button type="button" class="site-switch" aria-label="Switch site: {siteName}" aria-haspopup="menu">
43
- <span class="status-ring"></span>
44
- <span class="site-name" aria-hidden="true">{siteName}</span>
45
- <CaretIcon class="caret" />
46
- </button>
47
- <nav class="app-tabs" aria-label="Apps">
112
+ <!--
113
+ Motif-aware chrome: surfaces resolve via `var(--bg-page)`, text via
114
+ `var(--text-1)`, borders via `var(--border-1)`. In dark motif the
115
+ values still resolve to the original `#0a0a0b` / `#ffffff` /
116
+ rgba(255,255,255,0.06) so the baseline look is unchanged. Light motif
117
+ flips them to `#ffffff` / `#0a0a0b` / `#ececee`.
118
+ -->
119
+ <header
120
+ class="flex h-12 shrink-0 items-center gap-0 bg-bg-page px-2"
121
+ >
122
+ {#if siteSwitchable}
123
+ <button
124
+ type="button"
125
+ class="flex cursor-pointer items-center gap-2 rounded-md px-2.5 py-1.5 hover:bg-row-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-05"
126
+ aria-label="Switch site: {siteName}"
127
+ aria-haspopup="menu"
128
+ title={`${siteName} — status: ${status}`}
129
+ >
130
+ {#if siteLogo !== undefined}
131
+ <span class={logoRingClass} aria-hidden="true">
132
+ <img src={resolveLogo(siteLogo)} alt="" width="24" height="24" class="h-full w-full object-cover" />
133
+ </span>
134
+ {:else}
135
+ <span class={ringWrapClass}><span class={ringDotClass}></span></span>
136
+ {/if}
137
+ <span class="text-13 font-medium text-text-1" aria-hidden="true">{siteName}</span>
138
+ <CaretIcon class="h-3.5 w-3.5 text-text-3" />
139
+ </button>
140
+ {:else}
141
+ <div
142
+ class="flex items-center gap-2 px-2.5 py-1.5"
143
+ role="presentation"
144
+ title={`${siteName} — status: ${status}`}
145
+ >
146
+ {#if siteLogo !== undefined}
147
+ <span class={logoRingClass} aria-hidden="true">
148
+ <img src={resolveLogo(siteLogo)} alt="" width="24" height="24" class="h-full w-full object-cover" />
149
+ </span>
150
+ {:else}
151
+ <span class={ringWrapClass}><span class={ringDotClass}></span></span>
152
+ {/if}
153
+ <span class="text-13 font-medium text-text-1">{siteName}</span>
154
+ </div>
155
+ {/if}
156
+
157
+ <nav class="ml-3.5 flex h-12 items-center gap-0.5" aria-label="Apps">
48
158
  {#each apps as a (a.id)}
49
159
  <button
50
160
  type="button"
51
- class="app-tab {a.id === activeApp ? 'active' : ''}"
161
+ class="flex h-12 w-16 cursor-pointer flex-col items-center justify-center py-1.5 transition-colors duration-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-05
162
+ {a.id === activeApp
163
+ ? 'bg-bg-1 text-text-1'
164
+ : 'bg-transparent text-text-3 hover:text-text-1'}"
52
165
  aria-current={a.id === activeApp ? 'page' : undefined}
53
166
  onclick={() => { activeApp = a.id; onappchange?.(a.id); }}
54
167
  >
55
- <img src={appLogos[a.logo]} alt="" width="24" height="24" />
56
- <span class="label">{a.label}</span>
168
+ <img
169
+ src={resolveLogo(a.logo)}
170
+ alt=""
171
+ width="24"
172
+ height="24"
173
+ class="transition-[opacity,filter] duration-150
174
+ {a.id === activeApp
175
+ ? 'opacity-100 dark:brightness-[1.6]'
176
+ : 'opacity-65 hover:opacity-90 dark:hover:brightness-[1.4]'}"
177
+ />
178
+ <span class="mt-0.5 text-[10px] font-medium leading-none">{a.label}</span>
57
179
  </button>
58
180
  {/each}
59
181
  </nav>
60
- <div class="topbar-spacer"></div>
61
- <div class="topbar-right">
62
- <IconButton aria-label="Search" title="Search"><SearchIcon /></IconButton>
63
- <IconButton aria-label="Updates" title="Updates"><UpdatesIcon /></IconButton>
64
- <IconButton
65
- aria-label={notificationCount > 0 ? `Notifications, ${notificationCount} new` : 'Notifications'}
66
- title="Notifications"
67
- style="position:relative;"
68
- >
69
- <BellIcon />
70
- {#if notificationCount > 0}
71
- <span aria-hidden="true" style="position:absolute;top:6px;right:6px;width:6px;height:6px;background:#F03A3A;border-radius:50%;border:1.5px solid #0A0A0B;"></span>
72
- {/if}
73
- </IconButton>
74
- <IconButton aria-label="Help" title="Help"><HelpIcon /></IconButton>
75
- <Avatar initials={initials} size="sm" alt="Account, {initials}" />
182
+
183
+ <div class="flex-1"></div>
184
+
185
+ <div class="flex items-center gap-1 pr-2">
186
+ {#if actions}
187
+ {@render actions()}
188
+ {:else}
189
+ <IconButton aria-label="Search" title="Search"><SearchIcon /></IconButton>
190
+ <IconButton aria-label="Updates" title="Updates"><UpdatesIcon /></IconButton>
191
+ <IconButton
192
+ aria-label={notificationCount > 0
193
+ ? `Notifications, ${notificationCount} new`
194
+ : 'Notifications'}
195
+ title="Notifications"
196
+ class="relative"
197
+ >
198
+ <BellIcon />
199
+ {#if notificationCount > 0}
200
+ <span
201
+ aria-hidden="true"
202
+ class="absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full border-[1.5px] border-bg-page bg-status-danger"
203
+ ></span>
204
+ {/if}
205
+ </IconButton>
206
+ <IconButton aria-label="Help" title="Help"><HelpIcon /></IconButton>
207
+ <Avatar initials={initials} size="sm" alt="Account, {initials}" class="ml-1" />
208
+ {/if}
76
209
  </div>
77
210
  </header>
@@ -0,0 +1,52 @@
1
+ <script lang="ts">
2
+ import IconButton from './IconButton.svelte';
3
+ import Avatar from './Avatar.svelte';
4
+ import SearchIcon from '../icons/SearchIcon.svelte';
5
+ import UpdatesIcon from '../icons/UpdatesIcon.svelte';
6
+ import BellIcon from '../icons/BellIcon.svelte';
7
+ import HelpIcon from '../icons/HelpIcon.svelte';
8
+
9
+ interface Props {
10
+ /** Avatar initials shown in the trailing account chip. */
11
+ initials?: string;
12
+ /** Unread count surfaced via the bell `aria-label` and the red dot
13
+ * overlay. `0` hides the dot. */
14
+ notificationCount?: number;
15
+ }
16
+
17
+ let { initials = 'MS', notificationCount = 1 }: Props = $props();
18
+ </script>
19
+
20
+ <!--
21
+ The canonical right-side action group inside <Topbar>: Search, Updates,
22
+ Notifications (with red dot overlay when unread), Help, and the account
23
+ <Avatar>. Renders the same five controls in the same order and with the
24
+ same Tailwind chrome as the default block inside <Topbar> itself, so
25
+ passing <Topbar actions={...}>{<TopbarActions />}</> behaves identically
26
+ to omitting the snippet.
27
+
28
+ Use it when userland wants to slot extra controls (theme toggle, status
29
+ pill, environment switch) _alongside_ the canonical block rather than
30
+ replace it. Compose your custom controls plus <TopbarActions /> and the
31
+ Topbar keeps the search / bell / avatar rhythm the reference dashboard
32
+ ships without copying classes, icons, or the inline notif span.
33
+ -->
34
+ <IconButton aria-label="Search" title="Search"><SearchIcon /></IconButton>
35
+ <IconButton aria-label="Updates" title="Updates"><UpdatesIcon /></IconButton>
36
+ <IconButton
37
+ aria-label={notificationCount > 0
38
+ ? `Notifications, ${notificationCount} new`
39
+ : 'Notifications'}
40
+ title="Notifications"
41
+ class="relative"
42
+ >
43
+ <BellIcon />
44
+ {#if notificationCount > 0}
45
+ <span
46
+ aria-hidden="true"
47
+ class="absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full border-[1.5px] border-bg-page bg-status-danger"
48
+ ></span>
49
+ {/if}
50
+ </IconButton>
51
+ <IconButton aria-label="Help" title="Help"><HelpIcon /></IconButton>
52
+ <Avatar initials={initials} size="sm" alt="Account, {initials}" class="ml-1" />
@@ -107,22 +107,30 @@
107
107
  moveLeft(selTgt);
108
108
  }
109
109
  }
110
+
111
+ const PANEL_CLS = 'flex min-w-0 flex-1 flex-col gap-1.5 rounded-lg border border-border-1 bg-divider p-2';
112
+ const HEADER_CLS = 'flex items-center justify-between px-1 text-12 font-semibold uppercase tracking-[0.05em] text-text-3';
113
+ const COUNT_CLS = 'rounded bg-row-active px-1.5 py-0.5 text-11 text-text-4 tabular-nums';
114
+ const LIST_CLS = 'm-0 flex max-h-[240px] list-none flex-col gap-0.5 overflow-y-auto p-0 focus:outline-none';
115
+ const ITEM_CLS = 'flex cursor-pointer flex-col items-start gap-0 rounded px-2 py-1.5 text-13 text-text-2 hover:bg-row-hover aria-selected:bg-brand-05/[0.18] aria-selected:text-text-1 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-brand-05';
116
+ const EMPTY_CLS = 'rounded px-2 py-3 text-center text-11 text-text-4';
117
+ const BTN_CLS = 'inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded border border-border-2 bg-transparent text-13 text-text-3 hover:bg-row-hover hover:text-text-1 disabled:cursor-not-allowed disabled:opacity-30 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-05';
110
118
  </script>
111
119
 
112
- <div class="tl {className}">
113
- <div class="tl__panel">
114
- <div class="tl__header">
115
- {sourceLabel} <span class="tl__count">{source.length}</span>
120
+ <div class="flex items-start gap-3 {className}">
121
+ <div class={PANEL_CLS}>
122
+ <div class={HEADER_CLS}>
123
+ {sourceLabel} <span class={COUNT_CLS}>{source.length}</span>
116
124
  </div>
117
125
  <ul
118
126
  role="listbox"
119
127
  aria-label={sourceLabel}
120
128
  aria-multiselectable="true"
121
- class="tl__list"
129
+ class={LIST_CLS}
122
130
  onkeydown={srcKey}
123
131
  >
124
132
  {#if source.length === 0}
125
- <li class="tl__empty" role="option" aria-selected="false" aria-disabled="true">Empty</li>
133
+ <li class={EMPTY_CLS} role="option" aria-selected="false" aria-disabled="true">Empty</li>
126
134
  {:else}
127
135
  {#each source as item, i (item.id)}
128
136
  <li
@@ -130,46 +138,46 @@
130
138
  role="option"
131
139
  aria-selected={selSrc.has(item.id)}
132
140
  tabindex={i === focSrc ? 0 : -1}
133
- class="tl__item{selSrc.has(item.id) ? ' tl__item--sel' : ''}"
141
+ class={ITEM_CLS}
134
142
  onclick={() => { focSrc = i; toggleSrc(item.id); }}
135
143
  onkeydown={(e) => { if (e.key === ' ') { e.preventDefault(); focSrc = i; toggleSrc(item.id); } }}
136
144
  >
137
- <span class="tl__item-label">{item.label}</span>
138
- {#if item.description}<span class="tl__item-desc">{item.description}</span>{/if}
145
+ <span>{item.label}</span>
146
+ {#if item.description}<span class="text-11 text-text-4">{item.description}</span>{/if}
139
147
  </li>
140
148
  {/each}
141
149
  {/if}
142
150
  </ul>
143
151
  </div>
144
152
 
145
- <div class="tl__controls" role="group" aria-label="Transfer controls">
146
- <button type="button" class="tl__btn" aria-label="Move all to {targetLabel}"
153
+ <div class="flex flex-col items-center gap-1 pt-7" role="group" aria-label="Transfer controls">
154
+ <button type="button" class={BTN_CLS} aria-label={`Move all to ${targetLabel}`}
147
155
  disabled={source.length === 0}
148
156
  onclick={() => moveRight(new Set(source.map((i) => i.id)))}>»</button>
149
- <button type="button" class="tl__btn" aria-label="Move selected to {targetLabel}"
157
+ <button type="button" class={BTN_CLS} aria-label={`Move selected to ${targetLabel}`}
150
158
  disabled={selSrc.size === 0}
151
159
  onclick={() => moveRight(selSrc)}>›</button>
152
- <button type="button" class="tl__btn" aria-label="Move selected back to {sourceLabel}"
160
+ <button type="button" class={BTN_CLS} aria-label={`Move selected back to ${sourceLabel}`}
153
161
  disabled={selTgt.size === 0}
154
162
  onclick={() => moveLeft(selTgt)}>‹</button>
155
- <button type="button" class="tl__btn" aria-label="Move all back to {sourceLabel}"
163
+ <button type="button" class={BTN_CLS} aria-label={`Move all back to ${sourceLabel}`}
156
164
  disabled={target.length === 0}
157
165
  onclick={() => moveLeft(new Set(target.map((i) => i.id)))}>«</button>
158
166
  </div>
159
167
 
160
- <div class="tl__panel">
161
- <div class="tl__header">
162
- {targetLabel} <span class="tl__count">{target.length}</span>
168
+ <div class={PANEL_CLS}>
169
+ <div class={HEADER_CLS}>
170
+ {targetLabel} <span class={COUNT_CLS}>{target.length}</span>
163
171
  </div>
164
172
  <ul
165
173
  role="listbox"
166
174
  aria-label={targetLabel}
167
175
  aria-multiselectable="true"
168
- class="tl__list"
176
+ class={LIST_CLS}
169
177
  onkeydown={tgtKey}
170
178
  >
171
179
  {#if target.length === 0}
172
- <li class="tl__empty" role="option" aria-selected="false" aria-disabled="true">Empty</li>
180
+ <li class={EMPTY_CLS} role="option" aria-selected="false" aria-disabled="true">Empty</li>
173
181
  {:else}
174
182
  {#each target as item, i (item.id)}
175
183
  <li
@@ -177,12 +185,12 @@
177
185
  role="option"
178
186
  aria-selected={selTgt.has(item.id)}
179
187
  tabindex={i === focTgt ? 0 : -1}
180
- class="tl__item{selTgt.has(item.id) ? ' tl__item--sel' : ''}"
188
+ class={ITEM_CLS}
181
189
  onclick={() => { focTgt = i; toggleTgt(item.id); }}
182
190
  onkeydown={(e) => { if (e.key === ' ') { e.preventDefault(); focTgt = i; toggleTgt(item.id); } }}
183
191
  >
184
- <span class="tl__item-label">{item.label}</span>
185
- {#if item.description}<span class="tl__item-desc">{item.description}</span>{/if}
192
+ <span>{item.label}</span>
193
+ {#if item.description}<span class="text-11 text-text-4">{item.description}</span>{/if}
186
194
  </li>
187
195
  {/each}
188
196
  {/if}
@@ -17,12 +17,7 @@
17
17
  focusedId: string;
18
18
  }
19
19
 
20
- let {
21
- node,
22
- expanded,
23
- selected,
24
- focusedId
25
- }: Props = $props();
20
+ let { node, expanded, selected, focusedId }: Props = $props();
26
21
 
27
22
  let hasChildren = $derived(!!node.children?.length);
28
23
  let isExpanded = $derived(expanded.has(node.id));
@@ -35,15 +30,18 @@
35
30
  aria-selected={isSelected}
36
31
  tabindex={focusedId === node.id ? 0 : -1}
37
32
  data-tree-id={node.id}
38
- class="tree__item"
33
+ class="list-none"
39
34
  >
40
- <div class="tree__row{isSelected ? ' tree__row--selected' : ''}">
35
+ <div
36
+ data-selected={isSelected ? 'true' : undefined}
37
+ class="flex items-center gap-2 rounded px-2 py-1 text-13 text-text-2 hover:bg-row-hover data-[selected=true]:bg-brand-05/[0.18] data-[selected=true]:text-text-1"
38
+ >
41
39
  {#if hasChildren}
42
40
  <svg
43
- class="tree__chevron{isExpanded ? ' tree__chevron--open' : ''}"
44
41
  viewBox="0 0 12 12"
45
42
  fill="none"
46
43
  aria-hidden="true"
44
+ class="h-3 w-3 shrink-0 text-text-4 transition-transform duration-100 motion-reduce:transition-none {isExpanded ? 'rotate-90' : ''}"
47
45
  >
48
46
  <path
49
47
  d="M4 2l4 4-4 4"
@@ -54,13 +52,13 @@
54
52
  />
55
53
  </svg>
56
54
  {:else}
57
- <span class="tree__indent" aria-hidden="true"></span>
55
+ <span class="inline-block w-3 shrink-0" aria-hidden="true"></span>
58
56
  {/if}
59
- <span class="tree__label">{node.label}</span>
60
- {#if node.meta}<span class="tree__meta">{node.meta}</span>{/if}
57
+ <span class="flex-1 truncate">{node.label}</span>
58
+ {#if node.meta}<span class="text-11 text-text-4">{node.meta}</span>{/if}
61
59
  </div>
62
60
  {#if hasChildren && isExpanded && node.children}
63
- <ul role="group" class="tree__group">
61
+ <ul role="group" class="m-0 list-none pl-4">
64
62
  {#each node.children as child (child.id)}
65
63
  <TreeItem
66
64
  node={child}
@@ -0,0 +1,22 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ class?: string;
4
+ children?: import('svelte').Snippet;
5
+ }
6
+
7
+ let { class: className = '', children }: Props = $props();
8
+ </script>
9
+
10
+ <!--
11
+ Small inline label that trails a hero number inside a .stat row or a <Card>
12
+ title row (e.g. "Mbps · down", "Last 24 h", "ms · jitter band"). Renders a
13
+ <span class="unit"> so the .unit rule in @w5-ui/tokens/dashboard.css sets
14
+ the canonical muted colour, 12 px size, and inline-block rhythm. Stops
15
+ userland reaching for inline font-size / color overrides next to bare
16
+ numbers. Pair with <Stat> (which already wires this internally) for hero
17
+ tiles, or compose by hand inside a <Card> when the number sits above a
18
+ chart.
19
+ -->
20
+ <span class="unit {className}">
21
+ {@render children?.()}
22
+ </span>
@@ -14,7 +14,7 @@
14
14
  height = 400,
15
15
  label = undefined,
16
16
  overscan = 3,
17
- children
17
+ children,
18
18
  }: Props = $props();
19
19
 
20
20
  let scrollTop = $state(0);
@@ -22,27 +22,30 @@
22
22
  let totalHeight = $derived(items.length * itemHeight);
23
23
  let firstVisible = $derived(Math.floor(scrollTop / itemHeight));
24
24
  let startIndex = $derived(Math.max(0, firstVisible - overscan));
25
- let endIndex = $derived(Math.min(items.length, firstVisible + Math.ceil(height / itemHeight) + overscan + 1));
26
- let visible = $derived(items.slice(startIndex, endIndex).map((item, i) => ({ item, index: startIndex + i })));
25
+ let endIndex = $derived(
26
+ Math.min(items.length, firstVisible + Math.ceil(height / itemHeight) + overscan + 1),
27
+ );
28
+ let visible = $derived(
29
+ items.slice(startIndex, endIndex).map((item, i) => ({ item, index: startIndex + i })),
30
+ );
27
31
  </script>
28
32
 
29
33
  <div
30
34
  role="list"
31
35
  aria-label={label}
32
- class="vl"
36
+ class="relative overflow-y-auto"
33
37
  style:height="{height}px"
34
- style:overflow-y="auto"
35
38
  onscroll={(e) => (scrollTop = e.currentTarget.scrollTop)}
36
39
  >
37
- <div style:height="{totalHeight}px" style:position="relative">
38
- <div style:position="absolute" style:top="{startIndex * itemHeight}px" style:width="100%">
40
+ <div class="relative" style:height="{totalHeight}px">
41
+ <div class="absolute left-0 right-0" style:top="{startIndex * itemHeight}px">
39
42
  {#each visible as { item, index } (index)}
40
43
  <div
41
44
  role="listitem"
45
+ class="overflow-hidden"
42
46
  style:height="{itemHeight}px"
43
- style:overflow="hidden"
44
47
  >
45
- {@render children?.({ item, index, })}
48
+ {@render children?.({ item, index })}
46
49
  </div>
47
50
  {/each}
48
51
  </div>
package/src/lib/index.ts CHANGED
@@ -28,6 +28,7 @@ export { default as Signal } from './components/Signal.svelte'
28
28
  export { default as StatusIndicator } from './components/StatusIndicator.svelte'
29
29
  export { default as Donut } from './components/Donut.svelte'
30
30
  export { default as Topbar } from './components/Topbar.svelte'
31
+ export type { AppDef, TopbarStatus } from './components/Topbar.svelte'
31
32
  export { default as Sidebar } from './components/Sidebar.svelte'
32
33
  export type { SidebarSectionDef, SidebarItemDef } from './components/Sidebar.svelte'
33
34
  export { default as Skeleton } from './components/Skeleton.svelte'
@@ -271,5 +272,41 @@ export type {
271
272
  GroupedListGroup,
272
273
  GroupedListItemStatus,
273
274
  } from './components/GroupedList.svelte'
275
+ export { default as PageHeader } from './components/PageHeader.svelte'
276
+ export { default as AppLayout } from './components/AppLayout.svelte'
277
+ export { default as Grid } from './components/Grid.svelte'
278
+ export { default as FieldStack } from './components/FieldStack.svelte'
279
+ export { default as FormRow } from './components/FormRow.svelte'
280
+ export { default as FormActions } from './components/FormActions.svelte'
281
+ export { default as TableShell } from './components/TableShell.svelte'
282
+ export { default as TablePager } from './components/TablePager.svelte'
283
+ export { default as Submeta } from './components/Submeta.svelte'
284
+ export { default as Delta } from './components/Delta.svelte'
285
+ export { default as CardMore } from './components/CardMore.svelte'
286
+ export { default as Unit } from './components/Unit.svelte'
287
+ export { default as StatNumber } from './components/StatNumber.svelte'
288
+ export { default as CardTitle } from './components/CardTitle.svelte'
289
+ export { default as Mac } from './components/Mac.svelte'
290
+ export { default as NameCell } from './components/NameCell.svelte'
291
+ export { default as SrOnly } from './components/SrOnly.svelte'
292
+ export { default as NotifDot } from './components/NotifDot.svelte'
293
+ export { default as StatusRing } from './components/StatusRing.svelte'
294
+ export { default as TopbarActions } from './components/TopbarActions.svelte'
295
+ export { default as Muted } from './components/Muted.svelte'
296
+ export { default as Code } from './components/Code.svelte'
297
+ export { default as Eyebrow } from './components/Eyebrow.svelte'
298
+ export { default as Mono } from './components/Mono.svelte'
299
+ export { default as Caption } from './components/Caption.svelte'
300
+ export { default as BodyLg } from './components/BodyLg.svelte'
301
+ export { default as Body } from './components/Body.svelte'
302
+ export { default as Label } from './components/Label.svelte'
303
+ export { default as Display1 } from './components/Display1.svelte'
304
+ export { default as Display2 } from './components/Display2.svelte'
305
+ export { default as H1 } from './components/H1.svelte'
306
+ export { default as H2 } from './components/H2.svelte'
307
+ export { default as H3 } from './components/H3.svelte'
308
+ export { default as H4 } from './components/H4.svelte'
274
309
 
275
310
  export * from './icons.js'
311
+
312
+ export { portal } from './actions/portal.ts'