@spaethtech/svelte-ui 0.21.1-dev.131.6298c61 → 0.21.1-dev.132.b812036

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.
@@ -95,8 +95,9 @@
95
95
  link?: string;
96
96
  badge?: SideBarMenuBadge;
97
97
  /** Child items — rendered as a fly-out popout. Recursive: a child that itself has children
98
- * cascades into a further popout (or steps deeper in `stepped` mode). */
99
- children?: SideBarMenuItem[];
98
+ * cascades into a further popout (or steps deeper in `stepped` mode). May include
99
+ * `{ divider: true }` entries to draw a separator between children. */
100
+ children?: SideBarMenuEntry[];
100
101
  /** Action row (no `link`) — renders a `<button>` and calls this on click. For a "toggle"
101
102
  * row (an AI panel, etc.) or a non-navigating action. Runs after the drawer/popout reset. */
102
103
  onclick?: (e: MouseEvent) => void;
@@ -114,6 +115,20 @@
114
115
  * the default `currentPath` segment-boundary match against `link`. */
115
116
  activeMatch?: SideBarMenuActiveRule;
116
117
  };
118
+
119
+ /**
120
+ * A horizontal rule between adjacent rows in an `items` / `bottomItems` / `children` list — a 1px
121
+ * full-bleed separator (`--ui-border-color`) drawn between the two rows it sits between. It adds no
122
+ * navigable row and (visually) only the 1px line, so it never disturbs the fixed-height row rhythm.
123
+ * Leading, trailing, and consecutive dividers collapse — a divider renders ONLY between two real
124
+ * rows. Honoured identically in the fly-out popout, the stepped sub-view, and the inline
125
+ * expanded/drawer rail. Use the discriminated form `{ divider: true }` so a genuinely malformed item
126
+ * still falls through to the visible "malformed item" row rather than silently becoming a separator.
127
+ */
128
+ export type SideBarMenuDivider = { divider: true };
129
+
130
+ /** A SideBarMenu list entry: either a {@link SideBarMenuItem} or a {@link SideBarMenuDivider}. */
131
+ export type SideBarMenuEntry = SideBarMenuItem | SideBarMenuDivider;
117
132
  </script>
118
133
 
119
134
  <script lang="ts">
@@ -159,8 +174,8 @@
159
174
  class: additionalClass = "",
160
175
  ...rest
161
176
  }: {
162
- items?: SideBarMenuItem[];
163
- bottomItems?: SideBarMenuItem[];
177
+ items?: SideBarMenuEntry[];
178
+ bottomItems?: SideBarMenuEntry[];
164
179
  currentPath: string;
165
180
  size?: Size;
166
181
  /**
@@ -492,7 +507,34 @@
492
507
  // reserving group that lack an icon still get an empty box so everything aligns. The main rail counts
493
508
  // `items` + `bottomItems` as ONE group (so top/bottom align); each popout / stepped sub-view is its
494
509
  // own group.
495
- const anyIcon = (list: SideBarMenuItem[]) => list.some((i) => i.icon || i.badge);
510
+ // ── Dividers ────────────────────────────────────────────────────
511
+ // A list entry is either a real item or a `{ divider: true }` separator. Dividers are not navigable
512
+ // rows — they render as a 1px full-bleed rule BETWEEN two real rows (see `renderRows`).
513
+ const isDivider = (e: SideBarMenuEntry): e is SideBarMenuDivider =>
514
+ typeof e === "object" && e !== null && (e as SideBarMenuDivider).divider === true;
515
+ const itemsOnly = (list: SideBarMenuEntry[] = []): SideBarMenuItem[] =>
516
+ list.filter((e): e is SideBarMenuItem => !isDivider(e));
517
+
518
+ // Normalize a list into the rows to render, each tagged with whether a divider precedes it. Leading,
519
+ // trailing, and consecutive dividers collapse — `dividerBefore` is set only when a divider sits
520
+ // between two real rows. `index` is the entry's ORIGINAL position in the list, so open-path ids
521
+ // (`top:3`, `id>5`) stay stable and unique no matter how many dividers precede a row.
522
+ type RenderRow = { item: SideBarMenuItem; index: number; dividerBefore: boolean };
523
+ function renderRows(list: SideBarMenuEntry[] = []): RenderRow[] {
524
+ const out: RenderRow[] = [];
525
+ let pending = false;
526
+ list.forEach((e, index) => {
527
+ if (isDivider(e)) {
528
+ if (out.length > 0) pending = true; // drop leading; collapse runs of dividers
529
+ return;
530
+ }
531
+ out.push({ item: e, index, dividerBefore: pending });
532
+ pending = false;
533
+ });
534
+ return out; // a trailing divider is never consumed → dropped
535
+ }
536
+
537
+ const anyIcon = (list: SideBarMenuEntry[] = []) => itemsOnly(list).some((i) => i.icon || i.badge);
496
538
  const railReserve = $derived(anyIcon(items) || anyIcon(bottomItems));
497
539
 
498
540
  // ── Mobile drawer state ─────────────────────────────────────────
@@ -605,7 +647,8 @@
605
647
  } else if (item.link && pathMatches(item.link, currentPath)) {
606
648
  return true;
607
649
  }
608
- if (item.children?.length) return item.children.some(isItemActive);
650
+ const kids = itemsOnly(item.children);
651
+ if (kids.length) return kids.some(isItemActive);
609
652
  return false;
610
653
  }
611
654
  // A row's OWN active state (drives `aria-current`) — its own rule/link only, never descendants.
@@ -919,7 +962,7 @@
919
962
  {@const active = isItemActive(item)}
920
963
  {@const ownActive = isItemOwnActive(item)}
921
964
  {@const reserveIcon = opts.reserveIcon ?? true}
922
- {@const hasChildren = (item.children?.length ?? 0) > 0}
965
+ {@const hasChildren = itemsOnly(item.children).length > 0}
923
966
  {@const isParent = hasChildren || !!item.popout}
924
967
  {@const hasLink = !!item.link}
925
968
  {@const isOpen = !!opts.id && isOnPath(opts.id)}
@@ -1028,7 +1071,7 @@
1028
1071
  depth: number,
1029
1072
  )}
1030
1073
  {@render row(item, { id, isPopoutChild: depth > 0, reserveIcon })}
1031
- {#if (((item.children?.length ?? 0) > 0) || !!item.popout) && !isStepped}
1074
+ {#if (itemsOnly(item.children).length > 0 || !!item.popout) && !isStepped}
1032
1075
  {@const top = depth === 0}
1033
1076
  {@const geom = top
1034
1077
  ? geometryFor(id, group)
@@ -1085,9 +1128,9 @@
1085
1128
  {#snippet popoutPanel(item: SideBarMenuItem, id: ItemId, group: "top" | "bottom", maxHeight: string)}
1086
1129
  {@const popoutId = popoutDomId(id)}
1087
1130
  {@const isOpen = isOnPath(id)}
1088
- {@const kids = item.children ?? []}
1089
- {@const win = popoutWindow(id, kids.length)}
1090
- {@const childReserve = anyIcon(kids)}
1131
+ {@const rows = renderRows(item.children)}
1132
+ {@const win = popoutWindow(id, rows.length)}
1133
+ {@const childReserve = anyIcon(item.children)}
1091
1134
  {@const rowsShown = (win.up ? 1 : 0) + (win.end - win.start) + (win.down ? 1 : 0)}
1092
1135
  {@const bd = popoutBorder(id, group, !id.includes(">"), rowsShown)}
1093
1136
  <!-- Nav submenu popout — a list of links (NOT role="menu"); Tab navigates, per the disclosure-nav
@@ -1106,7 +1149,7 @@
1106
1149
  : (e) => {
1107
1150
  if (win.up || win.down) {
1108
1151
  e.preventDefault();
1109
- stepPopout(id, kids.length, Math.sign(e.deltaY) * 3);
1152
+ stepPopout(id, rows.length, Math.sign(e.deltaY) * 3);
1110
1153
  }
1111
1154
  }}
1112
1155
  onkeydown={item.popout
@@ -1114,10 +1157,10 @@
1114
1157
  : (e) => {
1115
1158
  if (e.key === "ArrowDown") {
1116
1159
  e.preventDefault();
1117
- stepPopout(id, kids.length, 1);
1160
+ stepPopout(id, rows.length, 1);
1118
1161
  } else if (e.key === "ArrowUp") {
1119
1162
  e.preventDefault();
1120
- stepPopout(id, kids.length, -1);
1163
+ stepPopout(id, rows.length, -1);
1121
1164
  }
1122
1165
  }}
1123
1166
  >
@@ -1129,15 +1172,20 @@
1129
1172
  {@render item.popout()}
1130
1173
  </div>
1131
1174
  {:else}
1132
- {#if win.up}{@render arrowRow(id, kids.length, -1)}{/if}
1175
+ {#if win.up}{@render arrowRow(id, rows.length, -1)}{/if}
1133
1176
  <ul class="flex flex-col">
1134
- {#each kids.slice(win.start, win.end) as child, i (child.label)}
1177
+ {#each rows.slice(win.start, win.end) as r, i (r.index)}
1178
+ <!-- A divider renders only BETWEEN real rows: suppress it at the window's top edge (i===0),
1179
+ where there's nothing above it and it would collide with the panel border. -->
1180
+ {#if r.dividerBefore && i > 0}
1181
+ <li class="list-none border-t [border-color:var(--ui-border-color)]" role="separator"></li>
1182
+ {/if}
1135
1183
  <li class="list-none">
1136
- {@render menuNode(child, `${id}>${win.start + i}`, group, childReserve, 1)}
1184
+ {@render menuNode(r.item, `${id}>${r.index}`, group, childReserve, 1)}
1137
1185
  </li>
1138
1186
  {/each}
1139
1187
  </ul>
1140
- {#if win.down}{@render arrowRow(id, kids.length, 1)}{/if}
1188
+ {#if win.down}{@render arrowRow(id, rows.length, 1)}{/if}
1141
1189
  {/if}
1142
1190
  </div>
1143
1191
  {/snippet}
@@ -1203,20 +1251,29 @@
1203
1251
  return here — there is no in-drawer back row by design so
1204
1252
  the back affordance stays in a single, consistent spot. -->
1205
1253
  {@const currentParent = stepPath[stepPath.length - 1]}
1206
- {@const stepReserve = anyIcon(currentParent.children ?? [])}
1254
+ {@const stepRows = renderRows(currentParent.children)}
1255
+ {@const stepReserve = anyIcon(currentParent.children)}
1207
1256
  <ul class="flex flex-col {scrollList}">
1208
- {#each currentParent.children ?? [] as child, ci (child.label)}
1257
+ {#each stepRows as r, i (r.index)}
1258
+ {#if r.dividerBefore && i > 0}
1259
+ <li class="list-none border-t [border-color:var(--ui-border-color)]" role="separator"></li>
1260
+ {/if}
1209
1261
  <li class="list-none">
1210
- {@render row(child, { id: `step:${ci}`, isPopoutChild: true, reserveIcon: stepReserve })}
1262
+ {@render row(r.item, { id: `step:${r.index}`, isPopoutChild: true, reserveIcon: stepReserve })}
1211
1263
  </li>
1212
1264
  {/each}
1213
1265
  </ul>
1214
1266
  {:else}
1215
- {#if items.length > 0}
1267
+ {@const topRows = renderRows(items)}
1268
+ {@const bottomRows = renderRows(bottomItems)}
1269
+ {#if topRows.length > 0}
1216
1270
  <ul class="flex flex-col {scrollList}">
1217
- {#each items as item, idx (item.label)}
1271
+ {#each topRows as r, i (r.index)}
1272
+ {#if r.dividerBefore && i > 0}
1273
+ <li class="list-none border-t [border-color:var(--ui-border-color)]" role="separator"></li>
1274
+ {/if}
1218
1275
  <li class="list-none">
1219
- {@render menuNode(item, `top:${idx}`, "top", railReserve, 0)}
1276
+ {@render menuNode(r.item, `top:${r.index}`, "top", railReserve, 0)}
1220
1277
  </li>
1221
1278
  {/each}
1222
1279
  </ul>
@@ -1224,15 +1281,18 @@
1224
1281
 
1225
1282
  <!-- With items present, the scrollable top list (flex-1) already fills the gap; only add a spacer
1226
1283
  to push the bottom cluster down when there are no top items. -->
1227
- {#if items.length === 0 && bottomItems.length > 0}
1284
+ {#if topRows.length === 0 && bottomRows.length > 0}
1228
1285
  <div class="flex-1"></div>
1229
1286
  {/if}
1230
1287
 
1231
- {#if bottomItems.length > 0}
1288
+ {#if bottomRows.length > 0}
1232
1289
  <ul class="flex flex-col shrink-0">
1233
- {#each bottomItems as item, idx (item.label)}
1290
+ {#each bottomRows as r, i (r.index)}
1291
+ {#if r.dividerBefore && i > 0}
1292
+ <li class="list-none border-t [border-color:var(--ui-border-color)]" role="separator"></li>
1293
+ {/if}
1234
1294
  <li class="list-none">
1235
- {@render menuNode(item, `bottom:${idx}`, "bottom", railReserve, 0)}
1295
+ {@render menuNode(r.item, `bottom:${r.index}`, "bottom", railReserve, 0)}
1236
1296
  </li>
1237
1297
  {/each}
1238
1298
  </ul>
@@ -75,8 +75,9 @@ export type SideBarMenuItem = {
75
75
  link?: string;
76
76
  badge?: SideBarMenuBadge;
77
77
  /** Child items — rendered as a fly-out popout. Recursive: a child that itself has children
78
- * cascades into a further popout (or steps deeper in `stepped` mode). */
79
- children?: SideBarMenuItem[];
78
+ * cascades into a further popout (or steps deeper in `stepped` mode). May include
79
+ * `{ divider: true }` entries to draw a separator between children. */
80
+ children?: SideBarMenuEntry[];
80
81
  /** Action row (no `link`) — renders a `<button>` and calls this on click. For a "toggle"
81
82
  * row (an AI panel, etc.) or a non-navigating action. Runs after the drawer/popout reset. */
82
83
  onclick?: (e: MouseEvent) => void;
@@ -94,14 +95,28 @@ export type SideBarMenuItem = {
94
95
  * the default `currentPath` segment-boundary match against `link`. */
95
96
  activeMatch?: SideBarMenuActiveRule;
96
97
  };
98
+ /**
99
+ * A horizontal rule between adjacent rows in an `items` / `bottomItems` / `children` list — a 1px
100
+ * full-bleed separator (`--ui-border-color`) drawn between the two rows it sits between. It adds no
101
+ * navigable row and (visually) only the 1px line, so it never disturbs the fixed-height row rhythm.
102
+ * Leading, trailing, and consecutive dividers collapse — a divider renders ONLY between two real
103
+ * rows. Honoured identically in the fly-out popout, the stepped sub-view, and the inline
104
+ * expanded/drawer rail. Use the discriminated form `{ divider: true }` so a genuinely malformed item
105
+ * still falls through to the visible "malformed item" row rather than silently becoming a separator.
106
+ */
107
+ export type SideBarMenuDivider = {
108
+ divider: true;
109
+ };
110
+ /** A SideBarMenu list entry: either a {@link SideBarMenuItem} or a {@link SideBarMenuDivider}. */
111
+ export type SideBarMenuEntry = SideBarMenuItem | SideBarMenuDivider;
97
112
  import type { HTMLAttributes } from "svelte/elements";
98
113
  import type { Size } from "../../types/sizes.js";
99
114
  import { type Variant } from "../../types/variants.js";
100
115
  import { type Breakpoint } from "../../types/breakpoints.js";
101
116
  type ModesMap = Partial<Record<Breakpoint, SideBarMenuMode>>;
102
117
  type $$ComponentProps = {
103
- items?: SideBarMenuItem[];
104
- bottomItems?: SideBarMenuItem[];
118
+ items?: SideBarMenuEntry[];
119
+ bottomItems?: SideBarMenuEntry[];
105
120
  currentPath: string;
106
121
  size?: Size;
107
122
  /**
package/dist/index.d.ts CHANGED
@@ -76,7 +76,7 @@ export { default as Disclosure2 } from "./components/Disclosure2.svelte";
76
76
  export { default as Accordion2 } from "./components/Accordion2.svelte";
77
77
  export { default as DatePicker2 } from "./components/DatePicker2.svelte";
78
78
  export { default as SideBarMenu } from "./components/SideBarMenu/SideBarMenu.svelte";
79
- export type { SideBarMenuItem, SideBarMenuBadge, SideBarMenuBadgeVariant, SideBarMenuApi, SideBarMenuMode, SideBarMenuActiveRule, } from "./components/SideBarMenu/SideBarMenu.svelte";
79
+ export type { SideBarMenuItem, SideBarMenuDivider, SideBarMenuEntry, SideBarMenuBadge, SideBarMenuBadgeVariant, SideBarMenuApi, SideBarMenuMode, SideBarMenuActiveRule, } from "./components/SideBarMenu/SideBarMenu.svelte";
80
80
  export { Breadcrumbs } from "./components/Breadcrumbs/index.js";
81
81
  export type { BreadcrumbItem } from "./components/Breadcrumbs/index.js";
82
82
  export { default as TabStrip } from "./components/TabStrip/TabStrip.svelte";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaethtech/svelte-ui",
3
- "version": "0.21.1-dev.131.6298c61",
3
+ "version": "0.21.1-dev.132.b812036",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/spaethtech/svelte-ui.git"