@octabits-io/nuxt-ui-kit 0.17.2 → 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.2",
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
  });
@@ -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
+ }