@octabits-io/nuxt-ui-kit 0.17.0 → 0.17.2

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.17.0",
3
+ "version": "0.17.2",
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",
@@ -177,6 +177,21 @@ const menuGroups = computed<DropdownMenuItem[][]>(() => {
177
177
  const hasUtilityRegion = computed(() =>
178
178
  inlineUtilityItems.value.length > 0 || (showHelp.value && !utilitiesCollapsed.value),
179
179
  );
180
+
181
+ /**
182
+ * Is there anything to the LEFT of the utility region for the separator to
183
+ * separate it from? Everything the template renders before it, in order:
184
+ * the inline actions, the AI cluster, and the overflow menu (which draws
185
+ * nothing when its group list is empty).
186
+ *
187
+ * Without this the rule is drawn against the start of the cluster. A record
188
+ * route that declares no actions — only the help registry — renders a bar
189
+ * whose entire content is a vertical line and the Help button beside it, the
190
+ * line dividing Help from nothing.
191
+ */
192
+ const hasLeadingContent = computed(() =>
193
+ inlineEntries.value.length > 0 || inlineAiItems.value.length > 0 || menuGroups.value.length > 0,
194
+ );
180
195
  </script>
181
196
 
182
197
  <template>
@@ -251,7 +266,7 @@ const hasUtilityRegion = computed(() =>
251
266
  </UDropdownMenu>
252
267
  <PageActionMenu :items="menuGroups" />
253
268
  <template v-if="hasUtilityRegion">
254
- <USeparator orientation="vertical" class="h-5 mx-1" />
269
+ <USeparator v-if="hasLeadingContent" orientation="vertical" class="h-5 mx-1" />
255
270
  <PageAction
256
271
  v-for="item in inlineUtilityItems"
257
272
  :key="item.key"
@@ -117,11 +117,21 @@ const titleClass = computed(() => props.density === 'compact' ? 'font-display te
117
117
  * that was stacking.
118
118
  */
119
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
+ */
120
130
  const headingClass = computed(() => inlineHeading.value
121
- ? 'flex min-w-0 flex-wrap items-baseline gap-x-2'
131
+ ? 'flex min-w-0 items-baseline gap-x-2'
122
132
  : 'min-w-0')
123
133
  const subtitleClass = computed(() => inlineHeading.value
124
- ? 'text-sm text-muted min-w-0'
134
+ ? 'text-sm text-muted min-w-0 truncate'
125
135
  : 'text-sm text-muted mt-1')
126
136
  </script>
127
137
 
@@ -66,8 +66,13 @@ watch(
66
66
  row down, on fixed chrome that never scrolls away). That is a visual
67
67
  economy, not a semantic one: the page still has a name, and with nothing
68
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".
69
74
  -->
70
- <h1 class="sr-only">{{ title }}</h1>
75
+ <h1 v-if="headerless" class="sr-only">{{ title }}</h1>
71
76
 
72
77
  <!-- Page header zone: rendered once (unlike #sidebar), full width above both columns -->
73
78
  <div v-if="$slots.header" class="shrink-0">