@octabits-io/nuxt-ui-kit 0.17.1 → 0.17.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octabits-io/nuxt-ui-kit",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "description": "Frontend kit for Nuxt/Vue admin SPAs: OIDC session harness (oidc-client-ts), API-client seams (base URL + OIDC bearer), auth/org store cores, and a route-guard builder — factory-style seams the app wires into its own plugins, stores, and middleware",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,6 +21,7 @@ import {
21
21
  foldInlineActions,
22
22
  groupIsPrimary,
23
23
  isInlineBound as isItemInlineBound,
24
+ buildMenuActionGroups,
24
25
  resolveCollapseStages,
25
26
  type PageActionsItem,
26
27
  } from './pageActions.ts';
@@ -137,26 +138,6 @@ function toMenuItem(item: PageActionsItem): DropdownMenuItem {
137
138
  }
138
139
 
139
140
  const menuGroups = computed<DropdownMenuItem[][]>(() => {
140
- const collapsedAutos = collapsed.value
141
- ? actionItems.value.filter(item => (item.visibility ?? 'auto') === 'auto')
142
- : [];
143
-
144
- // Menu-only items grouped by section, in first-appearance order.
145
- const sections = new Map<string, PageActionsItem[]>();
146
- for (const item of actionItems.value) {
147
- if ((item.visibility ?? 'auto') !== 'menu') continue;
148
- const section = item.section ?? item.group?.id ?? 'default';
149
- if (!sections.has(section)) sections.set(section, []);
150
- sections.get(section)!.push(item);
151
- }
152
-
153
- // AI items bound to the menu (explicit 'menu', or 'auto' while collapsed)
154
- // form their own group between the action sections and the utilities.
155
- const aiGroup = aiItems.value.filter(item =>
156
- (item.visibility ?? 'auto') === 'menu'
157
- || ((item.visibility ?? 'auto') === 'auto' && collapsed.value),
158
- );
159
-
160
141
  const utilityGroup: DropdownMenuItem[] = utilitiesCollapsed.value
161
142
  ? [
162
143
  ...props.utilityItems.map(toMenuItem),
@@ -166,10 +147,11 @@ const menuGroups = computed<DropdownMenuItem[][]>(() => {
166
147
  ]
167
148
  : [];
168
149
 
150
+ // Action groups (incl. their ORDER — see buildMenuActionGroups) are pure and
151
+ // live in the module; the utilities need i18n and the help registry.
169
152
  return [
170
- collapsedAutos.map(toMenuItem),
171
- ...[...sections.values()].map(group => group.map(toMenuItem)),
172
- aiGroup.map(toMenuItem),
153
+ ...buildMenuActionGroups(actionItems.value, aiItems.value, collapsed.value)
154
+ .map(group => group.map(toMenuItem)),
173
155
  utilityGroup,
174
156
  ].filter(group => group.length > 0);
175
157
  });
@@ -177,6 +159,21 @@ const menuGroups = computed<DropdownMenuItem[][]>(() => {
177
159
  const hasUtilityRegion = computed(() =>
178
160
  inlineUtilityItems.value.length > 0 || (showHelp.value && !utilitiesCollapsed.value),
179
161
  );
162
+
163
+ /**
164
+ * Is there anything to the LEFT of the utility region for the separator to
165
+ * separate it from? Everything the template renders before it, in order:
166
+ * the inline actions, the AI cluster, and the overflow menu (which draws
167
+ * nothing when its group list is empty).
168
+ *
169
+ * Without this the rule is drawn against the start of the cluster. A record
170
+ * route that declares no actions — only the help registry — renders a bar
171
+ * whose entire content is a vertical line and the Help button beside it, the
172
+ * line dividing Help from nothing.
173
+ */
174
+ const hasLeadingContent = computed(() =>
175
+ inlineEntries.value.length > 0 || inlineAiItems.value.length > 0 || menuGroups.value.length > 0,
176
+ );
180
177
  </script>
181
178
 
182
179
  <template>
@@ -251,7 +248,7 @@ const hasUtilityRegion = computed(() =>
251
248
  </UDropdownMenu>
252
249
  <PageActionMenu :items="menuGroups" />
253
250
  <template v-if="hasUtilityRegion">
254
- <USeparator orientation="vertical" class="h-5 mx-1" />
251
+ <USeparator v-if="hasLeadingContent" orientation="vertical" class="h-5 mx-1" />
255
252
  <PageAction
256
253
  v-for="item in inlineUtilityItems"
257
254
  :key="item.key"
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import {
3
+ buildMenuActionGroups,
3
4
  foldInlineActions,
4
5
  groupIsPrimary,
5
6
  isInlineBound,
@@ -156,3 +157,53 @@ describe('resolveCollapseStages', () => {
156
157
  }
157
158
  });
158
159
  });
160
+
161
+ describe('buildMenuActionGroups', () => {
162
+ const item = (over: Partial<PageActionsItem>): PageActionsItem =>
163
+ ({ key: over.key ?? 'k', icon: 'i', label: over.label ?? 'l', ...over });
164
+
165
+ const keys = (groups: PageActionsItem[][]) => groups.map(g => g.map(i => i.key));
166
+
167
+ it('keeps an AI row above the destructive section', () => {
168
+ // The regression this exists for: an AI item appended AFTER the sections
169
+ // rendered under "Delete", where a "Generate page content" row reads as an
170
+ // afterthought to the deletion.
171
+ const actions = [
172
+ item({ key: 'export', visibility: 'menu' }),
173
+ item({ key: 'delete', visibility: 'menu', section: 'destructive', color: 'error' }),
174
+ ];
175
+ const ai = [item({ key: 'generate', kind: 'ai' })];
176
+
177
+ expect(keys(buildMenuActionGroups(actions, ai, true)))
178
+ .toEqual([['generate'], ['export'], ['delete']]);
179
+ });
180
+
181
+ it('sits with the other collapsed actions, not behind them', () => {
182
+ const actions = [item({ key: 'edit' }), item({ key: 'delete', visibility: 'menu' })];
183
+ const ai = [item({ key: 'generate', kind: 'ai' })];
184
+
185
+ // 'edit' collapses out of the bar; the AI row is its own group beside it.
186
+ expect(keys(buildMenuActionGroups(actions, ai, true)))
187
+ .toEqual([['edit'], ['generate'], ['delete']]);
188
+ });
189
+
190
+ it('leaves inline-bound items out of the menu entirely', () => {
191
+ const actions = [item({ key: 'publish', visibility: 'always' }), item({ key: 'edit' })];
192
+ const ai = [item({ key: 'generate', kind: 'ai', visibility: 'always' })];
193
+
194
+ // Wide: nothing collapses. Narrow: 'always' items still render inline, so
195
+ // the menu holds only 'edit'.
196
+ expect(keys(buildMenuActionGroups(actions, ai, false))).toEqual([]);
197
+ expect(keys(buildMenuActionGroups(actions, ai, true))).toEqual([['edit']]);
198
+ });
199
+
200
+ it('orders menu sections by first appearance, so the caller decides', () => {
201
+ const actions = [
202
+ item({ key: 'a', visibility: 'menu', section: 'one' }),
203
+ item({ key: 'b', visibility: 'menu', section: 'two' }),
204
+ item({ key: 'c', visibility: 'menu', section: 'one' }),
205
+ ];
206
+ expect(keys(buildMenuActionGroups(actions, [], false)))
207
+ .toEqual([['a', 'c'], ['b']]);
208
+ });
209
+ })
@@ -193,3 +193,46 @@ export function resolveCollapseStages(
193
193
  utilitiesCollapsed: collapsed || width < (utilityCollapseBelow ?? collapseBelow),
194
194
  };
195
195
  }
196
+
197
+ /**
198
+ * The overflow menu's ACTION groups, in render order — everything above the
199
+ * utility group, which the component builds itself (it needs i18n and the help
200
+ * registry).
201
+ *
202
+ * The order is the whole point, and it is a convention the type cannot express:
203
+ * destructive rows are the last-declared menu section, so anything appended
204
+ * after the sections lands under "Delete". AI items used to, which read as an
205
+ * afterthought to the deletion rather than as a thing you might do to the
206
+ * record. They belong with the other collapsed actions instead.
207
+ *
208
+ * Groups render separated, and empty ones are dropped by the caller.
209
+ */
210
+ export function buildMenuActionGroups(
211
+ actionItems: PageActionsItem[],
212
+ aiItems: PageActionsItem[],
213
+ collapsed: boolean,
214
+ ): PageActionsItem[][] {
215
+ const visibilityOf = (item: PageActionsItem) => item.visibility ?? 'auto';
216
+
217
+ // 'auto' actions the current width pushed out of the bar.
218
+ const collapsedAutos = collapsed
219
+ ? actionItems.filter(item => visibilityOf(item) === 'auto')
220
+ : [];
221
+
222
+ // Menu-only items, grouped by section in first-appearance order — so a
223
+ // caller's declaration order decides, and 'destructive' stays last by
224
+ // being declared last.
225
+ const sections = new Map<string, PageActionsItem[]>();
226
+ for (const item of actionItems) {
227
+ if (visibilityOf(item) !== 'menu') continue;
228
+ const section = item.section ?? item.group?.id ?? 'default';
229
+ if (!sections.has(section)) sections.set(section, []);
230
+ sections.get(section)!.push(item);
231
+ }
232
+
233
+ const aiGroup = aiItems.filter(item =>
234
+ visibilityOf(item) === 'menu' || (visibilityOf(item) === 'auto' && collapsed),
235
+ );
236
+
237
+ return [collapsedAutos, aiGroup, ...sections.values()].filter(group => group.length > 0);
238
+ }