@leavepulse/ui 0.14.10 → 0.15.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.
@@ -1,7 +1,7 @@
1
1
  import { defineComponent, ref, watch, computed, openBlock, createElementBlock, Fragment, renderList, normalizeStyle, createBlock, createVNode, withCtx, createElementVNode, renderSlot, mergeProps, normalizeProps, guardReactiveProps, createTextVNode, toDisplayString, createCommentVNode } from "vue";
2
2
  import { subtreeIds, subtreeSize, formatSize, sortNodes, ancestorIds, checkStateOf } from "../components/fileTree.js";
3
3
  import { _ as _sfc_main$1 } from "./LpEmptyState-CrfyRJUG.js";
4
- import { _ as _sfc_main$3 } from "./LpFileTreeNode-D3lNEP-1.js";
4
+ import { _ as _sfc_main$3 } from "./LpFileTreeNode-3e71ywJV.js";
5
5
  import { _ as _sfc_main$2 } from "./LpScrollArea-BtnZCCBT.js";
6
6
  const _hoisted_1 = {
7
7
  key: 0,
@@ -9,7 +9,7 @@ const _hoisted_1 = {
9
9
  };
10
10
  const _hoisted_2 = {
11
11
  key: 2,
12
- class: "flex min-h-0 flex-col"
12
+ class: "flex h-full min-h-0 flex-col"
13
13
  };
14
14
  const _hoisted_3 = ["aria-multiselectable"];
15
15
  const _hoisted_4 = {
@@ -195,6 +195,10 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
195
195
  }
196
196
  function onSelect(node) {
197
197
  if (node.disabled) return;
198
+ if (props.selected === node.id) {
199
+ emit("update:selected", "");
200
+ return;
201
+ }
198
202
  emit("update:selected", node.id);
199
203
  emit("select", node);
200
204
  if (node.kind === "file") emit("open", node);
@@ -318,7 +322,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
318
322
  ref: root,
319
323
  role: "tree",
320
324
  "aria-multiselectable": __props.checkable,
321
- class: "flex flex-col",
325
+ class: "flex min-w-0 flex-col",
322
326
  onKeydown
323
327
  }, [
324
328
  (openBlock(true), createElementBlock(Fragment, null, renderList(roots.value, (node) => {
@@ -1,5 +1,5 @@
1
1
  import { defineComponent, computed, resolveComponent, openBlock, createElementBlock, createVNode, withCtx, createElementVNode, withModifiers, normalizeStyle, normalizeClass, createBlock, createCommentVNode, unref, renderSlot, createTextVNode, toDisplayString, Transition, Fragment, renderList, mergeProps } from "vue";
2
- import { sortNodes, checkStateOf, subtreeSize, formatSize, formatModified, fileIcon } from "../components/fileTree.js";
2
+ import { sortNodes, checkStateOf, subtreeSize, formatSize, checkedCount, formatModified, fileIcon } from "../components/fileTree.js";
3
3
  import { _ as _sfc_main$3 } from "./LpCheckbox-BO0zuuPh.js";
4
4
  import { _ as _sfc_main$1 } from "./LpContextMenu-D0mzXqA7.js";
5
5
  import { _ as _sfc_main$2 } from "./LpIcon-CCnX5_2j.js";
@@ -9,20 +9,24 @@ const _hoisted_3 = { class: "flex size-4 shrink-0 items-center justify-center" }
9
9
  const _hoisted_4 = { class: "min-w-0 flex-1 truncate" };
10
10
  const _hoisted_5 = {
11
11
  key: 1,
12
- class: "shrink-0 text-xs text-muted"
12
+ class: "shrink-0 rounded-pill bg-brand-soft px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-brand"
13
13
  };
14
14
  const _hoisted_6 = {
15
15
  key: 2,
16
- class: "shrink-0 text-xs tabular-nums text-muted/80"
16
+ class: "shrink-0 text-xs text-muted"
17
17
  };
18
18
  const _hoisted_7 = {
19
19
  key: 3,
20
- class: "w-16 shrink-0 text-right text-xs tabular-nums text-muted"
20
+ class: "shrink-0 text-xs tabular-nums text-muted/80"
21
21
  };
22
22
  const _hoisted_8 = {
23
+ key: 4,
24
+ class: "w-16 shrink-0 text-right text-xs tabular-nums text-muted"
25
+ };
26
+ const _hoisted_9 = {
23
27
  key: 0,
24
28
  role: "group",
25
- class: "flex flex-col overflow-hidden"
29
+ class: "flex min-w-0 flex-col overflow-hidden"
26
30
  };
27
31
  const _sfc_main = /* @__PURE__ */ defineComponent({
28
32
  __name: "LpFileTreeNode",
@@ -63,6 +67,11 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
63
67
  const bytes = subtreeSize(props.node);
64
68
  return bytes === void 0 ? "" : formatSize(bytes);
65
69
  });
70
+ const countLabel = computed(() => {
71
+ if (!props.checkable || !isDir.value) return "";
72
+ const { checked, total } = checkedCount(props.node, props.checked);
73
+ return total && checked && checked < total ? `${checked} / ${total}` : "";
74
+ });
66
75
  const modifiedLabel = computed(
67
76
  () => props.showModified && props.node.modified !== void 0 ? formatModified(props.node.modified) : ""
68
77
  );
@@ -104,6 +113,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
104
113
  const _component_LpFileTreeNode = resolveComponent("LpFileTreeNode", true);
105
114
  return openBlock(), createElementBlock("li", {
106
115
  role: "treeitem",
116
+ class: "min-w-0",
107
117
  "aria-level": __props.depth + 1,
108
118
  "aria-selected": isSelected.value,
109
119
  "aria-expanded": canExpand.value ? isOpen.value : void 0,
@@ -116,7 +126,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
116
126
  createElementVNode("div", {
117
127
  "data-node-id": __props.node.id,
118
128
  tabindex: isFocused.value ? 0 : -1,
119
- class: normalizeClass(["group/row relative flex cursor-pointer select-none items-center gap-1.5 rounded-control py-1 pr-2 text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring", [
129
+ class: normalizeClass(["group/row relative flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-control py-1 pr-2 text-sm outline-none transition-colors duration-[var(--duration-fast)] focus-visible:ring-2 focus-visible:ring-ring", [
120
130
  __props.node.disabled ? "cursor-not-allowed text-muted/50" : isSelected.value ? "bg-brand-soft text-ink" : "text-muted hover:bg-surface-soft hover:text-ink"
121
131
  ]]),
122
132
  style: normalizeStyle({ paddingLeft: `${__props.depth * 14 + 6}px` }),
@@ -161,9 +171,10 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
161
171
  createTextVNode(toDisplayString(__props.node.name), 1)
162
172
  ])
163
173
  ]),
164
- __props.node.meta ? (openBlock(), createElementBlock("span", _hoisted_5, toDisplayString(__props.node.meta), 1)) : createCommentVNode("", true),
165
- modifiedLabel.value ? (openBlock(), createElementBlock("span", _hoisted_6, toDisplayString(modifiedLabel.value), 1)) : createCommentVNode("", true),
166
- sizeLabel.value ? (openBlock(), createElementBlock("span", _hoisted_7, toDisplayString(sizeLabel.value), 1)) : createCommentVNode("", true)
174
+ countLabel.value ? (openBlock(), createElementBlock("span", _hoisted_5, toDisplayString(countLabel.value), 1)) : createCommentVNode("", true),
175
+ __props.node.meta ? (openBlock(), createElementBlock("span", _hoisted_6, toDisplayString(__props.node.meta), 1)) : createCommentVNode("", true),
176
+ modifiedLabel.value ? (openBlock(), createElementBlock("span", _hoisted_7, toDisplayString(modifiedLabel.value), 1)) : createCommentVNode("", true),
177
+ sizeLabel.value ? (openBlock(), createElementBlock("span", _hoisted_8, toDisplayString(sizeLabel.value), 1)) : createCommentVNode("", true)
167
178
  ], 14, _hoisted_2)
168
179
  ]),
169
180
  _: 3
@@ -174,7 +185,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
174
185
  onLeave: onBranchLeave
175
186
  }, {
176
187
  default: withCtx(() => [
177
- isOpen.value && hasChildren.value ? (openBlock(), createElementBlock("ul", _hoisted_8, [
188
+ isOpen.value && hasChildren.value ? (openBlock(), createElementBlock("ul", _hoisted_9, [
178
189
  (openBlock(true), createElementBlock(Fragment, null, renderList(children.value, (child, i) => {
179
190
  return openBlock(), createBlock(_component_LpFileTreeNode, {
180
191
  key: child.id,
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpFileTree-0Ko_uj8q.js";
1
+ import { _ as _sfc_main } from "../chunks/LpFileTree-D0DEpKof.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -1,4 +1,4 @@
1
- import { _ as _sfc_main } from "../chunks/LpFileTreeNode-D3lNEP-1.js";
1
+ import { _ as _sfc_main } from "../chunks/LpFileTreeNode-3e71ywJV.js";
2
2
  export {
3
3
  _sfc_main as default
4
4
  };
@@ -50,5 +50,14 @@ export type CheckState = "checked" | "unchecked" | "indeterminate";
50
50
  * ticked as a whole — which is exactly what a backup picker needs.
51
51
  */
52
52
  export declare function checkStateOf(node: FileNode, checked: ReadonlySet<string>): CheckState;
53
+ /**
54
+ * How much of a directory's subtree is ticked, counting FILES only (folders are
55
+ * containers, not payload). Lets a row say "3 / 12" so a half-filled checkbox
56
+ * isn't the only clue that a folder is partially selected.
57
+ */
58
+ export declare function checkedCount(node: FileNode, checked: ReadonlySet<string>): {
59
+ checked: number;
60
+ total: number;
61
+ };
53
62
  /** Ids of every ancestor directory of `id`, so callers can reveal a deep node. */
54
63
  export declare function ancestorIds(nodes: FileNode[], id: string): string[];
@@ -128,6 +128,22 @@ function checkStateOf(node, checked) {
128
128
  if (all) return "checked";
129
129
  return any ? "indeterminate" : "unchecked";
130
130
  }
131
+ function checkedCount(node, checked) {
132
+ let count = 0;
133
+ let total = 0;
134
+ function walk(list) {
135
+ for (const child of list) {
136
+ if (child.kind === "file") {
137
+ total++;
138
+ if (checked.has(child.id)) count++;
139
+ } else if (child.children?.length) {
140
+ walk(child.children);
141
+ }
142
+ }
143
+ }
144
+ walk(node.children ?? []);
145
+ return { checked: count, total };
146
+ }
131
147
  function ancestorIds(nodes, id) {
132
148
  const path = [];
133
149
  function walk(list, trail) {
@@ -146,6 +162,7 @@ function ancestorIds(nodes, id) {
146
162
  export {
147
163
  ancestorIds,
148
164
  checkStateOf,
165
+ checkedCount,
149
166
  fileIcon,
150
167
  formatModified,
151
168
  formatSize,
package/dist/index.d.ts CHANGED
@@ -28,7 +28,7 @@ export { default as LpEmptyState } from './components/LpEmptyState.vue';
28
28
  export { default as LpFileTree } from './components/LpFileTree.vue';
29
29
  export type { FileTreeSummary } from './components/LpFileTree.vue';
30
30
  export type { CheckState, FileNode } from './components/fileTree';
31
- export { ancestorIds, checkStateOf, fileIcon, formatModified, formatSize, sortNodes, subtreeIds, subtreeSize, } from './components/fileTree';
31
+ export { ancestorIds, checkedCount, checkStateOf, fileIcon, formatModified, formatSize, sortNodes, subtreeIds, subtreeSize, } from './components/fileTree';
32
32
  export { default as LpFormField } from './components/LpFormField.vue';
33
33
  export { default as LpIcon } from './components/LpIcon.vue';
34
34
  export { default as LpInfraNode } from './components/LpInfraNode.vue';
package/dist/index.js CHANGED
@@ -19,8 +19,8 @@ import { _ as _17 } from "./chunks/LpDivider-CUX46KR0.js";
19
19
  import { _ as _18 } from "./chunks/LpDrawer-6n5Hd-iF.js";
20
20
  import { _ as _19 } from "./chunks/LpDropdownMenu-DJpoqRCb.js";
21
21
  import { _ as _20 } from "./chunks/LpEmptyState-CrfyRJUG.js";
22
- import { _ as _21 } from "./chunks/LpFileTree-0Ko_uj8q.js";
23
- import { ancestorIds, checkStateOf, fileIcon, formatModified, formatSize, sortNodes, subtreeIds, subtreeSize } from "./components/fileTree.js";
22
+ import { _ as _21 } from "./chunks/LpFileTree-D0DEpKof.js";
23
+ import { ancestorIds, checkStateOf, checkedCount, fileIcon, formatModified, formatSize, sortNodes, subtreeIds, subtreeSize } from "./components/fileTree.js";
24
24
  import { _ as _22 } from "./chunks/LpFormField-Bn-ZwC6z.js";
25
25
  import { _ as _23 } from "./chunks/LpIcon-CCnX5_2j.js";
26
26
  import { _ as _24 } from "./chunks/LpInfraNode-C_PHcthC.js";
@@ -168,6 +168,7 @@ export {
168
168
  blockTitle,
169
169
  e as bootstrapTheme,
170
170
  checkStateOf,
171
+ checkedCount,
171
172
  countBlocks,
172
173
  countLeaves,
173
174
  f as dark,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leavepulse/ui",
3
- "version": "0.14.10",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "description": "LeavePulse token-driven Vue component kit.",
6
6
  "repository": {
@@ -31,7 +31,8 @@
31
31
  "./dist/*": "./dist/*"
32
32
  },
33
33
  "publishConfig": {
34
- "registry": "https://registry.npmjs.org"
34
+ "registry": "https://registry.npmjs.org",
35
+ "access": "public"
35
36
  },
36
37
  "sideEffects": [
37
38
  "**/*.css",
@@ -321,6 +321,12 @@ function onToggle(node: FileNode) {
321
321
 
322
322
  function onSelect(node: FileNode) {
323
323
  if (node.disabled) return
324
+ // Clicking the highlighted row again clears it — a selection you can't undo
325
+ // without picking something else is a trap.
326
+ if (props.selected === node.id) {
327
+ emit("update:selected", "")
328
+ return
329
+ }
324
330
  emit("update:selected", node.id)
325
331
  emit("select", node)
326
332
  if (node.kind === "file") emit("open", node)
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { computed } from "vue"
13
13
  import {
14
+ checkedCount,
14
15
  checkStateOf,
15
16
  fileIcon,
16
17
  formatModified,
@@ -82,6 +83,17 @@ const sizeLabel = computed(() => {
82
83
  return bytes === undefined ? "" : formatSize(bytes)
83
84
  })
84
85
 
86
+ /**
87
+ * "3 / 12" on a directory whose subtree is partly ticked. Only shown while it
88
+ * actually differs from the whole — a fully ticked or untouched folder reads
89
+ * fine from the checkbox alone, and a count on every row would be noise.
90
+ */
91
+ const countLabel = computed(() => {
92
+ if (!props.checkable || !isDir.value) return ""
93
+ const { checked, total } = checkedCount(props.node, props.checked)
94
+ return total && checked && checked < total ? `${checked} / ${total}` : ""
95
+ })
96
+
85
97
  const modifiedLabel = computed(() =>
86
98
  props.showModified && props.node.modified !== undefined
87
99
  ? formatModified(props.node.modified)
@@ -207,6 +219,15 @@ function onActivate() {
207
219
  </slot>
208
220
  </span>
209
221
 
222
+ <!-- Partial-selection counter: says outright how much of the folder is
223
+ ticked, which a half-state checkbox alone doesn't convey. -->
224
+ <span
225
+ v-if="countLabel"
226
+ class="shrink-0 rounded-pill bg-brand-soft px-1.5 py-0.5 text-[11px] font-medium tabular-nums text-brand"
227
+ >
228
+ {{ countLabel }}
229
+ </span>
230
+
210
231
  <!-- Metadata columns: tabular figures so sizes line up down the tree. -->
211
232
  <span v-if="node.meta" class="shrink-0 text-xs text-muted">{{ node.meta }}</span>
212
233
  <span
@@ -196,6 +196,31 @@ export function checkStateOf(node: FileNode, checked: ReadonlySet<string>): Chec
196
196
  return any ? "indeterminate" : "unchecked"
197
197
  }
198
198
 
199
+ /**
200
+ * How much of a directory's subtree is ticked, counting FILES only (folders are
201
+ * containers, not payload). Lets a row say "3 / 12" so a half-filled checkbox
202
+ * isn't the only clue that a folder is partially selected.
203
+ */
204
+ export function checkedCount(
205
+ node: FileNode,
206
+ checked: ReadonlySet<string>,
207
+ ): { checked: number; total: number } {
208
+ let count = 0
209
+ let total = 0
210
+ function walk(list: FileNode[]) {
211
+ for (const child of list) {
212
+ if (child.kind === "file") {
213
+ total++
214
+ if (checked.has(child.id)) count++
215
+ } else if (child.children?.length) {
216
+ walk(child.children)
217
+ }
218
+ }
219
+ }
220
+ walk(node.children ?? [])
221
+ return { checked: count, total }
222
+ }
223
+
199
224
  /** Ids of every ancestor directory of `id`, so callers can reveal a deep node. */
200
225
  export function ancestorIds(nodes: FileNode[], id: string): string[] {
201
226
  const path: string[] = []
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export type { FileTreeSummary } from "./components/LpFileTree.vue"
30
30
  export type { CheckState, FileNode } from "./components/fileTree"
31
31
  export {
32
32
  ancestorIds,
33
+ checkedCount,
33
34
  checkStateOf,
34
35
  fileIcon,
35
36
  formatModified,