@celestia-island/hikari 0.55.274 → 0.55.276
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/package.json +1 -1
- package/src/components/HkBarChart.scss +28 -2
- package/src/components/HkBarChart.test.tsx +126 -0
- package/src/components/HkBarChart.tsx +119 -20
- package/src/components/HkDraggableList.scss +26 -0
- package/src/components/HkDraggableList.styles.test.ts +63 -0
- package/src/components/HkDraggableList.test.tsx +190 -0
- package/src/components/HkDraggableList.tsx +38 -10
- package/src/components/HkTrendChart.test.tsx +307 -1
- package/src/components/HkTrendChart.tsx +58 -6
- package/src/components/barChart.ts +12 -3
- package/src/components/trendGeometry.ts +330 -12
- package/src/runtime/index.ts +7 -0
package/package.json
CHANGED
|
@@ -40,15 +40,41 @@
|
|
|
40
40
|
font-variant-numeric: tabular-nums;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
// Above-bar value label (showValues) — the same 10-unit voice as the
|
|
44
|
+
// axis, a step stronger so the count reads first.
|
|
45
|
+
&__value {
|
|
46
|
+
fill: rgb(var(--color-text) / 0.75);
|
|
47
|
+
font-size: 10px;
|
|
48
|
+
text-anchor: middle;
|
|
49
|
+
font-variant-numeric: tabular-nums;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Slot mark (a bar's icon — the supership Star): a nested <svg> whose
|
|
53
|
+
// ink follows currentColor, so lucide's stroke picks up the axis voice
|
|
54
|
+
// without per-icon wiring.
|
|
55
|
+
&__xmark {
|
|
56
|
+
color: rgb(var(--color-text) / 0.55);
|
|
57
|
+
}
|
|
58
|
+
|
|
43
59
|
&__bar {
|
|
44
60
|
fill: rgb(var(--color-primary));
|
|
45
61
|
outline: none;
|
|
46
62
|
cursor: pointer;
|
|
47
|
-
transition: filter 0.16s ease;
|
|
63
|
+
transition: filter 0.16s ease, opacity 0.16s ease;
|
|
48
64
|
|
|
49
65
|
&:hover,
|
|
50
|
-
&:focus-visible
|
|
66
|
+
&:focus-visible,
|
|
67
|
+
// Dim-pair head (activeIndex): keeps the hover lift while the
|
|
68
|
+
// pointer may sit anywhere — the host pairs bars by wiring
|
|
69
|
+
// activeIndex from the hover events.
|
|
70
|
+
&[data-hot] {
|
|
51
71
|
filter: brightness(1.08) saturate(1.15);
|
|
52
72
|
}
|
|
73
|
+
|
|
74
|
+
// Dim-pair follower: every other bar recedes, same ease family as
|
|
75
|
+
// the lift so the pair breathes together.
|
|
76
|
+
&[data-dim] {
|
|
77
|
+
opacity: 0.45;
|
|
78
|
+
}
|
|
53
79
|
}
|
|
54
80
|
}
|
|
@@ -27,6 +27,10 @@ const BARS = [
|
|
|
27
27
|
{ label: "Wed", value: 0 },
|
|
28
28
|
];
|
|
29
29
|
|
|
30
|
+
/** A tiny stand-in for a lucide glyph: any icon FUNCTION draws the
|
|
31
|
+
* 24-unit box the slot-mark wrapper maps onto its 12-unit mark. */
|
|
32
|
+
const StarGlyph = () => <rect x="0" y="0" width="24" height="24" />;
|
|
33
|
+
|
|
30
34
|
describe("HkBarChart", () => {
|
|
31
35
|
it("renders one labeled bar per bucket and skips zero buckets' paths", () => {
|
|
32
36
|
const el = mount(<HkBarChart bars={BARS} />);
|
|
@@ -59,4 +63,126 @@ describe("HkBarChart", () => {
|
|
|
59
63
|
"weekly playtime",
|
|
60
64
|
);
|
|
61
65
|
});
|
|
66
|
+
|
|
67
|
+
// ── showValues ───────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
it("showValues renders formatValue above each bar and skips zero buckets", () => {
|
|
70
|
+
const el = mount(
|
|
71
|
+
<HkBarChart bars={BARS} showValues formatValue={(v) => `${Math.round(v / 3600)}h`} />,
|
|
72
|
+
);
|
|
73
|
+
const values = [...el.querySelectorAll(".hk-bar-chart__value")];
|
|
74
|
+
// The zero bucket (Wed) carries no label — the wowsp tier histogram's
|
|
75
|
+
// old labelY-null behavior: an empty slot never shows a stray "0".
|
|
76
|
+
expect(values.map((t) => t.textContent)).toEqual(["1h", "2h"]);
|
|
77
|
+
// Each label's baseline sits exactly 5 units above its bar's top —
|
|
78
|
+
// the path's y coordinates sit at odd indices ("M x0 y L x0 y… Q x y
|
|
79
|
+
// x y …"), and their minimum IS the top.
|
|
80
|
+
const topOf = (d: string) => {
|
|
81
|
+
const nums = d.split(/[\sMLQAZ]+/).filter(Boolean).map(Number);
|
|
82
|
+
return Math.min(...nums.filter((_, i) => i % 2 === 1));
|
|
83
|
+
};
|
|
84
|
+
const tops = [...el.querySelectorAll(".hk-bar-chart__bar")].map((p) =>
|
|
85
|
+
topOf(p.getAttribute("d") ?? ""),
|
|
86
|
+
);
|
|
87
|
+
expect(Number(values[0].getAttribute("y"))).toBeCloseTo(tops[0] - 5, 6);
|
|
88
|
+
expect(Number(values[1].getAttribute("y"))).toBeCloseTo(tops[1] - 5, 6);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("showValues off by default — old usage renders no value labels", () => {
|
|
92
|
+
const el = mount(<HkBarChart bars={BARS} />);
|
|
93
|
+
expect(el.querySelectorAll(".hk-bar-chart__value")).toHaveLength(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── hover event + activeIndex dim-pair ───────────────────────────
|
|
97
|
+
|
|
98
|
+
it("emits hover on bar mouseenter / mouseleave with focus / blur parity", () => {
|
|
99
|
+
const hovers: (number | null)[] = [];
|
|
100
|
+
const el = mount(<HkBarChart bars={BARS} onHover={(i) => hovers.push(i)} />);
|
|
101
|
+
const bars = [...el.querySelectorAll(".hk-bar-chart__bar")];
|
|
102
|
+
bars[0].dispatchEvent(new Event("mouseenter"));
|
|
103
|
+
bars[1].dispatchEvent(new Event("focus"));
|
|
104
|
+
bars[1].dispatchEvent(new Event("blur"));
|
|
105
|
+
bars[0].dispatchEvent(new Event("mouseleave"));
|
|
106
|
+
expect(hovers).toEqual([0, 1, null, null]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("activeIndex carries data-hot on the paired bar and data-dim on the others", () => {
|
|
110
|
+
const el = mount(<HkBarChart bars={BARS} activeIndex={0} />);
|
|
111
|
+
const bars = [...el.querySelectorAll(".hk-bar-chart__bar")];
|
|
112
|
+
expect(bars[0].getAttribute("data-hot")).toBe("true");
|
|
113
|
+
expect(bars[0].hasAttribute("data-dim")).toBe(false);
|
|
114
|
+
expect(bars[1].getAttribute("data-dim")).toBe("true");
|
|
115
|
+
expect(bars[1].hasAttribute("data-hot")).toBe(false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("defaults to no pair — no data-hot / data-dim anywhere", () => {
|
|
119
|
+
const el = mount(<HkBarChart bars={BARS} />);
|
|
120
|
+
expect(el.querySelector("[data-hot], [data-dim]")).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── icon slot mark (the supership Star) ──────────────────────────
|
|
124
|
+
|
|
125
|
+
it("renders a resolvable icon in place of the x-label text, sized and centered", () => {
|
|
126
|
+
const el = mount(
|
|
127
|
+
<HkBarChart
|
|
128
|
+
bars={[
|
|
129
|
+
{ label: "10", value: 5, icon: StarGlyph },
|
|
130
|
+
{ label: "11", value: 7 },
|
|
131
|
+
]}
|
|
132
|
+
/>,
|
|
133
|
+
);
|
|
134
|
+
const marks = [...el.querySelectorAll(".hk-bar-chart__xmark")];
|
|
135
|
+
expect(marks).toHaveLength(1);
|
|
136
|
+
// The nested-svg wrapper maps the 24-unit lucide box onto a 12-unit
|
|
137
|
+
// mark centered on slot 0 (centerX 182) at the x-label's optical
|
|
138
|
+
// center (baselineY 196 + 4).
|
|
139
|
+
expect(marks[0].getAttribute("viewBox")).toBe("0 0 24 24");
|
|
140
|
+
expect(marks[0].getAttribute("width")).toBe("12");
|
|
141
|
+
expect(marks[0].getAttribute("height")).toBe("12");
|
|
142
|
+
expect(marks[0].getAttribute("x")).toBe("176");
|
|
143
|
+
expect(marks[0].getAttribute("y")).toBe("200");
|
|
144
|
+
// The icon swapped out that slot's text; the other slot keeps its text.
|
|
145
|
+
expect([...el.querySelectorAll(".hk-bar-chart__x")].map((t) => t.textContent)).toEqual([
|
|
146
|
+
"11",
|
|
147
|
+
]);
|
|
148
|
+
// The bar's aria string keeps the TEXT label, icon or not.
|
|
149
|
+
expect(el.querySelector(".hk-bar-chart__bar")?.getAttribute("aria-label")).toContain("10");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("normalizes icons like HkPillToggleGroup — vnode passes through, junk falls back to text", () => {
|
|
153
|
+
const el = mount(
|
|
154
|
+
<HkBarChart
|
|
155
|
+
bars={[
|
|
156
|
+
{ label: "a", value: 3, icon: <rect x="4" y="4" width="16" height="16" /> },
|
|
157
|
+
{ label: "b", value: 4, icon: "not-an-icon" },
|
|
158
|
+
]}
|
|
159
|
+
/>,
|
|
160
|
+
);
|
|
161
|
+
// The prebuilt vnode rides the mark wrapper; the junk string does not
|
|
162
|
+
// blank the slot — it falls back to the text label.
|
|
163
|
+
expect(el.querySelectorAll(".hk-bar-chart__xmark")).toHaveLength(1);
|
|
164
|
+
expect(el.querySelectorAll(".hk-bar-chart__xmark rect")).toHaveLength(1);
|
|
165
|
+
expect([...el.querySelectorAll(".hk-bar-chart__x")].map((t) => t.textContent)).toEqual(["b"]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// ── back-compat: the wowsp PlaytimeView usage surface ────────────
|
|
169
|
+
|
|
170
|
+
it("old-props usage renders exactly the old surface", () => {
|
|
171
|
+
const el = mount(
|
|
172
|
+
<HkBarChart
|
|
173
|
+
bars={BARS.map((b) => ({ label: b.label, value: b.value, hint: b.hint }))}
|
|
174
|
+
width={480}
|
|
175
|
+
formatValue={(v) => `${Math.round(v / 3600)}h`}
|
|
176
|
+
/>,
|
|
177
|
+
);
|
|
178
|
+
expect(el.querySelectorAll(".hk-bar-chart__bar")).toHaveLength(2);
|
|
179
|
+
expect(el.querySelectorAll(".hk-bar-chart__value")).toHaveLength(0);
|
|
180
|
+
expect(el.querySelectorAll(".hk-bar-chart__xmark")).toHaveLength(0);
|
|
181
|
+
expect(el.querySelector("[data-hot], [data-dim]")).toBeNull();
|
|
182
|
+
expect([...el.querySelectorAll(".hk-bar-chart__x")].map((t) => t.textContent)).toEqual([
|
|
183
|
+
"Mon",
|
|
184
|
+
"Tue",
|
|
185
|
+
"Wed",
|
|
186
|
+
]);
|
|
187
|
+
});
|
|
62
188
|
});
|
|
@@ -1,8 +1,43 @@
|
|
|
1
|
-
import { computed, defineComponent, type PropType } from "vue";
|
|
1
|
+
import { computed, defineComponent, h, type PropType, type VNode } from "vue";
|
|
2
2
|
|
|
3
3
|
import { barLayout, type BarSlot } from "./barChart";
|
|
4
4
|
import "./HkBarChart.scss";
|
|
5
5
|
|
|
6
|
+
/** One bar entry. */
|
|
7
|
+
export interface HkBarChartBar {
|
|
8
|
+
/** Text label under the slot — also the bar's aria string, even when
|
|
9
|
+
* an `icon` replaces the visible text. */
|
|
10
|
+
label: string;
|
|
11
|
+
value: number;
|
|
12
|
+
/** Hover/focus hint; defaults to `label: formatValue(value)`. */
|
|
13
|
+
hint?: string;
|
|
14
|
+
/** Optional slot mark rendered IN PLACE of the x-label text (the tier
|
|
15
|
+
* histogram's supership Star). Accepts EITHER a component (a lucide
|
|
16
|
+
* icon function, a defineComponent options object) or a prebuilt
|
|
17
|
+
* vnode — normalized exactly like HkPillToggleGroup's icon handling;
|
|
18
|
+
* anything else falls back to the text label (never a blank slot). */
|
|
19
|
+
icon?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Normalize a bar icon to a renderable vnode — the HkPillToggleGroup
|
|
23
|
+
* convention verbatim: a component (function or options object) is
|
|
24
|
+
* instantiated with `h`; a prebuilt vnode passes through; anything
|
|
25
|
+
* else (string, number, null) renders nothing, and the caller falls
|
|
26
|
+
* back to the text label. */
|
|
27
|
+
function resolveIcon(icon: unknown): VNode | null {
|
|
28
|
+
if (icon == null) return null;
|
|
29
|
+
if (typeof icon === "object") return icon as VNode;
|
|
30
|
+
if (typeof icon === "function") return h(icon as Parameters<typeof h>[0]);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The slot-mark box (wowsp's tier-star convention): lucide glyphs draw
|
|
35
|
+
* in a 24×24 box, so the wrapper nested <svg> maps that box onto a
|
|
36
|
+
* 12-unit mark centered on the x-label's optical center — the 10-unit
|
|
37
|
+
* text's baseline is baselineY + 14 and its glyph box spans
|
|
38
|
+
* ≈ baselineY + 6 … + 14, so a mark from +4 to +16 shares its center. */
|
|
39
|
+
const BAR_MARK = { glyphBox: 24, size: 12, top: 4 } as const;
|
|
40
|
+
|
|
6
41
|
/**
|
|
7
42
|
* HkBarChart — one bar per bucket over a hand-drawn declarative SVG (no
|
|
8
43
|
* chart library; the HkTreemap/HkCalendarHeatmap dependency-free line).
|
|
@@ -16,6 +51,15 @@ import "./HkBarChart.scss";
|
|
|
16
51
|
* wowsp delegated-tooltip hook reads — hikari itself renders no native
|
|
17
52
|
* title bubble).
|
|
18
53
|
*
|
|
54
|
+
* The tier histogram's needs ride along as OPTIONAL props (wowsp
|
|
55
|
+
* ShipDistCharts): `showValues` paints formatValue(value) above each
|
|
56
|
+
* bar (empty slots carry no label), `hover` + `activeIndex` implement a
|
|
57
|
+
* dim-pair — the HOST wires activeIndex from the hover events, the bar
|
|
58
|
+
* at activeIndex carries `data-hot` and every other bar `data-dim` —
|
|
59
|
+
* and a bar's `icon` replaces its x-label text (the supership slot's
|
|
60
|
+
* nested-svg Star, positioned/sized here from the 24-unit lucide box).
|
|
61
|
+
* Defaults reproduce the old output exactly.
|
|
62
|
+
*
|
|
19
63
|
* Dumb by design: the caller owns aggregation and passes the settled
|
|
20
64
|
* buckets in. All geometry lives in ./barChart (pure, pinned by its own
|
|
21
65
|
* tests).
|
|
@@ -27,7 +71,7 @@ export default defineComponent({
|
|
|
27
71
|
props: {
|
|
28
72
|
/** The bars, in axis order (oldest → newest for trend charts). */
|
|
29
73
|
bars: {
|
|
30
|
-
type: Array as PropType<
|
|
74
|
+
type: Array as PropType<HkBarChartBar[]>,
|
|
31
75
|
required: true,
|
|
32
76
|
},
|
|
33
77
|
/** Frame size (the SVG viewBox; CSS scales it). */
|
|
@@ -42,10 +86,25 @@ export default defineComponent({
|
|
|
42
86
|
ariaLabel: { type: String, default: null },
|
|
43
87
|
/** Rendered when every bucket is zero / the array is empty. */
|
|
44
88
|
emptyText: { type: String, default: "No data" },
|
|
89
|
+
/** Paint formatValue(value) as a small label ABOVE each bar. Skip
|
|
90
|
+
* rule: bars with value <= 0 carry NO label (their slot stays an
|
|
91
|
+
* empty slot) — the wowsp tier histogram's old labelY-null behavior,
|
|
92
|
+
* so an unplayed bucket never shows a stray "0". */
|
|
93
|
+
showValues: { type: Boolean, default: false },
|
|
94
|
+
/** The dim-pair head: when set, the bar at this index carries
|
|
95
|
+
* `data-hot` (kept hover lift) and every other bar carries
|
|
96
|
+
* `data-dim` (≈0.45 opacity). The HOST implements the pair by
|
|
97
|
+
* wiring this from the `hover` events; null (the default) draws no
|
|
98
|
+
* pair at all. */
|
|
99
|
+
activeIndex: { type: Number as PropType<number | null>, default: null },
|
|
45
100
|
},
|
|
46
101
|
emits: {
|
|
47
102
|
/** A bar was clicked (its bucket index). */
|
|
48
103
|
select: (index: number) => typeof index === "number",
|
|
104
|
+
/** The pointer entered a bar (its bucket index) or left it / the
|
|
105
|
+
* keyboard focus moved onto or off one (focus/blur parity — null
|
|
106
|
+
* when the hover ended). */
|
|
107
|
+
hover: (index: number | null) => typeof index === "number" || index === null,
|
|
49
108
|
},
|
|
50
109
|
setup(props, { emit }) {
|
|
51
110
|
const layout = computed(() =>
|
|
@@ -60,7 +119,12 @@ export default defineComponent({
|
|
|
60
119
|
props.ariaLabel ??
|
|
61
120
|
props.bars.map((b) => `${b.label} ${props.formatValue(b.value)}`).join(", "),
|
|
62
121
|
);
|
|
63
|
-
|
|
122
|
+
/** The dim-pair head, normalized: anything but a number (undefined,
|
|
123
|
+
* null, garbage) means "no pair". */
|
|
124
|
+
const active = computed(() =>
|
|
125
|
+
typeof props.activeIndex === "number" ? props.activeIndex : null,
|
|
126
|
+
);
|
|
127
|
+
const hintOf = (b: HkBarChartBar, i: number): string =>
|
|
64
128
|
b.hint ?? `${b.label}: ${props.formatValue(b.value)}`;
|
|
65
129
|
|
|
66
130
|
return () => {
|
|
@@ -90,23 +154,58 @@ export default defineComponent({
|
|
|
90
154
|
</text>
|
|
91
155
|
</g>
|
|
92
156
|
))}
|
|
93
|
-
{l.bars.map((s: BarSlot, i: number) =>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
157
|
+
{l.bars.map((s: BarSlot, i: number) => {
|
|
158
|
+
const bar = props.bars[i];
|
|
159
|
+
const mark = resolveIcon(bar.icon);
|
|
160
|
+
return (
|
|
161
|
+
<g key={i}>
|
|
162
|
+
{s.path ? (
|
|
163
|
+
<path
|
|
164
|
+
class="hk-bar-chart__bar"
|
|
165
|
+
d={s.path}
|
|
166
|
+
data-hint={hintOf(bar, i)}
|
|
167
|
+
aria-label={hintOf(bar, i)}
|
|
168
|
+
tabindex={0}
|
|
169
|
+
data-hot={active.value === i || undefined}
|
|
170
|
+
data-dim={(active.value !== null && active.value !== i) || undefined}
|
|
171
|
+
onClick={() => emit("select", i)}
|
|
172
|
+
onMouseenter={() => emit("hover", i)}
|
|
173
|
+
onMouseleave={() => emit("hover", null)}
|
|
174
|
+
onFocus={() => emit("hover", i)}
|
|
175
|
+
onBlur={() => emit("hover", null)}
|
|
176
|
+
/>
|
|
177
|
+
) : null}
|
|
178
|
+
{props.showValues && s.labelY != null ? (
|
|
179
|
+
<text class="hk-bar-chart__value" x={s.centerX} y={s.labelY}>
|
|
180
|
+
{props.formatValue(bar.value)}
|
|
181
|
+
</text>
|
|
182
|
+
) : null}
|
|
183
|
+
{mark != null ? (
|
|
184
|
+
// The slot mark (the supership's Star): a nested <svg>
|
|
185
|
+
// wrapper maps the icon's 24-unit glyph box onto a
|
|
186
|
+
// 12-unit mark centered on the x-label's optical
|
|
187
|
+
// center — positioned HERE so the consumer passes a
|
|
188
|
+
// bare lucide component and knows no geometry. Ink
|
|
189
|
+
// (currentColor) lives in SCSS.
|
|
190
|
+
<svg
|
|
191
|
+
class="hk-bar-chart__xmark"
|
|
192
|
+
x={s.centerX - BAR_MARK.size / 2}
|
|
193
|
+
y={l.baselineY + BAR_MARK.top}
|
|
194
|
+
width={BAR_MARK.size}
|
|
195
|
+
height={BAR_MARK.size}
|
|
196
|
+
viewBox={`0 0 ${BAR_MARK.glyphBox} ${BAR_MARK.glyphBox}`}
|
|
197
|
+
aria-hidden="true"
|
|
198
|
+
>
|
|
199
|
+
{mark}
|
|
200
|
+
</svg>
|
|
201
|
+
) : (
|
|
202
|
+
<text class="hk-bar-chart__x" x={s.centerX} y={l.baselineY + 14}>
|
|
203
|
+
{bar.label}
|
|
204
|
+
</text>
|
|
205
|
+
)}
|
|
206
|
+
</g>
|
|
207
|
+
);
|
|
208
|
+
})}
|
|
110
209
|
</svg>
|
|
111
210
|
);
|
|
112
211
|
};
|
|
@@ -53,6 +53,32 @@
|
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/* The horizontal axis: the SAME list laid out as ONE never-wrapping row
|
|
57
|
+
* (wowsp's overlay-columns editor is the first host). Only geometry
|
|
58
|
+
* changes — the drop line moves to the leading/trailing edge (the
|
|
59
|
+
* horizontal twin of the top/bottom inset above; the descendant rules
|
|
60
|
+
* below out-specify the vertical box-shadow, so they REPLACE it rather
|
|
61
|
+
* than stack onto it), and min-width: 0 lets an over-full row shrink
|
|
62
|
+
* its items so the single line always holds (the content lane keeps its
|
|
63
|
+
* own min-width, so ellipsis chains keep working inside them). The
|
|
64
|
+
* reserved handle lane stays the row's leading column exactly as it is
|
|
65
|
+
* vertically, and the actions lane stays the row's trailing edge. */
|
|
66
|
+
.hk-draggable-list[data-axis="horizontal"] {
|
|
67
|
+
flex-direction: row;
|
|
68
|
+
flex-wrap: nowrap;
|
|
69
|
+
|
|
70
|
+
.hk-draggable-list-item {
|
|
71
|
+
min-width: 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.hk-draggable-list-item[data-placeholder="before"] {
|
|
75
|
+
box-shadow: inset 2px 0 0 0 var(--hi-color-primary, #7aa2f7);
|
|
76
|
+
}
|
|
77
|
+
.hk-draggable-list-item[data-placeholder="after"] {
|
|
78
|
+
box-shadow: inset -2px 0 0 0 var(--hi-color-primary, #7aa2f7);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
56
82
|
.hk-draggable-list-handle {
|
|
57
83
|
display: flex;
|
|
58
84
|
align-items: center;
|
|
@@ -108,3 +108,66 @@ describe("HkDraggableList actions-lane sheet", () => {
|
|
|
108
108
|
expect(content).toContain("flex: 1");
|
|
109
109
|
});
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
describe("HkDraggableList horizontal-axis sheet", () => {
|
|
113
|
+
/** axis="horizontal" (DOM twin: the horizontal-axis cases in
|
|
114
|
+
* HkDraggableList.test.tsx) lays the SAME list as one never-wrapping
|
|
115
|
+
* row. Only geometry changes: the drop line moves to the item's
|
|
116
|
+
* leading/trailing edge — the horizontal twin of the top/bottom inset
|
|
117
|
+
* — and the items become shrinkable so the single line always holds. */
|
|
118
|
+
const css = listCss();
|
|
119
|
+
|
|
120
|
+
// ruleBody selectors are written in the COMPILED form: sass expanded
|
|
121
|
+
// output drops attribute-value quotes ([data-axis=horizontal]).
|
|
122
|
+
|
|
123
|
+
it("lays the horizontal list out as one never-wrapping row", () => {
|
|
124
|
+
const body = ruleBody(css, ".hk-draggable-list[data-axis=horizontal]");
|
|
125
|
+
expect(body, "the horizontal-axis layout rule must exist").not.toBeNull();
|
|
126
|
+
expect(body).toContain("flex-direction: row");
|
|
127
|
+
expect(body).toContain("flex-wrap: nowrap");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("lets an over-full row shrink its items so the one line holds", () => {
|
|
131
|
+
const body = ruleBody(
|
|
132
|
+
css,
|
|
133
|
+
".hk-draggable-list[data-axis=horizontal] .hk-draggable-list-item",
|
|
134
|
+
);
|
|
135
|
+
expect(body, "the horizontal item rule must exist").not.toBeNull();
|
|
136
|
+
expect(body).toContain("min-width: 0");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("marks the drop edge on the leading/trailing side, not top/bottom", () => {
|
|
140
|
+
const before = ruleBody(
|
|
141
|
+
css,
|
|
142
|
+
".hk-draggable-list[data-axis=horizontal] .hk-draggable-list-item[data-placeholder=before]",
|
|
143
|
+
);
|
|
144
|
+
expect(before, "the horizontal before rule must exist").not.toBeNull();
|
|
145
|
+
expect(before).toContain("inset 2px 0 0 0");
|
|
146
|
+
expect(before).not.toContain("inset 0 2px");
|
|
147
|
+
expect(before).not.toContain("inset 0 -2px");
|
|
148
|
+
|
|
149
|
+
const after = ruleBody(
|
|
150
|
+
css,
|
|
151
|
+
".hk-draggable-list[data-axis=horizontal] .hk-draggable-list-item[data-placeholder=after]",
|
|
152
|
+
);
|
|
153
|
+
expect(after, "the horizontal after rule must exist").not.toBeNull();
|
|
154
|
+
expect(after).toContain("inset -2px 0 0 0");
|
|
155
|
+
expect(after).not.toContain("inset 0 2px");
|
|
156
|
+
expect(after).not.toContain("inset 0 -2px");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("keeps the vertical drop edges on top/bottom for the default axis", () => {
|
|
160
|
+
// First source match = the plain vertical rule (the horizontal twin
|
|
161
|
+
// comes later and under a descendant selector); its body must carry
|
|
162
|
+
// the top/bottom inset and never the horizontal one.
|
|
163
|
+
const before = ruleBody(css, ".hk-draggable-list-item[data-placeholder=before]");
|
|
164
|
+
expect(before, "the vertical before rule must exist").not.toBeNull();
|
|
165
|
+
expect(before).toContain("inset 0 2px 0 0");
|
|
166
|
+
expect(before).not.toContain("inset 2px 0 0 0");
|
|
167
|
+
|
|
168
|
+
const after = ruleBody(css, ".hk-draggable-list-item[data-placeholder=after]");
|
|
169
|
+
expect(after, "the vertical after rule must exist").not.toBeNull();
|
|
170
|
+
expect(after).toContain("inset 0 -2px 0 0");
|
|
171
|
+
expect(after).not.toContain("inset -2px 0 0 0");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -525,3 +525,193 @@ describe("HkDraggableList actions lane", () => {
|
|
|
525
525
|
fireWin("pointerup");
|
|
526
526
|
});
|
|
527
527
|
});
|
|
528
|
+
|
|
529
|
+
describe("HkDraggableList horizontal axis", () => {
|
|
530
|
+
/** axis="horizontal" lays the SAME list as one never-wrapping row
|
|
531
|
+
* (wowsp's overlay-columns editor). Only the pointer geometry
|
|
532
|
+
* changes: the arm reads |dx| alone, the ghost follows BOTH axes,
|
|
533
|
+
* and the placeholder targets slots by pointer.x against their
|
|
534
|
+
* horizontal midpoints (the plain single-line rule — the list never
|
|
535
|
+
* wraps). Roles, slots, the reserved handle lane and the
|
|
536
|
+
* commit-once-at-pointerup contract stay exactly as vertical. */
|
|
537
|
+
|
|
538
|
+
const PointerEventCtor: typeof MouseEvent =
|
|
539
|
+
(globalThis as { PointerEvent?: typeof MouseEvent }).PointerEvent ?? MouseEvent;
|
|
540
|
+
|
|
541
|
+
const pointerInit: MouseEventInit = { bubbles: true, cancelable: true };
|
|
542
|
+
|
|
543
|
+
function fire(el: Element, type: string, x = 0, y = 0) {
|
|
544
|
+
el.dispatchEvent(new PointerEventCtor(type, {
|
|
545
|
+
...pointerInit,
|
|
546
|
+
button: 0,
|
|
547
|
+
clientX: x,
|
|
548
|
+
clientY: y,
|
|
549
|
+
}));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function fireWin(type: string, x = 0, y = 0) {
|
|
553
|
+
window.dispatchEvent(new PointerEventCtor(type, {
|
|
554
|
+
...pointerInit,
|
|
555
|
+
clientX: x,
|
|
556
|
+
clientY: y,
|
|
557
|
+
}));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const emitLog: Array<{ from: number; to: number }> = [];
|
|
561
|
+
|
|
562
|
+
function mountHorizontal(items: string[], opts?: { lockedKeys?: string[] }): HTMLElement {
|
|
563
|
+
emitLog.length = 0;
|
|
564
|
+
const container = document.createElement("div");
|
|
565
|
+
document.body.appendChild(container);
|
|
566
|
+
const app = createApp({
|
|
567
|
+
render: () =>
|
|
568
|
+
h(
|
|
569
|
+
HDraggableList,
|
|
570
|
+
{
|
|
571
|
+
items: items.map((key) => ({ key })),
|
|
572
|
+
axis: "horizontal",
|
|
573
|
+
lockedKeys: opts?.lockedKeys ?? [],
|
|
574
|
+
onReorder: (from: number, to: number) => emitLog.push({ from, to }),
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
item: ({ item }: { item: { key: string } }) =>
|
|
578
|
+
h("span", { class: "row-body" }, item.key),
|
|
579
|
+
},
|
|
580
|
+
),
|
|
581
|
+
});
|
|
582
|
+
app.mount(container);
|
|
583
|
+
mounts.push({ app, container });
|
|
584
|
+
return container.querySelector(".hk-draggable-list") as HTMLElement;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function rowsOf(root: HTMLElement): HTMLElement[] {
|
|
588
|
+
return Array.from(root.querySelectorAll(".hk-draggable-list-item")) as HTMLElement[];
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** happy-dom reports zero rects; pin the slots to a 100px-each single
|
|
592
|
+
* line [0..100][100..200][200..300] (40px tall) so the midpoint rule
|
|
593
|
+
* walks real geometry. */
|
|
594
|
+
function layLine(root: HTMLElement, width = 100, height = 40): HTMLElement[] {
|
|
595
|
+
const rows = rowsOf(root);
|
|
596
|
+
rows.forEach((row, i) => {
|
|
597
|
+
const left = i * width;
|
|
598
|
+
row.getBoundingClientRect = () =>
|
|
599
|
+
({
|
|
600
|
+
left,
|
|
601
|
+
top: 0,
|
|
602
|
+
right: left + width,
|
|
603
|
+
bottom: height,
|
|
604
|
+
width,
|
|
605
|
+
height,
|
|
606
|
+
x: left,
|
|
607
|
+
y: 0,
|
|
608
|
+
toJSON: () => ({}),
|
|
609
|
+
}) as DOMRect;
|
|
610
|
+
});
|
|
611
|
+
return rows;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
it("keeps list semantics and marks no axis on the vertical default", () => {
|
|
615
|
+
const verticalRoot = mountList(["a"], []);
|
|
616
|
+
expect(verticalRoot.hasAttribute("data-axis")).toBe(false);
|
|
617
|
+
|
|
618
|
+
const root = mountHorizontal(["a", "b"]);
|
|
619
|
+
expect(root.getAttribute("data-axis")).toBe("horizontal");
|
|
620
|
+
expect(root.getAttribute("role")).toBe("list");
|
|
621
|
+
const rows = rowsOf(root);
|
|
622
|
+
expect(rows).toHaveLength(2);
|
|
623
|
+
expect(rows.every((row) => row.getAttribute("role") === "listitem")).toBe(true);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("arms on |dx| alone — vertical travel never arms a horizontal drag", async () => {
|
|
627
|
+
const root = mountHorizontal(["a", "b"]);
|
|
628
|
+
layLine(root);
|
|
629
|
+
const row = rowsOf(root)[0]!;
|
|
630
|
+
|
|
631
|
+
fire(row, "pointerdown", 50, 20);
|
|
632
|
+
// 30px of pure-Y travel: under the |dx| arm, still a plain press.
|
|
633
|
+
fireWin("pointermove", 50, 50);
|
|
634
|
+
await nextTick();
|
|
635
|
+
expect(row.hasAttribute("data-dragging")).toBe(false);
|
|
636
|
+
|
|
637
|
+
// 6px of pure-X travel crosses ARM_PX: the drag publishes.
|
|
638
|
+
fireWin("pointermove", 56, 50);
|
|
639
|
+
await nextTick();
|
|
640
|
+
expect(row.getAttribute("data-dragging")).not.toBeNull();
|
|
641
|
+
|
|
642
|
+
fireWin("pointerup");
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it("moves the ghost on BOTH axes", async () => {
|
|
646
|
+
const root = mountHorizontal(["a"]);
|
|
647
|
+
layLine(root); // slot [0..100]
|
|
648
|
+
// A stale ghost from an earlier failed run would shadow this assert.
|
|
649
|
+
document.querySelectorAll(".hk-draggable-list-ghost").forEach((n) => n.remove());
|
|
650
|
+
const row = rowsOf(root)[0]!;
|
|
651
|
+
|
|
652
|
+
fire(row, "pointerdown", 50, 20); // grabOffset (50, 20)
|
|
653
|
+
fireWin("pointermove", 106, 20); // arm past ARM_PX on |dx|
|
|
654
|
+
await new Promise((resolve) => requestAnimationFrame(resolve));
|
|
655
|
+
let ghost = document.querySelector(".hk-draggable-list-ghost") as HTMLElement | null;
|
|
656
|
+
expect(ghost, "the ghost must rise next frame").not.toBeNull();
|
|
657
|
+
|
|
658
|
+
fireWin("pointermove", 260, 130); // both axes travel: (260-50, 130-20)
|
|
659
|
+
ghost = document.querySelector(".hk-draggable-list-ghost");
|
|
660
|
+
expect(ghost!.style.transform).toBe("translate3d(210px, 110px, 0)");
|
|
661
|
+
|
|
662
|
+
fireWin("pointerup");
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("targets the placeholder by pointer.x against slot midpoints", async () => {
|
|
666
|
+
const root = mountHorizontal(["a", "b", "c"]);
|
|
667
|
+
const rows = layLine(root);
|
|
668
|
+
const row = rows[0]!;
|
|
669
|
+
|
|
670
|
+
fire(row, "pointerdown", 50, 20);
|
|
671
|
+
fireWin("pointermove", 60, 20); // arm; still left of b's midpoint (150) → before b
|
|
672
|
+
await nextTick();
|
|
673
|
+
expect(rows[1]!.getAttribute("data-placeholder")).toBe("after");
|
|
674
|
+
|
|
675
|
+
fireWin("pointermove", 180, 20); // past b's midpoint, under c's (250) → before c
|
|
676
|
+
await nextTick();
|
|
677
|
+
expect(rows[1]!.hasAttribute("data-placeholder")).toBe(false);
|
|
678
|
+
expect(rows[2]!.getAttribute("data-placeholder")).toBe("after");
|
|
679
|
+
|
|
680
|
+
fireWin("pointerup");
|
|
681
|
+
// Commit-once-at-pointerup: ONE reorder, at the placeholder slot.
|
|
682
|
+
expect(emitLog).toEqual([{ from: 0, to: 2 }]);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("marks the leading edge when the pointer retreats before earlier slots", async () => {
|
|
686
|
+
const root = mountHorizontal(["a", "b", "c"]);
|
|
687
|
+
const rows = layLine(root);
|
|
688
|
+
const row = rows[2]!;
|
|
689
|
+
|
|
690
|
+
fire(row, "pointerdown", 250, 20);
|
|
691
|
+
fireWin("pointermove", 240, 20); // arm
|
|
692
|
+
await nextTick();
|
|
693
|
+
|
|
694
|
+
fireWin("pointermove", 20, 20); // before a's midpoint (50) → lands before a
|
|
695
|
+
await nextTick();
|
|
696
|
+
expect(rows[0]!.getAttribute("data-placeholder")).toBe("before");
|
|
697
|
+
expect(rows[1]!.hasAttribute("data-placeholder")).toBe(false);
|
|
698
|
+
|
|
699
|
+
fireWin("pointerup");
|
|
700
|
+
expect(emitLog).toEqual([{ from: 2, to: 0 }]);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("skips locked slots when walking the row", async () => {
|
|
704
|
+
const root = mountHorizontal(["a", "b", "c"], { lockedKeys: ["b"] });
|
|
705
|
+
const rows = layLine(root);
|
|
706
|
+
const row = rows[0]!;
|
|
707
|
+
|
|
708
|
+
fire(row, "pointerdown", 50, 20);
|
|
709
|
+
fireWin("pointermove", 160, 20); // past b's midpoint — b is locked, c takes the drop
|
|
710
|
+
await nextTick();
|
|
711
|
+
expect(rows[1]!.hasAttribute("data-placeholder")).toBe(false);
|
|
712
|
+
expect(rows[2]!.getAttribute("data-placeholder")).toBe("after");
|
|
713
|
+
|
|
714
|
+
fireWin("pointerup");
|
|
715
|
+
expect(emitLog).toEqual([{ from: 0, to: 2 }]);
|
|
716
|
+
});
|
|
717
|
+
});
|