@keyfunc/keybricks-ds 2.4.2 → 2.4.4

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.
@@ -11,7 +11,8 @@ const Pill = ({ hue = "gray", children }) => <span style={{ display: "inline-fle
11
11
 
12
12
  /** Notion/Airtable-style database grid ("smart table"): field-type icons in headers, colored select pills, person/date/checkbox/url/progress cells, row hover + expand, inline text/number editing, + New row, add-field column, per-column footer aggregates, and a view toolbar (Hide fields · Filter · Group · Sort · search). */
13
13
  /* A size region that defaults to sm: a database grid is a dense surface, so rows are the sm bar height (36), cells and toolbar read the sm control text (12px, 13px in CJK) and the toolbar's buttons are 28px. Pass size="md" for a roomier grid.
14
- density: "default" rows are --bar-h (36 / 40 / 48) with --cell-px; "compact" rows are --control-h (28 / 32 / 40, the Spreadsheet's cell height) with the Spreadsheet's 8px padding (so at sm only the height changes). Header and footer follow. The toolbar's "Row height" menu switches it; rowHeight (a raw height) overrides density. */
14
+ density: "default" rows are --bar-h (36 / 40 / 48) with --cell-px; "compact" rows are --control-h (28 / 32 / 40, the Spreadsheet's cell height) with the Spreadsheet's 8px padding (so at sm only the height changes). Header and footer follow. The toolbar's "Row height" menu switches it; rowHeight (a raw height) overrides density.
15
+ Frozen edges (like Spreadsheet): the field header row is sticky to the top and the 40px row-number column to the left; the corner cell is sticky on both axes. Sticky cells keep the --bg-inset fill so body cells never show through, and the frozen edge is a --border-subtle hairline. */
15
16
  const DENSITY = { default: { h: "var(--bar-h)", px: "var(--cell-px)" }, compact: { h: "var(--control-h)", px: "8px" } };
16
17
  export function SmartTable({ fields = [], rows = [], title = "Grid view", toolbar = true, height, rowHeight, density: densityProp, defaultDensity = "default", onDensityChange, size = "sm", onChange, style }) {
17
18
  const [data, setData] = React.useState(rows);
@@ -27,6 +28,10 @@ export function SmartTable({ fields = [], rows = [], title = "Grid view", toolba
27
28
  const grid = { display: "grid", gridTemplateColumns: widths.join(" "), width: "fit-content", minWidth: "100%" };
28
29
  const cellBase = { display: "flex", alignItems: "center", gap: 5, padding: "0 " + DENSITY[density].px, borderRight: "1px solid var(--border-subtle)", borderBottom: "1px solid var(--border-subtle)", height: rowH, boxSizing: "border-box", overflow: "hidden", whiteSpace: "nowrap", fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", lineHeight: "var(--control-leading)", letterSpacing: "var(--control-tracking)", color: "var(--text-primary)" };
29
30
  const tierIcon = { width: "var(--control-icon)", height: "var(--control-icon)" };
31
+ // Frozen edges — zIndex 1 (row-number column) < 2 (header row) < 3 (corner, both axes); the editing input sits inside a body cell, under them.
32
+ const stickyTop = { position: "sticky", top: 0, zIndex: 2, background: "var(--bg-inset)" };
33
+ const stickyLeft = { position: "sticky", left: 0, zIndex: 1, background: "var(--bg-inset)" };
34
+ const stickyCorner = { ...stickyTop, left: 0, zIndex: 3 };
30
35
  const agg = (f) => {
31
36
  if (!f.aggregate) return "";
32
37
  const vals = data.map(r => r[f.key]);
@@ -64,16 +69,16 @@ export function SmartTable({ fields = [], rows = [], title = "Grid view", toolba
64
69
  </div>}
65
70
  <div style={{ overflow: "auto", maxHeight: height }}>
66
71
  <div style={grid}>
67
- <span style={{ ...cellBase, background: "var(--bg-inset)", justifyContent: "center" }}></span>
68
- {fields.map(f => <span key={f.key} style={{ ...cellBase, background: "var(--bg-inset)", color: "var(--text-secondary)", fontWeight: "var(--weight-semibold)" }}><Icon name={typeIcon[f.type] || "type"} size={14} style={{ ...tierIcon, color: "var(--text-muted)" }} />{f.label}</span>)}
69
- <span style={{ ...cellBase, background: "var(--bg-inset)", justifyContent: "center", padding: 0 }}><IconButton icon="plus" muted label="Add field" /></span>
72
+ <span style={{ ...cellBase, ...stickyCorner, justifyContent: "center" }}></span>
73
+ {fields.map(f => <span key={f.key} style={{ ...cellBase, ...stickyTop, color: "var(--text-secondary)", fontWeight: "var(--weight-semibold)" }}><Icon name={typeIcon[f.type] || "type"} size={14} style={{ ...tierIcon, color: "var(--text-muted)" }} />{f.label}</span>)}
74
+ <span style={{ ...cellBase, ...stickyTop, justifyContent: "center", padding: 0 }}><IconButton icon="plus" muted label="Add field" /></span>
70
75
  {data.map((row, r) => [
71
- <span key={"n" + r} onMouseEnter={() => setHover(r)} onMouseLeave={() => setHover(-1)} style={{ ...cellBase, justifyContent: "center", color: "var(--text-muted)", fontFamily: "var(--font-sans)", fontVariantNumeric: "tabular-nums", fontSize: "var(--text-xs)", letterSpacing: 0, background: hover === r ? "var(--bg-inset)" : "transparent", cursor: "pointer" }}>{hover === r ? <Icon name="maximize-2" size={14} style={{ ...tierIcon, color: "var(--action-primary)" }} /> : r + 1}</span>,
76
+ <span key={"n" + r} onMouseEnter={() => setHover(r)} onMouseLeave={() => setHover(-1)} style={{ ...cellBase, ...stickyLeft, justifyContent: "center", color: "var(--text-muted)", fontFamily: "var(--font-sans)", fontVariantNumeric: "tabular-nums", fontSize: "var(--text-xs)", letterSpacing: 0, cursor: "pointer" }}>{hover === r ? <Icon name="maximize-2" size={14} style={{ ...tierIcon, color: "var(--action-primary)" }} /> : r + 1}</span>,
72
77
  ...fields.map(f => <span key={r + f.key} onMouseEnter={() => setHover(r)} onMouseLeave={() => setHover(-1)} onDoubleClick={() => { if (f.type === "text" || f.type === "number" || f.type === "date" || f.type === "url") { setEditing({ r, key: f.key }); setDraft(String(row[f.key] == null ? "" : row[f.key])); } }} style={{ ...cellBase, position: "relative", background: hover === r ? "var(--bg-inset)" : "transparent" }}>{renderCell(f, row, r)}</span>),
73
78
  <span key={"a" + r} style={{ ...cellBase, background: hover === r ? "var(--bg-inset)" : "transparent" }}></span>,
74
79
  ])}
75
80
  <button type="button" onClick={() => { const empty = {}; fields.forEach(f => empty[f.key] = f.type === "multiSelect" ? [] : f.type === "checkbox" ? false : ""); const next = [...data, empty]; setData(next); onChange && onChange(next); }} style={{ gridColumn: "1 / -1", display: "flex", alignItems: "center", gap: 6, height: rowH, padding: "0 var(--edge-pad)", border: "none", borderBottom: "1px solid var(--border-subtle)", background: "transparent", cursor: "pointer", color: "var(--text-secondary)", fontFamily: "var(--font-sans)", fontSize: "var(--control-text)", letterSpacing: "var(--control-tracking)", textAlign: "left" }} onMouseEnter={(e) => e.currentTarget.style.background = "var(--bg-inset)"} onMouseLeave={(e) => e.currentTarget.style.background = "transparent"}><Icon name="plus" size={13} />New</button>
76
- <span style={{ ...cellBase, borderBottom: "none", background: "var(--bg-inset)" }}></span>
81
+ <span style={{ ...cellBase, ...stickyLeft, borderBottom: "none" }}></span>
77
82
  {fields.map(f => <span key={"g" + f.key} style={{ ...cellBase, borderBottom: "none", background: "var(--bg-inset)", justifyContent: "flex-end", fontFamily: "var(--font-sans)", fontVariantNumeric: "tabular-nums", fontSize: "var(--text-xs)", letterSpacing: 0, color: "var(--text-secondary)" }}>{agg(f)}</span>)}
78
83
  <span style={{ ...cellBase, borderBottom: "none", background: "var(--bg-inset)" }}></span>
79
84
  </div>
@@ -11,8 +11,13 @@ const typeIcon = { text: 'type', longText: 'align-left', number: 'hash', select:
11
11
  const props = defineProps({ fields: { type: Array, default: () => [] }, rows: { type: Array, default: () => [] }, title: { type: String, default: 'Grid view' }, toolbar: { type: Boolean, default: true }, height: [Number, String], rowHeight: [Number, String], density: String, defaultDensity: { type: String, default: 'default' }, size: { type: String, default: 'sm' }, style: Object })
12
12
  // A size region that defaults to sm: a database grid is a dense surface, so rows are the sm bar height (36), cells and toolbar read the sm control text (12px, 13px in CJK) and the toolbar's buttons are 28px. Pass size="md" for a roomier grid.
13
13
  // density: 'default' rows are --bar-h (36 / 40 / 48) with --cell-px; 'compact' rows are --control-h (28 / 32 / 40, the Spreadsheet's cell height) with the Spreadsheet's 8px padding (so at sm only the height changes). Header and footer follow. The toolbar's "Row height" menu switches it (emits density-change; v-model:density to control); rowHeight (a raw height) overrides density.
14
+ // Frozen edges (like Spreadsheet): the field header row is sticky to the top and the 40px row-number column to the left; the corner cell is sticky on both axes. Sticky cells keep the --bg-inset fill so body cells never show through, and the frozen edge is a --border-subtle hairline.
14
15
  const DENSITY = { default: { h: 'var(--bar-h)', px: 'var(--cell-px)' }, compact: { h: 'var(--control-h)', px: '8px' } }
15
16
  const tierIcon = { width: 'var(--control-icon)', height: 'var(--control-icon)' }
17
+ // Frozen edges — zIndex 1 (row-number column) < 2 (header row) < 3 (corner, both axes); the editing input sits inside a body cell, under them.
18
+ const stickyTop = { position: 'sticky', top: 0, zIndex: 2, background: 'var(--bg-inset)' }
19
+ const stickyLeft = { position: 'sticky', left: 0, zIndex: 1, background: 'var(--bg-inset)' }
20
+ const stickyCorner = { ...stickyTop, left: 0, zIndex: 3 }
16
21
  const emit = defineEmits(['change', 'update:density', 'densityChange'])
17
22
  const densityState = ref(props.defaultDensity)
18
23
  const density = computed(() => DENSITY[props.density || densityState.value] ? (props.density || densityState.value) : 'default')
@@ -48,11 +53,11 @@ function agg(f) {
48
53
  </div>
49
54
  <div :style="{ overflow: 'auto', maxHeight: typeof height === 'number' ? height + 'px' : height }">
50
55
  <div :style="{ display: 'grid', gridTemplateColumns: gridCols, width: 'fit-content', minWidth: '100%' }">
51
- <span :style="{ ...cellBase, height: rowH, background: 'var(--bg-inset)' }" />
52
- <span v-for="f in fields" :key="f.key" :style="{ ...cellBase, height: rowH, background: 'var(--bg-inset)', color: 'var(--text-secondary)', fontWeight: 'var(--weight-semibold)' }"><Icon :name="typeIcon[f.type] || 'type'" :size="14" :style="{ ...tierIcon, color: 'var(--text-muted)' }" />{{ f.label }}</span>
53
- <span :style="{ ...cellBase, height: rowH, background: 'var(--bg-inset)', justifyContent: 'center', padding: 0 }"><IconButton icon="plus" muted label="Add field" /></span>
56
+ <span :style="{ ...cellBase, height: rowH, ...stickyCorner }" />
57
+ <span v-for="f in fields" :key="f.key" :style="{ ...cellBase, height: rowH, ...stickyTop, color: 'var(--text-secondary)', fontWeight: 'var(--weight-semibold)' }"><Icon :name="typeIcon[f.type] || 'type'" :size="14" :style="{ ...tierIcon, color: 'var(--text-muted)' }" />{{ f.label }}</span>
58
+ <span :style="{ ...cellBase, height: rowH, ...stickyTop, justifyContent: 'center', padding: 0 }"><IconButton icon="plus" muted label="Add field" /></span>
54
59
  <template v-for="(row, r) in data" :key="r">
55
- <span @mouseenter="hover = r" @mouseleave="hover = -1" :style="{ ...cellBase, height: rowH, justifyContent: 'center', color: 'var(--text-muted)', fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums', fontSize: 'var(--text-xs)', letterSpacing: 0, background: hover === r ? 'var(--bg-inset)' : 'transparent', cursor: 'pointer' }">
60
+ <span @mouseenter="hover = r" @mouseleave="hover = -1" :style="{ ...cellBase, height: rowH, ...stickyLeft, justifyContent: 'center', color: 'var(--text-muted)', fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums', fontSize: 'var(--text-xs)', letterSpacing: 0, cursor: 'pointer' }">
56
61
  <Icon v-if="hover === r" name="maximize-2" :size="14" :style="{ ...tierIcon, color: 'var(--action-primary)' }" /><template v-else>{{ r + 1 }}</template>
57
62
  </span>
58
63
  <span v-for="f in fields" :key="r + f.key" @mouseenter="hover = r" @mouseleave="hover = -1" @dblclick="startEdit(r, f, row)" :style="{ ...cellBase, height: rowH, position: 'relative', background: hover === r ? 'var(--bg-inset)' : 'transparent' }">
@@ -69,7 +74,7 @@ function agg(f) {
69
74
  <span :style="{ ...cellBase, height: rowH, background: hover === r ? 'var(--bg-inset)' : 'transparent' }" />
70
75
  </template>
71
76
  <button type="button" @click="addRow" class="kb-st-new" :style="{ gridColumn: '1 / -1', height: rowH }"><Icon name="plus" :size="14" :style="tierIcon" />New</button>
72
- <span :style="{ ...cellBase, height: rowH, borderBottom: 'none', background: 'var(--bg-inset)' }" />
77
+ <span :style="{ ...cellBase, height: rowH, ...stickyLeft, borderBottom: 'none' }" />
73
78
  <span v-for="f in fields" :key="'g' + f.key" :style="{ ...cellBase, height: rowH, borderBottom: 'none', background: 'var(--bg-inset)', justifyContent: 'flex-end', fontFamily: 'var(--font-sans)', fontVariantNumeric: 'tabular-nums', fontSize: 'var(--text-xs)', letterSpacing: 0, color: 'var(--text-secondary)' }">{{ agg(f) }}</span>
74
79
  <span :style="{ ...cellBase, height: rowH, borderBottom: 'none', background: 'var(--bg-inset)' }" />
75
80
  </div>
@@ -25,7 +25,7 @@ export interface SidebarProps {
25
25
  onToggleCollapse?: () => void;
26
26
  logo?: string;
27
27
  brand?: string;
28
- /** Version line under the footer items ("Version 2.4.2"); the rail shows "v2.4.2" */
28
+ /** Version line under the footer items ("Version 2.4.4"); the rail shows "v2.4.4" */
29
29
  version?: string;
30
30
  tone?: "recessed" | "flush" | "contrast" | "custom";
31
31
  /** For tone "custom": a SIDEBAR_COLORS key (slate, navy, indigo, violet, plum, wine, forest, teal, umber, charcoal) or any hex */
@@ -67,7 +67,7 @@ export interface TreeProps {
67
67
  /** Draw connector lines instead of plain indentation */
68
68
  showLine?: boolean;
69
69
  draggable?: boolean;
70
- /** Also allow insert-between: a folder's outer 20% becomes a before/after rule. Off by default — a drag moves a node INTO a folder, and files are never drop targets. */
70
+ /** Also allow insert-between: a folder's outer 20% becomes a before/after rule, and a leaf becomes an insert target over its whole height (top half before, bottom half after) — so leaves can be reordered among themselves. Before / after draw an insert marker (2px line + 6px dot) on the gap between rows, starting at the target's indent. Off by default — a drag moves a node INTO a folder, and files are never drop targets. */
71
71
  reorder?: boolean;
72
72
  /** Highlights matches and auto-expands their ancestors */
73
73
  searchValue?: string;
@@ -4,6 +4,19 @@ import { Checkbox } from "../forms/Checkbox.jsx";
4
4
 
5
5
  const docHues = { orange: "var(--cat-transform)", green: "var(--cat-source)", purple: "var(--cat-merge)", blue: "var(--info-fg)", teal: "var(--cat-output)", gray: "var(--text-muted)" };
6
6
  const INDENT = 18;
7
+ /* Insert marker (reorder before / after): a 2px --action-primary line with a 6px dot at its left end, drawn between rows.
8
+ It starts at the target row's content indent (8 + depth * INDENT — the chevron column, which is also the elbow column
9
+ under showLine) and ends at the row's 8px right padding, so the line says which level the item lands on. Its centre
10
+ sits on the gap between rows (gap 1px, or 0 under showLine). */
11
+ function insertMarker(pos, depth, gap) {
12
+ const centre = -(gap / 2);
13
+ return {
14
+ position: "absolute", left: 8 + depth * INDENT - 3, right: 8, height: 6, pointerEvents: "none", zIndex: 1,
15
+ ...(pos === "before" ? { top: centre - 3 } : { bottom: centre - 3 }),
16
+ };
17
+ }
18
+ const markerDot = { position: "absolute", left: 0, top: 0, width: 6, height: 6, borderRadius: "50%", background: "var(--action-primary)" };
19
+ const markerLine = { position: "absolute", left: 3, right: 0, top: 2, height: 2, borderRadius: 1, background: "var(--action-primary)" };
7
20
 
8
21
  const keyOf = (n) => (n.key != null ? n.key : n.label);
9
22
  const titleOf = (n) => (n.title != null ? n.title : n.label);
@@ -141,14 +154,14 @@ function Row({ item, state, cfg, api }) {
141
154
  onKeyDown={(e) => api.keyDown(e, item)}
142
155
  onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}
143
156
  style={{
144
- minHeight: "var(--h-tree-item)", borderRadius: "var(--radius-md)", display: "flex", alignItems: "center", gap: 6,
157
+ position: "relative", minHeight: "var(--h-tree-item)", borderRadius: "var(--radius-md)", display: "flex", alignItems: "center", gap: 6,
145
158
  padding: cfg.showLine ? "0 8px 0 2px" : "5px 8px", paddingLeft: cfg.showLine ? 2 : 8 + depth * INDENT, boxSizing: "border-box",
146
159
  cursor: disabled ? "not-allowed" : "pointer", opacity: disabled ? 0.45 : 1,
147
160
  background: dropAt === "inside" ? "var(--soft-bg)" : selected ? "var(--bg-active)" : hover && !disabled ? "var(--bg-hover)" : "transparent",
148
161
  outline: dropAt === "inside" ? "2px solid var(--action-primary)" : ringed ? "2px solid var(--focus-border)" : "none", outlineOffset: -2,
149
- boxShadow: dropAt === "before" ? "inset 0 2px 0 var(--action-primary)" : dropAt === "after" ? "inset 0 -2px 0 var(--action-primary)" : "none",
150
162
  transition: "var(--t-hover)",
151
163
  }}>
164
+ {dropAt === "before" || dropAt === "after" ? <span aria-hidden="true" data-drop-marker={dropAt} style={insertMarker(dropAt, depth, cfg.showLine ? 0 : 1)}><span style={markerDot} /><span style={markerLine} /></span> : null}
152
165
  {cfg.showLine ? <Guides trail={trail} isLast={isLast} hasKids={branch} /> : null}
153
166
  {chevron}
154
167
  {cfg.checkable && node.checkable !== false ? (
@@ -279,9 +292,11 @@ export function Tree({
279
292
  dragOver: (k, ratio) => {
280
293
  if (k == null) return setDrop(null);
281
294
  const node = indexNodes(data).byKey[k];
282
- /* Only folders are drop targets: a file can't contain anything, so hovering one shows
283
- nothing at all. With reorder, a folder's outer 20% becomes an insert rule instead. */
284
- if (!node || node.disabled || !dragKey || !isBranch(node) || inSubtree(data, dragKey, k)) return setDrop(null);
295
+ if (!node || node.disabled || !dragKey || inSubtree(data, dragKey, k)) return setDrop(null);
296
+ /* Without reorder only folders are drop targets: a file can't contain anything, so hovering
297
+ one shows nothing at all. With reorder, a folder's outer 20% becomes an insert rule, and a
298
+ file is an insert target over its whole height — top half before, bottom half after. */
299
+ if (!isBranch(node)) return reorder ? setDrop({ key: k, pos: ratio < 0.5 ? "before" : "after" }) : setDrop(null);
285
300
  const pos = reorder ? (ratio < 0.2 ? "before" : ratio > 0.8 ? "after" : "inside") : "inside";
286
301
  setDrop({ key: k, pos });
287
302
  },
@@ -290,6 +305,7 @@ export function Tree({
290
305
  const info = drop && drop.key === k ? drop : null;
291
306
  setDrop(null);
292
307
  if (!dragKey || !info || inSubtree(data, dragKey, k)) return setDragKey(null);
308
+ if (info.pos === "inside" && !isBranch(indexNodes(data).byKey[k])) return setDragKey(null);
293
309
  /* Computed outside setData: React re-invokes updaters (StrictMode, concurrent
294
310
  rebasing), which would fire the consumer's onDrop twice for one gesture. */
295
311
  const clone = JSON.parse(JSON.stringify(data));
@@ -7,6 +7,15 @@ import Checkbox from '../forms/Checkbox.vue'
7
7
  // loadData, search highlight, drag to move, keyboard navigation.
8
8
  const docHues = { orange: 'var(--cat-transform)', green: 'var(--cat-source)', purple: 'var(--cat-merge)', blue: 'var(--info-fg)', teal: 'var(--cat-output)', gray: 'var(--text-muted)' }
9
9
  const INDENT = 18
10
+ // Insert marker (reorder before / after): a 2px --action-primary line with a 6px dot at its left end, drawn between rows.
11
+ // It starts at the target row's content indent (8 + depth * INDENT — the chevron column, which is also the elbow column
12
+ // under showLine) and ends at the row's 8px right padding; its centre sits on the gap between rows (1px, or 0 under showLine).
13
+ function insertMarker(pos, depth) {
14
+ const centre = -((props.showLine ? 0 : 1) / 2)
15
+ return { position: 'absolute', left: (8 + depth * INDENT - 3) + 'px', right: '8px', height: '6px', pointerEvents: 'none', zIndex: 1, ...(pos === 'before' ? { top: (centre - 3) + 'px' } : { bottom: (centre - 3) + 'px' }) }
16
+ }
17
+ const markerDot = { position: 'absolute', left: 0, top: 0, width: '6px', height: '6px', borderRadius: '50%', background: 'var(--action-primary)' }
18
+ const markerLine = { position: 'absolute', left: '3px', right: 0, top: '2px', height: '2px', borderRadius: '1px', background: 'var(--action-primary)' }
10
19
  const props = defineProps({
11
20
  items: { type: Array, default: () => [] },
12
21
  treeData: Array, // alias of items (same as Tree.jsx)
@@ -151,16 +160,19 @@ function onDragOver(e, k) {
151
160
  e.preventDefault()
152
161
  if (!dragKey.value) return
153
162
  const n = index(data.value).byKey[k]
154
- // Only folders are drop targets: hovering a file shows nothing, since it can't contain anything.
155
- if (!n || n.disabled || !isBranch(n) || inSubtree(dragKey.value, k)) { drop.value = null; return }
163
+ if (!n || n.disabled || inSubtree(dragKey.value, k)) { drop.value = null; return }
156
164
  const r = e.currentTarget.getBoundingClientRect()
157
165
  const y = (e.clientY - r.top) / r.height
166
+ // Without reorder only folders are drop targets: hovering a file shows nothing, since it can't contain anything.
167
+ // With reorder, a folder's outer 20% becomes an insert rule, and a file is an insert target over its whole height — top half before, bottom half after.
168
+ if (!isBranch(n)) { drop.value = props.reorder ? { key: k, pos: y < 0.5 ? 'before' : 'after' } : null; return }
158
169
  drop.value = { key: k, pos: props.reorder ? (y < 0.2 ? 'before' : y > 0.8 ? 'after' : 'inside') : 'inside' }
159
170
  }
160
171
  function onDrop(k) {
161
172
  const info = drop.value && drop.value.key === k ? drop.value : null
162
173
  drop.value = null
163
174
  if (!dragKey.value || !info || inSubtree(dragKey.value, k)) { dragKey.value = null; return }
175
+ if (info.pos === 'inside' && !isBranch(index(data.value).byKey[k])) { dragKey.value = null; return }
164
176
  const clone = JSON.parse(JSON.stringify(data.value))
165
177
  let moved = null
166
178
  const prune = nodes => nodes.filter(n => {
@@ -218,14 +230,14 @@ const dropAt = k => (drop.value && drop.value.key === k ? drop.value.pos : null)
218
230
  @click="rowClick(row.n)" @keydown="keyDown($event, row)"
219
231
  :class="{ 'is-disabled': row.n.disabled }"
220
232
  :style="{
221
- minHeight: 'var(--h-tree-item)', borderRadius: 'var(--radius-md)', display: 'flex', alignItems: 'center', gap: '6px',
233
+ position: 'relative', minHeight: 'var(--h-tree-item)', borderRadius: 'var(--radius-md)', display: 'flex', alignItems: 'center', gap: '6px',
222
234
  padding: showLine ? '0 8px 0 2px' : '5px 8px', paddingLeft: showLine ? '2px' : (8 + row.depth * INDENT) + 'px', boxSizing: 'border-box',
223
235
  cursor: row.n.disabled ? 'not-allowed' : 'pointer', opacity: row.n.disabled ? 0.45 : 1,
224
236
  background: dropAt(row.key) === 'inside' ? 'var(--soft-bg)' : selected.includes(row.key) ? 'var(--bg-active)' : 'transparent',
225
237
  outline: dropAt(row.key) === 'inside' ? '2px solid var(--action-primary)' : 'none',
226
238
  outlineOffset: '-2px',
227
- boxShadow: dropAt(row.key) === 'before' ? 'inset 0 2px 0 var(--action-primary)' : dropAt(row.key) === 'after' ? 'inset 0 -2px 0 var(--action-primary)' : 'none',
228
239
  transition: 'var(--t-hover)' }">
240
+ <span v-if="dropAt(row.key) === 'before' || dropAt(row.key) === 'after'" aria-hidden="true" :data-drop-marker="dropAt(row.key)" :style="insertMarker(dropAt(row.key), row.depth)"><span :style="markerDot" /><span :style="markerLine" /></span>
229
241
  <template v-if="showLine">
230
242
  <span v-for="(hasNext, i) in row.trail" :key="i"
231
243
  :style="{ width: INDENT + 'px', alignSelf: 'stretch', flexShrink: 0, marginLeft: i === 0 ? '6px' : 0, borderLeft: hasNext ? '1px solid var(--border-subtle)' : '1px solid transparent' }" />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyfunc/keybricks-ds",
3
- "version": "2.4.2",
3
+ "version": "2.4.4",
4
4
  "description": "Keybricks design system — design tokens, three size tiers and 121 UI components in React and Vue 3, source-only.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@keyfunc/keybricks-ds",
3
- "version": "2.4.2",
3
+ "version": "2.4.4",
4
4
  "source": "tokens/*.css",
5
5
  "note": "Generated by _scripts/build-tokens.mjs — CSS is the source of truth.",
6
6
  "tokens": {