@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.
- package/DEPLOY.md +25 -3
- package/DESIGN.md +17 -5
- package/INTEGRATION.md +12 -3
- package/components/_cards/smart-table.card.html +23 -13
- package/components/_cards/tree.card.html +49 -3
- package/components/_shared/src-data.js +1 -1
- package/components/_shared/src-navigation.js +1 -1
- package/components/data/SmartTable.d.ts +3 -2
- package/components/data/SmartTable.jsx +11 -6
- package/components/data/SmartTable.vue +10 -5
- package/components/navigation/Sidebar.d.ts +1 -1
- package/components/navigation/Tree.d.ts +1 -1
- package/components/navigation/Tree.jsx +21 -5
- package/components/navigation/Tree.vue +16 -4
- package/package.json +1 -1
- package/tokens/tokens.json +1 -1
|
@@ -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,
|
|
68
|
-
{fields.map(f => <span key={f.key} style={{ ...cellBase,
|
|
69
|
-
<span style={{ ...cellBase,
|
|
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,
|
|
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"
|
|
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,
|
|
52
|
-
<span v-for="f in fields" :key="f.key" :style="{ ...cellBase, height: rowH,
|
|
53
|
-
<span :style="{ ...cellBase, height: rowH,
|
|
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,
|
|
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'
|
|
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.
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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