@celestia-island/hikari 0.55.157 → 0.55.158
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/HkScrollContainer.test.ts +9 -5
- package/src/components/HkScrollContainer.tsx +4 -1
- package/src/components/overlayScrollbarChrome.contract.test.ts +46 -31
- package/src/composables/useOverlayScrollbar.test.ts +97 -13
- package/src/composables/useOverlayScrollbar.ts +77 -41
- package/src/styles/theme/scrollbar.scss +54 -46
package/package.json
CHANGED
|
@@ -404,7 +404,7 @@ describe("HkScrollContainer back-to-top signal", () => {
|
|
|
404
404
|
});
|
|
405
405
|
|
|
406
406
|
describe("HkScrollContainer change-mark rail", () => {
|
|
407
|
-
it("renders the marks prop, and re-applies
|
|
407
|
+
it("widens the track, renders the marks prop, and re-applies them without rebuilding", async () => {
|
|
408
408
|
const markers = ref<OverlayScrollbarMarker[]>([{ start: 0.1, end: 0.2, kind: "added" }]);
|
|
409
409
|
const container = document.createElement("div");
|
|
410
410
|
document.body.appendChild(container);
|
|
@@ -419,9 +419,11 @@ describe("HkScrollContainer change-mark rail", () => {
|
|
|
419
419
|
mounts.push(app);
|
|
420
420
|
app.mount(container);
|
|
421
421
|
|
|
422
|
-
|
|
422
|
+
// The marks live INSIDE the vertical track: the rail IS the track.
|
|
423
|
+
const rail = () => container.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])");
|
|
423
424
|
const firstRail = rail();
|
|
424
|
-
expect(firstRail, "the
|
|
425
|
+
expect(firstRail, "the track exists once marks arrive").not.toBeNull();
|
|
426
|
+
expect(firstRail!.classList.contains("has-markers"), "the track widens for marks").toBe(true);
|
|
425
427
|
expect(firstRail!.querySelectorAll(".hk-scrollbar-marker").length).toBe(1);
|
|
426
428
|
|
|
427
429
|
markers.value = [
|
|
@@ -438,8 +440,10 @@ describe("HkScrollContainer change-mark rail", () => {
|
|
|
438
440
|
app.unmount();
|
|
439
441
|
});
|
|
440
442
|
|
|
441
|
-
it("keeps the
|
|
443
|
+
it("keeps the narrow track and no marks when none are passed", () => {
|
|
442
444
|
const m = mountScroller({});
|
|
443
|
-
|
|
445
|
+
const track = m.container.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])");
|
|
446
|
+
expect(track?.classList.contains("has-markers") ?? false, "no widening").toBe(false);
|
|
447
|
+
expect(track?.querySelectorAll(".hk-scrollbar-marker").length ?? 0).toBe(0);
|
|
444
448
|
});
|
|
445
449
|
});
|
|
@@ -52,6 +52,9 @@ export default defineComponent({
|
|
|
52
52
|
* it out renders exactly the chrome this container had before the
|
|
53
53
|
* rail existed. */
|
|
54
54
|
markers: { type: Array as PropType<OverlayScrollbarMarker[]>, default: () => [] },
|
|
55
|
+
/** Width the vertical track widens to while marks show, in px. Omitted
|
|
56
|
+
* = the library default (14). Only read when `markers` is non-empty. */
|
|
57
|
+
markersWidth: { type: Number, default: undefined },
|
|
55
58
|
autoFollow: { type: Boolean, default: false },
|
|
56
59
|
overscanScreens: { type: Number, default: 1 },
|
|
57
60
|
/** Inline-axis alignment of the content when it fits the viewport.
|
|
@@ -271,7 +274,7 @@ export default defineComponent({
|
|
|
271
274
|
const vp = viewportRef.value;
|
|
272
275
|
if (!vp) return;
|
|
273
276
|
overlay?.detach();
|
|
274
|
-
overlay = attachOverlayScrollbars(vp, { axis: props.axis, markers: props.markers });
|
|
277
|
+
overlay = attachOverlayScrollbars(vp, { axis: props.axis, markers: props.markers, markersWidth: props.markersWidth });
|
|
275
278
|
}
|
|
276
279
|
|
|
277
280
|
/** Re-apply marks without rebuilding the chrome: a diff re-renders its
|
|
@@ -71,43 +71,58 @@ describe("overlay scrollbar chrome contract", () => {
|
|
|
71
71
|
expect(chrome).toMatch(/&?\.is-scrolling,\s*&?\.is-hovering\s*\{[^}]*pointer-events:\s*auto/);
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it("
|
|
75
|
-
// The
|
|
76
|
-
//
|
|
74
|
+
it("widens the track into the ruler and keeps the click rules straight", () => {
|
|
75
|
+
// The marks live INSIDE the track, which widens only when it carries
|
|
76
|
+
// them (`.has-markers`) — VS Code's overview ruler is the scrollbar's
|
|
77
|
+
// own strip widened, not a second strip. Three facts must survive any
|
|
78
|
+
// future restyle:
|
|
77
79
|
//
|
|
78
|
-
// 1.
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
expect(
|
|
88
|
-
const trackRight = Number(/right:\s*(\d+)px/.exec(track)?.[1]);
|
|
89
|
-
const trackWidth = Number(/width:\s*(\d+)px/.exec(track)?.[1]);
|
|
80
|
+
// 1. The widened ground is FAINT and theme-adaptive (a translucent
|
|
81
|
+
// mid-grey: grey over a light page, slightly lighter over a dark
|
|
82
|
+
// one), with the 2px rounding the band promised;
|
|
83
|
+
// 2. it stays pointer-transparent AT REST — the widened band spans
|
|
84
|
+
// the whole host, docks included, and a live hot zone there would
|
|
85
|
+
// eat presses meant for right-aligned dock controls;
|
|
86
|
+
// 3. the MARKS are the hit area and hug the band's edges: removed
|
|
87
|
+
// content at the start, added at the end (VS Code's two lanes),
|
|
88
|
+
// so one vertical position can carry both.
|
|
89
|
+
expect(chrome, "no second strip — the separate rail is gone").not.toContain(".hk-scrollbar-markers");
|
|
90
90
|
|
|
91
|
-
const
|
|
92
|
-
expect(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
expect(
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
expect(
|
|
91
|
+
const widened = /&\.has-markers:not\(\[data-axis="horizontal"\]\)\s*\{[^}]*\}/.exec(chrome)?.[0] ?? "";
|
|
92
|
+
expect(widened, "positive control: the widened-band rule was found").not.toBe("");
|
|
93
|
+
expect(widened, "the ground is faint (translucent grey)").toMatch(
|
|
94
|
+
/color-mix\(in srgb,\s*var\(--hi-color-muted,\s*rgb\(118 122 128\)\)\s*\d+%,\s*transparent\)/,
|
|
95
|
+
);
|
|
96
|
+
expect(widened, "the band keeps its 2px rounding").toMatch(/border-radius:\s*2px/);
|
|
97
|
+
expect(widened, "and a parameterised width the engine overrides").toMatch(/width:\s*\d+px/);
|
|
98
|
+
// The base track's own pointer contract (pinned above) is untouched;
|
|
99
|
+
// the widened rule must not sneak a `pointer-events: auto` in.
|
|
100
|
+
expect(widened).not.toMatch(/pointer-events:\s*auto/);
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
expect(
|
|
106
|
-
|
|
102
|
+
// The mark's whole rule (it is the file's last block, and its nested
|
|
103
|
+
// lanes/hover/kind blocks make a single-block regex fragile).
|
|
104
|
+
const markerBlock = chrome.slice(chrome.indexOf(".hk-scrollbar-marker {"));
|
|
105
|
+
expect(markerBlock, "positive control: the mark rule was found").not.toBe("");
|
|
106
|
+
expect(markerBlock, "the marks are the hit area").toMatch(/pointer-events:\s*auto/);
|
|
107
|
+
expect(markerBlock, "the band's 2px rounding reaches the marks").toMatch(/border-radius:\s*2px/);
|
|
108
|
+
// The two lanes, at the band's edges (the rules nest inside the mark
|
|
109
|
+
// selector, so they are written `&[data-side=…]`).
|
|
110
|
+
expect(markerBlock).toMatch(/&\[data-side="start"\]\s*\{[^}]*left:\s*0/);
|
|
111
|
+
expect(markerBlock).toMatch(/&\[data-side="end"\]\s*\{[^}]*right:\s*0/);
|
|
112
|
+
// …and their WIDTH: a restyle that silently stretched a lane across
|
|
113
|
+
// the whole band would collapse the two-lane reading into one strip
|
|
114
|
+
// (caught only by the vendored-sync byte comparison before this pin).
|
|
115
|
+
expect(markerBlock, "the start lane hugs a third of the band").toMatch(
|
|
116
|
+
/&\[data-side="start"\]\s*\{[^}]*width:\s*34%/,
|
|
117
|
+
);
|
|
118
|
+
expect(markerBlock, "and the end lane the other third").toMatch(
|
|
119
|
+
/&\[data-side="end"\]\s*\{[^}]*width:\s*34%/,
|
|
120
|
+
);
|
|
121
|
+
// The hover ring must be drawn INSIDE the mark: the track clips its
|
|
107
122
|
// children, and a plain outline loses both side strokes to that clip,
|
|
108
123
|
// leaving a two-line "ring" that reads as a stray rule rather than a
|
|
109
124
|
// hover cue. Nothing else would notice that regression.
|
|
110
|
-
expect(
|
|
125
|
+
expect(markerBlock, "the hover ring is drawn inside the mark").toMatch(
|
|
111
126
|
/outline-offset:\s*-1px/,
|
|
112
127
|
);
|
|
113
128
|
});
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
1
4
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
5
|
|
|
3
6
|
import { attachOverlayScrollbars, type OverlayScrollbarHandle, type OverlayScrollbarOptions } from "./useOverlayScrollbar";
|
|
@@ -23,10 +26,11 @@ function mountViewport(
|
|
|
23
26
|
return { container, wrapper, viewport, handle };
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
/** A scrollable vertical viewport with a deterministic
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
29
|
+
/** A scrollable vertical viewport with a deterministic band box: happy-dom
|
|
30
|
+
* performs no layout, so both the geometry and the track's bounding box
|
|
31
|
+
* are stubbed. `height` of the band is what maps a mark's fraction to a
|
|
32
|
+
* pixel position. The marks live INSIDE the vertical track (the widened
|
|
33
|
+
* scrollbar is the ruler), so "the rail" below IS the track. */
|
|
30
34
|
function mountRailed(railHeight = 100) {
|
|
31
35
|
const mounted = mountViewport("vertical");
|
|
32
36
|
stubGeometry(mounted.viewport, {
|
|
@@ -37,12 +41,13 @@ function mountRailed(railHeight = 100) {
|
|
|
37
41
|
return {
|
|
38
42
|
...mounted,
|
|
39
43
|
rail: () => {
|
|
40
|
-
const el = mounted.wrapper.querySelector<HTMLElement>(".hk-scrollbar-
|
|
44
|
+
const el = mounted.wrapper.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])");
|
|
41
45
|
if (el) {
|
|
42
46
|
vi.spyOn(el, "getBoundingClientRect").mockReturnValue({
|
|
43
|
-
top: 0, left: 0, width:
|
|
44
|
-
bottom: railHeight, right:
|
|
47
|
+
top: 0, left: 0, width: 14, height: railHeight,
|
|
48
|
+
bottom: railHeight, right: 14, x: 0, y: 0, toJSON: () => ({}),
|
|
45
49
|
} as DOMRect);
|
|
50
|
+
Object.defineProperty(el, "clientHeight", { configurable: true, get: () => railHeight });
|
|
46
51
|
}
|
|
47
52
|
return el;
|
|
48
53
|
},
|
|
@@ -354,9 +359,11 @@ describe("attachOverlayScrollbars", () => {
|
|
|
354
359
|
});
|
|
355
360
|
|
|
356
361
|
describe("change-mark rail", () => {
|
|
357
|
-
it("
|
|
362
|
+
it("keeps the narrow track, unmarked, when the consumer passes no markers", () => {
|
|
358
363
|
const { wrapper } = mountViewport("vertical");
|
|
359
|
-
|
|
364
|
+
const track = wrapper.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])");
|
|
365
|
+
expect(track?.classList.contains("has-markers") ?? false, "no widening").toBe(false);
|
|
366
|
+
expect(track?.querySelectorAll(".hk-scrollbar-marker").length ?? 0).toBe(0);
|
|
360
367
|
});
|
|
361
368
|
|
|
362
369
|
it("creates the rail and one mark per entry, tagged by kind", () => {
|
|
@@ -439,6 +446,79 @@ describe("change-mark rail", () => {
|
|
|
439
446
|
expect(mark.style.height).toBe("60%");
|
|
440
447
|
});
|
|
441
448
|
|
|
449
|
+
it("honours the consumer's markersWidth, clamped to a sane band", () => {
|
|
450
|
+
const m = mountRailed();
|
|
451
|
+
m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
|
|
452
|
+
const width = () => m.rail()!.style.width;
|
|
453
|
+
// The default band, then the consumer's own, then the clamps.
|
|
454
|
+
expect(width()).toBe("14px");
|
|
455
|
+
m.handle.detach();
|
|
456
|
+
handles.splice(handles.indexOf(m.handle), 1);
|
|
457
|
+
|
|
458
|
+
const custom = mountViewport("vertical", { markers: [{ start: 0.1, end: 0.2, kind: "added" }], markersWidth: 22 });
|
|
459
|
+
custom.handle.update();
|
|
460
|
+
expect(
|
|
461
|
+
custom.wrapper.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])")!.style.width,
|
|
462
|
+
).toBe("22px");
|
|
463
|
+
custom.handle.detach();
|
|
464
|
+
handles.splice(handles.indexOf(custom.handle), 1);
|
|
465
|
+
|
|
466
|
+
const clamped = mountViewport("vertical", { markers: [{ start: 0.1, end: 0.2, kind: "added" }], markersWidth: 999 });
|
|
467
|
+
clamped.handle.update();
|
|
468
|
+
expect(
|
|
469
|
+
clamped.wrapper.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])")!.style.width,
|
|
470
|
+
"above the 40px ceiling it clamps").toBe("40px");
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it("mirrors side onto data-side so the lanes can be styled", () => {
|
|
474
|
+
const m = mountRailed();
|
|
475
|
+
m.handle.setMarkers([
|
|
476
|
+
{ start: 0.1, end: 0.2, kind: "deleted", side: "start" },
|
|
477
|
+
{ start: 0.1, end: 0.2, kind: "added", side: "end" },
|
|
478
|
+
{ start: 0.5, end: 0.6, kind: "modified" },
|
|
479
|
+
]);
|
|
480
|
+
const marks = m.rail()!.querySelectorAll<HTMLElement>(".hk-scrollbar-marker");
|
|
481
|
+
expect(marks[0].getAttribute("data-side")).toBe("start");
|
|
482
|
+
expect(marks[1].getAttribute("data-side")).toBe("end");
|
|
483
|
+
expect(marks[2].getAttribute("data-side"), "no side = full band").toBeNull();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it("does not scrub on a press on the band's empty ground", () => {
|
|
487
|
+
const m = mountRailed();
|
|
488
|
+
m.handle.setMarkers([{ start: 0.3, end: 0.35, kind: "added" }]);
|
|
489
|
+
stubGeometry(m.viewport, {
|
|
490
|
+
scrollHeight: 1000, clientHeight: 100, scrollTop: 0,
|
|
491
|
+
scrollWidth: 100, clientWidth: 100, scrollLeft: 0,
|
|
492
|
+
});
|
|
493
|
+
// A press on the TRACK (not on a mark) keeps the track's own
|
|
494
|
+
// click-to-page behaviour: no scrub, no drag state.
|
|
495
|
+
const track = m.rail()!;
|
|
496
|
+
track.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, clientY: 0 }));
|
|
497
|
+
expect(track.classList.contains("is-dragging"), "no drag state").toBe(false);
|
|
498
|
+
document.dispatchEvent(new MouseEvent("mousemove", { clientY: 100 }));
|
|
499
|
+
expect(m.viewport.scrollTop, "no scrub followed the pointer").toBe(0);
|
|
500
|
+
document.dispatchEvent(new MouseEvent("mouseup"));
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("keeps the band and its marks visible at rest (an overview ruler is a glance)", () => {
|
|
504
|
+
const m = mountRailed();
|
|
505
|
+
m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
|
|
506
|
+
// The track is pointer-transparent at rest, but the widened band and
|
|
507
|
+
// its marks must still SHOW: hiding them made the ruler a hover-only
|
|
508
|
+
// affordance, which is exactly what an overview ruler is not.
|
|
509
|
+
// happy-dom does not apply stylesheets to computed styles, so this
|
|
510
|
+
// pins the rule in the shipped chrome (the vendored copy) instead.
|
|
511
|
+
const chrome = readFileSync(
|
|
512
|
+
resolve(dirname(fileURLToPath(import.meta.url)), "../styles/theme/scrollbar.scss"),
|
|
513
|
+
"utf-8",
|
|
514
|
+
);
|
|
515
|
+
const widened = /&\.has-markers:not\(\[data-axis="horizontal"\]\)\s*\{[^}]*\}/.exec(chrome)?.[0] ?? "";
|
|
516
|
+
expect(widened, "positive control: the widened rule was found").not.toBe("");
|
|
517
|
+
expect(widened, "the band is visible at rest").toMatch(/opacity:\s*1/);
|
|
518
|
+
// …and the thumb rides above it.
|
|
519
|
+
expect(chrome).toMatch(/\.hk-scrollbar-thumb\s*\{[^}]*z-index:\s*2/);
|
|
520
|
+
});
|
|
521
|
+
|
|
442
522
|
it("positions marks against the content, so scrolling does not move them", () => {
|
|
443
523
|
const m = mountRailed();
|
|
444
524
|
m.handle.setMarkers([{ start: 0.25, end: 0.3, kind: "added" }]);
|
|
@@ -458,7 +538,9 @@ describe("change-mark rail", () => {
|
|
|
458
538
|
m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
|
|
459
539
|
expect(m.rail()).not.toBeNull();
|
|
460
540
|
m.handle.setMarkers([]);
|
|
461
|
-
|
|
541
|
+
const track = m.wrapper.querySelector<HTMLElement>(".hk-scrollbar-track:not([data-axis='horizontal'])");
|
|
542
|
+
expect(track?.classList.contains("has-markers") ?? false, "the widening is undone").toBe(false);
|
|
543
|
+
expect(track?.querySelectorAll(".hk-scrollbar-marker").length ?? 0).toBe(0);
|
|
462
544
|
});
|
|
463
545
|
|
|
464
546
|
it("hides the rail while the content fits, and shows it again when it scrolls", () => {
|
|
@@ -486,7 +568,9 @@ describe("change-mark rail", () => {
|
|
|
486
568
|
});
|
|
487
569
|
m.handle.update();
|
|
488
570
|
m.handle.setMarkers([{ start: 0.1, end: 0.2, kind: "added" }]);
|
|
489
|
-
|
|
571
|
+
const hTrack = m.wrapper.querySelector<HTMLElement>('.hk-scrollbar-track[data-axis="horizontal"]');
|
|
572
|
+
expect(hTrack?.classList.contains("has-markers") ?? false, "no band on a horizontal track").toBe(false);
|
|
573
|
+
expect(hTrack?.querySelectorAll(".hk-scrollbar-marker").length ?? 0).toBe(0);
|
|
490
574
|
});
|
|
491
575
|
|
|
492
576
|
it("ignores non-finite mark bounds instead of parking them at NaN%", () => {
|
|
@@ -556,7 +640,7 @@ describe("change-mark rail", () => {
|
|
|
556
640
|
mark.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, clientY: 0 }));
|
|
557
641
|
m.handle.detach();
|
|
558
642
|
handles.splice(handles.indexOf(m.handle), 1);
|
|
559
|
-
expect(m.wrapper.
|
|
643
|
+
expect(m.wrapper.querySelectorAll(".hk-scrollbar-marker").length).toBe(0);
|
|
560
644
|
m.viewport.scrollTop = 0;
|
|
561
645
|
document.dispatchEvent(new MouseEvent("mousemove", { clientY: 100 }));
|
|
562
646
|
expect(m.viewport.scrollTop, "detach dropped the drag listener").toBe(0);
|
|
@@ -568,6 +652,6 @@ describe("change-mark rail", () => {
|
|
|
568
652
|
m.handle.detach();
|
|
569
653
|
handles.splice(handles.indexOf(m.handle), 1);
|
|
570
654
|
expect(() => m.handle.setMarkers([{ start: 0, end: 1, kind: "added" }])).not.toThrow();
|
|
571
|
-
expect(m.wrapper.
|
|
655
|
+
expect(m.wrapper.querySelectorAll(".hk-scrollbar-marker").length).toBe(0);
|
|
572
656
|
});
|
|
573
657
|
});
|
|
@@ -51,6 +51,13 @@ export interface OverlayScrollbarMarker {
|
|
|
51
51
|
/** Semantic tag mirrored onto `data-kind`, so the consumer's theme owns
|
|
52
52
|
* the palette (this engine ships no colours of its own). */
|
|
53
53
|
kind?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Which third of the band the mark hugs — VS Code's diff overview ruler
|
|
56
|
+
* draws REMOVED content against the band's start edge and ADDED content
|
|
57
|
+
* against its end edge, so one vertical position can carry both. Omitted
|
|
58
|
+
* = the mark spans the full band width.
|
|
59
|
+
*/
|
|
60
|
+
side?: "start" | "end";
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
/** Shortest a mark may render, as a fraction of the rail. A one-line edit
|
|
@@ -66,6 +73,11 @@ const MARKER_MIN_FRACTION = 0.005;
|
|
|
66
73
|
* from the SAME number, which CSS cannot express. */
|
|
67
74
|
const MARKER_MIN_PX = 2;
|
|
68
75
|
|
|
76
|
+
/** Width the vertical track widens to when marks arrive, unless the
|
|
77
|
+
* consumer passes its own. VS Code's scrollbar strip is ~14px; the
|
|
78
|
+
* default keeps the marks' two lanes readable at a glance. */
|
|
79
|
+
const DEFAULT_MARKERS_WIDTH = 14;
|
|
80
|
+
|
|
69
81
|
/** Clamp to [0, 1]; non-finite input is the caller's bug, but the rail
|
|
70
82
|
* must never render a mark at a meaningless position, so it becomes 0. */
|
|
71
83
|
function clamp01(n: number): number {
|
|
@@ -98,6 +110,10 @@ export interface OverlayScrollbarOptions {
|
|
|
98
110
|
* "position in the document". Omitted or empty creates no rail at all,
|
|
99
111
|
* so consumers that never pass markers keep the exact chrome they had. */
|
|
100
112
|
markers?: OverlayScrollbarMarker[];
|
|
113
|
+
/** Width the vertical track widens to while marks are showing, in CSS
|
|
114
|
+
* pixels. Selective: consumers without marks keep the narrow track.
|
|
115
|
+
* Clamped to a sane range (the thumb and marks share the band). */
|
|
116
|
+
markersWidth?: number;
|
|
101
117
|
}
|
|
102
118
|
|
|
103
119
|
export interface OverlayScrollbarHandle {
|
|
@@ -216,22 +232,27 @@ export function attachOverlayScrollbars(
|
|
|
216
232
|
let detached = false;
|
|
217
233
|
|
|
218
234
|
// ── Change-mark rail (vertical only) ─────────────────────────────────
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
235
|
+
// The marks live INSIDE the vertical track, which widens to
|
|
236
|
+
// `markersWidth` for as long as marks are showing (VS Code's overview
|
|
237
|
+
// ruler is a widening of the same strip, not a second one). Consumers
|
|
238
|
+
// that never pass markers keep byte-identical chrome.
|
|
239
|
+
let track: HTMLElement | null = null;
|
|
223
240
|
let markers: OverlayScrollbarMarker[] = [];
|
|
224
241
|
let railDragging = false;
|
|
225
242
|
/** Rail height the marks were last sized against — the pixel floor is a
|
|
226
243
|
* function of it, so a resize has to re-render them. */
|
|
227
244
|
let renderedRailHeight = 0;
|
|
228
245
|
|
|
246
|
+
/** The vertical track, found once and reused. */
|
|
247
|
+
const verticalTrack = (): HTMLElement | null =>
|
|
248
|
+
states.find((s) => !s.horizontal)?.track ?? null;
|
|
249
|
+
|
|
229
250
|
/** Scroll so the rail position under `clientY` lands in the viewport —
|
|
230
251
|
* the same fraction → scrollTop mapping the track's own click uses, so
|
|
231
252
|
* a mark and the track at the same height agree. */
|
|
232
253
|
function railScrollTo(clientY: number) {
|
|
233
|
-
if (!
|
|
234
|
-
const rect =
|
|
254
|
+
if (!track) return;
|
|
255
|
+
const rect = track.getBoundingClientRect();
|
|
235
256
|
if (rect.height <= 0) return;
|
|
236
257
|
const ratio = clamp01((clientY - rect.top) / rect.height);
|
|
237
258
|
const { scrollSize, clientSize } = axisMetrics(viewport, false);
|
|
@@ -243,28 +264,34 @@ export function attachOverlayScrollbars(
|
|
|
243
264
|
}
|
|
244
265
|
function onRailUp() {
|
|
245
266
|
railDragging = false;
|
|
246
|
-
|
|
267
|
+
track?.classList.remove("is-dragging");
|
|
247
268
|
document.removeEventListener("mousemove", onRailMove);
|
|
248
269
|
document.removeEventListener("mouseup", onRailUp);
|
|
249
270
|
}
|
|
250
271
|
function onRailDown(e: MouseEvent) {
|
|
272
|
+
// Only a press ON a mark scrubs: the rest of the band keeps the
|
|
273
|
+
// track's own click-to-page behaviour.
|
|
274
|
+
const mark = (e.target as Element | null)?.closest?.(".hk-scrollbar-marker");
|
|
275
|
+
if (!mark) return;
|
|
251
276
|
e.preventDefault();
|
|
252
|
-
// The
|
|
253
|
-
//
|
|
277
|
+
// The band rides inside modals/panels that may dismiss on an outside
|
|
278
|
+
// press — scrubbing a mark must not double as that press.
|
|
254
279
|
e.stopPropagation();
|
|
255
280
|
railDragging = true;
|
|
256
|
-
|
|
281
|
+
track?.classList.add("is-dragging");
|
|
257
282
|
railScrollTo(e.clientY);
|
|
258
283
|
document.addEventListener("mousemove", onRailMove);
|
|
259
284
|
document.addEventListener("mouseup", onRailUp);
|
|
260
285
|
}
|
|
261
286
|
|
|
262
287
|
function renderMarkers() {
|
|
263
|
-
if (!
|
|
264
|
-
|
|
288
|
+
if (!track) return;
|
|
289
|
+
// The thumb is a sibling that must stay on top of the marks (it slides
|
|
290
|
+
// over them, VS Code's semi-transparent thumb over the ruler).
|
|
291
|
+
track.querySelectorAll(":scope > .hk-scrollbar-marker").forEach((m) => m.remove());
|
|
265
292
|
// Measured, not assumed: happy-dom and a `display:none` rail both
|
|
266
293
|
// report 0, and the fraction floor is the honest answer there.
|
|
267
|
-
const railH =
|
|
294
|
+
const railH = track.clientHeight;
|
|
268
295
|
renderedRailHeight = railH;
|
|
269
296
|
const floor = railH > 0
|
|
270
297
|
? Math.max(MARKER_MIN_FRACTION, MARKER_MIN_PX / railH)
|
|
@@ -283,12 +310,28 @@ export function attachOverlayScrollbars(
|
|
|
283
310
|
const el = document.createElement("div");
|
|
284
311
|
el.className = "hk-scrollbar-marker";
|
|
285
312
|
if (m.kind) el.setAttribute("data-kind", m.kind);
|
|
313
|
+
if (m.side) el.setAttribute("data-side", m.side);
|
|
286
314
|
el.style.top = pct(top);
|
|
287
315
|
el.style.height = pct(size);
|
|
288
|
-
|
|
316
|
+
track.appendChild(el);
|
|
289
317
|
}
|
|
290
318
|
}
|
|
291
319
|
|
|
320
|
+
/** Widen the track to the markers width (and back when they go away). */
|
|
321
|
+
function applyBandWidth() {
|
|
322
|
+
if (!track) return;
|
|
323
|
+
if (markers.length === 0) {
|
|
324
|
+
track.classList.remove("has-markers");
|
|
325
|
+
track.style.width = "";
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
track.classList.add("has-markers");
|
|
329
|
+
const w = Math.round(
|
|
330
|
+
Math.min(Math.max(opts.markersWidth ?? DEFAULT_MARKERS_WIDTH, 8), 40),
|
|
331
|
+
);
|
|
332
|
+
track.style.width = `${w}px`;
|
|
333
|
+
}
|
|
334
|
+
|
|
292
335
|
// An arrow const, not a function declaration: the guard above narrows
|
|
293
336
|
// `parent` to non-null, and only a closure created AFTER that narrowing
|
|
294
337
|
// keeps it (a hoisted declaration may run at any time, so TS widens the
|
|
@@ -296,40 +339,35 @@ export function attachOverlayScrollbars(
|
|
|
296
339
|
const setMarkers = (next: OverlayScrollbarMarker[]) => {
|
|
297
340
|
if (detached) return;
|
|
298
341
|
markers = Array.isArray(next) ? next : [];
|
|
342
|
+
track = verticalTrack();
|
|
299
343
|
if (markers.length === 0) {
|
|
300
|
-
// No marks =
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
rail.remove();
|
|
304
|
-
rail = null;
|
|
305
|
-
}
|
|
344
|
+
// No marks = exactly the pre-marker chrome.
|
|
345
|
+
track?.querySelectorAll(":scope > .hk-scrollbar-marker").forEach((m) => m.remove());
|
|
346
|
+
applyBandWidth();
|
|
306
347
|
onRailUp();
|
|
307
348
|
return;
|
|
308
349
|
}
|
|
309
|
-
if (!wantV) return;
|
|
310
|
-
if (!
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
rail.addEventListener("mousedown", onRailDown);
|
|
314
|
-
parent.appendChild(rail);
|
|
350
|
+
if (!wantV || !track) return;
|
|
351
|
+
if (!track.dataset.markersWired) {
|
|
352
|
+
track.dataset.markersWired = "true";
|
|
353
|
+
track.addEventListener("mousedown", onRailDown);
|
|
315
354
|
}
|
|
355
|
+
applyBandWidth();
|
|
316
356
|
renderMarkers();
|
|
317
|
-
// The
|
|
357
|
+
// The marks' visibility follows the track's own fit test.
|
|
318
358
|
update();
|
|
319
359
|
};
|
|
320
360
|
|
|
321
361
|
function updateAxis(s: AxisState, horizontal: boolean) {
|
|
322
362
|
const { scrollSize, clientSize, scrollPos } = axisMetrics(viewport, horizontal);
|
|
323
363
|
if (scrollSize <= clientSize) {
|
|
324
|
-
// Content fits — no bar at all. The
|
|
325
|
-
//
|
|
326
|
-
// never reach.
|
|
364
|
+
// Content fits — no bar at all. The marks live inside the track, so
|
|
365
|
+
// hiding it hides them too: marks on a region that cannot scroll
|
|
366
|
+
// would claim changes the reader can never reach.
|
|
327
367
|
s.track.style.display = "none";
|
|
328
|
-
if (!horizontal && rail) rail.style.display = "none";
|
|
329
368
|
return;
|
|
330
369
|
}
|
|
331
370
|
s.track.style.display = "";
|
|
332
|
-
if (!horizontal && rail) rail.style.display = "";
|
|
333
371
|
const trackSize = horizontal ? s.track.clientWidth : s.track.clientHeight;
|
|
334
372
|
const ratio = clientSize / scrollSize;
|
|
335
373
|
const thumbSize = Math.max(ratio * trackSize, 20);
|
|
@@ -354,13 +392,14 @@ export function attachOverlayScrollbars(
|
|
|
354
392
|
function update() {
|
|
355
393
|
if (detached) return;
|
|
356
394
|
for (const s of states) updateAxis(s, s.horizontal);
|
|
357
|
-
// The marks' pixel floor is a fraction OF the
|
|
358
|
-
// invalidates the sizes they were rendered with. A hidden
|
|
395
|
+
// The marks' pixel floor is a fraction OF the band, so a height change
|
|
396
|
+
// invalidates the sizes they were rendered with. A hidden band reports
|
|
359
397
|
// 0 and is skipped here; `renderMarkers` leaves that 0 behind as its
|
|
360
398
|
// "never sized against a real height" sentinel, so the first visible
|
|
361
399
|
// update re-renders even when the height matches the last one used.
|
|
362
|
-
const
|
|
363
|
-
|
|
400
|
+
const band = verticalTrack();
|
|
401
|
+
const railH = band?.clientHeight ?? 0;
|
|
402
|
+
if (band && markers.length > 0 && railH > 0 && railH !== renderedRailHeight) {
|
|
364
403
|
renderMarkers();
|
|
365
404
|
}
|
|
366
405
|
}
|
|
@@ -480,17 +519,14 @@ export function attachOverlayScrollbars(
|
|
|
480
519
|
ro = null;
|
|
481
520
|
scheduled?.disconnect();
|
|
482
521
|
scheduled = null;
|
|
483
|
-
if (rail) {
|
|
484
|
-
rail.removeEventListener("mousedown", onRailDown);
|
|
485
|
-
rail.remove();
|
|
486
|
-
rail = null;
|
|
487
|
-
}
|
|
488
522
|
onRailUp();
|
|
489
523
|
for (const { state, handlers } of axisBindings) {
|
|
490
524
|
state.thumb.removeEventListener("mousedown", handlers.onDown);
|
|
491
525
|
state.track.removeEventListener("click", handlers.onTrackClick);
|
|
492
526
|
state.track.removeEventListener("mouseenter", handlers.onEnter);
|
|
493
527
|
state.track.removeEventListener("mouseleave", handlers.onLeave);
|
|
528
|
+
state.track.removeEventListener("mousedown", onRailDown);
|
|
529
|
+
delete state.track.dataset.markersWired;
|
|
494
530
|
document.removeEventListener("mousemove", handlers.onMove);
|
|
495
531
|
document.removeEventListener("mouseup", handlers.onUp);
|
|
496
532
|
state.hideTimer?.disconnect();
|
|
@@ -51,6 +51,31 @@
|
|
|
51
51
|
opacity: 1;
|
|
52
52
|
pointer-events: auto;
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/* ── The widened band (marks showing) ───────────────────────────────
|
|
56
|
+
VS Code's overview ruler is the scrollbar's own strip WIDENED, not a
|
|
57
|
+
second strip beside it. Selective: only a track carrying marks gets
|
|
58
|
+
the width, the faint ground and the rounding.
|
|
59
|
+
The ground is a translucent mid-grey — over a light page it reads as
|
|
60
|
+
a faint grey, over a dark one as slightly lighter than the page, so
|
|
61
|
+
one declaration serves both modes (the muted token does not flip). */
|
|
62
|
+
&.has-markers:not([data-axis="horizontal"]) {
|
|
63
|
+
width: 14px; /* the engine overrides this inline with markersWidth */
|
|
64
|
+
border-radius: 2px;
|
|
65
|
+
background: color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 12%, transparent);
|
|
66
|
+
/* The band and its marks are ALWAYS visible — the whole point of an
|
|
67
|
+
overview ruler is a glance, not a hover. The track itself stays
|
|
68
|
+
`pointer-events: none` (below), so the ground still never steals a
|
|
69
|
+
press; only the marks accept input. */
|
|
70
|
+
opacity: 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* The thumb rides OVER the marks (VS Code's translucent thumb over the
|
|
75
|
+
ruler): without a z-index of its own it would paint UNDER the opaque
|
|
76
|
+
marks, which are appended after it in the same stacking context. */
|
|
77
|
+
.hk-scrollbar-thumb {
|
|
78
|
+
z-index: 2;
|
|
54
79
|
}
|
|
55
80
|
|
|
56
81
|
.hk-scrollbar-thumb {
|
|
@@ -84,65 +109,48 @@
|
|
|
84
109
|
min-height: 20px;
|
|
85
110
|
}
|
|
86
111
|
|
|
87
|
-
/* ── Change
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
groove: the marks are the content. */
|
|
94
|
-
.hk-scrollbar-markers {
|
|
95
|
-
position: absolute;
|
|
96
|
-
z-index: 11;
|
|
97
|
-
top: 4px;
|
|
98
|
-
bottom: 4px;
|
|
99
|
-
/* Vertical track: right 2px + width 6px → occupies 2–8px; a 2px gutter
|
|
100
|
-
puts the rail at 10px. The contract test pins this non-overlap. */
|
|
101
|
-
right: 10px;
|
|
102
|
-
width: 8px;
|
|
103
|
-
border-radius: 4px;
|
|
104
|
-
background: color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 18%, transparent);
|
|
105
|
-
overflow: hidden;
|
|
106
|
-
transition: background var(--hi-duration-fast, 0.15s) ease;
|
|
107
|
-
|
|
108
|
-
/* Pointer-transparent, exactly like the track above and for the same
|
|
109
|
-
reason: an overlay hugging the region's edge must never steal a press
|
|
110
|
-
meant for the content beneath it — and this rail spans the WHOLE host,
|
|
111
|
-
docks included, so it sits over right-aligned dock controls too. Only
|
|
112
|
-
the MARKS take input (see below): they are the targets that mean
|
|
113
|
-
something, and they are the rail's entire content. */
|
|
114
|
-
pointer-events: none;
|
|
115
|
-
|
|
116
|
-
&.is-dragging {
|
|
117
|
-
background: color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 32%, transparent);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/* One mark. `data-kind` is whatever tag the consumer passed; the three
|
|
122
|
-
diff kinds get the diff palette, anything else the neutral muted one.
|
|
123
|
-
The fallbacks mirror the thumb's: a themeless standalone render must
|
|
124
|
-
still show marks rather than an invalid-at-computed-value-time
|
|
125
|
-
transparent.
|
|
112
|
+
/* ── Change marks (VS Code's overview ruler) ──────────────────────────
|
|
113
|
+
The marks live INSIDE the widened track (`.hk-scrollbar-track.has-markers`
|
|
114
|
+
above), so the scrollbar itself is the ruler — one band, not two. The
|
|
115
|
+
thumb slides OVER them (its z-index is higher and it is translucent, as
|
|
116
|
+
in VS Code). Only a track carrying marks changes: every other consumer's
|
|
117
|
+
chrome above is unchanged.
|
|
126
118
|
|
|
127
119
|
Height comes ENTIRELY from the engine, which floors a hairline mark in
|
|
128
|
-
both fractions and pixels and sizes the end-of-
|
|
120
|
+
both fractions and pixels and sizes the end-of-band inset against that
|
|
129
121
|
same number. A `min-height` here is therefore redundant — the engine
|
|
130
|
-
already guarantees 2px — and, because the
|
|
122
|
+
already guarantees 2px — and, because the track clips, would win over
|
|
131
123
|
the inset on the occasions it did bite. */
|
|
132
124
|
.hk-scrollbar-marker {
|
|
133
125
|
position: absolute;
|
|
126
|
+
z-index: 1;
|
|
134
127
|
left: 0;
|
|
135
128
|
width: 100%;
|
|
136
|
-
border-radius:
|
|
129
|
+
border-radius: 2px;
|
|
137
130
|
background: var(--hi-color-muted, rgb(118 122 128));
|
|
138
|
-
/* The
|
|
131
|
+
/* The track is pointer-transparent at rest; the marks are the hit area. */
|
|
139
132
|
pointer-events: auto;
|
|
140
133
|
cursor: pointer;
|
|
141
134
|
|
|
135
|
+
/* VS Code's two lanes: REMOVED content hugs the band's start edge,
|
|
136
|
+
ADDED content its end edge, so one vertical position can carry both.
|
|
137
|
+
No `side` = the mark spans the full band. */
|
|
138
|
+
&[data-side="start"] {
|
|
139
|
+
left: 0;
|
|
140
|
+
right: auto;
|
|
141
|
+
width: 34%;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
&[data-side="end"] {
|
|
145
|
+
left: auto;
|
|
146
|
+
right: 0;
|
|
147
|
+
width: 34%;
|
|
148
|
+
}
|
|
149
|
+
|
|
142
150
|
&:hover {
|
|
143
|
-
/* Negative offset:
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
/* Negative offset: an outset ring would draw past the mark's own box
|
|
152
|
+
and read as a stray rule floating beside it — drawn inside, the cue
|
|
153
|
+
stays attached to the mark it belongs to. */
|
|
146
154
|
outline: 1px solid color-mix(in srgb, var(--hi-color-muted, rgb(118 122 128)) 60%, transparent);
|
|
147
155
|
outline-offset: -1px;
|
|
148
156
|
}
|