@close-by/clay 0.2.5 → 0.2.6

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 (2) hide show
  1. package/package.json +5 -5
  2. package/src/pages.ts +79 -3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@close-by/clay",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "description": "NiceGUI-like ui.* facade for Clay apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -27,10 +27,10 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@close-by/clay-compiler": "0.2.5",
31
- "@close-by/clay-core": "0.2.5",
32
- "@close-by/clay-components": "0.2.5",
33
- "@close-by/clay-server": "0.2.5",
30
+ "@close-by/clay-compiler": "0.2.6",
31
+ "@close-by/clay-core": "0.2.6",
32
+ "@close-by/clay-components": "0.2.6",
33
+ "@close-by/clay-server": "0.2.6",
34
34
  "@tailwindcss/cli": "^4.3.3",
35
35
  "tailwindcss": "^4.3.3"
36
36
  }
package/src/pages.ts CHANGED
@@ -58,6 +58,10 @@ export type PageMeta = {
58
58
  * UX only — still call `requireRole` in the page builder.
59
59
  */
60
60
  roles?: string[];
61
+ /** Nest this page under a collapsible sidebar group with the given label. */
62
+ group?: string;
63
+ /** Lucide icon for the group parent row (first page in the group with this set wins). */
64
+ groupIcon?: string;
61
65
  };
62
66
 
63
67
  const pageMetaByPath = new Map<string, PageMeta>();
@@ -140,8 +144,76 @@ export type NavFromPagesOptions = {
140
144
  role?: string;
141
145
  /** Viewer roles — page is shown if it has no `roles` meta, or any overlap. */
142
146
  roles?: string[];
147
+ /**
148
+ * When true, nest `/examples/*` routes (except `/examples/auth`) under an
149
+ * "Examples" group. Individual pages can override with `pageMeta.group`.
150
+ */
151
+ groupExamples?: boolean;
152
+ /** Label for the auto examples group. Default `"Examples"`. */
153
+ groupExamplesLabel?: string;
154
+ /** Icon for the auto examples group. Default `"layout-grid"`. */
155
+ groupExamplesIcon?: string;
143
156
  };
144
157
 
158
+ type RankedNavPage = {
159
+ href: string;
160
+ label: string;
161
+ icon: string;
162
+ order: number;
163
+ group?: string;
164
+ groupIcon?: string;
165
+ };
166
+
167
+ function resolvePageGroup(
168
+ href: string,
169
+ meta: PageMeta,
170
+ opts?: NavFromPagesOptions,
171
+ ): { group?: string; groupIcon?: string } {
172
+ if (meta.group !== undefined) {
173
+ return meta.group ? { group: meta.group, groupIcon: meta.groupIcon } : {};
174
+ }
175
+ if (opts?.groupExamples && href.startsWith('/examples/') && href !== '/examples/auth') {
176
+ return {
177
+ group: opts.groupExamplesLabel ?? 'Examples',
178
+ groupIcon: opts.groupExamplesIcon ?? 'layout-grid',
179
+ };
180
+ }
181
+ return {};
182
+ }
183
+
184
+ function buildNavFromRanked(ranked: RankedNavPage[]): AppNavItem[] {
185
+ const top: Array<{ order: number; node: AppNavItem }> = [];
186
+ const groups = new Map<string, AppNavItem>();
187
+
188
+ for (const row of ranked) {
189
+ const leaf: AppNavItem = { label: row.label, href: row.href, icon: row.icon };
190
+ if (!row.group) {
191
+ top.push({ order: row.order, node: leaf });
192
+ continue;
193
+ }
194
+
195
+ let parent = groups.get(row.group);
196
+ if (!parent) {
197
+ parent = {
198
+ label: row.group,
199
+ href: row.href,
200
+ icon: row.groupIcon ?? 'folder',
201
+ items: [],
202
+ };
203
+ groups.set(row.group, parent);
204
+ top.push({ order: row.order, node: parent });
205
+ } else {
206
+ const entry = top.find((t) => t.node === parent);
207
+ if (entry) entry.order = Math.min(entry.order, row.order);
208
+ if (row.groupIcon && parent.icon === 'folder') parent.icon = row.groupIcon;
209
+ }
210
+ parent.items!.push(leaf);
211
+ }
212
+
213
+ top.sort((a, b) => a.order - b.order || a.node.label.localeCompare(b.node.label));
214
+ return top.map((t) => t.node);
215
+ }
216
+
145
217
  /**
146
218
  * Build primary sidebar nav from registered pages + collected `pageMeta`.
147
219
  * Filters by `nav: false` and optional role overlap; sorts by `order` then path.
@@ -160,10 +232,11 @@ export function navFromPages(opts?: NavFromPagesOptions): AppNavItem[] {
160
232
  }
161
233
 
162
234
  const paths = getRegisteredPaths();
163
- const ranked = paths
235
+ const ranked: RankedNavPage[] = paths
164
236
  .map((href) => {
165
237
  const meta = pageMetaByPath.get(href) ?? {};
166
238
  const order = href === '/' ? 0 : (meta.order ?? 100);
239
+ const { group, groupIcon } = resolvePageGroup(href, meta, opts);
167
240
  return {
168
241
  href,
169
242
  label: meta.label ?? labelFromPath(href),
@@ -171,6 +244,8 @@ export function navFromPages(opts?: NavFromPagesOptions): AppNavItem[] {
171
244
  order,
172
245
  nav: meta.nav !== false,
173
246
  roles: meta.roles,
247
+ group,
248
+ groupIcon,
174
249
  };
175
250
  })
176
251
  .filter((item) => {
@@ -178,9 +253,10 @@ export function navFromPages(opts?: NavFromPagesOptions): AppNavItem[] {
178
253
  if (!item.roles || item.roles.length === 0) return true;
179
254
  if (viewer.size === 0) return false;
180
255
  return item.roles.some((r) => viewer.has(r));
181
- });
256
+ })
257
+ .map(({ nav: _nav, roles: _roles, ...rest }) => rest);
182
258
  ranked.sort((a, b) => a.order - b.order || a.href.localeCompare(b.href));
183
- return ranked.map(({ href, label, icon }) => ({ href, label, icon }));
259
+ return buildNavFromRanked(ranked);
184
260
  }
185
261
 
186
262
  /** Test helper: clear page registry + meta map. */