@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 +17 -10
- package/dist/api/index.d.ts +1 -39
- package/dist/api/index.js +1 -30
- package/dist/events/index.js +0 -1
- package/dist/index.d.ts +53 -4
- package/dist/index.js +10 -1
- package/package.json +4 -14
- package/src/components/PageActions.vue +90 -28
- package/src/components/pageActions.test.ts +158 -0
- package/src/components/pageActions.ts +139 -1
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
|
-
- **
|
|
30
|
-
`
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
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', () =>
|
package/dist/api/index.d.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
22
|
+
export { createAccessTokenProvider, resolveApiBaseUrl };
|
package/dist/events/index.js
CHANGED
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 —
|
|
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
|
-
*
|
|
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.
|
|
4
|
-
"description": "Frontend kit for Nuxt/Vue admin SPAs: OIDC session harness (oidc-client-ts),
|
|
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.
|
|
78
|
+
"vitest": "^4.1.11",
|
|
81
79
|
"vue": "^3.5.41",
|
|
82
80
|
"zod": "^4.4.3",
|
|
83
|
-
"@octabits-io/framework": "^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
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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(() =>
|
|
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[] =
|
|
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 && !
|
|
178
|
+
inlineUtilityItems.value.length > 0 || (showHelp.value && !utilitiesCollapsed.value),
|
|
141
179
|
);
|
|
142
180
|
</script>
|
|
143
181
|
|
|
144
182
|
<template>
|
|
145
|
-
<
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
}
|