@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 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
- /** Register help actions for a tab */
246
- register(tabValue: string, actions: HelpPanelAction[]): void;
247
- /** Unregister help actions for a tab */
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
- registrations.delete(tabValue);
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.16.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; smaller title + thinner divider.
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-4'
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-lg font-semibold tracking-tight' : 'font-display text-2xl font-semibold tracking-tight')
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
- <div class="min-w-0">
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="text-sm text-muted mt-1">{{ subtitle }}</p>
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" />