@leavepulse/ui 0.30.0 → 0.31.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/dist/assets/{LpTable-Bz3uJNa3.css → LpTable-Ckoi05Am.css} +8 -8
- package/dist/chunks/{LpActionBar-CxmBZpGu.js → LpActionBar-BlvtGB2P.js} +1 -1
- package/dist/chunks/{LpAddressInput-Bk6D8Bcy.js → LpAddressInput-BDkp3-Bp.js} +1 -1
- package/dist/chunks/{LpAppShell-DnocEJAu.js → LpAppShell-Chz8Vbtu.js} +1 -1
- package/dist/chunks/LpBadge-Fe0D1bYx.js +62 -0
- package/dist/chunks/{LpDisclosure-C1_YkMrd.js → LpDisclosure-B4kVAWfg.js} +1 -1
- package/dist/chunks/{LpInput-CYcTL9gj.js → LpInput-Bt4J71xn.js} +3 -2
- package/dist/chunks/{LpNotificationBell-CTsIcPqX.js → LpNotificationBell-gdvQY89m.js} +1 -1
- package/dist/chunks/{LpPasswordInput-DrYE4J5d.js → LpPasswordInput-BqMAc5HA.js} +1 -1
- package/dist/chunks/{LpSidebar-CWSbczWi.js → LpSidebar-BiWM_lDB.js} +64 -14
- package/dist/chunks/{LpSidebarNav-D6v12-36.js → LpSidebarNav-CsgeuGAu.js} +1 -1
- package/dist/components/LpActionBar.vue.js +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpBadge.vue.d.ts +13 -43
- package/dist/components/LpBadge.vue.js +1 -1
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpPasswordInput.vue.js +1 -1
- package/dist/components/LpSidebar.vue.d.ts +28 -10
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpTable.vue.js +47 -44
- package/dist/components/sidebar.d.ts +6 -0
- package/dist/composables/useMergedClass.d.ts +22 -0
- package/dist/composables/useMergedClass.js +13 -0
- package/dist/index.js +9 -9
- package/package.json +1 -1
- package/src/components/LpActionBar.vue +9 -1
- package/src/components/LpBadge.vue +32 -6
- package/src/components/LpDisclosure.vue +7 -1
- package/src/components/LpInput.vue +2 -1
- package/src/components/LpSidebar.vue +86 -3
- package/src/components/LpTable.vue +59 -39
- package/src/components/sidebar.ts +6 -0
- package/src/composables/useMergedClass.ts +56 -0
- 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-
|
|
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
|
-
|
|
54
|
-
|
|
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:
|
|
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(),
|
|
362
|
+
return openBlock(), createElementBlock("tr", mergeProps({
|
|
354
363
|
key: keyFor(row, index),
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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-
|
|
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-
|
|
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
|
-
import { _ as _4 } from "./chunks/LpActionBar-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
@@ -177,9 +177,17 @@ watch(
|
|
|
177
177
|
blur). Declaring it next to the form it belongs to should not decide
|
|
178
178
|
where on screen it lands. -->
|
|
179
179
|
<Teleport to="body">
|
|
180
|
+
<!-- `forwards` on the way out, because the leave animation finishing is not
|
|
181
|
+
the same moment as the element leaving. `rise-out` is a bare `to {}`, so
|
|
182
|
+
once it ends the element snaps back to opacity 1 at its resting
|
|
183
|
+
position, and Vue then takes a frame to unmount it — a bar that had
|
|
184
|
+
faded to nothing flashed back at full strength and vanished. Holding the
|
|
185
|
+
final frame means the last thing painted is the last frame of the fade.
|
|
186
|
+
Reduced motion skips it entirely rather than holding a frame it never
|
|
187
|
+
animated to. -->
|
|
180
188
|
<Transition
|
|
181
189
|
enter-active-class="animate-[rise-in_var(--duration-medium)_var(--ease-emphasized)]"
|
|
182
|
-
leave-active-class="animate-[rise-out_var(--duration-fast)
|
|
190
|
+
leave-active-class="animate-[rise-out_var(--duration-fast)_ease_forwards] motion-reduce:animate-none"
|
|
183
191
|
>
|
|
184
192
|
<div
|
|
185
193
|
v-if="open"
|
|
@@ -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 {
|
|
9
|
+
import { useMergedClass } from "../composables/useMergedClass"
|
|
4
10
|
|
|
5
11
|
const badge = tv({
|
|
6
|
-
|
|
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<{
|
|
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 =
|
|
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
|
|
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="
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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="
|
|
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
|
-
<!--
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
v-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
<
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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>
|