@spaethtech/svelte-ui 0.1.10 → 0.3.1-dev.10.ff03a8d

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 (218) hide show
  1. package/.claude/skills/svelte-ui/SKILL.md +118 -0
  2. package/.claude/skills/themes/SKILL.md +303 -0
  3. package/CLAUDE.md +248 -0
  4. package/README.md +180 -42
  5. package/dist/components/Alert.svelte +119 -0
  6. package/dist/components/Alert.svelte.d.ts +29 -0
  7. package/dist/components/Badge/Badge.svelte +142 -69
  8. package/dist/components/Badge/Badge.svelte.d.ts +26 -6
  9. package/dist/components/Badge/index.d.ts +1 -1
  10. package/dist/components/Badge/index.js +1 -1
  11. package/dist/components/Banner.svelte +133 -0
  12. package/dist/components/Banner.svelte.d.ts +31 -0
  13. package/dist/components/Button.svelte +240 -0
  14. package/dist/components/Button.svelte.d.ts +33 -0
  15. package/dist/components/ButtonDropdown.svelte +145 -0
  16. package/dist/components/ButtonDropdown.svelte.d.ts +31 -0
  17. package/dist/components/Calendar.svelte +259 -0
  18. package/dist/components/Calendar.svelte.d.ts +27 -0
  19. package/dist/components/Card.svelte +79 -0
  20. package/dist/components/Card.svelte.d.ts +17 -0
  21. package/dist/components/CardBody.svelte +41 -0
  22. package/dist/components/CardBody.svelte.d.ts +17 -0
  23. package/dist/components/CardFooter.svelte +51 -0
  24. package/dist/components/CardFooter.svelte.d.ts +16 -0
  25. package/dist/components/CardHeader.svelte +51 -0
  26. package/dist/components/CardHeader.svelte.d.ts +16 -0
  27. package/dist/components/Checkbox.svelte +131 -0
  28. package/dist/components/Checkbox.svelte.d.ts +16 -0
  29. package/dist/components/ConfirmDialog.svelte +103 -0
  30. package/dist/components/ConfirmDialog.svelte.d.ts +33 -0
  31. package/dist/components/DataTable.svelte +518 -0
  32. package/dist/components/DataTable.svelte.d.ts +74 -0
  33. package/dist/components/DatePicker.svelte +312 -0
  34. package/dist/components/DatePicker.svelte.d.ts +32 -0
  35. package/dist/components/DateTimeInput.svelte +93 -0
  36. package/dist/components/DateTimeInput.svelte.d.ts +20 -0
  37. package/dist/components/Dialog.svelte +151 -0
  38. package/dist/components/Dialog.svelte.d.ts +30 -0
  39. package/dist/components/{EmailInput/EmailInput.svelte → EmailInput.svelte} +17 -8
  40. package/dist/components/EmailInput.svelte.d.ts +21 -0
  41. package/dist/components/Input.svelte +369 -0
  42. package/dist/components/Input.svelte.d.ts +35 -0
  43. package/dist/components/{List/List.svelte → List.svelte} +194 -133
  44. package/dist/components/{List/List.svelte.d.ts → List.svelte.d.ts} +9 -1
  45. package/dist/components/Menu.svelte +117 -0
  46. package/dist/components/Menu.svelte.d.ts +20 -0
  47. package/dist/components/NotesEditor.svelte +127 -0
  48. package/dist/components/NotesEditor.svelte.d.ts +17 -0
  49. package/dist/components/{NumberInput/NumberInput.svelte → NumberInput.svelte} +146 -105
  50. package/dist/components/{NumberInput/NumberInput.svelte.d.ts → NumberInput.svelte.d.ts} +11 -4
  51. package/dist/components/PasswordInput.svelte +52 -0
  52. package/dist/components/PasswordInput.svelte.d.ts +20 -0
  53. package/dist/components/Popup.svelte +140 -0
  54. package/dist/components/Popup.svelte.d.ts +31 -0
  55. package/dist/components/Query.svelte +284 -0
  56. package/dist/components/Query.svelte.d.ts +15 -0
  57. package/dist/components/{Rating/Rating.svelte → Rating.svelte} +19 -10
  58. package/dist/components/Rating.svelte.d.ts +13 -0
  59. package/dist/components/{SearchInput/SearchInput.svelte → SearchInput.svelte} +19 -8
  60. package/dist/components/{SearchInput/SearchInput.svelte.d.ts → SearchInput.svelte.d.ts} +9 -2
  61. package/dist/components/{Select/Select.svelte → Select.svelte} +48 -76
  62. package/dist/components/{Select/Select.svelte.d.ts → Select.svelte.d.ts} +11 -1
  63. package/dist/components/SideBarMenu/SideBarMenu.svelte +724 -0
  64. package/dist/components/SideBarMenu/SideBarMenu.svelte.d.ts +109 -0
  65. package/dist/components/SideBarMenu/index.d.ts +2 -0
  66. package/dist/components/SideBarMenu/index.js +1 -0
  67. package/dist/components/TabStrip/TabStrip.svelte +384 -0
  68. package/dist/components/TabStrip/TabStrip.svelte.d.ts +50 -0
  69. package/dist/components/TabStrip/index.d.ts +3 -0
  70. package/dist/components/TabStrip/index.js +2 -0
  71. package/dist/components/{TextArea/TextArea.svelte → TextArea.svelte} +143 -112
  72. package/dist/components/TextArea.svelte.d.ts +38 -0
  73. package/dist/components/ThemeSelector.svelte +81 -0
  74. package/dist/components/ThemeSelector.svelte.d.ts +10 -0
  75. package/dist/components/TimePicker.svelte +148 -0
  76. package/dist/components/TimePicker.svelte.d.ts +28 -0
  77. package/dist/components/TimeRangeInput.svelte +203 -0
  78. package/dist/components/TimeRangeInput.svelte.d.ts +29 -0
  79. package/dist/components/TimeSpinner.svelte +202 -0
  80. package/dist/components/TimeSpinner.svelte.d.ts +26 -0
  81. package/dist/components/Toast/Toaster.svelte +51 -0
  82. package/dist/components/Toast/Toaster.svelte.d.ts +12 -0
  83. package/dist/components/Toast/toast.svelte.d.ts +29 -0
  84. package/dist/components/Toast/toast.svelte.js +27 -0
  85. package/dist/components/Toggle.svelte +93 -0
  86. package/dist/components/Toggle.svelte.d.ts +15 -0
  87. package/dist/data/dataset.d.ts +42 -0
  88. package/dist/data/dataset.js +3 -0
  89. package/dist/data/index.d.ts +3 -0
  90. package/dist/data/index.js +3 -0
  91. package/dist/data/query/ast.d.ts +62 -0
  92. package/dist/data/query/ast.js +12 -0
  93. package/dist/data/query/index.d.ts +3 -0
  94. package/dist/data/query/index.js +3 -0
  95. package/dist/data/query/lexer.d.ts +33 -0
  96. package/dist/data/query/lexer.js +225 -0
  97. package/dist/data/query/parser.d.ts +11 -0
  98. package/dist/data/query/parser.js +252 -0
  99. package/dist/data/table/grid.svelte.d.ts +57 -0
  100. package/dist/data/table/grid.svelte.js +139 -0
  101. package/dist/data/table/index.d.ts +2 -0
  102. package/dist/data/table/index.js +2 -0
  103. package/dist/data/table/types.d.ts +79 -0
  104. package/dist/index.d.ts +49 -22
  105. package/dist/index.js +46 -21
  106. package/dist/positioning/anchored.d.ts +61 -0
  107. package/dist/positioning/anchored.js +122 -0
  108. package/dist/positioning/tooltip.d.ts +41 -0
  109. package/dist/positioning/tooltip.js +147 -0
  110. package/dist/theme.css +205 -0
  111. package/dist/types/breakpoints.d.ts +24 -0
  112. package/dist/types/breakpoints.js +13 -0
  113. package/dist/types/responsive.d.ts +32 -0
  114. package/dist/types/responsive.js +54 -0
  115. package/dist/types/sizes.d.ts +10 -3
  116. package/dist/types/time.d.ts +19 -0
  117. package/dist/types/time.js +10 -0
  118. package/dist/types/variants.d.ts +8 -1
  119. package/dist/types/variants.js +16 -1
  120. package/docs/components.md +255 -0
  121. package/docs/examples.md +323 -0
  122. package/docs/paradigm.md +240 -0
  123. package/docs/themes.md +170 -0
  124. package/docs/usage.md +450 -0
  125. package/package.json +97 -63
  126. package/dist/components/Button/Button.svelte +0 -172
  127. package/dist/components/Button/Button.svelte.d.ts +0 -32
  128. package/dist/components/Button/index.d.ts +0 -1
  129. package/dist/components/Button/index.js +0 -1
  130. package/dist/components/Dialog/Dialog.svelte +0 -101
  131. package/dist/components/Dialog/Dialog.svelte.d.ts +0 -18
  132. package/dist/components/Dialog/index.d.ts +0 -1
  133. package/dist/components/Dialog/index.js +0 -1
  134. package/dist/components/ElementManager/ElementManager.svelte +0 -397
  135. package/dist/components/ElementManager/ElementManager.svelte.d.ts +0 -43
  136. package/dist/components/ElementManager/index.d.ts +0 -1
  137. package/dist/components/ElementManager/index.js +0 -1
  138. package/dist/components/EmailInput/EmailInput.svelte.d.ts +0 -14
  139. package/dist/components/EmailInput/index.d.ts +0 -1
  140. package/dist/components/EmailInput/index.js +0 -1
  141. package/dist/components/Icon/Icon.svelte +0 -74
  142. package/dist/components/Icon/Icon.svelte.d.ts +0 -13
  143. package/dist/components/Icon/index.d.ts +0 -1
  144. package/dist/components/Icon/index.js +0 -1
  145. package/dist/components/Input/Input.svelte +0 -268
  146. package/dist/components/Input/Input.svelte.d.ts +0 -18
  147. package/dist/components/Input/index.d.ts +0 -1
  148. package/dist/components/Input/index.js +0 -1
  149. package/dist/components/List/index.d.ts +0 -1
  150. package/dist/components/List/index.js +0 -1
  151. package/dist/components/ListItem/ListItem.svelte +0 -175
  152. package/dist/components/ListItem/ListItem.svelte.d.ts +0 -24
  153. package/dist/components/ListItem/index.d.ts +0 -2
  154. package/dist/components/ListItem/index.js +0 -2
  155. package/dist/components/ListView/ListView.svelte +0 -463
  156. package/dist/components/ListView/ListView.svelte.d.ts +0 -37
  157. package/dist/components/ListView/index.d.ts +0 -2
  158. package/dist/components/ListView/index.js +0 -2
  159. package/dist/components/NodeGraph/BaseNode.d.ts +0 -57
  160. package/dist/components/NodeGraph/BaseNode.js +0 -30
  161. package/dist/components/NodeGraph/Edge.svelte +0 -60
  162. package/dist/components/NodeGraph/Edge.svelte.d.ts +0 -16
  163. package/dist/components/NodeGraph/ExpressionEvaluator.d.ts +0 -82
  164. package/dist/components/NodeGraph/ExpressionEvaluator.js +0 -152
  165. package/dist/components/NodeGraph/Node.svelte +0 -100
  166. package/dist/components/NodeGraph/Node.svelte.d.ts +0 -10
  167. package/dist/components/NodeGraph/NodeGraph.svelte +0 -52
  168. package/dist/components/NodeGraph/NodeGraph.svelte.d.ts +0 -14
  169. package/dist/components/NodeGraph/NodeInstance.svelte.d.ts +0 -80
  170. package/dist/components/NodeGraph/NodeInstance.svelte.js +0 -241
  171. package/dist/components/NodeGraph/NodePort.svelte +0 -75
  172. package/dist/components/NodeGraph/NodePort.svelte.d.ts +0 -11
  173. package/dist/components/NodeGraph/decorators.d.ts +0 -81
  174. package/dist/components/NodeGraph/decorators.js +0 -148
  175. package/dist/components/NodeGraph/index.d.ts +0 -9
  176. package/dist/components/NodeGraph/index.js +0 -9
  177. package/dist/components/NodeGraph/types.d.ts +0 -94
  178. package/dist/components/NodeGraph/types.js +0 -33
  179. package/dist/components/NotesEditor/NotesEditor.svelte +0 -203
  180. package/dist/components/NotesEditor/NotesEditor.svelte.d.ts +0 -9
  181. package/dist/components/NotesEditor/index.d.ts +0 -1
  182. package/dist/components/NotesEditor/index.js +0 -1
  183. package/dist/components/NumberInput/index.d.ts +0 -1
  184. package/dist/components/NumberInput/index.js +0 -1
  185. package/dist/components/PasswordInput/PasswordInput.svelte +0 -41
  186. package/dist/components/PasswordInput/PasswordInput.svelte.d.ts +0 -13
  187. package/dist/components/PasswordInput/index.d.ts +0 -1
  188. package/dist/components/PasswordInput/index.js +0 -1
  189. package/dist/components/Popup/index.d.ts +0 -1
  190. package/dist/components/Popup/index.js +0 -1
  191. package/dist/components/Rating/Rating.svelte.d.ts +0 -7
  192. package/dist/components/Rating/index.d.ts +0 -1
  193. package/dist/components/Rating/index.js +0 -1
  194. package/dist/components/ScrollView/ScrollView.svelte +0 -285
  195. package/dist/components/ScrollView/ScrollView.svelte.d.ts +0 -19
  196. package/dist/components/ScrollView/index.d.ts +0 -1
  197. package/dist/components/ScrollView/index.js +0 -1
  198. package/dist/components/SearchInput/index.d.ts +0 -1
  199. package/dist/components/SearchInput/index.js +0 -1
  200. package/dist/components/Select/index.d.ts +0 -1
  201. package/dist/components/Select/index.js +0 -1
  202. package/dist/components/TextArea/TextArea.svelte.d.ts +0 -19
  203. package/dist/components/TextArea/index.d.ts +0 -1
  204. package/dist/components/TextArea/index.js +0 -1
  205. package/dist/components/ThemeSelector/ThemeSelector.svelte +0 -64
  206. package/dist/components/ThemeSelector/ThemeSelector.svelte.d.ts +0 -3
  207. package/dist/components/ThemeSelector/index.d.ts +0 -1
  208. package/dist/components/ThemeSelector/index.js +0 -1
  209. package/dist/components/TooltipHandler/TooltipHandler.svelte +0 -593
  210. package/dist/components/TooltipHandler/TooltipHandler.svelte.d.ts +0 -10
  211. package/dist/components/TooltipHandler/index.d.ts +0 -1
  212. package/dist/components/TooltipHandler/index.js +0 -1
  213. package/dist/styles/sizes.d.ts +0 -78
  214. package/dist/styles/sizes.js +0 -120
  215. package/dist/styles/variants.d.ts +0 -106
  216. package/dist/styles/variants.js +0 -194
  217. package/dist/types/ManagerTypes.d.ts +0 -42
  218. /package/dist/{types/ManagerTypes.js → data/table/types.js} +0 -0
@@ -0,0 +1,724 @@
1
+ <!--
2
+ /**
3
+ * SideBarMenu — a vertical nav strip with a top-anchored group, an optional
4
+ * bottom-anchored group (pushed to the strip's bottom edge), and hover-open
5
+ * popouts for parent items.
6
+ *
7
+ * Driven by data — pass `topItems` / `bottomItems` arrays of {@link SideBarMenuItem}.
8
+ * Each item declares its own icon (Snippet), label, link, badge, and optional
9
+ * children. Children render in a top-layer Popup on hover or click.
10
+ *
11
+ * See `SideBarMenu.spec.md` for the full API + behavior contract.
12
+ */
13
+ -->
14
+ <script lang="ts" module>
15
+ /**
16
+ * Visual / interaction mode at a given viewport tier.
17
+ *
18
+ * - `stepped` — `position: absolute`, slides in from the left,
19
+ * full-width. Parent items use click-to-step
20
+ * navigation (no hover popouts). Requires a
21
+ * hamburger trigger. Designed for phones.
22
+ * - `drawer` — `position: absolute`, slides in from the left,
23
+ * `--sbm-expanded-w` wide. Parent items show hover
24
+ * popouts to the right. Requires a hamburger.
25
+ * Designed for tablet portrait / phablet.
26
+ * - `icon` — `position: relative` (in flow), `--sbm-collapsed-w`
27
+ * wide, icon-only rows. Parent items show hover
28
+ * popouts. Designed for tablet landscape / narrow
29
+ * desktop.
30
+ * - `expanded` — `position: relative`, `--sbm-expanded-w` wide,
31
+ * icon + label rows. Parent items show hover popouts.
32
+ * Designed for wide desktop.
33
+ */
34
+ export type SideBarMenuMode = "stepped" | "drawer" | "icon" | "expanded";
35
+
36
+ export type SideBarMenuBadgeVariant = "error" | "warning" | "info" | "success";
37
+
38
+ export type SideBarMenuBadge = {
39
+ /** Color of the indicator. */
40
+ variant: SideBarMenuBadgeVariant;
41
+ /**
42
+ * Optional numeric count. Present → filled pill with the number.
43
+ * Absent → bare icon (variant-coloured). Useful for "has-any" signals
44
+ * where the precise count isn't meaningful to the user.
45
+ */
46
+ count?: number;
47
+ };
48
+
49
+ /**
50
+ * Icon component slot. Typed permissively because `unplugin-icons` emits
51
+ * its own callable shape (`typeof default`) that doesn't satisfy Svelte's
52
+ * strict `Component<…>` primitive in every consumer tsconfig. The runtime
53
+ * shape is always a Svelte component the renderer can `<Icon />` — TS just
54
+ * can't always prove it.
55
+ */
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ export type SideBarMenuItemIcon = any;
58
+
59
+ export type SideBarMenuItem = {
60
+ /**
61
+ * Icon component constructor — same idiom as `MenuItem.icon`. Pass an
62
+ * imported MDI icon (e.g. `import IconActivity from '~icons/mdi/pulse'`)
63
+ * straight into the data array; no snippet wrapper needed.
64
+ */
65
+ icon?: SideBarMenuItemIcon;
66
+ label: string;
67
+ /** href; absent when the item is a parent that only hosts children. */
68
+ link?: string;
69
+ badge?: SideBarMenuBadge;
70
+ /** v1 renders only the first level; deeper levels emit a DEV warning. */
71
+ children?: SideBarMenuItem[];
72
+ };
73
+ </script>
74
+
75
+ <script lang="ts">
76
+ import { DEV } from "esm-env";
77
+ import Popup from "../Popup.svelte";
78
+ import { tooltip } from "../../positioning/tooltip.js";
79
+ import IconAlert from "~icons/mdi/alert";
80
+ import IconCloseOctagon from "~icons/mdi/close-octagon";
81
+ import IconInformation from "~icons/mdi/information";
82
+ import IconCheckCircle from "~icons/mdi/check-circle";
83
+ import IconMenu from "~icons/mdi/menu";
84
+ import IconClose from "~icons/mdi/close";
85
+ import IconChevronRight from "~icons/mdi/chevron-right";
86
+ import type { Size } from "../../types/sizes.js";
87
+ import { BREAKPOINT_PX, type Breakpoint } from "../../types/breakpoints.js";
88
+
89
+ type ModesMap = Partial<Record<Breakpoint, SideBarMenuMode>>;
90
+
91
+ let {
92
+ topItems = [],
93
+ bottomItems = [],
94
+ currentPath,
95
+ size = "md",
96
+ modes,
97
+ collapsedAt = "lg",
98
+ collapsedWidth = 48,
99
+ expandedWidth = 200,
100
+ open = $bindable(false),
101
+ stepLevel = $bindable(0),
102
+ hamburger = true,
103
+ class: additionalClass = "",
104
+ }: {
105
+ topItems?: SideBarMenuItem[];
106
+ bottomItems?: SideBarMenuItem[];
107
+ currentPath: string;
108
+ size?: Size;
109
+ /**
110
+ * Per-tier visual mode. Each key in this map sets the mode at and
111
+ * above that breakpoint. The active mode at any viewport is the
112
+ * mode for the highest matching tier.
113
+ *
114
+ * When omitted, defaults to
115
+ * `{ base: 'stepped', '2xs': 'drawer', xs: 'icon', [collapsedAt]: 'expanded' }`
116
+ * so the prop is opt-in for consumers happy with the standard
117
+ * phone→tablet→desktop progression.
118
+ */
119
+ modes?: ModesMap;
120
+ /**
121
+ * Legacy shorthand: which tier the strip expands to icon + label
122
+ * at when `modes` isn't supplied. Equivalent to overriding only
123
+ * the `expanded` slot in the default `modes` map. Ignored when
124
+ * `modes` is set explicitly. Prefer passing `modes` directly.
125
+ */
126
+ collapsedAt?: Exclude<Breakpoint, "base" | "4xs" | "3xs" | "2xs" | "xs">;
127
+ /** Pixel width of the strip in `icon` mode. */
128
+ collapsedWidth?: number;
129
+ /** Pixel width of the strip in `expanded` mode and the slide-in drawer when open. */
130
+ expandedWidth?: number;
131
+ /**
132
+ * Drawer open state when the active mode is `drawer` or `stepped`.
133
+ * Bind from the consumer to drive it from your own header button
134
+ * (set `hamburger={false}` to suppress the built-in trigger).
135
+ * Ignored in `icon` and `expanded` modes (strip is always in-layout).
136
+ */
137
+ open?: boolean;
138
+ /**
139
+ * Step-navigation level inside the `stepped`-mode drawer.
140
+ * `0` = top-level. `1` = inside a parent item's children list.
141
+ * Bind from the consumer so a header button can read it (e.g.
142
+ * to flip X → ← icon) and write it (set to `0` to navigate back).
143
+ */
144
+ stepLevel?: number;
145
+ /**
146
+ * Render the built-in fixed-position hamburger button when the
147
+ * active mode is `drawer` or `stepped`. Set to `false` if the
148
+ * consumer renders its own trigger (typically inside a header)
149
+ * and binds to `open`.
150
+ */
151
+ hamburger?: boolean;
152
+ class?: string;
153
+ } = $props();
154
+
155
+ // Default mode map — replicates the pre-modes behavior so old
156
+ // consumers continue working unchanged. `collapsedAt` plugs into the
157
+ // `expanded` slot so the legacy prop still tunes where labels appear.
158
+ const effectiveModes = $derived<ModesMap>(
159
+ modes ?? {
160
+ base: "stepped",
161
+ "2xs": "drawer",
162
+ xs: "icon",
163
+ [collapsedAt]: "expanded",
164
+ },
165
+ );
166
+
167
+ function resolveMode(map: ModesMap, viewportPx: number): SideBarMenuMode {
168
+ const tiers = (Object.keys(map) as Breakpoint[]).sort(
169
+ (a, b) => BREAKPOINT_PX[a] - BREAKPOINT_PX[b],
170
+ );
171
+ let active: SideBarMenuMode = "expanded";
172
+ for (const tier of tiers) {
173
+ if (viewportPx >= BREAKPOINT_PX[tier]) {
174
+ const m = map[tier];
175
+ if (m) active = m;
176
+ }
177
+ }
178
+ return active;
179
+ }
180
+
181
+ // SSR-safe default — the effect below corrects on mount.
182
+ let currentMode = $state<SideBarMenuMode>("expanded");
183
+
184
+ $effect(() => {
185
+ const map = effectiveModes;
186
+ function sync() {
187
+ currentMode = resolveMode(map, window.innerWidth);
188
+ }
189
+ // One matchMedia listener per tier in the map. Cheaper than a
190
+ // general resize handler — only fires when the active tier
191
+ // actually flips.
192
+ const tiers = (Object.keys(map) as Breakpoint[]).filter((t) => t !== "base");
193
+ const queries = tiers.map((t) => window.matchMedia(`(min-width: ${BREAKPOINT_PX[t]}px)`));
194
+ sync();
195
+ queries.forEach((q) => q.addEventListener("change", sync));
196
+ return () => queries.forEach((q) => q.removeEventListener("change", sync));
197
+ });
198
+
199
+ const isStepped = $derived(currentMode === "stepped");
200
+ const isDrawer = $derived(currentMode === "drawer" || currentMode === "stepped");
201
+ const labelsVisible = $derived(currentMode !== "icon");
202
+
203
+ // ── DEV depth warning ───────────────────────────────────────────
204
+ if (DEV) {
205
+ const walk = (items: SideBarMenuItem[], depth: number) => {
206
+ for (const it of items) {
207
+ if (it.children?.length) {
208
+ if (depth >= 1) {
209
+ console.warn(
210
+ 'SideBarMenu: nesting deeper than 1 level is not yet rendered; flattening grandchildren of "' +
211
+ it.label +
212
+ '"',
213
+ );
214
+ return;
215
+ }
216
+ walk(it.children, depth + 1);
217
+ }
218
+ }
219
+ };
220
+ walk(topItems, 0);
221
+ walk(bottomItems, 0);
222
+ }
223
+
224
+ // ── Size axis ───────────────────────────────────────────────────
225
+ // All sub-element sizes derive from the icon size:
226
+ // - Badge icon ≈ icon × 0.5 (warning/error/info/success glyph)
227
+ // - Count pill ≈ icon × 0.6 (a touch bigger to leave room for digits)
228
+ // - Offsets = (boxSize − iconSize) / 2 − badgeSize / 2 so the
229
+ // badge centers on the icon's top-right corner
230
+ // - Chevron scales with the main icon (sm 14 / md 16 / lg 20)
231
+ type SizeStyles = {
232
+ row: string;
233
+ iconBox: string;
234
+ iconSvg: string;
235
+ text: string;
236
+ badgeIcon: string; // [&_svg]:w-N [&_svg]:h-N for warning/error glyph
237
+ badgeIconOffset: string; // absolute top-[N] right-[N] for the icon badge
238
+ pill: string; // min-w + h literal for the count pill
239
+ pillText: string; // text-[Npx] for the count digits
240
+ pillOffset: string; // absolute top-[N] right-[N] for the count pill
241
+ chevronBox: string; // wrapper around the parent-row chevron
242
+ chevronSvg: string; // chevron SVG sizing
243
+ };
244
+ const sizeMap: Record<Size, SizeStyles> = {
245
+ sm: {
246
+ row: "h-10",
247
+ iconBox: "w-10 h-10",
248
+ iconSvg: "[&_svg]:w-5 [&_svg]:h-5",
249
+ text: "text-xs",
250
+ // Badge dot scales roughly 1/3 of the main icon: solid circle
251
+ // that occludes the corner. Span is sized directly (not via
252
+ // descendant svg selector) so it's exactly as specified.
253
+ badgeIcon: "w-1.5 h-1.5",
254
+ badgeIconOffset: "top-[7px] right-[7px]",
255
+ pill: "min-w-[12px] h-[12px]",
256
+ pillText: "text-[8px]",
257
+ pillOffset: "top-[4px] right-[4px]",
258
+ chevronBox: "w-5 h-5",
259
+ chevronSvg: "[&_svg]:w-3.5 [&_svg]:h-3.5",
260
+ },
261
+ md: {
262
+ row: "h-12",
263
+ iconBox: "w-12 h-12",
264
+ iconSvg: "[&_svg]:w-6 [&_svg]:h-6",
265
+ text: "text-sm",
266
+ badgeIcon: "w-2 h-2",
267
+ badgeIconOffset: "top-[8px] right-[8px]",
268
+ pill: "min-w-[14px] h-[14px]",
269
+ pillText: "text-[9px]",
270
+ pillOffset: "top-[5px] right-[5px]",
271
+ chevronBox: "w-6 h-6",
272
+ chevronSvg: "[&_svg]:w-4 [&_svg]:h-4",
273
+ },
274
+ lg: {
275
+ row: "h-14",
276
+ iconBox: "w-14 h-14",
277
+ iconSvg: "[&_svg]:w-7 [&_svg]:h-7",
278
+ text: "text-base",
279
+ badgeIcon: "w-2.5 h-2.5",
280
+ badgeIconOffset: "top-[9px] right-[9px]",
281
+ pill: "min-w-[16px] h-[16px]",
282
+ pillText: "text-[10px]",
283
+ pillOffset: "top-[6px] right-[6px]",
284
+ chevronBox: "w-7 h-7",
285
+ chevronSvg: "[&_svg]:w-5 [&_svg]:h-5",
286
+ },
287
+ };
288
+ const sz = $derived(sizeMap[size]);
289
+
290
+ // ── Mobile drawer state ─────────────────────────────────────────
291
+ // Below `xs` the strip collapses to `width: 0` and either the built-in
292
+ // hamburger (default) or a consumer-provided trigger toggles a slide-in
293
+ // drawer. At `xs+` the strip is always visible in-layout and `open` is
294
+ // irrelevant. At `<2xs` the drawer is full-width and parent items use
295
+ // click-to-step navigation instead of hover popouts.
296
+ function closeDrawer() {
297
+ open = false;
298
+ }
299
+
300
+ // Reset every transient drawer state — used when a leaf navigates,
301
+ // so the next time the user opens the drawer they're back at the
302
+ // top of the menu (not still inside a stepped parent's children) and
303
+ // no popout is open.
304
+ function closeAfterNav() {
305
+ open = false;
306
+ stepLevel = 0;
307
+ openId = null;
308
+ }
309
+
310
+ // The parent whose children the drawer is currently displaying (when
311
+ // `stepLevel === 1`). Reset to null whenever stepLevel returns to 0
312
+ // or the drawer closes — the next step starts fresh from the top.
313
+ let currentParent = $state<SideBarMenuItem | null>(null);
314
+
315
+ $effect(() => {
316
+ if (stepLevel === 0) currentParent = null;
317
+ });
318
+ $effect(() => {
319
+ if (!open) {
320
+ stepLevel = 0;
321
+ currentParent = null;
322
+ }
323
+ });
324
+
325
+ function stepIntoChildren(item: SideBarMenuItem) {
326
+ currentParent = item;
327
+ stepLevel = 1;
328
+ }
329
+
330
+ // Label / chevron visibility are now driven by the active mode
331
+ // instead of by responsive class strings — the matchMedia effect
332
+ // above keeps `currentMode` (and therefore `labelsVisible`) in sync
333
+ // with the viewport. The chevron span needs `inline-flex` (not
334
+ // `inline`) so its SVG stays centered; otherwise the same logic.
335
+ const labelClass = $derived(labelsVisible ? "inline" : "hidden");
336
+ const chevronVisibility = $derived(labelsVisible ? "inline-flex" : "hidden");
337
+ // The corresponding "label is currently visible" check used to disable
338
+ // tooltips at the expanded breakpoint. We don't have direct access to
339
+ // the media query here without an effect, so we always pass the tooltip
340
+ // the label text and let the tooltip's own `disabled` option be flipped
341
+ // off by the popout-open state; when the label is on-screen the user
342
+ // has no reason to hover for it anyway.
343
+
344
+ // ── Active state ────────────────────────────────────────────────
345
+ function isItemActive(item: SideBarMenuItem): boolean {
346
+ if (item.link && currentPath.startsWith(item.link)) return true;
347
+ if (item.children?.length) return item.children.some(isItemActive);
348
+ return false;
349
+ }
350
+
351
+ // ── Hover/click open state ──────────────────────────────────────
352
+ type ItemId = `top:${number}` | `bottom:${number}`;
353
+
354
+ let openId = $state<ItemId | null>(null);
355
+ let closeTimer: ReturnType<typeof setTimeout> | null = null;
356
+ const CLOSE_GRACE_MS = 150;
357
+
358
+ function cancelClose() {
359
+ if (closeTimer !== null) {
360
+ clearTimeout(closeTimer);
361
+ closeTimer = null;
362
+ }
363
+ }
364
+ function scheduleClose() {
365
+ cancelClose();
366
+ closeTimer = setTimeout(() => {
367
+ openId = null;
368
+ closeTimer = null;
369
+ }, CLOSE_GRACE_MS);
370
+ }
371
+ function openOnHover(id: ItemId) {
372
+ cancelClose();
373
+ if (openId !== id) openId = id;
374
+ }
375
+ function toggleOnClick(id: ItemId) {
376
+ cancelClose();
377
+ openId = openId === id ? null : id;
378
+ }
379
+
380
+ // ── Refs ────────────────────────────────────────────────────────
381
+ let asideRef = $state<HTMLElement | undefined>();
382
+ // One ref per parent trigger. Keyed by ItemId so Popup can anchor.
383
+ const triggerRefs = $state<Record<string, HTMLElement | undefined>>({});
384
+
385
+ // Callback-ref action — `bind:this` can't take a {get, set} pair and can't
386
+ // take a ternary expression, so we use a tiny action to wire each row's
387
+ // DOM node into the triggerRefs map by id. No-op when id is undefined
388
+ // (popout-child rows pass no id since they're never anchors themselves).
389
+ function refIntoMap(node: HTMLElement, id: ItemId | undefined) {
390
+ if (id) triggerRefs[id] = node;
391
+ return {
392
+ update(newId: ItemId | undefined) {
393
+ if (newId) triggerRefs[newId] = node;
394
+ },
395
+ destroy() {
396
+ if (id) triggerRefs[id] = undefined;
397
+ },
398
+ };
399
+ }
400
+
401
+ // Spans rule — see SideBarMenu.spec.md → Popout Positioning.
402
+ function spansEdge(group: "top" | "bottom", idx: number, total: number, count: number) {
403
+ // positionFromEnd: how many rows from the anchored edge inclusive.
404
+ const positionFromEnd = group === "bottom" ? total - idx : idx + 1;
405
+ return count >= positionFromEnd;
406
+ }
407
+
408
+ function geometryFor(
409
+ id: ItemId,
410
+ group: "top" | "bottom",
411
+ idx: number,
412
+ total: number,
413
+ count: number,
414
+ ): {
415
+ anchor: HTMLElement | undefined;
416
+ align: "start" | "end";
417
+ borderClass: string;
418
+ } {
419
+ const spans = spansEdge(group, idx, total, count);
420
+ // Border policy:
421
+ // - Right border is always needed (popout meets free content
422
+ // area on the right).
423
+ // - Top/bottom borders are needed only on edges that DON'T
424
+ // coincide with the sidebar's edge. When `spans` is true,
425
+ // the popout's edge on the anchored side is flush with the
426
+ // sidebar's top/bottom — that edge gets no border. The
427
+ // opposite edge always extends into the sidebar's middle
428
+ // vertical range, so it always gets a border.
429
+ // - When `spans` is false the popout sits at the trigger row
430
+ // level; both top and bottom edges are mid-sidebar and need
431
+ // borders.
432
+ // - Left border never — the sidebar's own `border-right`
433
+ // covers that seam.
434
+ // NOTE: very tall popouts that would exceed the sidebar's
435
+ // height get clamped to the viewport by the `anchored` engine
436
+ // boundary. That case also needs in-popout scroll (TODO) and
437
+ // the border rule may need to account for viewport-edge cases.
438
+ let borderClass = "border-r";
439
+ if (group === "bottom") {
440
+ borderClass += spans ? " border-t" : " border-t border-b";
441
+ } else {
442
+ borderClass += spans ? " border-b" : " border-t border-b";
443
+ }
444
+ if (group === "bottom") {
445
+ return spans
446
+ ? { anchor: asideRef, align: "end", borderClass }
447
+ : { anchor: triggerRefs[id], align: "start", borderClass };
448
+ }
449
+ return spans
450
+ ? { anchor: asideRef, align: "start", borderClass }
451
+ : { anchor: triggerRefs[id], align: "end", borderClass };
452
+ }
453
+ </script>
454
+
455
+ <!-- Badge slot — count omitted = solid colored dot covering the icon's
456
+ top-right corner; count present = solid pill with the number. Both
457
+ have an opaque background that occludes the main icon underneath so
458
+ the badge always reads as "on top of" the icon. All sizing /
459
+ positioning comes from `sizeMap` and scales with the `size` prop. -->
460
+ {#snippet badge(b: SideBarMenuBadge)}
461
+ {@const colorVar = `var(--ui-color-${b.variant})`}
462
+ {#if b.count === undefined}
463
+ <span
464
+ class="absolute {sz.badgeIconOffset} inline-block rounded-full {sz.badgeIcon}"
465
+ style="background-color: {colorVar};"
466
+ aria-hidden="true"
467
+ ></span>
468
+ {:else}
469
+ <span
470
+ class="absolute {sz.pillOffset} inline-flex items-center justify-center rounded-full px-1 {sz.pill} {sz.pillText} font-semibold leading-none"
471
+ style="background-color: {colorVar}; color: white;"
472
+ aria-hidden="true"
473
+ >
474
+ {b.count}
475
+ </span>
476
+ {/if}
477
+ {/snippet}
478
+
479
+ <!-- Inner content of a row — icon (+ optional badge), label (truncated
480
+ when wider than the row), and a `>` chevron on parent items so
481
+ the user can see at a glance which items host children. The
482
+ `forceLabel` flag overrides the strip's responsive label-visibility
483
+ rule — popout children and stepped sub-view children always show
484
+ their text labels regardless of the outer strip's collapsed state. -->
485
+ {#snippet rowBody(item: SideBarMenuItem, isParent: boolean, forceLabel: boolean, isOpen: boolean)}
486
+ {@const labelClassResolved = forceLabel ? "inline" : labelClass}
487
+ {@const chevronClass = forceLabel ? "inline-flex" : chevronVisibility}
488
+ <span class="relative inline-flex shrink-0 items-center justify-center {sz.iconBox} {sz.iconSvg}">
489
+ {#if item.icon}{@const Icon = item.icon}<Icon />{/if}
490
+ {#if item.badge}{@render badge(item.badge)}{/if}
491
+ </span>
492
+ <!-- `flex-1 min-w-0 truncate` makes the label take the remaining
493
+ row width AND lets it shrink below its content size, which is
494
+ what `truncate` (overflow-hidden + text-ellipsis) needs to
495
+ actually clip overflow. Without `min-w-0` the label refuses
496
+ to shrink past its natural width and the row overflows. -->
497
+ <span class="flex-1 min-w-0 truncate {labelClassResolved} {sz.text}">{item.label}</span>
498
+ {#if isParent}
499
+ <!-- Chevron — visible only when the label is visible (no point
500
+ dangling a `>` next to an icon-only icon). Rotates 180° to
501
+ `<` whenever the parent is open OR being hovered, with a
502
+ `transition-transform` so it eases in/out. Uses
503
+ `chevronVisibility` (not `labelClassResolved`) so the span
504
+ stays `inline-flex` and the SVG centers vertically. -->
505
+ <span
506
+ class="shrink-0 items-center justify-center opacity-60 {sz.chevronBox} {sz.chevronSvg} {chevronClass}"
507
+ >
508
+ <IconChevronRight />
509
+ </span>
510
+ {/if}
511
+ {/snippet}
512
+
513
+ <!-- Single row template — used by topItems / bottomItems and the popout. -->
514
+ {#snippet row(item: SideBarMenuItem, opts: { id?: ItemId; isPopoutChild?: boolean })}
515
+ {@const active = isItemActive(item)}
516
+ {@const hasChildren = (item.children?.length ?? 0) > 0}
517
+ {@const isParent = hasChildren && !opts.isPopoutChild}
518
+ {@const hasLink = !!item.link}
519
+ {@const isOpen = !!opts.id && openId === opts.id}
520
+ {@const rowClass = `group flex items-center w-full ${sz.row} lg:pr-3 transition-colors cursor-pointer ${active ? "[background-color:var(--ui-color-primary)] text-white" : isOpen ? "[background-color:color-mix(in_srgb,var(--ui-color-text)_10%,transparent)]" : "hover:[background-color:color-mix(in_srgb,var(--ui-color-text)_10%,transparent)]"}`}
521
+ {#if hasLink}
522
+ <a
523
+ href={item.link}
524
+ use:refIntoMap={opts.id}
525
+ class={rowClass}
526
+ aria-current={active ? "page" : undefined}
527
+ use:tooltip={{
528
+ text: item.label,
529
+ side: "right",
530
+ disabled: isParent && opts.id ? openId === opts.id : false,
531
+ }}
532
+ onmouseenter={isParent && opts.id && !isStepped ? () => openOnHover(opts.id!) : undefined}
533
+ onmouseleave={isParent && opts.id && !isStepped ? scheduleClose : undefined}
534
+ onclick={(e) => {
535
+ // Power-user passthrough: Shift/Cmd/Ctrl click opens in
536
+ // a new tab — don't mutate UI state in that case.
537
+ if (e.shiftKey || e.metaKey || e.ctrlKey) return;
538
+ if (isParent && opts.id) {
539
+ if (isStepped) {
540
+ // At <2xs: tap parent → step into its children.
541
+ // Cancel default navigation so the click can't
542
+ // follow the parent's href out from under us.
543
+ e.preventDefault();
544
+ stepIntoChildren(item);
545
+ } else {
546
+ toggleOnClick(opts.id);
547
+ }
548
+ } else {
549
+ // Leaf or popout-child navigation. Reset the drawer
550
+ // so the next open starts at the top, the stepped
551
+ // sub-view doesn't linger, and any open popout closes.
552
+ closeAfterNav();
553
+ }
554
+ }}
555
+ >
556
+ {@render rowBody(item, isParent, !!opts.isPopoutChild, isOpen)}
557
+ </a>
558
+ {:else if isParent}
559
+ <button
560
+ type="button"
561
+ use:refIntoMap={opts.id}
562
+ class="{rowClass} text-left"
563
+ aria-haspopup="menu"
564
+ aria-expanded={openId === opts.id}
565
+ use:tooltip={{ text: item.label, side: "right", disabled: openId === opts.id }}
566
+ onmouseenter={!isStepped ? () => openOnHover(opts.id!) : undefined}
567
+ onmouseleave={!isStepped ? scheduleClose : undefined}
568
+ onclick={() => (isStepped ? stepIntoChildren(item) : toggleOnClick(opts.id!))}
569
+ >
570
+ {@render rowBody(item, isParent, !!opts.isPopoutChild, isOpen)}
571
+ </button>
572
+ {:else}
573
+ <!-- Malformed item: no link, no children. Render visibly so the
574
+ consumer notices, but with disabled-style cursor + opacity. -->
575
+ <span class="{rowClass} opacity-40 cursor-not-allowed" aria-disabled="true">
576
+ {@render rowBody(item, false, !!opts.isPopoutChild, false)}
577
+ </span>
578
+ {/if}
579
+ {/snippet}
580
+
581
+ <!-- Popout panel — fixed 200px (--sbm-expanded-w), edge-to-edge rows,
582
+ mirrors main strip styling. `borderClass` is computed per popout
583
+ by `geometryFor` so the visible edges get borders and the edges
584
+ that coincide with the sidebar's top/bottom don't. -->
585
+ {#snippet popout(item: SideBarMenuItem, borderClass: string)}
586
+ <!-- svelte-ignore a11y_interactive_supports_focus -->
587
+ <div
588
+ role="menu"
589
+ tabindex="-1"
590
+ class="w-[var(--sbm-expanded-w)] overflow-hidden shadow-lg [background-color:var(--ui-color-background)] [color:var(--ui-color-text)] [border-color:color-mix(in_srgb,var(--ui-color-text)_15%,transparent)] {borderClass}"
591
+ onmouseenter={cancelClose}
592
+ onmouseleave={scheduleClose}
593
+ >
594
+ <ul class="flex flex-col">
595
+ {#each item.children ?? [] as child (child.label)}
596
+ <li class="list-none" role="none">
597
+ {@render row(child, { isPopoutChild: true })}
598
+ </li>
599
+ {/each}
600
+ </ul>
601
+ </div>
602
+ {/snippet}
603
+
604
+ {#if hamburger && isDrawer}
605
+ <!-- Built-in hamburger — fixed top-left. Only rendered when the
606
+ active mode is `drawer` or `stepped`. Set `hamburger={false}`
607
+ to suppress this when the consumer provides its own trigger. -->
608
+ <button
609
+ type="button"
610
+ class="fixed top-3 left-3 z-[80] inline-flex w-10 h-10 items-center justify-center cursor-pointer [background-color:var(--ui-color-background)] border [border-color:color-mix(in_srgb,var(--ui-color-text)_15%,transparent)] shadow-md [&_svg]:w-5 [&_svg]:h-5"
611
+ aria-label={open ? "Close menu" : "Open menu"}
612
+ aria-expanded={open}
613
+ onclick={() => (open = !open)}
614
+ >
615
+ {#if open}<IconClose />{:else}<IconMenu />{/if}
616
+ </button>
617
+ {/if}
618
+
619
+ <!-- Backdrop for the mobile drawer. Click anywhere on it to close.
620
+ Only rendered when the active mode is `drawer` or `stepped` AND
621
+ the drawer is open. -->
622
+ {#if isDrawer && open}
623
+ <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
624
+ <div
625
+ class="absolute inset-0 z-[70] [background-color:color-mix(in_srgb,black_30%,transparent)]"
626
+ onclick={closeDrawer}
627
+ ></div>
628
+ {/if}
629
+
630
+ <aside
631
+ bind:this={asideRef}
632
+ style="--sbm-collapsed-w: {collapsedWidth}px; --sbm-expanded-w: {expandedWidth}px;"
633
+ class="
634
+ flex flex-col z-[60] overflow-hidden
635
+ [background-color:var(--ui-color-background)]
636
+ [color:var(--ui-color-text)]
637
+ [border-right:1px_solid_color-mix(in_srgb,var(--ui-color-text)_15%,transparent)]
638
+ {currentMode === 'stepped'
639
+ ? `absolute inset-y-0 left-0 w-full z-[75] transition-transform duration-200 ${open ? 'translate-x-0' : '-translate-x-full'}`
640
+ : currentMode === 'drawer'
641
+ ? `absolute inset-y-0 left-0 w-[var(--sbm-expanded-w)] z-[75] transition-transform duration-200 ${open ? 'translate-x-0' : '-translate-x-full'}`
642
+ : currentMode === 'icon'
643
+ ? 'relative w-[var(--sbm-collapsed-w)]'
644
+ : 'relative w-[var(--sbm-expanded-w)]'}
645
+ {additionalClass}
646
+ "
647
+ >
648
+ {#if isStepped && stepLevel === 1 && currentParent}
649
+ <!-- Stepped sub-view (<2xs only): show just the children of the
650
+ parent the user tapped. The consumer's hamburger button is
651
+ expected to flip to a `←` icon and set `stepLevel = 0` to
652
+ return here — there is no in-drawer back row by design so
653
+ the back affordance stays in a single, consistent spot. -->
654
+ <ul class="flex flex-col">
655
+ {#each currentParent.children ?? [] as child (child.label)}
656
+ <li class="list-none">
657
+ {@render row(child, { isPopoutChild: true })}
658
+ </li>
659
+ {/each}
660
+ </ul>
661
+ {:else}
662
+ {#if topItems.length > 0}
663
+ <ul class="flex flex-col">
664
+ {#each topItems as item, idx (item.label)}
665
+ {@const id = `top:${idx}` as ItemId}
666
+ <li class="list-none">
667
+ {@render row(item, { id })}
668
+ </li>
669
+ {#if (item.children?.length ?? 0) > 0 && !isStepped}
670
+ {@const geom = geometryFor(id, "top", idx, topItems.length, item.children!.length)}
671
+ <Popup
672
+ anchor={geom.anchor}
673
+ open={openId === id}
674
+ side="right"
675
+ align={geom.align}
676
+ offset={0}
677
+ onclose={() => {
678
+ if (openId === id) openId = null;
679
+ }}
680
+ >
681
+ {@render popout(item, geom.borderClass)}
682
+ </Popup>
683
+ {/if}
684
+ {/each}
685
+ </ul>
686
+ {/if}
687
+
688
+ {#if topItems.length > 0 && bottomItems.length > 0}
689
+ <div class="flex-1"></div>
690
+ {/if}
691
+
692
+ {#if bottomItems.length > 0}
693
+ <ul class="flex flex-col">
694
+ {#each bottomItems as item, idx (item.label)}
695
+ {@const id = `bottom:${idx}` as ItemId}
696
+ <li class="list-none">
697
+ {@render row(item, { id })}
698
+ </li>
699
+ {#if (item.children?.length ?? 0) > 0 && !isStepped}
700
+ {@const geom = geometryFor(
701
+ id,
702
+ "bottom",
703
+ idx,
704
+ bottomItems.length,
705
+ item.children!.length,
706
+ )}
707
+ <Popup
708
+ anchor={geom.anchor}
709
+ open={openId === id}
710
+ side="right"
711
+ align={geom.align}
712
+ offset={0}
713
+ onclose={() => {
714
+ if (openId === id) openId = null;
715
+ }}
716
+ >
717
+ {@render popout(item, geom.borderClass)}
718
+ </Popup>
719
+ {/if}
720
+ {/each}
721
+ </ul>
722
+ {/if}
723
+ {/if}
724
+ </aside>