@leavepulse/ui 0.30.1 → 0.31.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.
Files changed (35) hide show
  1. package/dist/assets/{LpTable-Bz3uJNa3.css → LpTable-Ckoi05Am.css} +8 -8
  2. package/dist/chunks/{LpAddressInput-Bk6D8Bcy.js → LpAddressInput-BDkp3-Bp.js} +1 -1
  3. package/dist/chunks/{LpAppShell-DnocEJAu.js → LpAppShell-Chz8Vbtu.js} +1 -1
  4. package/dist/chunks/LpBadge-Fe0D1bYx.js +62 -0
  5. package/dist/chunks/{LpDisclosure-C1_YkMrd.js → LpDisclosure-B4kVAWfg.js} +1 -1
  6. package/dist/chunks/{LpInput-CYcTL9gj.js → LpInput-Bt4J71xn.js} +3 -2
  7. package/dist/chunks/{LpNotificationBell-CTsIcPqX.js → LpNotificationBell-gdvQY89m.js} +1 -1
  8. package/dist/chunks/{LpPasswordInput-DrYE4J5d.js → LpPasswordInput-BqMAc5HA.js} +1 -1
  9. package/dist/chunks/{LpSidebar-CWSbczWi.js → LpSidebar-BiWM_lDB.js} +64 -14
  10. package/dist/chunks/{LpSidebarNav-D6v12-36.js → LpSidebarNav-CsgeuGAu.js} +1 -1
  11. package/dist/components/LpAddressInput.vue.js +1 -1
  12. package/dist/components/LpAppShell.vue.js +1 -1
  13. package/dist/components/LpBadge.vue.d.ts +13 -43
  14. package/dist/components/LpBadge.vue.js +1 -1
  15. package/dist/components/LpDisclosure.vue.js +1 -1
  16. package/dist/components/LpInput.vue.js +1 -1
  17. package/dist/components/LpNotificationBell.vue.js +1 -1
  18. package/dist/components/LpPasswordInput.vue.js +1 -1
  19. package/dist/components/LpSidebar.vue.d.ts +28 -10
  20. package/dist/components/LpSidebar.vue.js +1 -1
  21. package/dist/components/LpSidebarNav.vue.js +1 -1
  22. package/dist/components/LpTable.vue.js +47 -44
  23. package/dist/components/sidebar.d.ts +6 -0
  24. package/dist/composables/useMergedClass.d.ts +22 -0
  25. package/dist/composables/useMergedClass.js +13 -0
  26. package/dist/index.js +8 -8
  27. package/package.json +1 -1
  28. package/src/components/LpBadge.vue +32 -6
  29. package/src/components/LpDisclosure.vue +7 -1
  30. package/src/components/LpInput.vue +2 -1
  31. package/src/components/LpSidebar.vue +86 -3
  32. package/src/components/LpTable.vue +59 -39
  33. package/src/components/sidebar.ts +6 -0
  34. package/src/composables/useMergedClass.ts +56 -0
  35. package/dist/chunks/LpBadge-BPodVcJa.js +0 -45
@@ -5,7 +5,7 @@ import { _ as _sfc_main$4 } from "../chunks/LpIcon-CCnX5_2j.js";
5
5
  import { _ as _sfc_main$1 } from "../chunks/LpScrollArea-D8xyKXcF.js";
6
6
  import { useRovingFocus } from "../composables/useRovingFocus.js";
7
7
  import { _ as _export_sfc } from "../chunks/_plugin-vue_export-helper-1tPrXgE0.js";
8
- import '../assets/LpTable-Bz3uJNa3.css';const _hoisted_1 = { class: "w-full border-collapse text-sm" };
8
+ import '../assets/LpTable-Ckoi05Am.css';const _hoisted_1 = { class: "w-full border-collapse text-sm" };
9
9
  const _hoisted_2 = { class: "border-b border-line bg-surface-soft" };
10
10
  const _hoisted_3 = {
11
11
  key: 0,
@@ -15,7 +15,7 @@ const _hoisted_4 = ["aria-sort"];
15
15
  const _hoisted_5 = ["onClick"];
16
16
  const _hoisted_6 = { key: "lp-table-empty" };
17
17
  const _hoisted_7 = ["colspan"];
18
- const _hoisted_8 = ["onClick"];
18
+ const _hoisted_8 = ["onContextmenu", "onClick"];
19
19
  const _sfc_main = /* @__PURE__ */ defineComponent({
20
20
  __name: "LpTable",
21
21
  props: {
@@ -50,9 +50,17 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
50
50
  if (align === "center") return "text-center";
51
51
  return "text-left";
52
52
  }
53
- function menuFor(row) {
54
- return props.rowMenu?.(row) ?? [];
53
+ const menuRow = ref(null);
54
+ function onRowContextMenu(row) {
55
+ menuRow.value = row;
55
56
  }
57
+ const contextMenu = computed(() => {
58
+ const table = tableMenu();
59
+ const row = menuRow.value ? props.rowMenu?.(menuRow.value) ?? [] : [];
60
+ if (!row.length) return table;
61
+ if (!table.length) return row;
62
+ return [...row, { ...table[0], separatorBefore: true }, ...table.slice(1)];
63
+ });
56
64
  const ownSort = ref(null);
57
65
  const activeSort = computed(() => props.sort ?? ownSort.value);
58
66
  const hiddenValues = ref({});
@@ -274,7 +282,8 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
274
282
  }, {
275
283
  default: withCtx(() => [
276
284
  createVNode(_sfc_main$2, {
277
- items: tableMenu()
285
+ items: contextMenu.value,
286
+ always: ""
278
287
  }, {
279
288
  default: withCtx(() => [
280
289
  createElementVNode("table", _hoisted_1, [
@@ -350,45 +359,39 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
350
359
  ], 8, _hoisted_7)
351
360
  ])) : createCommentVNode("", true),
352
361
  (openBlock(true), createElementBlock(Fragment, null, renderList(displayRows.value, (row, index) => {
353
- return openBlock(), createBlock(_sfc_main$2, {
362
+ return openBlock(), createElementBlock("tr", mergeProps({
354
363
  key: keyFor(row, index),
355
- items: menuFor(row)
356
- }, {
357
- default: withCtx(() => [
358
- createElementVNode("tr", mergeProps({
359
- class: ["group border-b border-line/60 outline-none transition-colors last:border-0 hover:bg-surface-soft/60 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[active]:bg-surface-soft/40", selectedSet.value.has(keyFor(row, index)) ? "bg-brand-soft/40" : ""]
360
- }, { ref_for: true }, unref(roving).itemProps(String(keyFor(row, index))), {
361
- onClick: ($event) => emit("rowClick", row),
362
- onKeydown: onRowKeydown
363
- }), [
364
- __props.selectable ? (openBlock(), createElementBlock("td", {
365
- key: 0,
366
- class: "w-px px-4 py-3",
367
- onClick: _cache[0] || (_cache[0] = withModifiers(() => {
368
- }, ["stop"]))
369
- }, [
370
- createVNode(_sfc_main$3, {
371
- "model-value": selectedSet.value.has(keyFor(row, index)),
372
- "onUpdate:modelValue": (v) => toggleRow(keyFor(row, index), v)
373
- }, null, 8, ["model-value", "onUpdate:modelValue"])
374
- ])) : createCommentVNode("", true),
375
- (openBlock(true), createElementBlock(Fragment, null, renderList(visibleColumns.value, (col) => {
376
- return openBlock(), createElementBlock("td", {
377
- key: col.key,
378
- class: normalizeClass(["px-4 py-3 text-ink", alignClass(col.align)])
379
- }, [
380
- renderSlot(_ctx.$slots, `cell-${col.key}`, {
381
- row,
382
- value: row[col.key]
383
- }, () => [
384
- createTextVNode(toDisplayString(row[col.key]), 1)
385
- ], true)
386
- ], 2);
387
- }), 128))
388
- ], 16, _hoisted_8)
389
- ]),
390
- _: 2
391
- }, 1032, ["items"]);
364
+ onContextmenu: ($event) => onRowContextMenu(row),
365
+ class: ["group border-b border-line/60 outline-none transition-colors last:border-0 hover:bg-surface-soft/60 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[active]:bg-surface-soft/40", selectedSet.value.has(keyFor(row, index)) ? "bg-brand-soft/40" : ""]
366
+ }, { ref_for: true }, unref(roving).itemProps(String(keyFor(row, index))), {
367
+ onClick: ($event) => emit("rowClick", row),
368
+ onKeydown: onRowKeydown
369
+ }), [
370
+ __props.selectable ? (openBlock(), createElementBlock("td", {
371
+ key: 0,
372
+ class: "w-px px-4 py-3",
373
+ onClick: _cache[0] || (_cache[0] = withModifiers(() => {
374
+ }, ["stop"]))
375
+ }, [
376
+ createVNode(_sfc_main$3, {
377
+ "model-value": selectedSet.value.has(keyFor(row, index)),
378
+ "onUpdate:modelValue": (v) => toggleRow(keyFor(row, index), v)
379
+ }, null, 8, ["model-value", "onUpdate:modelValue"])
380
+ ])) : createCommentVNode("", true),
381
+ (openBlock(true), createElementBlock(Fragment, null, renderList(visibleColumns.value, (col) => {
382
+ return openBlock(), createElementBlock("td", {
383
+ key: col.key,
384
+ class: normalizeClass(["px-4 py-3 text-ink", alignClass(col.align)])
385
+ }, [
386
+ renderSlot(_ctx.$slots, `cell-${col.key}`, {
387
+ row,
388
+ value: row[col.key]
389
+ }, () => [
390
+ createTextVNode(toDisplayString(row[col.key]), 1)
391
+ ], true)
392
+ ], 2);
393
+ }), 128))
394
+ ], 16, _hoisted_8);
392
395
  }), 128))
393
396
  ]),
394
397
  _: 3
@@ -403,7 +406,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
403
406
  };
404
407
  }
405
408
  });
406
- const LpTable = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-f9c874a7"]]);
409
+ const LpTable = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-d139c3bb"]]);
407
410
  export {
408
411
  LpTable as default
409
412
  };
@@ -10,6 +10,12 @@ export interface SidebarItem {
10
10
  disabled?: boolean;
11
11
  /** Right-click menu for this item (e.g. "Open in new tab", "Pin"). */
12
12
  menu?: ContextMenuItemDef[];
13
+ /**
14
+ * Extra terms the sidebar's filter should match, beyond the label. For the
15
+ * item people look for under a name it does not carry — "Billing" found by
16
+ * "invoice", a page found by the feature it used to be called.
17
+ */
18
+ keywords?: string[];
13
19
  }
14
20
  export interface SidebarSection {
15
21
  /** Optional heading above the group. */
@@ -0,0 +1,22 @@
1
+ import { ComputedRef, MaybeRefOrGetter } from 'vue';
2
+ /**
3
+ * Merges the component's own classes with the `class` attribute the consumer
4
+ * passed, resolving Tailwind conflicts in the consumer's favour.
5
+ *
6
+ * ```ts
7
+ * const rootClass = useMergedClass(() => shell({ size: props.size }))
8
+ * ```
9
+ * ```vue
10
+ * <div :class="rootClass">
11
+ * ```
12
+ *
13
+ * Pair it with `inheritAttrs: false` (and bind the rest of `$attrs` yourself)
14
+ * so the raw `class` does not land on the element a second time, unmerged.
15
+ */
16
+ export declare function useMergedClass(own: MaybeRefOrGetter<string | undefined>): ComputedRef<string>;
17
+ /**
18
+ * The same merge as a plain function, for the places that already have both
19
+ * strings in hand and do not need the attrs lookup — a render helper, or a
20
+ * component merging a class onto a child rather than its own root.
21
+ */
22
+ export declare function mergeClass(...classes: (string | undefined | null | false)[]): string;
@@ -0,0 +1,13 @@
1
+ import { useAttrs, computed, toValue } from "vue";
2
+ import { cn } from "tailwind-variants";
3
+ function useMergedClass(own) {
4
+ const attrs = useAttrs();
5
+ return computed(() => cn(toValue(own), attrs.class) ?? "");
6
+ }
7
+ function mergeClass(...classes) {
8
+ return cn(...classes) ?? "";
9
+ }
10
+ export {
11
+ mergeClass,
12
+ useMergedClass
13
+ };
package/dist/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { _ } from "./chunks/LayoutCanvas-Dd20boUG.js";
2
- import { _ as _2 } from "./chunks/LpAddressInput-Bk6D8Bcy.js";
2
+ import { _ as _2 } from "./chunks/LpAddressInput-BDkp3-Bp.js";
3
3
  import { _ as _3 } from "./chunks/LpMapPicker-CXvoTrAe.js";
4
4
  import { OSM_TILES, createNominatimProvider, emptyAddress, formatAddress, project, tileUrl, unproject } from "./components/address.js";
5
5
  import { _ as _4 } from "./chunks/LpActionBar-BlvtGB2P.js";
6
6
  import { _ as _5 } from "./chunks/LpAlert-BAYHDSS1.js";
7
- import { _ as _6 } from "./chunks/LpAppShell-DnocEJAu.js";
7
+ import { _ as _6 } from "./chunks/LpAppShell-Chz8Vbtu.js";
8
8
  import { _ as _7 } from "./chunks/LpAutocomplete-DExlIZe_.js";
9
9
  import { _ as _8 } from "./chunks/LpAvatar-D3l4nabg.js";
10
- import { _ as _9 } from "./chunks/LpBadge-BPodVcJa.js";
10
+ import { _ as _9 } from "./chunks/LpBadge-Fe0D1bYx.js";
11
11
  import { _ as _10 } from "./chunks/LpBreadcrumbs-5uvViCXi.js";
12
12
  import { _ as _11 } from "./chunks/LpButton-5VXXCjwF.js";
13
13
  import { _ as _12 } from "./chunks/LpCalendar-B7ZL0ty3.js";
@@ -22,7 +22,7 @@ import { default as default3 } from "./components/LpCommandPalette.vue.js";
22
22
  import { _ as _17 } from "./chunks/LpConfirmDialog-CjOGcj1e.js";
23
23
  import { _ as _18 } from "./chunks/LpContextMenu-B6WBLleS.js";
24
24
  import { _ as _19 } from "./chunks/LpDatePicker-dY2q4dr4.js";
25
- import { _ as _20 } from "./chunks/LpDisclosure-C1_YkMrd.js";
25
+ import { _ as _20 } from "./chunks/LpDisclosure-B4kVAWfg.js";
26
26
  import { _ as _21 } from "./chunks/LpDivider-CUX46KR0.js";
27
27
  import { _ as _22 } from "./chunks/LpDrawer-DAJN5rGK.js";
28
28
  import { _ as _23 } from "./chunks/LpDropdownMenu-DJpoqRCb.js";
@@ -34,18 +34,18 @@ import { _ as _26 } from "./chunks/LpFormField-fV_2ZdCk.js";
34
34
  import { _ as _27 } from "./chunks/LpIcon-CCnX5_2j.js";
35
35
  import { default as default5 } from "./components/LpImageEditor.vue.js";
36
36
  import { _ as _28 } from "./chunks/LpInfraNode-Ds_8SZgN.js";
37
- import { _ as _29 } from "./chunks/LpInput-CYcTL9gj.js";
37
+ import { _ as _29 } from "./chunks/LpInput-Bt4J71xn.js";
38
38
  import { _ as _30 } from "./chunks/LpLightbox-Ci35Y_xP.js";
39
39
  import { fileNameOf } from "./components/lightbox.js";
40
40
  import { _ as _31 } from "./chunks/LpLink-BHZ1CLlF.js";
41
41
  import { _ as _32 } from "./chunks/LpLogViewer-TSRyt3t9.js";
42
42
  import { _ as _33 } from "./chunks/LpModal-D_o-56To.js";
43
- import { _ as _34 } from "./chunks/LpNotificationBell-CTsIcPqX.js";
43
+ import { _ as _34 } from "./chunks/LpNotificationBell-gdvQY89m.js";
44
44
  import { _ as _35 } from "./chunks/LpNumberField-NPE8Bumx.js";
45
45
  import { _ as _36 } from "./chunks/LpNumberFlow-amWYaeuA.js";
46
46
  import { _ as _37 } from "./chunks/LpOtpInput-DqaT0Z75.js";
47
47
  import { _ as _38 } from "./chunks/LpPagination-CeGuNe_q.js";
48
- import { _ as _39 } from "./chunks/LpPasswordInput-DrYE4J5d.js";
48
+ import { _ as _39 } from "./chunks/LpPasswordInput-BqMAc5HA.js";
49
49
  import { _ as _40 } from "./chunks/LpPie-BL1vTDSM.js";
50
50
  import { _ as _41 } from "./chunks/LpPhoneInput-NXXjciJJ.js";
51
51
  import { flagEmoji, loadCountries, matchCountryByValue } from "./components/countries.js";
@@ -61,7 +61,7 @@ import { _ as _50 } from "./chunks/LpSelect-ANIVtrpM.js";
61
61
  import { _ as _51 } from "./chunks/LpServiceNode-D8uJ8sGk.js";
62
62
  import { _ as _52 } from "./chunks/LpShift-DXNsauQZ.js";
63
63
  import { default as default6 } from "./components/LpShortcutsDialog.vue.js";
64
- import { _ as _53 } from "./chunks/LpSidebar-CWSbczWi.js";
64
+ import { _ as _53 } from "./chunks/LpSidebar-BiWM_lDB.js";
65
65
  import { _ as _54 } from "./chunks/LpSkeleton-CTI-vBgW.js";
66
66
  import { _ as _55 } from "./chunks/LpSlider-B1SF3pRV.js";
67
67
  import { _ as _56 } from "./chunks/LpSparkline-C6xQ2dwv.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.30.1",
3
+ "version": "0.31.1",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -1,10 +1,31 @@
1
+ <script lang="ts">
2
+ // The root class is merged with the consumer's (useMergedClass), so the raw
3
+ // `class` must not also land on the span unmerged.
4
+ export default { inheritAttrs: false }
5
+ </script>
6
+
1
7
  <script setup lang="ts">
2
8
  import { tv, type VariantProps } from "tailwind-variants"
3
- import { computed } from "vue"
9
+ import { useMergedClass } from "../composables/useMergedClass"
4
10
 
5
11
  const badge = tv({
6
- base: "inline-flex items-center gap-1.5 rounded-pill px-2.5 py-0.5 text-xs font-medium",
12
+ /*
13
+ * `align-middle` and a line-height pinned to the box, because the default
14
+ * `inline-flex` baseline is the one thing a badge must not bring with it: a
15
+ * badge dropped next to a text-sm heading sat a pixel or two off the text's
16
+ * baseline and pushed the whole row taller, so a section title grew a step
17
+ * the moment it gained a status. The badge now occupies the height it says it
18
+ * does and leaves the line it sits on alone.
19
+ */
20
+ base: "inline-flex shrink-0 items-center gap-1.5 rounded-pill align-middle font-medium leading-[1.2]",
7
21
  variants: {
22
+ size: {
23
+ // Fits inside a text-sm line without growing it — for a badge beside a
24
+ // heading, in a table cell, or anywhere a row's height is already set.
25
+ sm: "h-[18px] px-1.5 text-[11px]",
26
+ md: "px-2.5 py-0.5 text-xs",
27
+ lg: "px-3 py-1 text-sm",
28
+ },
8
29
  tone: {
9
30
  neutral: "bg-surface-soft text-muted-strong",
10
31
  brand: "bg-brand-soft text-brand",
@@ -17,18 +38,23 @@ const badge = tv({
17
38
  outline: "border border-line text-ink",
18
39
  },
19
40
  },
20
- defaultVariants: { tone: "neutral" },
41
+ defaultVariants: { tone: "neutral", size: "md" },
21
42
  })
22
43
 
23
44
  type BadgeVariants = VariantProps<typeof badge>
24
45
 
25
- const props = defineProps<{ tone?: BadgeVariants["tone"]; dot?: boolean }>()
46
+ const props = defineProps<{
47
+ tone?: BadgeVariants["tone"]
48
+ /** `sm` is the one that does not disturb a text-sm line it sits on. */
49
+ size?: BadgeVariants["size"]
50
+ dot?: boolean
51
+ }>()
26
52
 
27
- const classes = computed(() => badge({ tone: props.tone }))
53
+ const classes = useMergedClass(() => badge({ tone: props.tone, size: props.size }))
28
54
  </script>
29
55
 
30
56
  <template>
31
- <span :class="classes">
57
+ <span v-bind="$attrs" :class="classes">
32
58
  <span v-if="dot" class="size-1.5 rounded-full bg-current" />
33
59
  <slot />
34
60
  </span>
@@ -96,9 +96,15 @@ function onToggle(value: boolean) {
96
96
  The height animation lives on the wrapper; the inner panel fades + drifts
97
97
  up so the content eases in rather than snapping at full opacity. The slide
98
98
  is short (4px) and runs slightly behind the expand for a settled feel.
99
+
100
+ The bottom padding matches the sides rather than exceeding them: a taller
101
+ gap under the content reads as the block having been cut off short, and
102
+ with a control in there (a field, a row of buttons) the extra space below
103
+ it looked like a mistake next to the 12px at either side. The top stays
104
+ smaller because the trigger's own padding already separates the two.
99
105
  -->
100
106
  <div
101
- class="grid gap-2.5 px-3 pt-1 pb-3.5 group-data-[state=open]/content:animate-[disclosure-body-in_300ms_var(--ease-emphasized)] motion-reduce:animate-none"
107
+ class="grid gap-2.5 px-3 pt-1 pb-3 group-data-[state=open]/content:animate-[disclosure-body-in_300ms_var(--ease-emphasized)] motion-reduce:animate-none"
102
108
  >
103
109
  <slot />
104
110
  </div>
@@ -6,6 +6,7 @@ export default { inheritAttrs: false }
6
6
  import { tv, type VariantProps } from "tailwind-variants"
7
7
  import { computed, ref, useAttrs, useSlots } from "vue"
8
8
  import { useInputFilter } from "../composables/useInputFilter"
9
+ import { mergeClass } from "../composables/useMergedClass"
9
10
  import LpFormField from "./LpFormField.vue"
10
11
  import LpIcon from "./LpIcon.vue"
11
12
 
@@ -130,7 +131,7 @@ defineExpose({
130
131
  >
131
132
  <div
132
133
  data-lp-ring-owner
133
- :class="[shellClass, hasLeading ? 'pl-2.5' : '', hasTrailing ? 'pr-1.5' : '', hasField ? '' : rootClass]"
134
+ :class="mergeClass(shellClass, hasLeading ? 'pl-2.5' : '', hasTrailing ? 'pr-1.5' : '', hasField ? '' : (rootClass as string))"
134
135
  :style="hasField ? undefined : rootStyle"
135
136
  >
136
137
  <span v-if="hasLeading" class="flex shrink-0 items-center text-muted">
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { computed, ref, useId } from "vue"
17
17
  import LpDrawer from "./LpDrawer.vue"
18
+ import LpInput from "./LpInput.vue"
18
19
  import LpSidebarNav from "./LpSidebarNav.vue"
19
20
  import type { SidebarItem, SidebarSection } from "./sidebar"
20
21
 
@@ -33,6 +34,21 @@ const props = withDefaults(
33
34
  * given it overrides the modelValue check. The consumer owns the routing.
34
35
  */
35
36
  isActive?: (item: SidebarItem) => boolean
37
+ /**
38
+ * Put a filter field above the nav. A hundred-item sidebar is not read, it
39
+ * is searched: past a screenful, scanning costs more than typing, and every
40
+ * app that has one had built the same LpInput into the #header slot.
41
+ *
42
+ * Filtering is on `label` (and any `keywords` on the item), case- and
43
+ * accent-insensitive. Sections whose items all fail drop out rather than
44
+ * leaving a run of empty headings, and a collapsed section opens while a
45
+ * query is active — a hidden match is the same as no match.
46
+ */
47
+ searchable?: boolean
48
+ /** Placeholder for that field. */
49
+ searchPlaceholder?: string
50
+ /** The query, when the app wants to own it (v-model:search). */
51
+ search?: string
36
52
  /** Show a skeleton placeholder instead of the items. */
37
53
  loading?: boolean
38
54
  /** Skeleton row count while loading. */
@@ -97,6 +113,7 @@ const props = withDefaults(
97
113
  autoCollapseAfter?: number
98
114
  }>(),
99
115
  {
116
+ searchPlaceholder: "Search",
100
117
  skeletonRows: 6,
101
118
  skeletonHeader: true,
102
119
  responsive: false,
@@ -112,13 +129,52 @@ const emit = defineEmits<{
112
129
  (e: "update:open", value: boolean): void
113
130
  (e: "update:collapsed", keys: string[]): void
114
131
  (e: "select", item: SidebarItem): void
132
+ (e: "update:search", value: string): void
115
133
  }>()
116
134
 
117
135
  // Normalise both inputs to a single section list.
118
- const groups = computed<SidebarSection[]>(() =>
136
+ const allGroups = computed<SidebarSection[]>(() =>
119
137
  props.sections ?? (props.items ? [{ items: props.items }] : []),
120
138
  )
121
139
 
140
+ /*
141
+ * The filter query. Controlled through v-model:search when the app wants it —
142
+ * a nav whose query is part of the URL, say — and kept here otherwise, so
143
+ * `searchable` alone is enough to get a working field.
144
+ */
145
+ const ownSearch = ref("")
146
+ const query = computed({
147
+ get: () => props.search ?? ownSearch.value,
148
+ set: (v) => {
149
+ ownSearch.value = v
150
+ emit("update:search", v)
151
+ },
152
+ })
153
+
154
+ /** Case- and accent-insensitive, so "reglas" finds "Reglas" and "Règles". */
155
+ function normalise(s: string): string {
156
+ return s.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase()
157
+ }
158
+
159
+ function matches(item: SidebarItem, needle: string): boolean {
160
+ if (normalise(item.label).includes(needle)) return true
161
+ return (item.keywords ?? []).some((k) => normalise(k).includes(needle))
162
+ }
163
+
164
+ const groups = computed<SidebarSection[]>(() => {
165
+ const needle = normalise(query.value.trim())
166
+ if (!needle) return allGroups.value
167
+ // A section with no surviving items is dropped whole: a heading with nothing
168
+ // under it reads as a section that failed to load rather than one that
169
+ // matched nothing.
170
+ return allGroups.value
171
+ .map((section) => ({ ...section, items: section.items.filter((i) => matches(i, needle)) }))
172
+ .filter((section) => section.items.length > 0)
173
+ })
174
+
175
+ /** True while a query is narrowing the list — used to force groups open. */
176
+ const filtering = computed(() => query.value.trim().length > 0)
177
+
122
178
  function itemActive(item: SidebarItem): boolean {
123
179
  return props.isActive ? props.isActive(item) : item.id === props.modelValue
124
180
  }
@@ -152,8 +208,11 @@ const autoCollapsed = computed<string[]>(() => {
152
208
  .filter(Boolean)
153
209
  })
154
210
 
155
- const collapsedKeys = computed<string[]>(
156
- () => props.collapsed ?? ownCollapsed.value ?? autoCollapsed.value,
211
+ const collapsedKeys = computed<string[]>(() =>
212
+ // Nothing stays folded while a query is on: a section that matched but is
213
+ // closed looks exactly like a section that did not match, and the person is
214
+ // typing precisely because they cannot find the thing by eye.
215
+ filtering.value ? [] : (props.collapsed ?? ownCollapsed.value ?? autoCollapsed.value),
157
216
  )
158
217
 
159
218
  function toggleSection(key: string, collapsed: boolean) {
@@ -230,6 +289,18 @@ defineOptions({ inheritAttrs: false })
230
289
  <slot name="header" />
231
290
  </div>
232
291
 
292
+ <!-- The filter sits between the brand and the nav: it belongs to the list,
293
+ not to the header, and a person reaching for it is already looking at
294
+ the items. -->
295
+ <LpInput
296
+ v-if="searchable"
297
+ v-model="query"
298
+ size="sm"
299
+ icon="lucide:search"
300
+ class="mb-2 shrink-0"
301
+ :placeholder="searchPlaceholder"
302
+ :aria-label="searchPlaceholder"
303
+ />
233
304
  <LpSidebarNav
234
305
  :groups="groups"
235
306
  :pill-id="railPillId"
@@ -277,6 +348,18 @@ defineOptions({ inheritAttrs: false })
277
348
  <slot name="header" />
278
349
  </div>
279
350
 
351
+ <!-- The filter sits between the brand and the nav: it belongs to the list,
352
+ not to the header, and a person reaching for it is already looking at
353
+ the items. -->
354
+ <LpInput
355
+ v-if="searchable"
356
+ v-model="query"
357
+ size="sm"
358
+ icon="lucide:search"
359
+ class="mb-2 shrink-0"
360
+ :placeholder="searchPlaceholder"
361
+ :aria-label="searchPlaceholder"
362
+ />
280
363
  <LpSidebarNav
281
364
  :groups="groups"
282
365
  :pill-id="drawerPillId"
@@ -100,12 +100,40 @@ function alignClass(align?: "left" | "right" | "center"): string {
100
100
  return "text-left"
101
101
  }
102
102
 
103
- // Resolved right-click menu for a row, or [] when none — keeps the template
104
- // branch simple and avoids calling rowMenu twice per row.
105
- function menuFor(row: T): ContextMenuItemDef[] {
106
- return props.rowMenu?.(row) ?? []
103
+ /*
104
+ * The row a right-click landed on, and the menu the table shows because of it.
105
+ *
106
+ * Rows used to be wrapped one LpContextMenu each. Two things were wrong with
107
+ * that. LpContextMenu's root is a v-if/v-else pair and reka's ContextMenuRoot
108
+ * is a context provider with no element of its own, so every row handed
109
+ * <TransitionGroup> a fragment where it needs an element: Vue warned once per
110
+ * row ("renders non-element root node that cannot be animated") and the row
111
+ * enter/leave/move animations silently did nothing. It also stood up a full
112
+ * reka menu instance per row — fifty rows, fifty poppers, all closed.
113
+ *
114
+ * So the <tr> is now a plain child of the TransitionGroup, and the ONE menu
115
+ * already wrapping the whole table serves the rows too: the right-click is
116
+ * caught on the way up, the row it came from is remembered, and the menu is
117
+ * built for that row. `always` keeps that menu mounted even while it would
118
+ * otherwise be empty, so the first right-click is not swallowed while it
119
+ * populates.
120
+ */
121
+ const menuRow = ref<T | null>(null)
122
+
123
+ function onRowContextMenu(row: T) {
124
+ menuRow.value = row
107
125
  }
108
126
 
127
+ // Row items first — they are what the pointer is actually on — then the
128
+ // table-wide ones under a rule, when the row has any of its own.
129
+ const contextMenu = computed<ContextMenuItemDef[]>(() => {
130
+ const table = tableMenu()
131
+ const row = menuRow.value ? (props.rowMenu?.(menuRow.value) ?? []) : []
132
+ if (!row.length) return table
133
+ if (!table.length) return row
134
+ return [...row, { ...table[0], separatorBefore: true }, ...table.slice(1)]
135
+ })
136
+
109
137
  // ── sorting ──────────────────────────────────────────────────
110
138
  //
111
139
  // The parent may drive `sort` through v-model, which is how a server-side sort
@@ -421,7 +449,7 @@ const barInsetTop = computed(() =>
421
449
  <!-- The table-wide menu, which is what a right-click on empty header space
422
450
  reaches: rows and headers carry their own and take the event first, so
423
451
  this is the fallback rather than a competitor. -->
424
- <LpContextMenu :items="tableMenu()">
452
+ <LpContextMenu :items="contextMenu" always>
425
453
  <table class="w-full border-collapse text-sm">
426
454
  <thead ref="headEl" :class="stickyHeader ? 'sticky top-0 z-10' : ''">
427
455
  <tr class="border-b border-line bg-surface-soft">
@@ -500,44 +528,36 @@ const barInsetTop = computed(() =>
500
528
  <div>{{ emptyLabel }}</div>
501
529
  </td>
502
530
  </tr>
503
- <!-- Each row is wrapped in a right-click menu; with no rowMenu (or an
504
- empty result) LpContextMenu is a passthrough and the row keeps the
505
- browser's native menu. It renders via as-child, so the DOM stays a
506
- bare <tr> either way. -->
507
- <LpContextMenu
531
+ <!-- A plain <tr>, because TransitionGroup animates element children and
532
+ nothing else. The right-click menu is the table's one menu, told
533
+ which row the event came from — see `contextMenu`. -->
534
+ <tr
508
535
  v-for="(row, index) in displayRows"
509
536
  :key="keyFor(row, index)"
510
- :items="menuFor(row)"
537
+ @contextmenu="onRowContextMenu(row)"
538
+ class="group border-b border-line/60 outline-none transition-colors last:border-0 hover:bg-surface-soft/60 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[active]:bg-surface-soft/40"
539
+ :class="selectedSet.has(keyFor(row, index)) ? 'bg-brand-soft/40' : ''"
540
+ v-bind="roving.itemProps(String(keyFor(row, index)))"
541
+ @click="emit('rowClick', row)"
542
+ @keydown="onRowKeydown"
511
543
  >
512
- <!-- `group` so a cell's own content can answer the row's hover: a
513
- link inside a row wants its underline to sweep in when the row
514
- lights up, not when the pointer crosses the text exactly. The
515
- class paints nothing by itself. -->
516
- <tr
517
- class="group border-b border-line/60 outline-none transition-colors last:border-0 hover:bg-surface-soft/60 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring data-[active]:bg-surface-soft/40"
518
- :class="selectedSet.has(keyFor(row, index)) ? 'bg-brand-soft/40' : ''"
519
- v-bind="roving.itemProps(String(keyFor(row, index)))"
520
- @click="emit('rowClick', row)"
521
- @keydown="onRowKeydown"
544
+ <td v-if="selectable" class="w-px px-4 py-3" @click.stop>
545
+ <LpCheckbox
546
+ :model-value="selectedSet.has(keyFor(row, index))"
547
+ @update:model-value="(v) => toggleRow(keyFor(row, index), v)"
548
+ />
549
+ </td>
550
+ <td
551
+ v-for="col in visibleColumns"
552
+ :key="col.key"
553
+ class="px-4 py-3 text-ink"
554
+ :class="alignClass(col.align)"
522
555
  >
523
- <td v-if="selectable" class="w-px px-4 py-3" @click.stop>
524
- <LpCheckbox
525
- :model-value="selectedSet.has(keyFor(row, index))"
526
- @update:model-value="(v) => toggleRow(keyFor(row, index), v)"
527
- />
528
- </td>
529
- <td
530
- v-for="col in visibleColumns"
531
- :key="col.key"
532
- class="px-4 py-3 text-ink"
533
- :class="alignClass(col.align)"
534
- >
535
- <slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
536
- {{ row[col.key] }}
537
- </slot>
538
- </td>
539
- </tr>
540
- </LpContextMenu>
556
+ <slot :name="`cell-${col.key}`" :row="row" :value="row[col.key]">
557
+ {{ row[col.key] }}
558
+ </slot>
559
+ </td>
560
+ </tr>
541
561
  </TransitionGroup>
542
562
  </table>
543
563
  </LpContextMenu>
@@ -17,6 +17,12 @@ export interface SidebarItem {
17
17
  disabled?: boolean
18
18
  /** Right-click menu for this item (e.g. "Open in new tab", "Pin"). */
19
19
  menu?: ContextMenuItemDef[]
20
+ /**
21
+ * Extra terms the sidebar's filter should match, beyond the label. For the
22
+ * item people look for under a name it does not carry — "Billing" found by
23
+ * "invoice", a page found by the feature it used to be called.
24
+ */
25
+ keywords?: string[]
20
26
  }
21
27
 
22
28
  export interface SidebarSection {