@octabits-io/nuxt-ui-kit 0.16.0 → 0.17.1
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/dist/index.d.ts +19 -3
- package/dist/index.js +31 -2
- package/package.json +3 -1
- package/src/components/PageHeader.vue +43 -6
- package/src/components/SubSidebar.vue +16 -0
package/dist/index.d.ts
CHANGED
|
@@ -242,9 +242,19 @@ interface HelpPanel {
|
|
|
242
242
|
currentActions: ComputedRef<HelpPanelAction[]>;
|
|
243
243
|
/** Whether the active tab has any help actions */
|
|
244
244
|
hasActions: ComputedRef<boolean>;
|
|
245
|
-
/**
|
|
246
|
-
|
|
247
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Register help actions for a tab. Returns a disposer that removes *this*
|
|
247
|
+
* registration and no other — safe to call after the tab has been claimed by
|
|
248
|
+
* a later component, where it is a no-op.
|
|
249
|
+
*/
|
|
250
|
+
register(tabValue: string, actions: HelpPanelAction[]): () => void;
|
|
251
|
+
/**
|
|
252
|
+
* Unregister help actions for a tab.
|
|
253
|
+
*
|
|
254
|
+
* Only the component that registered the tab can remove it (see the
|
|
255
|
+
* ownership note on `useHelpPanel`). Registrations made outside a component
|
|
256
|
+
* have no owner and are removed unconditionally.
|
|
257
|
+
*/
|
|
248
258
|
unregister(tabValue: string): void;
|
|
249
259
|
/** Set the currently active tab */
|
|
250
260
|
setActiveTab(tabValue: string): void;
|
|
@@ -265,6 +275,12 @@ interface HelpPanelOptions {
|
|
|
265
275
|
* localStorage; switching to a tab without actions auto-closes the panel.
|
|
266
276
|
*
|
|
267
277
|
* Provide it per page: `provide(HELP_PANEL_KEY, useHelpPanel())`.
|
|
278
|
+
*
|
|
279
|
+
* **A registration belongs to the component that made it.** Where one registry
|
|
280
|
+
* is shared across routes, two components routinely share a tab value, and
|
|
281
|
+
* teardown of the old one interleaves with setup of the new — so removal is
|
|
282
|
+
* owner-checked rather than by key alone. Prefer the disposer `register`
|
|
283
|
+
* returns; `unregister(tab)` is equivalent and stays for existing callers.
|
|
268
284
|
*/
|
|
269
285
|
declare function useHelpPanel(options?: HelpPanelOptions): HelpPanel;
|
|
270
286
|
//#endregion
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed, reactive, ref, watch } from "vue";
|
|
1
|
+
import { computed, getCurrentInstance, reactive, ref, watch } from "vue";
|
|
2
2
|
//#region src/org/orgStore.ts
|
|
3
3
|
/**
|
|
4
4
|
* Reactive granted-organizations state + switching — the setup body of an
|
|
@@ -177,6 +177,12 @@ const HELP_PANEL_KEY = Symbol("help-panel");
|
|
|
177
177
|
* localStorage; switching to a tab without actions auto-closes the panel.
|
|
178
178
|
*
|
|
179
179
|
* Provide it per page: `provide(HELP_PANEL_KEY, useHelpPanel())`.
|
|
180
|
+
*
|
|
181
|
+
* **A registration belongs to the component that made it.** Where one registry
|
|
182
|
+
* is shared across routes, two components routinely share a tab value, and
|
|
183
|
+
* teardown of the old one interleaves with setup of the new — so removal is
|
|
184
|
+
* owner-checked rather than by key alone. Prefer the disposer `register`
|
|
185
|
+
* returns; `unregister(tab)` is equivalent and stays for existing callers.
|
|
180
186
|
*/
|
|
181
187
|
function useHelpPanel(options = {}) {
|
|
182
188
|
const storageKey = options.storageKey ?? "help-panel-open";
|
|
@@ -189,11 +195,34 @@ function useHelpPanel(options = {}) {
|
|
|
189
195
|
return registrations.get(activeTabValue.value)?.actions ?? [];
|
|
190
196
|
});
|
|
191
197
|
const hasActions = computed(() => currentActions.value.length > 0);
|
|
198
|
+
/**
|
|
199
|
+
* Which component owns each tab's registration.
|
|
200
|
+
*
|
|
201
|
+
* Consumers key registrations by *surface*, so several pages legitimately
|
|
202
|
+
* share one tab value (an admin console where every flat page registers
|
|
203
|
+
* `'detail'` is the motivating case). On a client-side navigation Vue runs
|
|
204
|
+
* the INCOMING component's `setup()` before the outgoing one's
|
|
205
|
+
* `onUnmounted`, so the calls arrive as: new registers, old unregisters. A
|
|
206
|
+
* delete-by-key therefore let a departing component remove its successor's
|
|
207
|
+
* registration, and the help trigger vanished for the rest of the session —
|
|
208
|
+
* every arrival wiped by the page it had just replaced.
|
|
209
|
+
*/
|
|
210
|
+
const owners = /* @__PURE__ */ new Map();
|
|
211
|
+
function remove(tabValue, caller) {
|
|
212
|
+
if (owners.has(tabValue) && owners.get(tabValue) !== caller) return;
|
|
213
|
+
owners.delete(tabValue);
|
|
214
|
+
registrations.delete(tabValue);
|
|
215
|
+
}
|
|
192
216
|
function register(tabValue, actions) {
|
|
217
|
+
const owner = getCurrentInstance();
|
|
218
|
+
if (owner) owners.set(tabValue, owner);
|
|
193
219
|
registrations.set(tabValue, { actions });
|
|
220
|
+
return () => {
|
|
221
|
+
remove(tabValue, owner);
|
|
222
|
+
};
|
|
194
223
|
}
|
|
195
224
|
function unregister(tabValue) {
|
|
196
|
-
|
|
225
|
+
remove(tabValue, getCurrentInstance());
|
|
197
226
|
}
|
|
198
227
|
function setActiveTab(tabValue) {
|
|
199
228
|
activeTabValue.value = tabValue;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octabits-io/nuxt-ui-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
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",
|
|
@@ -73,7 +73,9 @@
|
|
|
73
73
|
"url": "https://github.com/octabits-io/platform/issues"
|
|
74
74
|
},
|
|
75
75
|
"devDependencies": {
|
|
76
|
+
"@vue/test-utils": "^2.4.6",
|
|
76
77
|
"date-fns": "^4.4.0",
|
|
78
|
+
"happy-dom": "^15.11.7",
|
|
77
79
|
"oidc-client-ts": "^3.5.0",
|
|
78
80
|
"vitest": "^4.1.11",
|
|
79
81
|
"vue": "^3.5.41",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Shipped as source: the consumer's Vite compiles this SFC. All imports are
|
|
3
3
|
// explicit — no reliance on the consumer's auto-import configuration.
|
|
4
4
|
// i18n key contract: pageChrome.back (+ PageActionMenu/PageUtilityActions keys).
|
|
5
|
-
import { computed, onBeforeUnmount, onMounted, provide, ref } from 'vue'
|
|
5
|
+
import { computed, onBeforeUnmount, onMounted, provide, ref, useSlots } from 'vue'
|
|
6
6
|
import { useI18n } from 'vue-i18n'
|
|
7
7
|
import { useRouter, type RouteLocationRaw } from 'vue-router'
|
|
8
8
|
import UButton from '@nuxt/ui/components/Button.vue'
|
|
@@ -43,7 +43,12 @@ const props = withDefaults(defineProps<{
|
|
|
43
43
|
loading?: boolean
|
|
44
44
|
/**
|
|
45
45
|
* `default` = full-width top-of-page header with padding.
|
|
46
|
-
* `compact` = sits inside a detail panel / sidebar;
|
|
46
|
+
* `compact` = sits inside a detail panel / sidebar; ONE row, and it means it —
|
|
47
|
+
* smaller title, the subtitle beside it rather than under it, and
|
|
48
|
+
* padding sized to the action buttons instead of to two text
|
|
49
|
+
* lines. It used to differ from `default` only in title size and
|
|
50
|
+
* still spent `py-4` on a stacked title/subtitle, which on a
|
|
51
|
+
* split-pane view is 85px of chrome that never scrolls away.
|
|
47
52
|
* `flush` = no padding/border (caller wraps it).
|
|
48
53
|
*/
|
|
49
54
|
density?: 'default' | 'compact' | 'flush'
|
|
@@ -61,6 +66,7 @@ const props = withDefaults(defineProps<{
|
|
|
61
66
|
|
|
62
67
|
const { t } = useI18n()
|
|
63
68
|
const router = useRouter()
|
|
69
|
+
const slots = useSlots()
|
|
64
70
|
|
|
65
71
|
// Measured content width for PageActions' collapse decision. null until the
|
|
66
72
|
// first observation (treated as wide — the flex-wrap fallback covers it).
|
|
@@ -89,7 +95,7 @@ function onBack() {
|
|
|
89
95
|
const wrapperClass = computed(() => {
|
|
90
96
|
switch (props.density) {
|
|
91
97
|
case 'compact':
|
|
92
|
-
return 'flex items-center gap-2 flex-wrap border-b border-default px-6 py-
|
|
98
|
+
return 'flex items-center gap-2 flex-wrap border-b border-default px-6 py-2.5'
|
|
93
99
|
case 'flush':
|
|
94
100
|
return 'flex items-center gap-2 flex-wrap'
|
|
95
101
|
case 'default':
|
|
@@ -98,7 +104,35 @@ const wrapperClass = computed(() => {
|
|
|
98
104
|
}
|
|
99
105
|
})
|
|
100
106
|
|
|
101
|
-
const titleClass = computed(() => props.density === 'compact' ? 'font-display text-
|
|
107
|
+
const titleClass = computed(() => props.density === 'compact' ? 'font-display text-base font-semibold tracking-tight' : 'font-display text-2xl font-semibold tracking-tight')
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Title and subtitle side by side, on `compact` only, and only when this
|
|
111
|
+
* component is the one rendering them.
|
|
112
|
+
*
|
|
113
|
+
* A `#title` slot carries its own layout — the CMS detail panels put an icon
|
|
114
|
+
* beside a two-line block — and a baseline-aligned flex row would re-align it
|
|
115
|
+
* against text it does not contain. Slot users keep the plain wrapper they
|
|
116
|
+
* were laid out against; the change is for the prop path, which is the one
|
|
117
|
+
* that was stacking.
|
|
118
|
+
*/
|
|
119
|
+
const inlineHeading = computed(() => props.density === 'compact' && !slots.title)
|
|
120
|
+
/**
|
|
121
|
+
* No wrapping, and a subtitle that shrinks before anything else does.
|
|
122
|
+
*
|
|
123
|
+
* The wrapper is `flex-wrap`, so without `min-w-0` + `truncate` a long
|
|
124
|
+
* subtitle keeps its min-content width, wins the line, and pushes the action
|
|
125
|
+
* cluster onto a second row — a compact band that ends up TALLER than the
|
|
126
|
+
* stacked one it replaced (observed on the places page: 101px against 85).
|
|
127
|
+
* Truncating is the right degradation here: `compact` promises one row, and a
|
|
128
|
+
* subtitle long enough to truncate is help-panel material, not chrome.
|
|
129
|
+
*/
|
|
130
|
+
const headingClass = computed(() => inlineHeading.value
|
|
131
|
+
? 'flex min-w-0 items-baseline gap-x-2'
|
|
132
|
+
: 'min-w-0')
|
|
133
|
+
const subtitleClass = computed(() => inlineHeading.value
|
|
134
|
+
? 'text-sm text-muted min-w-0 truncate'
|
|
135
|
+
: 'text-sm text-muted mt-1')
|
|
102
136
|
</script>
|
|
103
137
|
|
|
104
138
|
<template>
|
|
@@ -113,12 +147,15 @@ const titleClass = computed(() => props.density === 'compact' ? 'font-display te
|
|
|
113
147
|
@click="onBack"
|
|
114
148
|
/>
|
|
115
149
|
|
|
116
|
-
|
|
150
|
+
<!-- Skipped entirely when there is nothing to head with: a compact band
|
|
151
|
+
may legitimately carry actions alone (the page title is already in the
|
|
152
|
+
breadcrumb), and an empty box here would still take the `gap-2`. -->
|
|
153
|
+
<div v-if="$slots.title || title || subtitle || loading" :class="headingClass">
|
|
117
154
|
<slot name="title">
|
|
118
155
|
<USkeleton v-if="loading" class="h-7 w-40" />
|
|
119
156
|
<h1 v-else-if="title" :class="titleClass">{{ title }}</h1>
|
|
120
157
|
</slot>
|
|
121
|
-
<p v-if="subtitle && !loading" class="
|
|
158
|
+
<p v-if="subtitle && !loading" :class="subtitleClass">{{ subtitle }}</p>
|
|
122
159
|
</div>
|
|
123
160
|
|
|
124
161
|
<div v-if="$slots.badges" class="flex items-center gap-2">
|
|
@@ -58,6 +58,22 @@ watch(
|
|
|
58
58
|
|
|
59
59
|
<template>
|
|
60
60
|
<div class="flex h-full w-full flex-col overflow-hidden">
|
|
61
|
+
<!--
|
|
62
|
+
The page's name, for anyone not looking at the breadcrumb.
|
|
63
|
+
|
|
64
|
+
A split view names itself in the chrome above it, so the header slot no
|
|
65
|
+
longer repeats the title (it was the breadcrumb's string verbatim, one
|
|
66
|
+
row down, on fixed chrome that never scrolls away). That is a visual
|
|
67
|
+
economy, not a semantic one: the page still has a name, and with nothing
|
|
68
|
+
selected the detail column has no heading of its own to stand in for it.
|
|
69
|
+
|
|
70
|
+
`headerless` only: without it the rail draws its own visible heading, and
|
|
71
|
+
a second, invisible one for the same shell is noise. The settings layout
|
|
72
|
+
nests one of each, which is how that showed up — "Settings" announced
|
|
73
|
+
twice before "Places".
|
|
74
|
+
-->
|
|
75
|
+
<h1 v-if="headerless" class="sr-only">{{ title }}</h1>
|
|
76
|
+
|
|
61
77
|
<!-- Page header zone: rendered once (unlike #sidebar), full width above both columns -->
|
|
62
78
|
<div v-if="$slots.header" class="shrink-0">
|
|
63
79
|
<slot name="header" />
|