@octabits-io/nuxt-ui-kit 0.14.2 → 0.16.0

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/README.md CHANGED
@@ -26,16 +26,17 @@ itself, so it has no Nuxt dependency, only `vue`.
26
26
  - **Route guard builder** — `createAuthGuard` returns a handler yielding a
27
27
  redirect target or `undefined`; per-app policy (org validation, role gates)
28
28
  goes in the `afterAuthenticated` hook
29
- - **Eden Treaty client factory** (`@elysiajs/eden` peer) —
30
- `createTreatyClientFactory<App>` (lazy singleton, bearer injection,
31
- `parseDate: false` by default to keep ISO-date strings string-typed on the
32
- wire), plus `createAccessTokenProvider` and `resolveApiBaseUrl`
29
+ - **API-client seams** (`./api`, transport-agnostic) —
30
+ `resolveApiBaseUrl` (configured URL page origin in production → localhost
31
+ dev port) and `createAccessTokenProvider` (bearer from the OIDC session).
32
+ Build the client with Hono's `hc` and inject the token through its own async
33
+ `headers` thunk
33
34
  - **Org store core** — `createOrgStoreCore<TOrg>` (granted orgs, slug-keyed
34
35
  selection, persistence, lost-access revocation) for the app's own store
35
36
  - **API error → i18n** — `createApiErrorMessenger({ t, te })`: maps
36
37
  `{ key, message }` bodies and validation errors to user-facing strings via
37
38
  the `errors.*` / `validation.fields.*` / `validation.messages.*` key
38
- convention; unwraps Eden `{ value }` envelopes
39
+ convention; unwraps `{ value }` error envelopes
39
40
  - **Confirm dialog** — promise-based `useConfirm()` + singleton state, with a
40
41
  `./components/ConfirmDialog.vue` renderer (`common.cancel`/`common.confirm`
41
42
  i18n defaults, `zIndexClass` prop for stacking above slideovers)
@@ -113,11 +114,17 @@ export default defineNuxtPlugin((nuxtApp) => {
113
114
  })
114
115
  })
115
116
 
116
- // app/composables/useApi.ts
117
- const getClient = createTreatyClientFactory<App>({
118
- getBaseUrl: () => resolveApiBaseUrl({ configuredUrl, isProductionBuild: import.meta.env.PROD, devFallbackPort: 3002 }),
119
- getAccessToken: createAccessTokenProvider(getUserManager),
120
- })
117
+ // app/composables/useApi.ts — `hcWithType` is the API package's pre-compiled client type
118
+ const getAccessToken = createAccessTokenProvider(getUserManager)
119
+ const client = hcWithType(
120
+ resolveApiBaseUrl({ configuredUrl, isProductionBuild: import.meta.env.PROD, devFallbackPort: 3002 }),
121
+ {
122
+ headers: async () => {
123
+ const token = await getAccessToken()
124
+ return token ? { authorization: `Bearer ${token}` } : {}
125
+ },
126
+ },
127
+ )
121
128
 
122
129
  // app/stores/auth.ts
123
130
  export const useAuthStore = defineStore('auth', () =>
@@ -1,8 +1,5 @@
1
1
  import { UserManager } from "oidc-client-ts";
2
- import { Treaty } from "@elysiajs/eden";
3
- import { Elysia } from "elysia";
4
2
  //#region src/api/client.d.ts
5
- type AnyElysia = Elysia<any, any, any, any, any, any, any>;
6
3
  interface ResolveApiBaseUrlOptions {
7
4
  /**
8
5
  * The explicitly configured URL, first-match-wins — e.g.
@@ -26,40 +23,5 @@ declare function resolveApiBaseUrl(options: ResolveApiBaseUrlOptions): string;
26
23
  * access token, or `null` when there is no non-expired session.
27
24
  */
28
25
  declare function createAccessTokenProvider(getUserManager: () => UserManager): () => Promise<string | null>;
29
- interface TreatyClientFactoryOptions {
30
- /** Resolve (and memoize, if desired) the base URL at first client use. */
31
- getBaseUrl: () => string;
32
- /** Bearer token per request; `null` sends no Authorization header. */
33
- getAccessToken: () => Promise<string | null>;
34
- /**
35
- * Eden Treaty's auto-Date parsing on responses. Default `false`: with the
36
- * default `true`, any `YYYY-MM-DD` string in a response is silently
37
- * converted to a `Date` object, which then JSON-serializes back as a full
38
- * ISO datetime on the next request — breaking server-side "plain ISO date
39
- * string" validation. Keep the wire contract string-typed unless the API
40
- * genuinely round-trips Date objects.
41
- */
42
- parseDate?: boolean;
43
- /**
44
- * Additional header source(s), applied after the bearer injector — a later
45
- * entry wins on key collision, so consumers can add or override headers
46
- * without losing the Authorization injection.
47
- */
48
- headers?: Treaty.Config['headers'];
49
- /** Extra Treaty config (fetcher, onRequest, …), applied last. */
50
- treatyConfig?: Omit<Treaty.Config, 'headers' | 'parseDate'>;
51
- }
52
- /**
53
- * Lazily-created Eden Treaty client singleton with OIDC bearer injection.
54
- *
55
- * ```ts
56
- * const getClient = createTreatyClientFactory<App>({ getBaseUrl, getAccessToken })
57
- * export function useApi() {
58
- * const client = getClient()
59
- * return { api: client.api, client }
60
- * }
61
- * ```
62
- */
63
- declare function createTreatyClientFactory<App extends AnyElysia>(options: TreatyClientFactoryOptions): () => Treaty.Create<App>;
64
26
  //#endregion
65
- export { type ResolveApiBaseUrlOptions, type TreatyClientFactoryOptions, createAccessTokenProvider, createTreatyClientFactory, resolveApiBaseUrl };
27
+ export { type ResolveApiBaseUrlOptions, createAccessTokenProvider, resolveApiBaseUrl };
package/dist/api/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import { treaty } from "@elysiajs/eden";
2
1
  //#region src/api/client.ts
3
2
  /**
4
3
  * Resolve the API base URL: configured value, else the page origin in
@@ -19,33 +18,5 @@ function createAccessTokenProvider(getUserManager) {
19
18
  return user.access_token;
20
19
  };
21
20
  }
22
- /**
23
- * Lazily-created Eden Treaty client singleton with OIDC bearer injection.
24
- *
25
- * ```ts
26
- * const getClient = createTreatyClientFactory<App>({ getBaseUrl, getAccessToken })
27
- * export function useApi() {
28
- * const client = getClient()
29
- * return { api: client.api, client }
30
- * }
31
- * ```
32
- */
33
- function createTreatyClientFactory(options) {
34
- let client = null;
35
- return function getClient() {
36
- if (client) return client;
37
- const bearerInjector = async () => {
38
- const token = await options.getAccessToken();
39
- if (token) return { authorization: `Bearer ${token}` };
40
- };
41
- const extraHeaders = options.headers === void 0 ? [] : Array.isArray(options.headers) ? options.headers : [options.headers];
42
- client = treaty(options.getBaseUrl(), {
43
- parseDate: options.parseDate ?? false,
44
- headers: [bearerInjector, ...extraHeaders],
45
- ...options.treatyConfig
46
- });
47
- return client;
48
- };
49
- }
50
21
  //#endregion
51
- export { createAccessTokenProvider, createTreatyClientFactory, resolveApiBaseUrl };
22
+ export { createAccessTokenProvider, resolveApiBaseUrl };
@@ -43,7 +43,6 @@ function createSseFrameParser() {
43
43
  if (Number.isInteger(parsed) && parsed >= 0) retry = parsed;
44
44
  break;
45
45
  }
46
- default: break;
47
46
  }
48
47
  }
49
48
  function push(chunk) {
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ import { Component, ComputedRef, InjectionKey, Ref } from "vue";
2
2
  import { RouteLocationRaw } from "vue-router";
3
3
  //#region src/org/orgStore.d.ts
4
4
  type OrgStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
5
- /** Result seam for the org fetch — mirrors an Eden Treaty `{ data, error }`. */
5
+ /** Result seam for the org fetch — a `{ data, error }` pair, whatever the client. */
6
6
  type FetchOrganizationsResult<TOrg> = {
7
7
  items: TOrg[];
8
8
  error?: undefined;
@@ -119,6 +119,33 @@ declare function createApiErrorMessenger(options: ApiErrorMessengerOptions): {
119
119
  };
120
120
  //#endregion
121
121
  //#region src/components/pageActions.d.ts
122
+ /**
123
+ * A set of actions that are alternative ANSWERS to one question, collapsed
124
+ * into a single inline control.
125
+ *
126
+ * The distinction it draws is between a bar that asks one thing and a bar that
127
+ * asks several. Three buttons — "record yes", "record no", "record silence" —
128
+ * are not three operations; they are one operation with three outcomes, and
129
+ * rendered as peers they read as three independent things to do, indistinguish-
130
+ * able from the unrelated tools beside them. Grouped, the bar asks the question
131
+ * once and the answers live inside it.
132
+ *
133
+ * Use it ONLY for mutually exclusive outcomes. A pair like confirm/cancel that
134
+ * an operator may legitimately reach for at different moments is two buttons,
135
+ * not a group with two rows behind a chevron.
136
+ *
137
+ * Repeat the same descriptor object on every member (declare it once as a const
138
+ * and reference it); the first member's copy defines the trigger and fixes the
139
+ * group's position in the bar.
140
+ */
141
+ interface PageActionsGroup {
142
+ /** Stable group identity. Doubles as the overflow-menu section id. */
143
+ id: string;
144
+ /** The question, as a verb phrase — "Record answer", "Close out". */
145
+ label: string;
146
+ /** Trigger icon. Falls back to the first member's. */
147
+ icon?: string;
148
+ }
122
149
  /**
123
150
  * Declarative page-header action. One array describes every action of a page —
124
151
  * inline buttons and overflow-menu items alike — and `PageActions` decides
@@ -139,6 +166,20 @@ interface PageActionsItem {
139
166
  description?: string;
140
167
  /** Inline button tone. At most ONE 'primary' item should be visible per state. */
141
168
  tone?: 'primary' | 'neutral';
169
+ /**
170
+ * Collapse this item into a shared decision control with its group siblings.
171
+ *
172
+ * Inline, the group renders as ONE outline trigger labeled `group.label` with
173
+ * the members as rows — solid instead if any inline member carries
174
+ * `tone: 'primary'`, so the one-solid-primary rule still holds and a grouped
175
+ * beat still reads as the beat. A group with a single available member skips
176
+ * the dropdown and renders that member as an ordinary button; a chevron
177
+ * hiding one row is chrome pretending to be a choice.
178
+ *
179
+ * In the overflow menu the members are flat rows in a section of their own —
180
+ * a submenu would bury an answer two levels deep.
181
+ */
182
+ group?: PageActionsGroup;
142
183
  /**
143
184
  * Placement tier:
144
185
  * - 'always' — inline at every width (the state's main action)
@@ -151,7 +192,9 @@ interface PageActionsItem {
151
192
  * Menu group id for 'menu' items (default 'default'). Groups render as
152
193
  * separated menu sections in first-appearance order; collapsed 'auto' items
153
194
  * form the leading group and utility items the trailing one. Convention:
154
- * put destructive rows in the last-declared section.
195
+ * put destructive rows in the last-declared section. Defaults to `group.id`
196
+ * when the item belongs to a decision group, so grouped answers stay together
197
+ * in the menu without restating the section.
155
198
  */
156
199
  section?: string;
157
200
  /** Menu-item color (e.g. 'error' for destructive rows). Inline tone wins inline. */
@@ -243,10 +286,16 @@ declare function useDirtyTracking<T extends Record<string, unknown>>(state: T):
243
286
  * Offset-based table pagination: `page`/`itemsPerPage`/`total` state with a
244
287
  * derived `offset` and ready-to-spread `queryParams { limit, offset }`.
245
288
  * `onPaginationChange` fires whenever page or page size changes (refetch hook).
289
+ * It may be async — the refetch it triggers almost always is — and the return
290
+ * value is deliberately ignored rather than awaited: the watcher is a
291
+ * fire-and-forget notification, not a lifecycle the caller can join. Typing it
292
+ * `() => void` instead made every async loader passed here a
293
+ * `no-misused-promises` finding at the call site (18 of them in reynt's
294
+ * console) for a shape that is correct by design.
246
295
  */
247
296
  declare function usePagination(options?: {
248
297
  defaultLimit?: number;
249
- onPaginationChange?: () => void;
298
+ onPaginationChange?: () => void | Promise<void>;
250
299
  }): {
251
300
  page: import("vue").Ref<number, number>;
252
301
  itemsPerPage: import("vue").Ref<number, number>;
@@ -260,4 +309,4 @@ declare function usePagination(options?: {
260
309
  resetPagination: () => void;
261
310
  };
262
311
  //#endregion
263
- export { type ApiErrorLike, type ApiErrorMessengerOptions, type ConfirmOptions, type FetchOrganizationsResult, HELP_PANEL_KEY, type HelpPanel, type HelpPanelAction, type HelpPanelOptions, type HelpPanelRegistration, type OrgStoreCore, type OrgStoreCoreOptions, PAGE_ACTIONS_COLLAPSE_BELOW, PAGE_HEADER_WIDTH, type PageActionsItem, type ValidationApiErrorLike, createApiErrorMessenger, createOrgStoreCore, resolveRuntimeConfigValue, useConfirm, useConfirmState, useDirtyTracking, useHelpPanel, usePagination };
312
+ export { type ApiErrorLike, type ApiErrorMessengerOptions, type ConfirmOptions, type FetchOrganizationsResult, HELP_PANEL_KEY, type HelpPanel, type HelpPanelAction, type HelpPanelOptions, type HelpPanelRegistration, type OrgStoreCore, type OrgStoreCoreOptions, PAGE_ACTIONS_COLLAPSE_BELOW, PAGE_HEADER_WIDTH, type PageActionsGroup, type PageActionsItem, type ValidationApiErrorLike, createApiErrorMessenger, createOrgStoreCore, resolveRuntimeConfigValue, useConfirm, useConfirmState, useDirtyTracking, useHelpPanel, usePagination };
package/dist/index.js CHANGED
@@ -120,7 +120,10 @@ function useConfirmState() {
120
120
  *
121
121
  * Framework-free: pass `t`/`te` from your i18n instance (the app-side
122
122
  * composable is typically `const { t, te } = useI18n()` + this factory).
123
- * Eden Treaty error envelopes (`{ value }`) are unwrapped automatically.
123
+ * Errors wrapped in a `{ value }` envelope are unwrapped automatically — Eden
124
+ * Treaty's error shape originally, kept because it costs nothing and any
125
+ * client that boxes the body the same way gets the same handling. Hono's `hc`
126
+ * hands back the parsed body directly, so it takes the unwrapped path.
124
127
  */
125
128
  /** Lowercase + collapse every non-alphanumeric run to `_` (trimmed) — a flat, definable vue-i18n key segment. */
126
129
  function i18nSlug(value) {
@@ -246,6 +249,12 @@ function useDirtyTracking(state) {
246
249
  * Offset-based table pagination: `page`/`itemsPerPage`/`total` state with a
247
250
  * derived `offset` and ready-to-spread `queryParams { limit, offset }`.
248
251
  * `onPaginationChange` fires whenever page or page size changes (refetch hook).
252
+ * It may be async — the refetch it triggers almost always is — and the return
253
+ * value is deliberately ignored rather than awaited: the watcher is a
254
+ * fire-and-forget notification, not a lifecycle the caller can join. Typing it
255
+ * `() => void` instead made every async loader passed here a
256
+ * `no-misused-promises` finding at the call site (18 of them in reynt's
257
+ * console) for a shape that is correct by design.
249
258
  */
250
259
  function usePagination(options = {}) {
251
260
  const { defaultLimit = 50, onPaginationChange } = options;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@octabits-io/nuxt-ui-kit",
3
- "version": "0.14.2",
4
- "description": "Frontend kit for Nuxt/Vue admin SPAs: OIDC session harness (oidc-client-ts), Eden Treaty client factory, auth/org store cores, and a route-guard builder — factory-style seams the app wires into its own plugins, stores, and middleware",
3
+ "version": "0.16.0",
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",
7
7
  "exports": {
@@ -73,22 +73,18 @@
73
73
  "url": "https://github.com/octabits-io/platform/issues"
74
74
  },
75
75
  "devDependencies": {
76
- "@elysiajs/eden": "^1.4.9",
77
76
  "date-fns": "^4.4.0",
78
- "elysia": "^1.4.29",
79
77
  "oidc-client-ts": "^3.5.0",
80
- "vitest": "^4.1.10",
78
+ "vitest": "^4.1.11",
81
79
  "vue": "^3.5.41",
82
80
  "zod": "^4.4.3",
83
- "@octabits-io/framework": "^0.22.0"
81
+ "@octabits-io/framework": "^0.33.0"
84
82
  },
85
83
  "peerDependencies": {
86
- "@elysiajs/eden": "^1.4.0",
87
84
  "@internationalized/date": "^3",
88
85
  "@nuxt/ui": "^4",
89
86
  "@octabits-io/framework": ">=0.4.0 <1",
90
87
  "date-fns": "^3 || ^4",
91
- "elysia": ">=1.4 <3",
92
88
  "oidc-client-ts": "^3.3.0",
93
89
  "typescript": "^5 || ^6 || ^7",
94
90
  "vue": "^3.5.0",
@@ -97,9 +93,6 @@
97
93
  "zod": "^4"
98
94
  },
99
95
  "peerDependenciesMeta": {
100
- "@elysiajs/eden": {
101
- "optional": true
102
- },
103
96
  "@octabits-io/framework": {
104
97
  "optional": true
105
98
  },
@@ -112,9 +105,6 @@
112
105
  "date-fns": {
113
106
  "optional": true
114
107
  },
115
- "elysia": {
116
- "optional": true
117
- },
118
108
  "oidc-client-ts": {
119
109
  "optional": true
120
110
  },
@@ -14,7 +14,16 @@ import PageActionMenu from './PageActionMenu.vue';
14
14
  // Package-name import (not ../composables): only src/components is packed, and
15
15
  // the dist-barrel Symbol instance must match the consumer's HELP_PANEL_KEY provider.
16
16
  import { HELP_PANEL_KEY } from '@octabits-io/nuxt-ui-kit';
17
- import { PAGE_ACTIONS_COLLAPSE_BELOW, PAGE_HEADER_WIDTH, type PageActionsItem } from './pageActions.ts';
17
+ import UButton from '@nuxt/ui/components/Button.vue';
18
+ import {
19
+ PAGE_ACTIONS_COLLAPSE_BELOW,
20
+ PAGE_HEADER_WIDTH,
21
+ foldInlineActions,
22
+ groupIsPrimary,
23
+ isInlineBound as isItemInlineBound,
24
+ resolveCollapseStages,
25
+ type PageActionsItem,
26
+ } from './pageActions.ts';
18
27
 
19
28
  /**
20
29
  * Width-aware page-header action cluster: one declarative list drives both the
@@ -23,6 +32,12 @@ import { PAGE_ACTIONS_COLLAPSE_BELOW, PAGE_HEADER_WIDTH, type PageActionsItem }
23
32
  * only 'always' items stay inline and everything else — 'auto' items, utility
24
33
  * items, and the Help trigger — moves into the ⋯ menu, keeping its label.
25
34
  *
35
+ * Items sharing a `group` fold into ONE inline control (see `PageActionsGroup`):
36
+ * mutually exclusive answers to a single question stop competing for the bar as
37
+ * peers. This is the only rank between "inline button" and "buried in ⋯" —
38
+ * without it every neutral inline action is the same ghost weight, and a bar of
39
+ * six says nothing about which one an operator reaches for.
40
+ *
26
41
  * The Help trigger is rendered automatically when a `HELP_PANEL_KEY` registry
27
42
  * with registered actions is provided (replaces `PageUtilityActions` on pages
28
43
  * using this component — pass `:utility="false"` to `PageHeader`).
@@ -35,6 +50,25 @@ const props = withDefaults(defineProps<{
35
50
  utilityItems?: PageActionsItem[]
36
51
  /** Header width (px) below which 'auto'/utility items collapse into the menu. */
37
52
  collapseBelow?: number
53
+ /**
54
+ * Header width (px) below which ONLY the utility region (utility items + the
55
+ * Help trigger) collapses into the menu, while every action stays inline.
56
+ *
57
+ * Exists because the fallback below `collapseBelow` is `flex-wrap`, and a bar
58
+ * that does not fit therefore WRAPS rather than collapsing — silently, into
59
+ * two or three rows, with the record's title stranded on a line of its own.
60
+ * Between `collapseBelow` and "actually fits" that was the only behaviour
61
+ * available, and it dropped nothing, so it chose the wrap point arbitrarily.
62
+ *
63
+ * A second threshold makes the loss ordered instead: utilities go first,
64
+ * because they are the only things in the bar that change nothing — then the
65
+ * 'auto' actions at `collapseBelow`, then the wrap. Set it per header from
66
+ * what that header's widest state actually needs.
67
+ *
68
+ * Defaults to `collapseBelow`, i.e. no separate stage and no change for any
69
+ * caller that does not ask for one.
70
+ */
71
+ utilityCollapseBelow?: number
38
72
  /** Render the built-in Help trigger (when a help registry with actions is
39
73
  * provided). Disable in nested/panel headers where the page-level header
40
74
  * already owns Help. */
@@ -42,6 +76,7 @@ const props = withDefaults(defineProps<{
42
76
  }>(), {
43
77
  utilityItems: () => [],
44
78
  collapseBelow: PAGE_ACTIONS_COLLAPSE_BELOW,
79
+ utilityCollapseBelow: undefined,
45
80
  help: true,
46
81
  });
47
82
 
@@ -51,21 +86,24 @@ const headerWidth = inject(PAGE_HEADER_WIDTH, null);
51
86
 
52
87
  // null (no PageHeader provider / pre-measurement) counts as wide — the
53
88
  // flex-wrap fallback keeps an unexpectedly narrow first frame usable.
54
- const collapsed = computed(() => {
55
- const width = headerWidth?.value;
56
- return width != null && width < props.collapseBelow;
57
- });
89
+ const stages = computed(() =>
90
+ resolveCollapseStages(headerWidth?.value ?? null, props.collapseBelow, props.utilityCollapseBelow),
91
+ );
92
+ const collapsed = computed(() => stages.value.collapsed);
93
+ const utilitiesCollapsed = computed(() => stages.value.utilitiesCollapsed);
58
94
 
59
95
  const showHelp = computed(() => props.help && Boolean(helpPanel?.hasActions.value));
60
96
 
61
97
  const actionItems = computed(() => props.items.filter(item => (item.kind ?? 'action') !== 'ai'));
62
98
  const aiItems = computed(() => props.items.filter(item => item.kind === 'ai'));
63
99
 
64
- const isInlineBound = (item: PageActionsItem) =>
65
- (item.visibility ?? 'auto') === 'always'
66
- || ((item.visibility ?? 'auto') === 'auto' && !collapsed.value);
100
+ const isInlineBound = (item: PageActionsItem) => isItemInlineBound(item, collapsed.value);
67
101
 
68
- const inlineItems = computed(() => actionItems.value.filter(isInlineBound));
102
+ const inlineEntries = computed(() => foldInlineActions(actionItems.value, collapsed.value));
103
+
104
+ function toGroupMenuItem(item: PageActionsItem): DropdownMenuItem {
105
+ return { ...toMenuItem(item), description: item.description };
106
+ }
69
107
 
70
108
  // AI cluster: one inline item renders as its own verb-labeled AiButton; several
71
109
  // share a labeled "AI ∨" dropdown (icons + descriptions per row).
@@ -81,7 +119,7 @@ const aiDropdownItems = computed<DropdownMenuItem[]>(() =>
81
119
  })),
82
120
  );
83
121
 
84
- const inlineUtilityItems = computed(() => collapsed.value ? [] : props.utilityItems);
122
+ const inlineUtilityItems = computed(() => utilitiesCollapsed.value ? [] : props.utilityItems);
85
123
 
86
124
  // Descriptions render only in the dedicated AI dropdown (which has wrap/width
87
125
  // styling) — the compact ⋯ overflow stays label-only.
@@ -107,7 +145,7 @@ const menuGroups = computed<DropdownMenuItem[][]>(() => {
107
145
  const sections = new Map<string, PageActionsItem[]>();
108
146
  for (const item of actionItems.value) {
109
147
  if ((item.visibility ?? 'auto') !== 'menu') continue;
110
- const section = item.section ?? 'default';
148
+ const section = item.section ?? item.group?.id ?? 'default';
111
149
  if (!sections.has(section)) sections.set(section, []);
112
150
  sections.get(section)!.push(item);
113
151
  }
@@ -119,7 +157,7 @@ const menuGroups = computed<DropdownMenuItem[][]>(() => {
119
157
  || ((item.visibility ?? 'auto') === 'auto' && collapsed.value),
120
158
  );
121
159
 
122
- const utilityGroup: DropdownMenuItem[] = collapsed.value
160
+ const utilityGroup: DropdownMenuItem[] = utilitiesCollapsed.value
123
161
  ? [
124
162
  ...props.utilityItems.map(toMenuItem),
125
163
  ...(showHelp.value && helpPanel
@@ -137,25 +175,49 @@ const menuGroups = computed<DropdownMenuItem[][]>(() => {
137
175
  });
138
176
 
139
177
  const hasUtilityRegion = computed(() =>
140
- inlineUtilityItems.value.length > 0 || (showHelp.value && !collapsed.value),
178
+ inlineUtilityItems.value.length > 0 || (showHelp.value && !utilitiesCollapsed.value),
141
179
  );
142
180
  </script>
143
181
 
144
182
  <template>
145
- <PageAction
146
- v-for="item in inlineItems"
147
- :key="item.key"
148
- :icon="item.icon"
149
- :label="item.label"
150
- show-label
151
- :tone="item.tone ?? 'neutral'"
152
- :loading="item.loading"
153
- :disabled="item.disabled"
154
- :disabled-reason="item.disabledReason"
155
- :to="item.to"
156
- :target="item.target"
157
- @click="item.onSelect?.()"
158
- />
183
+ <template v-for="entry in inlineEntries" :key="entry.type === 'group' ? entry.group.id : entry.item.key">
184
+ <PageAction
185
+ v-if="entry.type === 'item'"
186
+ :icon="entry.item.icon"
187
+ :label="entry.item.label"
188
+ show-label
189
+ :tone="entry.item.tone ?? 'neutral'"
190
+ :loading="entry.item.loading"
191
+ :disabled="entry.item.disabled"
192
+ :disabled-reason="entry.item.disabledReason"
193
+ :to="entry.item.to"
194
+ :target="entry.item.target"
195
+ @click="entry.item.onSelect?.()"
196
+ />
197
+ <!-- A decision group: one question, its answers inside. Outline rather
198
+ than ghost — it is a decision, and the ghost tools beside it are not.
199
+ Solid when it carries the state's primary. -->
200
+ <UDropdownMenu
201
+ v-else
202
+ :items="[entry.items.map(toGroupMenuItem)]"
203
+ :content="{ align: 'end' }"
204
+ :ui="{
205
+ content: 'w-64',
206
+ item: 'gap-2.5 p-2',
207
+ itemLabel: 'font-medium text-highlighted',
208
+ itemDescription: 'mt-0.5 whitespace-normal text-xs/4',
209
+ }"
210
+ >
211
+ <UButton
212
+ :icon="entry.group.icon ?? entry.items[0]!.icon"
213
+ :label="entry.group.label"
214
+ size="md"
215
+ :color="groupIsPrimary(entry.items) ? 'primary' : 'neutral'"
216
+ :variant="groupIsPrimary(entry.items) ? 'solid' : 'outline'"
217
+ trailing-icon="i-lucide-chevron-down"
218
+ />
219
+ </UDropdownMenu>
220
+ </template>
159
221
  <!-- AI cluster: soft-primary sparkles = "AI acts on data". One item → its
160
222
  verb label; several → the shared labeled dropdown. `size="md"` matches
161
223
  PageAction — AiButton keeps its `sm` default for in-page triggers. -->
@@ -203,7 +265,7 @@ const hasUtilityRegion = computed(() =>
203
265
  @click="item.onSelect?.()"
204
266
  />
205
267
  <PageAction
206
- v-if="showHelp && helpPanel"
268
+ v-if="showHelp && !utilitiesCollapsed && helpPanel"
207
269
  icon="i-lucide-circle-help"
208
270
  :label="t('pageChrome.help')"
209
271
  show-label
@@ -0,0 +1,158 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ foldInlineActions,
4
+ groupIsPrimary,
5
+ isInlineBound,
6
+ resolveCollapseStages,
7
+ type PageActionsGroup,
8
+ type PageActionsItem,
9
+ } from './pageActions.ts';
10
+
11
+ /**
12
+ * The rank between "inline button" and "buried in ⋯".
13
+ *
14
+ * Without a group the bar has exactly two inline weights — one solid primary
15
+ * and N identical ghosts — so a set of alternative outcomes is indistinguish-
16
+ * able from the unrelated tools beside it. These tests pin the folding, because
17
+ * every property that makes a group readable (its position, what it swallows,
18
+ * what it refuses to swallow) is silent when it breaks: the bar still renders,
19
+ * it just renders wrong.
20
+ */
21
+
22
+ const ANSWER: PageActionsGroup = { id: 'answer', label: 'Record answer', icon: 'i-lucide-reply' };
23
+ const CLOSEOUT: PageActionsGroup = { id: 'closeout', label: 'Close out' };
24
+
25
+ const item = (over: Partial<PageActionsItem> & { key: string }): PageActionsItem => ({
26
+ icon: 'i-lucide-dot',
27
+ label: over.key,
28
+ ...over,
29
+ });
30
+
31
+ const keysOf = (entries: ReturnType<typeof foldInlineActions>) =>
32
+ entries.map((entry) => (entry.type === 'group' ? `${entry.group.id}[${entry.items.map((i) => i.key).join(',')}]` : entry.item.key));
33
+
34
+ describe('isInlineBound', () => {
35
+ it("keeps 'always' inline at every width and drops 'menu' at every width", () => {
36
+ for (const collapsed of [false, true]) {
37
+ expect(isInlineBound(item({ key: 'a', visibility: 'always' }), collapsed)).toBe(true);
38
+ expect(isInlineBound(item({ key: 'm', visibility: 'menu' }), collapsed)).toBe(false);
39
+ }
40
+ });
41
+
42
+ it("collapses 'auto' — the default — only when narrow", () => {
43
+ expect(isInlineBound(item({ key: 'a' }), false)).toBe(true);
44
+ expect(isInlineBound(item({ key: 'a' }), true)).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe('foldInlineActions', () => {
49
+ it('folds group members into one entry at the FIRST member position', () => {
50
+ // The group must not drift to the end of the bar: an operator reads the bar
51
+ // left to right, and the caller's declaration order is the reading order.
52
+ const entries = foldInlineActions([
53
+ item({ key: 'publish', visibility: 'always' }),
54
+ item({ key: 'yes', group: ANSWER, visibility: 'always' }),
55
+ item({ key: 'tool' }),
56
+ item({ key: 'no', group: ANSWER, visibility: 'always' }),
57
+ item({ key: 'silence', group: ANSWER, visibility: 'always' }),
58
+ ], false);
59
+
60
+ expect(keysOf(entries)).toEqual(['publish', 'answer[yes,no,silence]', 'tool']);
61
+ });
62
+
63
+ it('keeps separate groups separate', () => {
64
+ const entries = foldInlineActions([
65
+ item({ key: 'yes', group: ANSWER, visibility: 'always' }),
66
+ item({ key: 'departed', group: CLOSEOUT, visibility: 'always' }),
67
+ item({ key: 'no', group: ANSWER, visibility: 'always' }),
68
+ item({ key: 'no-show', group: CLOSEOUT, visibility: 'always' }),
69
+ ], false);
70
+
71
+ expect(keysOf(entries)).toEqual(['answer[yes,no]', 'closeout[departed,no-show]']);
72
+ });
73
+
74
+ it('unwraps a group left with one available member', () => {
75
+ // A chevron over a single row is chrome pretending to be a choice — and the
76
+ // unwrapped member must render as itself, keeping its own label and tone.
77
+ const entries = foldInlineActions([
78
+ item({ key: 'yes', group: ANSWER, visibility: 'always', tone: 'primary' }),
79
+ item({ key: 'no', group: ANSWER, visibility: 'menu' }),
80
+ ], false);
81
+
82
+ expect(entries).toHaveLength(1);
83
+ expect(entries[0]).toMatchObject({ type: 'item', item: { key: 'yes', tone: 'primary' } });
84
+ });
85
+
86
+ it('never offers a row the state disallows', () => {
87
+ // A member the caller sent to the menu is not an answer the operator may
88
+ // give — the trigger must not list it.
89
+ const entries = foldInlineActions([
90
+ item({ key: 'yes', group: ANSWER, visibility: 'always' }),
91
+ item({ key: 'no', group: ANSWER, visibility: 'always' }),
92
+ item({ key: 'silence', group: ANSWER, visibility: 'menu' }),
93
+ ], false);
94
+
95
+ expect(keysOf(entries)).toEqual(['answer[yes,no]']);
96
+ });
97
+
98
+ it("drops a whole group whose members are all 'auto' once collapsed", () => {
99
+ // They reappear as flat menu rows (menuGroups keys their section off
100
+ // group.id) rather than as a submenu — an answer two levels deep is buried.
101
+ const items = [
102
+ item({ key: 'yes', group: ANSWER }),
103
+ item({ key: 'no', group: ANSWER }),
104
+ ];
105
+ expect(keysOf(foldInlineActions(items, false))).toEqual(['answer[yes,no]']);
106
+ expect(foldInlineActions(items, true)).toEqual([]);
107
+ });
108
+
109
+ it('leaves ungrouped bars exactly as they were', () => {
110
+ const items = [
111
+ item({ key: 'confirm', tone: 'primary', visibility: 'always' }),
112
+ item({ key: 'cancel' }),
113
+ item({ key: 'archive', visibility: 'menu' }),
114
+ ];
115
+ expect(keysOf(foldInlineActions(items, false))).toEqual(['confirm', 'cancel']);
116
+ expect(keysOf(foldInlineActions(items, true))).toEqual(['confirm']);
117
+ });
118
+ });
119
+
120
+ describe('groupIsPrimary', () => {
121
+ it('is solid when the group carries the beat, outline otherwise', () => {
122
+ // Folding a beat into a group must not demote it — close-out IS what the
123
+ // rail asks for, and it has to keep reading as the one solid button.
124
+ expect(groupIsPrimary([item({ key: 'departed', tone: 'primary' }), item({ key: 'no-show' })])).toBe(true);
125
+ // Nothing is due while an offer waits on the guest, so nothing shouts.
126
+ expect(groupIsPrimary([item({ key: 'yes' }), item({ key: 'no' })])).toBe(false);
127
+ });
128
+ });
129
+
130
+ describe('resolveCollapseStages', () => {
131
+ const stages = (width: number | null) => resolveCollapseStages(width, 640, 880);
132
+
133
+ it('drops the utilities before it drops any action', () => {
134
+ // The band this exists for: wide enough that nothing used to collapse, too
135
+ // narrow to fit — where the header silently wrapped instead.
136
+ expect(stages(900)).toEqual({ collapsed: false, utilitiesCollapsed: false });
137
+ expect(stages(700)).toEqual({ collapsed: false, utilitiesCollapsed: true });
138
+ expect(stages(500)).toEqual({ collapsed: true, utilitiesCollapsed: true });
139
+ });
140
+
141
+ it('never leaves the utilities inline while the optional actions are gone', () => {
142
+ // A caller passing a utility threshold BELOW collapseBelow would otherwise
143
+ // produce exactly that bar. `collapsed` implies the utility stage.
144
+ expect(resolveCollapseStages(500, 640, 300)).toEqual({ collapsed: true, utilitiesCollapsed: true });
145
+ });
146
+
147
+ it('treats a pre-measurement width as wide', () => {
148
+ // Otherwise every mount flashes a collapsed bar before the observer fires.
149
+ expect(resolveCollapseStages(null, 640, 880)).toEqual({ collapsed: false, utilitiesCollapsed: false });
150
+ });
151
+
152
+ it('is a no-op stage when no utility threshold is given', () => {
153
+ for (const width of [500, 700, 900]) {
154
+ const s = resolveCollapseStages(width, 640);
155
+ expect(s.utilitiesCollapsed).toBe(s.collapsed);
156
+ }
157
+ });
158
+ });
@@ -1,6 +1,34 @@
1
1
  import type { InjectionKey, Ref } from 'vue';
2
2
  import type { RouteLocationRaw } from 'vue-router';
3
3
 
4
+ /**
5
+ * A set of actions that are alternative ANSWERS to one question, collapsed
6
+ * into a single inline control.
7
+ *
8
+ * The distinction it draws is between a bar that asks one thing and a bar that
9
+ * asks several. Three buttons — "record yes", "record no", "record silence" —
10
+ * are not three operations; they are one operation with three outcomes, and
11
+ * rendered as peers they read as three independent things to do, indistinguish-
12
+ * able from the unrelated tools beside them. Grouped, the bar asks the question
13
+ * once and the answers live inside it.
14
+ *
15
+ * Use it ONLY for mutually exclusive outcomes. A pair like confirm/cancel that
16
+ * an operator may legitimately reach for at different moments is two buttons,
17
+ * not a group with two rows behind a chevron.
18
+ *
19
+ * Repeat the same descriptor object on every member (declare it once as a const
20
+ * and reference it); the first member's copy defines the trigger and fixes the
21
+ * group's position in the bar.
22
+ */
23
+ export interface PageActionsGroup {
24
+ /** Stable group identity. Doubles as the overflow-menu section id. */
25
+ id: string;
26
+ /** The question, as a verb phrase — "Record answer", "Close out". */
27
+ label: string;
28
+ /** Trigger icon. Falls back to the first member's. */
29
+ icon?: string;
30
+ }
31
+
4
32
  /**
5
33
  * Declarative page-header action. One array describes every action of a page —
6
34
  * inline buttons and overflow-menu items alike — and `PageActions` decides
@@ -21,6 +49,20 @@ export interface PageActionsItem {
21
49
  description?: string;
22
50
  /** Inline button tone. At most ONE 'primary' item should be visible per state. */
23
51
  tone?: 'primary' | 'neutral';
52
+ /**
53
+ * Collapse this item into a shared decision control with its group siblings.
54
+ *
55
+ * Inline, the group renders as ONE outline trigger labeled `group.label` with
56
+ * the members as rows — solid instead if any inline member carries
57
+ * `tone: 'primary'`, so the one-solid-primary rule still holds and a grouped
58
+ * beat still reads as the beat. A group with a single available member skips
59
+ * the dropdown and renders that member as an ordinary button; a chevron
60
+ * hiding one row is chrome pretending to be a choice.
61
+ *
62
+ * In the overflow menu the members are flat rows in a section of their own —
63
+ * a submenu would bury an answer two levels deep.
64
+ */
65
+ group?: PageActionsGroup;
24
66
  /**
25
67
  * Placement tier:
26
68
  * - 'always' — inline at every width (the state's main action)
@@ -33,7 +75,9 @@ export interface PageActionsItem {
33
75
  * Menu group id for 'menu' items (default 'default'). Groups render as
34
76
  * separated menu sections in first-appearance order; collapsed 'auto' items
35
77
  * form the leading group and utility items the trailing one. Convention:
36
- * put destructive rows in the last-declared section.
78
+ * put destructive rows in the last-declared section. Defaults to `group.id`
79
+ * when the item belongs to a decision group, so grouped answers stay together
80
+ * in the menu without restating the section.
37
81
  */
38
82
  section?: string;
39
83
  /** Menu-item color (e.g. 'error' for destructive rows). Inline tone wins inline. */
@@ -55,3 +99,97 @@ export const PAGE_HEADER_WIDTH: InjectionKey<Ref<number | null>> = Symbol('page-
55
99
 
56
100
  /** Below this header width (px), 'auto' actions and utilities collapse into the menu. */
57
101
  export const PAGE_ACTIONS_COLLAPSE_BELOW = 640;
102
+
103
+ /** One slot in the inline bar: a lone action, or a folded decision group. */
104
+ export type PageActionsInlineEntry =
105
+ | { type: 'item'; item: PageActionsItem }
106
+ | { type: 'group'; group: PageActionsGroup; items: PageActionsItem[] };
107
+
108
+ /** Whether an item renders inline at the current width. */
109
+ export function isInlineBound(item: PageActionsItem, collapsed: boolean): boolean {
110
+ const visibility = item.visibility ?? 'auto';
111
+ return visibility === 'always' || (visibility === 'auto' && !collapsed);
112
+ }
113
+
114
+ /**
115
+ * The inline bar, in declaration order, with decision groups folded.
116
+ *
117
+ * Folded in a single pass over the original array rather than by partitioning
118
+ * it, because a group's position in the bar is its FIRST member's — pulling
119
+ * groups out and appending them would reorder the bar behind the caller's back.
120
+ *
121
+ * A group whose members are split across visibility tiers keeps only the
122
+ * inline-bound ones, so a trigger never offers a row the state disallows; and a
123
+ * group left with one member unwraps to an ordinary button, because a chevron
124
+ * over a single row is chrome pretending to be a choice.
125
+ *
126
+ * Pure, and exported for its test — the SFC only renders what this returns.
127
+ */
128
+ export function foldInlineActions(
129
+ items: PageActionsItem[],
130
+ collapsed: boolean,
131
+ ): PageActionsInlineEntry[] {
132
+ const entries: PageActionsInlineEntry[] = [];
133
+ const groupAt = new Map<string, number>();
134
+ for (const item of items) {
135
+ if (!isInlineBound(item, collapsed)) continue;
136
+ if (!item.group) {
137
+ entries.push({ type: 'item', item });
138
+ continue;
139
+ }
140
+ const at = groupAt.get(item.group.id);
141
+ if (at == null) {
142
+ groupAt.set(item.group.id, entries.length);
143
+ entries.push({ type: 'group', group: item.group, items: [item] });
144
+ } else {
145
+ (entries[at] as Extract<PageActionsInlineEntry, { type: 'group' }>).items.push(item);
146
+ }
147
+ }
148
+ return entries.map((entry) =>
149
+ entry.type === 'group' && entry.items.length === 1
150
+ ? { type: 'item' as const, item: entry.items[0]! }
151
+ : entry,
152
+ );
153
+ }
154
+
155
+ /**
156
+ * Solid only if the group carries the state's primary, so folding a beat into a
157
+ * group does not demote it and the one-solid-primary rule survives.
158
+ */
159
+ export function groupIsPrimary(items: PageActionsItem[]): boolean {
160
+ return items.some((item) => item.tone === 'primary');
161
+ }
162
+
163
+ /** What the current header width leaves inline. */
164
+ export interface PageActionsCollapseStages {
165
+ /** 'auto' actions, utility items and Help are all in the ⋯ menu. */
166
+ collapsed: boolean;
167
+ /** Utility items and Help are in the ⋯ menu; actions are still inline. */
168
+ utilitiesCollapsed: boolean;
169
+ }
170
+
171
+ /**
172
+ * The ordered stages of loss as a header narrows: utilities first, then the
173
+ * 'auto' actions, then (below everything) the header's own `flex-wrap`.
174
+ *
175
+ * `collapsed` implies `utilitiesCollapsed` by construction rather than by two
176
+ * comparisons agreeing — a caller passing a utility threshold BELOW
177
+ * `collapseBelow` would otherwise produce a bar that has dropped its optional
178
+ * actions while still rendering the optional utilities beside them.
179
+ *
180
+ * A null width is pre-measurement and counts as wide: the first frame renders
181
+ * everything and the ResizeObserver corrects it, rather than flashing a
182
+ * collapsed bar on every mount.
183
+ */
184
+ export function resolveCollapseStages(
185
+ width: number | null,
186
+ collapseBelow: number,
187
+ utilityCollapseBelow?: number,
188
+ ): PageActionsCollapseStages {
189
+ if (width == null) return { collapsed: false, utilitiesCollapsed: false };
190
+ const collapsed = width < collapseBelow;
191
+ return {
192
+ collapsed,
193
+ utilitiesCollapsed: collapsed || width < (utilityCollapseBelow ?? collapseBelow),
194
+ };
195
+ }