@celestia-island/hikari 0.55.299 → 0.55.301
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/HkIconButton.test.tsx +30 -0
- package/src/components/HkIconButton.tsx +8 -0
- package/src/components/HkPillToggleGroup.scss +34 -0
- package/src/components/HkPillToggleGroup.test.tsx +97 -0
- package/src/components/HkPillToggleGroup.tsx +10 -0
- package/src/components/HkRangeRibbon.scss +31 -25
- package/src/components/HkRangeRibbon.test.tsx +42 -2
- package/src/components/HkRangeRibbon.tsx +14 -5
- package/src/components/HkScrollContainer.fade.contract.test.ts +90 -16
- package/src/components/HkScrollContainer.scss +76 -20
- package/src/components/HkScrollContainer.test.ts +200 -0
- package/src/components/HkScrollContainer.tsx +130 -4
- package/src/components/range-ribbon-reset.contract.test.ts +52 -0
- package/src/index.ts +1 -1
package/package.json
CHANGED
|
@@ -122,6 +122,36 @@ describe("HkIconButton", () => {
|
|
|
122
122
|
});
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
|
|
126
|
+
describe("HkIconButton button semantics", () => {
|
|
127
|
+
it("renders type=button so a form never treats the chrome as a submitter", () => {
|
|
128
|
+
const c = mountComp(() => h(HkIconButton, { "aria-label": "probe" }));
|
|
129
|
+
const btn = c.querySelector<HTMLButtonElement>("button")!;
|
|
130
|
+
expect(btn.getAttribute("type")).toBe("button");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("never overwrites an explicit type", () => {
|
|
134
|
+
for (const explicit of ["submit", "reset"] as const) {
|
|
135
|
+
const c = mountComp(() => h(HkIconButton, { "aria-label": "probe", type: explicit } as never));
|
|
136
|
+
expect(c.querySelector<HTMLButtonElement>("button")!.getAttribute("type")).toBe(explicit);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("keeps the default when the attribute is explicitly undefined or empty", () => {
|
|
141
|
+
// The discriminating cases: a pre-fix ordering (default first, attrs
|
|
142
|
+
// after) elides the attribute for `undefined` and passes an empty
|
|
143
|
+
// string through — the first is an implicit submitter, the second an
|
|
144
|
+
// invalid value that ALSO defaults to submit.
|
|
145
|
+
for (const value of [undefined, null, ""] as const) {
|
|
146
|
+
const c = mountComp(() => h(HkIconButton, { "aria-label": "probe", type: value } as never));
|
|
147
|
+
expect(
|
|
148
|
+
c.querySelector<HTMLButtonElement>("button")!.getAttribute("type"),
|
|
149
|
+
`type=${JSON.stringify(value)} must fall back to button`,
|
|
150
|
+
).toBe("button");
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
125
155
|
/**
|
|
126
156
|
* Static pin for the header-chrome glyph step (user report 2026-09-29:
|
|
127
157
|
* the search / refresh / filter glyphs in the top-right clusters read
|
|
@@ -61,6 +61,14 @@ export default defineComponent({
|
|
|
61
61
|
disabled={props.disabled}
|
|
62
62
|
onClick={(e: MouseEvent) => emit("click", e)}
|
|
63
63
|
{...attrs}
|
|
64
|
+
// A rendered <button> defaults to type=submit, which silently turns
|
|
65
|
+
// every icon-only control inside a <form> into a submitter. The
|
|
66
|
+
// family is chrome, never a submitter: default it here, once, so
|
|
67
|
+
// call sites do not each spread an attribute the component does not
|
|
68
|
+
// declare. Placed AFTER the attrs spread and guarded with `||` so an
|
|
69
|
+
// explicit `type` still wins while an explicitly `undefined` one
|
|
70
|
+
// cannot silently defeat the default (HkButton's idiom).
|
|
71
|
+
type={((attrs.type as string) || "button") as "button" | "reset" | "submit"}
|
|
64
72
|
>
|
|
65
73
|
<span class="hk-icon-button-icon">
|
|
66
74
|
{slots.icon
|
|
@@ -95,6 +95,40 @@
|
|
|
95
95
|
background: rgb(var(--color-primary) / 12%);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/* Tone channels (2026-11-06): a pill can carry the hue identity of the
|
|
99
|
+
* thing it filters (the trace chips tint with their lane's token so a
|
|
100
|
+
* chip, its ribbon segments and its rows share one identity). A tone
|
|
101
|
+
* re-tints the ACTIVE state only — inactive pills stay muted regardless
|
|
102
|
+
* of tone. The four hue tones share one alpha (16%); the "muted" tone
|
|
103
|
+
* rides 18% because it has to read as a neutral wash rather than a hue.
|
|
104
|
+
* The attribute pair wins on SPECIFICITY ((0,3,0) vs (0,2,0)) — order
|
|
105
|
+
* independent; they are also written after the plain rule so the source
|
|
106
|
+
* order agrees with what specificity already decides. */
|
|
107
|
+
&[data-tone="success"][data-active] {
|
|
108
|
+
color: rgb(var(--color-success, 14 184 64));
|
|
109
|
+
background: rgb(var(--color-success, 14 184 64) / 16%);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
&[data-tone="secondary"][data-active] {
|
|
113
|
+
color: rgb(var(--color-secondary, 81 154 115));
|
|
114
|
+
background: rgb(var(--color-secondary, 81 154 115) / 16%);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
&[data-tone="warning"][data-active] {
|
|
118
|
+
color: rgb(var(--color-warning, 238 214 119));
|
|
119
|
+
background: rgb(var(--color-warning, 238 214 119) / 16%);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
&[data-tone="error"][data-active] {
|
|
123
|
+
color: rgb(var(--color-error, 247 118 142));
|
|
124
|
+
background: rgb(var(--color-error, 247 118 142) / 16%);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
&[data-tone="muted"][data-active] {
|
|
128
|
+
color: rgb(var(--color-text, 30 30 30));
|
|
129
|
+
background: rgb(var(--color-muted, 108 108 108) / 18%);
|
|
130
|
+
}
|
|
131
|
+
|
|
98
132
|
&:focus-visible {
|
|
99
133
|
outline: 2px solid rgb(var(--color-primary));
|
|
100
134
|
outline-offset: -2px;
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest";
|
|
2
2
|
import { createApp, h } from "vue";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { compile } from "sass";
|
|
3
7
|
|
|
4
8
|
import HkPillToggleGroup from "./HkPillToggleGroup";
|
|
5
9
|
|
|
@@ -175,3 +179,96 @@ describe("HkPillToggleGroup", () => {
|
|
|
175
179
|
expect(toggled).toEqual([OPTIONS[1].value]);
|
|
176
180
|
});
|
|
177
181
|
});
|
|
182
|
+
|
|
183
|
+
// ── Tone channel ─────────────────────────────────────────────────────────
|
|
184
|
+
describe("HkPillToggleGroup tone channel", () => {
|
|
185
|
+
const TONED_OPTIONS = [
|
|
186
|
+
{ value: "input", label: "Input", tone: "success" },
|
|
187
|
+
{ value: "text", label: "Text", tone: "secondary" },
|
|
188
|
+
{ value: "tool", label: "Tool", tone: "warning" },
|
|
189
|
+
{ value: "plain", label: "Plain" },
|
|
190
|
+
{ value: "primary", label: "Primary", tone: "primary" },
|
|
191
|
+
] as const;
|
|
192
|
+
|
|
193
|
+
it("carries data-tone for non-primary tones and omits it for default/primary", () => {
|
|
194
|
+
const c = mount(
|
|
195
|
+
h(HkPillToggleGroup, { label: "Kinds", options: [...TONED_OPTIONS], selected: new Set<string>() }),
|
|
196
|
+
);
|
|
197
|
+
const pills = [...c.querySelectorAll<HTMLButtonElement>(".hk-pill-group-pill")];
|
|
198
|
+
expect(pills[0].getAttribute("data-tone")).toBe("success");
|
|
199
|
+
expect(pills[1].getAttribute("data-tone")).toBe("secondary");
|
|
200
|
+
expect(pills[2].getAttribute("data-tone")).toBe("warning");
|
|
201
|
+
expect(pills[3].hasAttribute("data-tone"), "no tone → no attribute").toBe(false);
|
|
202
|
+
expect(pills[4].hasAttribute("data-tone"), "primary is the default → no attribute").toBe(false);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("renders the option label alongside the tone attribute", () => {
|
|
206
|
+
const c = mount(
|
|
207
|
+
h(HkPillToggleGroup, {
|
|
208
|
+
label: "Kinds",
|
|
209
|
+
options: [{ value: "tool", label: "Tool", tone: "warning" }],
|
|
210
|
+
selected: new Set<string>(["tool"]),
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
const pill = c.querySelector<HTMLButtonElement>(".hk-pill-group-pill")!;
|
|
214
|
+
expect(pill.textContent?.trim()).toBe("Tool");
|
|
215
|
+
expect(pill.hasAttribute("data-active")).toBe(true);
|
|
216
|
+
// Without this the whole channel could be reverted and the test stays green.
|
|
217
|
+
expect(pill.getAttribute("data-tone")).toBe("warning");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("stylesheet contract: every tone re-tints the ACTIVE state only, after the plain rule", () => {
|
|
221
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
222
|
+
const css = compile(join(here, "HkPillToggleGroup.scss"), { style: "expanded" }).css;
|
|
223
|
+
const tones = ["success", "secondary", "warning", "error", "muted"] as const;
|
|
224
|
+
// Same triplets styles/theme/channels.scss defines (light base).
|
|
225
|
+
const FALLBACKS: Record<(typeof tones)[number], string> = {
|
|
226
|
+
success: "14 184 64",
|
|
227
|
+
secondary: "81 154 115",
|
|
228
|
+
warning: "238 214 119",
|
|
229
|
+
error: "247 118 142",
|
|
230
|
+
muted: "108 108 108",
|
|
231
|
+
};
|
|
232
|
+
const plainActive = css.indexOf(".hk-pill-group-pill[data-active]");
|
|
233
|
+
expect(plainActive, "the plain active rule exists").toBeGreaterThan(-1);
|
|
234
|
+
for (const tone of tones) {
|
|
235
|
+
// Sass drops quotes around attribute values when expanding
|
|
236
|
+
// (`[data-status=pending]` — the HkRangeRibbon sheet contract hits
|
|
237
|
+
// the same formatting), so match the compiled shape.
|
|
238
|
+
const selector = `.hk-pill-group-pill[data-tone=${tone}][data-active]`;
|
|
239
|
+
const at = css.indexOf(selector);
|
|
240
|
+
expect(at, `${selector} compiles`).toBeGreaterThan(-1);
|
|
241
|
+
// Every tone rule must come AFTER the plain rule: the attribute pair
|
|
242
|
+
// outranks it, and the source order keeps that dominance explicit.
|
|
243
|
+
expect(at, `${tone} rule rides after the plain active rule`).toBeGreaterThan(plainActive);
|
|
244
|
+
// Tone must never repaint an INACTIVE pill: every tone selector has to
|
|
245
|
+
// carry the attribute pair. A bare `[data-tone=…]` rule would pass the
|
|
246
|
+
// assertions above while quietly tinting muted pills.
|
|
247
|
+
expect(css, "no tone rule without [data-active]").not.toMatch(
|
|
248
|
+
new RegExp(`\\[data-tone=${tone}\\]\\s*\\{`),
|
|
249
|
+
);
|
|
250
|
+
const body = css.slice(at, css.indexOf("}", at));
|
|
251
|
+
expect(body, `${tone} re-tints the ink`).toContain("color:");
|
|
252
|
+
expect(body, `${tone} re-tints the fill`).toContain("background:");
|
|
253
|
+
expect(body, `${tone} tint uses its theme token`).toContain(`var(--color-${tone}`);
|
|
254
|
+
// Presence alone is not enough: the token appears twice (ink + fill),
|
|
255
|
+
// so swapping one for another hue kept this green (R3 M9/M9b). Pin the
|
|
256
|
+
// ink and the fill to their own channels — muted is the documented
|
|
257
|
+
// exception (text ink, muted wash).
|
|
258
|
+
expect(body, `${tone} INK uses its own channel`).toContain(
|
|
259
|
+
`color: rgb(var(--color-${tone === "muted" ? "text" : tone}`,
|
|
260
|
+
);
|
|
261
|
+
expect(body, `${tone} FILL uses its own channel`).toContain(
|
|
262
|
+
`background: rgb(var(--color-${tone}`,
|
|
263
|
+
);
|
|
264
|
+
// The canonical fallback is what keeps the Rust-side mirror rendering
|
|
265
|
+
// the same hue without the theme layer — pin the exact triplet.
|
|
266
|
+
expect(body, `${tone} carries its canonical fallback`).toContain(FALLBACKS[tone]);
|
|
267
|
+
// Sass's expanded output drops the space before the alpha slash:
|
|
268
|
+
// `rgb(var(--color-x, t t t)/16%)`.
|
|
269
|
+
expect(body, `${tone} keeps the house alpha`).toMatch(
|
|
270
|
+
tone === "muted" ? /\/\s*18%/ : /\/\s*16%/,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -3,6 +3,13 @@ import { defineComponent, h, type PropType, type VNode } from "vue";
|
|
|
3
3
|
import { useOptionStrip } from "../composables/useOptionStrip";
|
|
4
4
|
import "./HkPillToggleGroup.scss";
|
|
5
5
|
|
|
6
|
+
/** Semantic tone channel for a pill's ACTIVE tint — the same vocabulary
|
|
7
|
+
* the HkRangeRibbon segment family speaks (a filter bar can keep its
|
|
8
|
+
* lane-hue identity while riding the shared pill chrome). Omit (or
|
|
9
|
+
* "primary") = the default primary tint. A tone recolors the ACTIVE
|
|
10
|
+
* state only; inactive pills stay muted regardless of tone. */
|
|
11
|
+
export type HkPillTone = "primary" | "success" | "secondary" | "warning" | "error" | "muted";
|
|
12
|
+
|
|
6
13
|
/** One toggleable pill of the group. */
|
|
7
14
|
export interface HkPillToggleGroupOption {
|
|
8
15
|
/** Stable identity — the value emitted back through `toggle`. */
|
|
@@ -14,6 +21,8 @@ export interface HkPillToggleGroupOption {
|
|
|
14
21
|
* (never stringified: a raw function child renders as source text,
|
|
15
22
|
* which is exactly the landmine this normalization defuses). */
|
|
16
23
|
icon?: unknown;
|
|
24
|
+
/** Active-tint hue channel (see {@link HkPillTone}). */
|
|
25
|
+
tone?: HkPillTone;
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
/** Normalize an option icon to a renderable vnode. A component (function
|
|
@@ -94,6 +103,7 @@ export default defineComponent({
|
|
|
94
103
|
type="button"
|
|
95
104
|
key={o.value}
|
|
96
105
|
class="hk-pill-group-pill"
|
|
106
|
+
data-tone={o.tone && o.tone !== "primary" ? o.tone : undefined}
|
|
97
107
|
data-active={props.selected.has(o.value) || undefined}
|
|
98
108
|
aria-pressed={props.selected.has(o.value) ? "true" : "false"}
|
|
99
109
|
onClick={() => emit("toggle", o.value)}
|
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
the strip is 32px tall, a handle is an 8px half-overlapping grip
|
|
10
10
|
(translateX(-50%) so `left: 20%` centers it on the window edge), a
|
|
11
11
|
clipped segment keeps a 2px minimum so edge slivers stay visible, and
|
|
12
|
-
the reset
|
|
13
|
-
half-width, so a handle at 0% / 100% is never
|
|
12
|
+
the reset button (a standard HkIconButton) clears the edge handle
|
|
13
|
+
(right: 4px = the handle's half-width, so a handle at 0% / 100% is never
|
|
14
|
+
covered). The 24px touch-floor seat costs the strip's top-right brush
|
|
15
|
+
band — the free band below the seat is what remains.
|
|
14
16
|
|
|
15
17
|
Colours and spacing come from hikari theme tokens; a few geometry values
|
|
16
18
|
(the 32px strip, the 8px grip, the 2px min-width floor) are deliberate
|
|
@@ -152,7 +154,12 @@
|
|
|
152
154
|
position: absolute;
|
|
153
155
|
top: 3px;
|
|
154
156
|
bottom: 3px;
|
|
155
|
-
z-index:
|
|
157
|
+
// ABOVE the reset seat (z-index 3): a 24px seat reaches ~10-13% into the
|
|
158
|
+
// strip's right edge, so at z-index 2 a window whose end lands in that
|
|
159
|
+
// band had both grips covered by the reset's box and unreachable by
|
|
160
|
+
// pointer (the seat is opaque and paints later). The grip wins the band;
|
|
161
|
+
// the reset keeps every other pixel of its area.
|
|
162
|
+
z-index: 4;
|
|
156
163
|
width: 8px;
|
|
157
164
|
transform: translateX(-50%);
|
|
158
165
|
border: 1px solid rgb(var(--color-surface));
|
|
@@ -174,32 +181,31 @@
|
|
|
174
181
|
outline-offset: 2px;
|
|
175
182
|
}
|
|
176
183
|
|
|
177
|
-
|
|
184
|
+
// The reset affordance is a STANDARD HkIconButton (size 24, ghost) — the
|
|
185
|
+
// icon-button sheet owns box, glyph size, hover, press and focus chrome.
|
|
186
|
+
// 24 is the touch floor the family uses for dense chrome (the same floor the
|
|
187
|
+
// consumers' icon-size guards enforce); the 32px strip still seats it with a
|
|
188
|
+
// 4px inset top and bottom.
|
|
189
|
+
// This rule only pins the seat on the overview field: right = the edge
|
|
190
|
+
// handle's half-width, so a handle at 0% / 100% is never covered.
|
|
191
|
+
// The seat rides the descendant key on purpose: `.hk-icon-button` declares
|
|
192
|
+
// `position: relative` at the SAME (0,1,0) specificity, so a bare
|
|
193
|
+
// `.hk-range-ribbon-reset` rule would only win while HkIconButton.scss
|
|
194
|
+
// happens to come first in the bundle (the exact failure the lightbox close
|
|
195
|
+
// hit in 2026-09-11 — its seat rule is `.hk-image-lightbox-close.hk-icon-button`
|
|
196
|
+
// for this reason). (0,2,0) makes the seat order-independent.
|
|
197
|
+
// The surface backing rides it for the same reason: the `--hi-icon-button-*`
|
|
198
|
+
// properties also live on `.hk-icon-button` (HkIconButtonVars). The ghost
|
|
199
|
+
// variant is transparent by default — a control floating on the strip's own
|
|
200
|
+
// paint keeps a surface tint to stay legible over segments and scrims.
|
|
201
|
+
.hk-range-ribbon .hk-range-ribbon-reset {
|
|
178
202
|
position: absolute;
|
|
179
203
|
top: 4px;
|
|
180
204
|
right: 4px;
|
|
181
205
|
z-index: 3;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
width: 18px;
|
|
186
|
-
height: 18px;
|
|
187
|
-
padding: 0;
|
|
188
|
-
border: 1px solid rgb(var(--color-border));
|
|
189
|
-
border-radius: var(--radius-sm);
|
|
190
|
-
background: rgb(var(--color-surface) / 85%);
|
|
191
|
-
color: rgb(var(--color-text));
|
|
192
|
-
cursor: pointer;
|
|
193
|
-
transition: background-color var(--duration-fast, 0.15s) ease;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
.hk-range-ribbon-reset:hover {
|
|
197
|
-
background: rgb(var(--color-surface));
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
.hk-range-ribbon-reset:focus-visible {
|
|
201
|
-
outline: 2px solid rgb(var(--color-primary));
|
|
202
|
-
outline-offset: 1px;
|
|
206
|
+
--hi-icon-button-bg: rgb(var(--color-surface) / 85%);
|
|
207
|
+
--hi-icon-button-bg-hover: rgb(var(--color-surface));
|
|
208
|
+
--hi-icon-button-bg-active: rgb(var(--color-surface));
|
|
203
209
|
}
|
|
204
210
|
|
|
205
211
|
// Visually hidden, still read by AT: labels the keyboard contract the
|
|
@@ -771,6 +771,18 @@ describe("HkRangeRibbon reset", () => {
|
|
|
771
771
|
const host = mountHost([0.2, 0.6]);
|
|
772
772
|
const button = host.root.querySelector<HTMLElement>(".hk-range-ribbon-reset");
|
|
773
773
|
expect(button, "reset button is shown while a window exists").toBeTruthy();
|
|
774
|
+
// The reset affordance rides the STANDARD icon button (user direction
|
|
775
|
+
// 2026-11-06: no hand-rolled chip chrome), carrying its lucide glyph
|
|
776
|
+
// through the house-normal slot — pin the lucide class the slot must
|
|
777
|
+
// produce so a glyph swap cannot silently vanish.
|
|
778
|
+
expect(button!.className, "reset is an HkIconButton, not a bespoke chip").toContain(
|
|
779
|
+
"hk-icon-button",
|
|
780
|
+
);
|
|
781
|
+
expect(
|
|
782
|
+
button!.querySelector("svg.lucide-rotate-ccw"),
|
|
783
|
+
"reset glyph renders through the icon slot",
|
|
784
|
+
).not.toBeNull();
|
|
785
|
+
expect(button!.getAttribute("type"), "reset never becomes a submit button").toBe("button");
|
|
774
786
|
button!.click();
|
|
775
787
|
await nextTick();
|
|
776
788
|
expect(host.windowChange).toHaveBeenLastCalledWith(null);
|
|
@@ -1405,8 +1417,36 @@ describe("HkRangeRibbon stylesheet contract", () => {
|
|
|
1405
1417
|
// A clipped 2px sliver must still be visible.
|
|
1406
1418
|
expect(declaration(ruleBody(css, ".hk-range-ribbon-segment"), "min-width")).toBe("2px");
|
|
1407
1419
|
expect(declaration(ruleBody(css, ".hk-range-ribbon-lane-track"), "height")).toBe("20px");
|
|
1408
|
-
// The reset
|
|
1409
|
-
|
|
1420
|
+
// The reset seat clears the edge handle (half its 8px width). The rule
|
|
1421
|
+
// must sit on the descendant key (0,2,0): `.hk-icon-button` declares
|
|
1422
|
+
// `position: relative` at (0,1,0), so a bare-class seat would depend on
|
|
1423
|
+
// bundle order.
|
|
1424
|
+
const resetSeat = ruleBody(css, ".hk-range-ribbon .hk-range-ribbon-reset");
|
|
1425
|
+
expect(declaration(resetSeat, "right")).toBe("4px");
|
|
1426
|
+
expect(declaration(resetSeat, "position"), "the seat must out-rank .hk-icon-button").toBe(
|
|
1427
|
+
"absolute",
|
|
1428
|
+
);
|
|
1429
|
+
expect(declaration(resetSeat, "top"), "the seat clears the strip edge").toBe("4px");
|
|
1430
|
+
// The grip must paint ABOVE the seat: a right-side window puts its end
|
|
1431
|
+
// handle inside the seat's box, and an opaque seat below it would make
|
|
1432
|
+
// the grip unreachable by pointer.
|
|
1433
|
+
const gripZ = Number(declaration(ruleBody(css, ".hk-range-ribbon-handle"), "z-index"));
|
|
1434
|
+
const seatZ = Number(declaration(resetSeat, "z-index"));
|
|
1435
|
+
expect(gripZ, "the edge grip outranks the reset seat").toBeGreaterThan(seatZ);
|
|
1436
|
+
// The surface backing is what keeps the control legible over the strip's
|
|
1437
|
+
// segments and scrims: ghost carries NO background (HkIconButtonVars),
|
|
1438
|
+
// so without these three the reset degrades to a bare glyph floating on
|
|
1439
|
+
// the paint. Removing them alone left the whole suite green (R3 M5b) —
|
|
1440
|
+
// pin the paint, not just the geometry.
|
|
1441
|
+
expect(declaration(resetSeat, "--hi-icon-button-bg"), "resting surface tint").toContain(
|
|
1442
|
+
"var(--color-surface)",
|
|
1443
|
+
);
|
|
1444
|
+
expect(declaration(resetSeat, "--hi-icon-button-bg-hover"), "hover surface tint").toContain(
|
|
1445
|
+
"var(--color-surface)",
|
|
1446
|
+
);
|
|
1447
|
+
expect(declaration(resetSeat, "--hi-icon-button-bg-active"), "press surface tint").toContain(
|
|
1448
|
+
"var(--color-surface)",
|
|
1449
|
+
);
|
|
1410
1450
|
expect(ruleBody(css, ".hk-range-ribbon-window")).toContain("var(--color-primary)");
|
|
1411
1451
|
expect(ruleBody(css, ".hk-range-ribbon-scrim")).toContain("var(--color-background)");
|
|
1412
1452
|
});
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "vue";
|
|
12
12
|
|
|
13
13
|
import { useI18n } from "../i18n/context";
|
|
14
|
+
import HIconButton from "./HkIconButton";
|
|
14
15
|
import "./HkRangeRibbon.scss";
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -744,15 +745,23 @@ export default defineComponent({
|
|
|
744
745
|
onPointerdown={(event: PointerEvent) => onHandlePointerDown("end", event)}
|
|
745
746
|
onKeydown={(event: KeyboardEvent) => onHandleKeyDown("end", event)}
|
|
746
747
|
/>
|
|
747
|
-
|
|
748
|
-
|
|
748
|
+
{/* The reset affordance is the STANDARD icon button (user
|
|
749
|
+
direction 2026-11-06: no hand-rolled chip chrome) —
|
|
750
|
+
HkIconButton owns box, glyph size, hover, press and
|
|
751
|
+
focus; this sheet only pins the seat (position) and a
|
|
752
|
+
surface backing via its override vars. The glyph slots
|
|
753
|
+
as a lucide component (the house-normal integration:
|
|
754
|
+
every in-tree HkIconButton slots its glyph). */}
|
|
755
|
+
<HIconButton
|
|
749
756
|
class="hk-range-ribbon-reset"
|
|
757
|
+
size={24}
|
|
758
|
+
variant="ghost"
|
|
750
759
|
aria-label={t("hikari::ribbon.reset", "Reset range")}
|
|
751
|
-
title
|
|
760
|
+
{...{ title: t("hikari::ribbon.reset", "Reset range") }}
|
|
752
761
|
onClick={resetWindow}
|
|
753
762
|
>
|
|
754
|
-
<RotateCcw
|
|
755
|
-
</
|
|
763
|
+
{{ icon: () => <RotateCcw aria-hidden="true" /> }}
|
|
764
|
+
</HIconButton>
|
|
756
765
|
</>
|
|
757
766
|
)}
|
|
758
767
|
</div>
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Source contract for the scroll-container edge fades (the 2026-11-
|
|
3
|
-
* revision of the 2026-
|
|
4
|
-
* viewport mask is built from the gradient LAYERS listed in
|
|
5
|
-
* --hk-scroll-fade-layers,
|
|
2
|
+
* Source contract for the scroll-container edge fades (the 2026-11-06
|
|
3
|
+
* progressive revision of the 2026-11-02 composition): each edge owns a
|
|
4
|
+
* CSS var, the viewport mask is built from the gradient LAYERS listed in
|
|
5
|
+
* --hk-scroll-fade-layers, the axis rules swap that list, and every lit
|
|
6
|
+
* band is the fade size scaled by a live strength the component
|
|
7
|
+
* publishes from the hidden-content depth (the progressive fade) —
|
|
6
8
|
*
|
|
7
9
|
* - two-axis containers list the vertical AND horizontal gradients and
|
|
8
10
|
* intersect them, so the fade reaches zero on all four sides and the
|
|
@@ -28,6 +30,17 @@
|
|
|
28
30
|
* - all four edge vars are declared (top/right/bottom/left, 0px base);
|
|
29
31
|
* - start/end/both rules exist for BOTH axes;
|
|
30
32
|
* - RTL swaps the horizontal vars instead of rewriting the mask.
|
|
33
|
+
*
|
|
34
|
+
* And the regression this file now exists to keep dead (2026-11-06,
|
|
35
|
+
* user report "still hard-clipped while swiping", measured in a real
|
|
36
|
+
* engine): #902 (2026-10-29) declared the four zero defaults on the
|
|
37
|
+
* VIEWPORT base rule, and a directly declared custom property beats any
|
|
38
|
+
* inherited value, so the viewport's own 0px shadowed every root state
|
|
39
|
+
* rule's band — no edge ever faded again (resolved mask stops
|
|
40
|
+
* `rgb(0,0,0) 0px … rgb(0,0,0) 100%`), every faded strip hard-clipped,
|
|
41
|
+
* and the dock fades died with it. The defaults therefore live on the
|
|
42
|
+
* ROOT rule that also carries the strength fallbacks, and the viewport
|
|
43
|
+
* declares only the layers/mask pair.
|
|
31
44
|
*/
|
|
32
45
|
import { describe, expect, it } from "vitest";
|
|
33
46
|
import { readFileSync } from "node:fs";
|
|
@@ -90,26 +103,87 @@ describe("scroll fade composition contract", () => {
|
|
|
90
103
|
}
|
|
91
104
|
});
|
|
92
105
|
|
|
93
|
-
it("
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
106
|
+
it("declares the zero defaults on the ROOT, never on the viewport (the #902 shadowing)", () => {
|
|
107
|
+
// A directly declared custom property beats any inherited value: the
|
|
108
|
+
// moment the viewport base rule re-declares these defaults, every
|
|
109
|
+
// root state rule's band is shadowed back to 0px and the fades die
|
|
110
|
+
// silently (the 2026-10-29..11-06 hard-clip regression, measured in
|
|
111
|
+
// a real engine). The viewport rule declares only the layer list.
|
|
112
|
+
const viewportBase = sheet.match(
|
|
113
|
+
/\.hk-scroll-container:is\(\[data-fade="true"\], \[data-dock-fade\]\) > \.hk-scroll-container-viewport \{[\s\S]*?\n\}/,
|
|
114
|
+
);
|
|
115
|
+
expect(viewportBase, "the viewport base rule exists").toBeTruthy();
|
|
116
|
+
for (const edge of ["top", "right", "bottom", "left"]) {
|
|
117
|
+
expect(viewportBase![0]).not.toContain(`--hk-scroll-fade-${edge}: 0px;`);
|
|
118
|
+
}
|
|
119
|
+
// And the root rule that carries them exists with the strengths.
|
|
120
|
+
const rootDefaults = sheet.match(
|
|
121
|
+
/\.hk-scroll-container:is\(\[data-fade="true"\], \[data-dock-fade\]\) \{[\s\S]*?\n\}/,
|
|
122
|
+
);
|
|
123
|
+
expect(rootDefaults, "the root defaults rule exists").toBeTruthy();
|
|
124
|
+
for (const edge of ["top", "right", "bottom", "left"]) {
|
|
125
|
+
expect(rootDefaults![0]).toContain(`--hk-scroll-fade-${edge}: 0px;`);
|
|
126
|
+
expect(rootDefaults![0]).toContain(`--hk-scroll-fade-strength-${edge}: 1;`);
|
|
127
|
+
}
|
|
128
|
+
// Sheet-wide: the four zero defaults live EXACTLY in the root rule.
|
|
129
|
+
// A second rule re-declaring any of them (the R2 escape: a bare
|
|
130
|
+
// `.hk-scroll-container-viewport { --hk-scroll-fade-top: 0px }`)
|
|
131
|
+
// would re-shadow the bands without this going red. The RTL swap
|
|
132
|
+
// rules also write 0px — the DEAD-side slot, not a default — so
|
|
133
|
+
// they are excluded from the stray scan.
|
|
134
|
+
const rtlBlocks = sheet.match(
|
|
135
|
+
/\.hk-scroll-container\[data-dir="rtl"\]\[data-fade="true"\]\[data-h-overflow="(?:start|end|both)"\] \{[^}]*\}/g,
|
|
136
|
+
) ?? [];
|
|
137
|
+
const sheetWithoutDefaults = sheet.replace(rootDefaults![0], "").replace(new RegExp(String.raw`${rtlBlocks.map((b) => b.replace(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)).join("|")}`, "g"), "");
|
|
138
|
+
for (const edge of ["top", "right", "bottom", "left"]) {
|
|
139
|
+
expect(
|
|
140
|
+
sheetWithoutDefaults.includes(`--hk-scroll-fade-${edge}: 0px;`),
|
|
141
|
+
`a stray zero default for ${edge} lives outside the root rule`,
|
|
142
|
+
).toBe(false);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("scales every lit band by the published strength (the progressive fade)", () => {
|
|
147
|
+
// Band = fade size × strength, so the component's hidden-content
|
|
148
|
+
// depth (0..1 over fadeDistance) deepens each edge from a hard cut
|
|
149
|
+
// to the full band. A bare `var(--hk-scroll-fade-size)` band in a
|
|
150
|
+
// state rule is the pre-progressive binary shape — drift.
|
|
151
|
+
const bandRules = sheet.match(
|
|
152
|
+
/^\.hk-scroll-container\[data-fade="true"\]\[data-[hv]-overflow="(?:start|end|both)"\] \{[^}]*\}/gm,
|
|
153
|
+
) ?? [];
|
|
154
|
+
expect(bandRules.length, "six lit-band state rules (3 per axis)").toBe(6);
|
|
155
|
+
for (const block of bandRules) {
|
|
156
|
+
const declarations = block.match(/--hk-scroll-fade-(?:top|right|bottom|left): [^;]+;/g) ?? [];
|
|
157
|
+
expect(declarations.length, "each state rule lights at least one band").toBeGreaterThan(0);
|
|
158
|
+
for (const declaration of declarations) {
|
|
159
|
+
expect(
|
|
160
|
+
declaration,
|
|
161
|
+
`strength-scaled band: ${declaration}`,
|
|
162
|
+
).toMatch(/^--hk-scroll-fade-(?:top|right|bottom|left): calc\(var\(--hk-scroll-fade-size\) \* var\(--hk-scroll-fade-strength-(?:top|right|bottom|left), 1\)\);$/);
|
|
99
163
|
}
|
|
100
164
|
}
|
|
101
|
-
|
|
102
|
-
expect(sheet).toContain('--hk-scroll-fade-top: var(--hk-scroll-fade-size)');
|
|
103
|
-
expect(sheet).toContain('--hk-scroll-fade-bottom: var(--hk-scroll-fade-size)');
|
|
165
|
+
expect(sheet).not.toMatch(/--hk-scroll-fade-(?:top|right|bottom|left): var\(--hk-scroll-fade-size\);/);
|
|
104
166
|
});
|
|
105
167
|
|
|
106
|
-
it("swaps horizontal sides under
|
|
168
|
+
it("swaps horizontal sides under the mirrored data-dir instead of rewriting the mask", () => {
|
|
169
|
+
// The slot swap keys on the component's mirrored data-dir (computed
|
|
170
|
+
// direction), NOT a literal [dir] selector: the physical-side
|
|
171
|
+
// strengths resolve direction in JS, and an ancestor attribute the
|
|
172
|
+
// selector misses (dir on the container, dir="auto", a deeper
|
|
173
|
+
// override) would light the wrong edge. See HkScrollContainer.tsx
|
|
174
|
+
// senseOverflow's data-dir mirror.
|
|
107
175
|
expect(sheet).toContain(
|
|
108
|
-
'[dir="rtl"]
|
|
176
|
+
'.hk-scroll-container[data-dir="rtl"][data-fade="true"][data-h-overflow="start"]',
|
|
109
177
|
);
|
|
110
178
|
expect(sheet).toContain(
|
|
111
|
-
'[dir="rtl"]
|
|
179
|
+
'.hk-scroll-container[data-dir="rtl"][data-fade="true"][data-h-overflow="end"]',
|
|
112
180
|
);
|
|
181
|
+
// A literal [dir="rtl"] band rule would disagree with the mirrored
|
|
182
|
+
// depths under exactly the configurations the mirror exists for.
|
|
183
|
+
expect(
|
|
184
|
+
sheet.match(/\[dir="rtl"\][^\n]*\{\s*--hk-scroll-fade-(?:left|right)/),
|
|
185
|
+
"no literal [dir]-keyed band rules remain",
|
|
186
|
+
).toBeNull();
|
|
113
187
|
});
|
|
114
188
|
|
|
115
189
|
it("routes the dock fades through the same edge vars", () => {
|
|
@@ -66,12 +66,46 @@
|
|
|
66
66
|
composite pair stays on the base rule: with one layer it is a no-op
|
|
67
67
|
(there is no layer below to composite against), so the axis rules only
|
|
68
68
|
swap the layer list. Dock fades ride the same vars — they feed the
|
|
69
|
-
vertical gradient, so they land on vertical and two-axis containers.
|
|
70
|
-
|
|
69
|
+
vertical gradient, so they land on vertical and two-axis containers.
|
|
70
|
+
|
|
71
|
+
PROGRESSIVE DEPTH (2026-11-06 revision) — two halves:
|
|
72
|
+
|
|
73
|
+
1. The state-driven vars moved back to the ROOT. #902 (2026-10-29)
|
|
74
|
+
added the four zero defaults to the VIEWPORT base rule below, and a
|
|
75
|
+
directly declared custom property beats any inherited value, so the
|
|
76
|
+
viewport's own 0px shadowed every state rule's band — measured in a
|
|
77
|
+
real engine (resolved mask stops `rgb(0,0,0) 0px … rgb(0,0,0)
|
|
78
|
+
100%`): no edge ever faded, every faded strip hard-clipped, and the
|
|
79
|
+
dock fades died with it. The defaults live on the root here (same
|
|
80
|
+
element the state rules write), where a consumer override on the
|
|
81
|
+
container still wins by the usual specificity order.
|
|
82
|
+
|
|
83
|
+
2. Each lit band is the fade size scaled by a live strength the
|
|
84
|
+
component publishes while `fade` is on: the hidden-content depth
|
|
85
|
+
past that edge (px) saturates over `fadeDistance` (default 96px)
|
|
86
|
+
into a 0..1 number mirrored as --hk-scroll-fade-strength-<side>.
|
|
87
|
+
Pull the strip and the trailing edge's fade grows from a hard cut
|
|
88
|
+
through a hint to the full band; scroll back and it recedes. Edges
|
|
89
|
+
with nothing hidden read strength 1 (the pre-JS / dock-fade
|
|
90
|
+
fallback), which with a 0px band contributes nothing. Physical-side
|
|
91
|
+
names AND a mirrored data-dir: the component resolves the computed
|
|
92
|
+
direction once and publishes both the depths and the direction
|
|
93
|
+
attribute from the same pass, so the LTR and RTL state rules
|
|
94
|
+
consume the same vars and the slot swap below never disagrees with
|
|
95
|
+
the depths (a literal [dir] selector can miss dir on the
|
|
96
|
+
container, dir="auto", or a deeper override). */
|
|
97
|
+
.hk-scroll-container:is([data-fade="true"], [data-dock-fade]) {
|
|
71
98
|
--hk-scroll-fade-top: 0px;
|
|
72
99
|
--hk-scroll-fade-right: 0px;
|
|
73
100
|
--hk-scroll-fade-bottom: 0px;
|
|
74
101
|
--hk-scroll-fade-left: 0px;
|
|
102
|
+
--hk-scroll-fade-strength-top: 1;
|
|
103
|
+
--hk-scroll-fade-strength-right: 1;
|
|
104
|
+
--hk-scroll-fade-strength-bottom: 1;
|
|
105
|
+
--hk-scroll-fade-strength-left: 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.hk-scroll-container:is([data-fade="true"], [data-dock-fade]) > .hk-scroll-container-viewport {
|
|
75
109
|
--hk-scroll-fade-layers:
|
|
76
110
|
linear-gradient(
|
|
77
111
|
to bottom,
|
|
@@ -122,49 +156,64 @@
|
|
|
122
156
|
--hk-scroll-fade-size: var(--space-24);
|
|
123
157
|
}
|
|
124
158
|
|
|
159
|
+
/* ── Lit bands: fade size scaled by the published strength ─────────
|
|
160
|
+
`calc(length * number)` keeps the band arithmetic in CSS (consumer
|
|
161
|
+
--hk-scroll-fade-size overrides keep working; the JS side publishes
|
|
162
|
+
only the unitless strength). An edge with nothing hidden keeps the
|
|
163
|
+
default strength 1 AND a 0px band — no fade either way. */
|
|
164
|
+
|
|
125
165
|
/* Vertical axis — the half the old implementation never had. */
|
|
126
166
|
.hk-scroll-container[data-fade="true"][data-v-overflow="start"] {
|
|
127
|
-
--hk-scroll-fade-top: var(--hk-scroll-fade-size);
|
|
167
|
+
--hk-scroll-fade-top: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-top, 1));
|
|
128
168
|
}
|
|
129
169
|
|
|
130
170
|
.hk-scroll-container[data-fade="true"][data-v-overflow="end"] {
|
|
131
|
-
--hk-scroll-fade-bottom: var(--hk-scroll-fade-size);
|
|
171
|
+
--hk-scroll-fade-bottom: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-bottom, 1));
|
|
132
172
|
}
|
|
133
173
|
|
|
134
174
|
.hk-scroll-container[data-fade="true"][data-v-overflow="both"] {
|
|
135
|
-
--hk-scroll-fade-top: var(--hk-scroll-fade-size);
|
|
136
|
-
--hk-scroll-fade-bottom: var(--hk-scroll-fade-size);
|
|
175
|
+
--hk-scroll-fade-top: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-top, 1));
|
|
176
|
+
--hk-scroll-fade-bottom: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-bottom, 1));
|
|
137
177
|
}
|
|
138
178
|
|
|
139
179
|
/* Horizontal axis (LTR: inline start = left). */
|
|
140
180
|
.hk-scroll-container[data-fade="true"][data-h-overflow="start"] {
|
|
141
|
-
--hk-scroll-fade-left: var(--hk-scroll-fade-size);
|
|
181
|
+
--hk-scroll-fade-left: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-left, 1));
|
|
142
182
|
}
|
|
143
183
|
|
|
144
184
|
.hk-scroll-container[data-fade="true"][data-h-overflow="end"] {
|
|
145
|
-
--hk-scroll-fade-right: var(--hk-scroll-fade-size);
|
|
185
|
+
--hk-scroll-fade-right: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-right, 1));
|
|
146
186
|
}
|
|
147
187
|
|
|
148
188
|
.hk-scroll-container[data-fade="true"][data-h-overflow="both"] {
|
|
149
|
-
--hk-scroll-fade-left: var(--hk-scroll-fade-size);
|
|
150
|
-
--hk-scroll-fade-right: var(--hk-scroll-fade-size);
|
|
189
|
+
--hk-scroll-fade-left: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-left, 1));
|
|
190
|
+
--hk-scroll-fade-right: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-right, 1));
|
|
151
191
|
}
|
|
152
192
|
|
|
153
|
-
/* RTL: the inline start edge is the right side, so the horizontal
|
|
154
|
-
swap instead of the mask (the mask itself is direction-neutral).
|
|
155
|
-
|
|
193
|
+
/* RTL: the inline start edge is the right side, so the horizontal band
|
|
194
|
+
slots swap instead of the mask (the mask itself is direction-neutral).
|
|
195
|
+
The swap keys on the component's MIRRORED data-dir, not on [dir]:
|
|
196
|
+
the strengths are published per PHYSICAL side from the computed
|
|
197
|
+
direction (normalized scroll signs), and a literal ancestor-[dir]
|
|
198
|
+
selector diverges from that resolution exactly when [dir] sits on the
|
|
199
|
+
container itself, on a dir="auto" ancestor, or under an explicit
|
|
200
|
+
deeper override — computed-direction-is-truth keeps slots and depths
|
|
201
|
+
in agreement. The attribute lands in the same sensing pass as the
|
|
202
|
+
overflow attrs (write-only-on-change, removed when fade turns off),
|
|
203
|
+
so slots and lit attrs are never from different frames. */
|
|
204
|
+
.hk-scroll-container[data-dir="rtl"][data-fade="true"][data-h-overflow="start"] {
|
|
156
205
|
--hk-scroll-fade-left: 0px;
|
|
157
|
-
--hk-scroll-fade-right: var(--hk-scroll-fade-size);
|
|
206
|
+
--hk-scroll-fade-right: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-right, 1));
|
|
158
207
|
}
|
|
159
208
|
|
|
160
|
-
[dir="rtl"]
|
|
209
|
+
.hk-scroll-container[data-dir="rtl"][data-fade="true"][data-h-overflow="end"] {
|
|
161
210
|
--hk-scroll-fade-right: 0px;
|
|
162
|
-
--hk-scroll-fade-left: var(--hk-scroll-fade-size);
|
|
211
|
+
--hk-scroll-fade-left: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-left, 1));
|
|
163
212
|
}
|
|
164
213
|
|
|
165
|
-
[dir="rtl"]
|
|
166
|
-
--hk-scroll-fade-left: var(--hk-scroll-fade-size);
|
|
167
|
-
--hk-scroll-fade-right: var(--hk-scroll-fade-size);
|
|
214
|
+
.hk-scroll-container[data-dir="rtl"][data-fade="true"][data-h-overflow="both"] {
|
|
215
|
+
--hk-scroll-fade-left: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-left, 1));
|
|
216
|
+
--hk-scroll-fade-right: calc(var(--hk-scroll-fade-size) * var(--hk-scroll-fade-strength-right, 1));
|
|
168
217
|
}
|
|
169
218
|
|
|
170
219
|
/* ── Anchored docks (dockTop / dockBottom slots) ─────────────────
|
|
@@ -187,7 +236,14 @@
|
|
|
187
236
|
(opacity 100 -> 0 across --hk-scroll-dock-fade) reads as intentional
|
|
188
237
|
instead of a hard clip against the dock's top edge. Content never
|
|
189
238
|
sits under the dock, so this fades the viewport's last few pixels,
|
|
190
|
-
not the dock itself.
|
|
239
|
+
not the dock itself. Ownership note (2026-11-06): when `fade` is ALSO
|
|
240
|
+
on, the state band rules below have higher specificity and own that
|
|
241
|
+
edge while it hides content — the dock band applies only when the
|
|
242
|
+
sense reports nothing hidden past it. With the progressive fade the
|
|
243
|
+
shared edge therefore deepens with the pull (≤ --space-24) and snaps
|
|
244
|
+
to the full dock fade only at the scroll end; no shipped consumer
|
|
245
|
+
combines the two, but a future one should treat that as the
|
|
246
|
+
documented shape, not a bug. */
|
|
191
247
|
.hk-scroll-container[data-dock-fade="bottom"] {
|
|
192
248
|
--hk-scroll-fade-bottom: var(--hk-scroll-dock-fade, 2rem);
|
|
193
249
|
}
|
|
@@ -180,6 +180,206 @@ describe("HkScrollContainer overflow sensing", () => {
|
|
|
180
180
|
});
|
|
181
181
|
});
|
|
182
182
|
|
|
183
|
+
// ── Progressive fade strengths (fadeDistance, 2026-11-06): while `fade`
|
|
184
|
+
// is on the container mirrors the hidden-content depth past each edge as
|
|
185
|
+
// a 0..1 band scale on the root's inline style — pull the strip and the
|
|
186
|
+
// trailing fade deepens, saturate at the full band, recede on the way
|
|
187
|
+
// back. CSS consumes the vars (fade.contract.test.ts pins the sheet). ──
|
|
188
|
+
describe("HkScrollContainer progressive fade strengths", () => {
|
|
189
|
+
const varOf = (m: Mounted, side: string): string =>
|
|
190
|
+
m.root.style.getPropertyValue(`--hk-scroll-fade-strength-${side}`);
|
|
191
|
+
|
|
192
|
+
it("publishes the hidden depth per edge, saturating at 1", () => {
|
|
193
|
+
const m = mountScroller({ axis: "horizontal", fade: true });
|
|
194
|
+
// scrollWidth 300 in a 100px viewport: hiddenLeft = scrollLeft,
|
|
195
|
+
// hiddenRight = 200 - scrollLeft. Default fadeDistance 96.
|
|
196
|
+
stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 48 });
|
|
197
|
+
m.instance?.refresh();
|
|
198
|
+
// 48/96 = exactly half depth on the start edge; 152 hidden past the
|
|
199
|
+
// end edge saturates the band.
|
|
200
|
+
expect(varOf(m, "left")).toBe("0.5");
|
|
201
|
+
expect(varOf(m, "right")).toBe("1");
|
|
202
|
+
// Vertical edges hide nothing: strength 1 with a 0px band (invisible).
|
|
203
|
+
expect(varOf(m, "top")).toBe("1");
|
|
204
|
+
expect(varOf(m, "bottom")).toBe("1");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("grows the strength with the pull and recedes on the way back", () => {
|
|
208
|
+
const m = mountScroller({ axis: "horizontal", fade: true });
|
|
209
|
+
stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 0 });
|
|
210
|
+
m.instance?.refresh();
|
|
211
|
+
expect(varOf(m, "left")).toBe("1"); // nothing hidden past the start
|
|
212
|
+
expect(varOf(m, "right")).toBe("1"); // 200 hidden: saturated
|
|
213
|
+
|
|
214
|
+
stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 24 });
|
|
215
|
+
m.viewport.dispatchEvent(new Event("scroll"));
|
|
216
|
+
m.instance?.refresh();
|
|
217
|
+
expect(varOf(m, "left")).toBe("0.25"); // 24/96: the pull deepens it
|
|
218
|
+
expect(varOf(m, "right")).toBe("1"); // 176 hidden: still saturated
|
|
219
|
+
|
|
220
|
+
stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 190 });
|
|
221
|
+
m.viewport.dispatchEvent(new Event("scroll"));
|
|
222
|
+
m.instance?.refresh();
|
|
223
|
+
expect(varOf(m, "left")).toBe("1"); // fully pulled: saturated
|
|
224
|
+
expect(varOf(m, "right")).toBe("0.104"); // 10 hidden past the end: receding
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("keeps the ≤1px deadband of the overflow sense on the strength side", () => {
|
|
228
|
+
const m = mountScroller({ axis: "horizontal", fade: true });
|
|
229
|
+
stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 199.5 });
|
|
230
|
+
m.instance?.refresh();
|
|
231
|
+
// 0.5px hidden past the end: the sense still reports "start" (the
|
|
232
|
+
// same deadband) and the strength must not read as a visible fade.
|
|
233
|
+
expect(m.root.getAttribute("data-h-overflow")).toBe("start");
|
|
234
|
+
expect(varOf(m, "right")).toBe("1");
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("degrades to the binary shape with a zero fadeDistance", () => {
|
|
238
|
+
const m = mountScroller({ axis: "horizontal", fade: true, fadeDistance: 0 });
|
|
239
|
+
stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 48 });
|
|
240
|
+
m.instance?.refresh();
|
|
241
|
+
expect(varOf(m, "left")).toBe("1");
|
|
242
|
+
expect(varOf(m, "right")).toBe("1");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("re-derives strengths when only fadeDistance changes at runtime", async () => {
|
|
246
|
+
// The distance is read live per pass: changing it with NO scroll and
|
|
247
|
+
// NO refresh() call must re-derive every strength on the watcher's
|
|
248
|
+
// scheduled pass alone (a scroll-driven delta would pass even with a
|
|
249
|
+
// dead watcher — the scroll event schedules its own pass).
|
|
250
|
+
const container = document.createElement("div");
|
|
251
|
+
document.body.appendChild(container);
|
|
252
|
+
containers.push(container);
|
|
253
|
+
const props = reactive({ fade: true, fadeDistance: 96 });
|
|
254
|
+
const Wrapper = defineComponent({
|
|
255
|
+
setup() {
|
|
256
|
+
return () =>
|
|
257
|
+
h(HkScrollContainer, { axis: "horizontal", fade: props.fade, fadeDistance: props.fadeDistance }, {
|
|
258
|
+
default: () => h("span", "content"),
|
|
259
|
+
});
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
const app = createApp(Wrapper);
|
|
263
|
+
mounts.push(app);
|
|
264
|
+
app.mount(container);
|
|
265
|
+
const root = container.querySelector<HTMLElement>(".hk-scroll-container")!;
|
|
266
|
+
const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
|
|
267
|
+
stubGeometry(viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 48 });
|
|
268
|
+
// One baseline pass (the mount pass ran before the stub landed);
|
|
269
|
+
// after this, NO further scroll happens — the flip below must
|
|
270
|
+
// re-derive on the watcher's pass alone.
|
|
271
|
+
viewport.dispatchEvent(new Event("scroll"));
|
|
272
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
273
|
+
expect(root.style.getPropertyValue("--hk-scroll-fade-strength-left")).toBe("0.5");
|
|
274
|
+
|
|
275
|
+
props.fadeDistance = 192; // same geometry, doubled distance
|
|
276
|
+
await nextTick();
|
|
277
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
278
|
+
expect(viewport.scrollLeft, "no scroll between the passes").toBe(48);
|
|
279
|
+
expect(root.style.getPropertyValue("--hk-scroll-fade-strength-left")).toBe("0.25");
|
|
280
|
+
app.unmount();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("publishes on a fade prop flip without any scroll (the re-sense watcher)", async () => {
|
|
284
|
+
// Mounting fade-off with content already hidden publishes nothing;
|
|
285
|
+
// flipping the prop on must light the strengths on the watcher's
|
|
286
|
+
// scheduled pass alone — no scroll event, no refresh() call.
|
|
287
|
+
const container = document.createElement("div");
|
|
288
|
+
document.body.appendChild(container);
|
|
289
|
+
containers.push(container);
|
|
290
|
+
const props = reactive({ fade: false });
|
|
291
|
+
const Wrapper = defineComponent({
|
|
292
|
+
setup() {
|
|
293
|
+
return () =>
|
|
294
|
+
h(HkScrollContainer, { axis: "horizontal", fade: props.fade }, {
|
|
295
|
+
default: () => h("span", "content"),
|
|
296
|
+
});
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
const app = createApp(Wrapper);
|
|
300
|
+
mounts.push(app);
|
|
301
|
+
app.mount(container);
|
|
302
|
+
const root = container.querySelector<HTMLElement>(".hk-scroll-container")!;
|
|
303
|
+
const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
|
|
304
|
+
stubGeometry(viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 48 });
|
|
305
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
306
|
+
expect(root.style.getPropertyValue("--hk-scroll-fade-strength-left")).toBe("");
|
|
307
|
+
|
|
308
|
+
props.fade = true;
|
|
309
|
+
await nextTick();
|
|
310
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
311
|
+
expect(root.style.getPropertyValue("--hk-scroll-fade-strength-left")).toBe("0.5");
|
|
312
|
+
expect(root.style.getPropertyValue("--hk-scroll-fade-strength-right")).toBe("1");
|
|
313
|
+
app.unmount();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("clears the published strengths and mirrored dir when fade flips off", async () => {
|
|
317
|
+
// Stale inline values must not outlive the feature (the
|
|
318
|
+
// stopSelectionSensing symmetry): off removes all four strength
|
|
319
|
+
// vars and data-dir from the root, so a downstream reader of the
|
|
320
|
+
// root's style never sees zombie strengths.
|
|
321
|
+
const container = document.createElement("div");
|
|
322
|
+
document.body.appendChild(container);
|
|
323
|
+
containers.push(container);
|
|
324
|
+
const props = reactive({ fade: true });
|
|
325
|
+
const Wrapper = defineComponent({
|
|
326
|
+
setup() {
|
|
327
|
+
return () =>
|
|
328
|
+
h(HkScrollContainer, { axis: "horizontal", fade: props.fade }, {
|
|
329
|
+
default: () => h("span", "content"),
|
|
330
|
+
});
|
|
331
|
+
},
|
|
332
|
+
});
|
|
333
|
+
const app = createApp(Wrapper);
|
|
334
|
+
mounts.push(app);
|
|
335
|
+
app.mount(container);
|
|
336
|
+
const root = container.querySelector<HTMLElement>(".hk-scroll-container")!;
|
|
337
|
+
const viewport = container.querySelector<HTMLElement>(".hk-scroll-container-viewport")!;
|
|
338
|
+
stubGeometry(viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 48 });
|
|
339
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
340
|
+
expect(root.getAttribute("data-dir")).toBe("ltr");
|
|
341
|
+
expect(root.style.getPropertyValue("--hk-scroll-fade-strength-left")).not.toBe("");
|
|
342
|
+
|
|
343
|
+
props.fade = false;
|
|
344
|
+
await nextTick();
|
|
345
|
+
await new Promise<void>((r) => requestAnimationFrame(() => r()));
|
|
346
|
+
for (const side of ["left", "right", "top", "bottom"]) {
|
|
347
|
+
expect(root.style.getPropertyValue(`--hk-scroll-fade-strength-${side}`)).toBe("");
|
|
348
|
+
}
|
|
349
|
+
expect(root.hasAttribute("data-dir")).toBe(false);
|
|
350
|
+
app.unmount();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("publishes nothing while the fade prop is off", () => {
|
|
354
|
+
const m = mountScroller({ axis: "horizontal" });
|
|
355
|
+
stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: 48 });
|
|
356
|
+
m.instance?.refresh();
|
|
357
|
+
expect(varOf(m, "left")).toBe("");
|
|
358
|
+
expect(varOf(m, "right")).toBe("");
|
|
359
|
+
expect(m.root.getAttribute("data-h-overflow")).toBe("both"); // sense still runs
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("normalizes RTL scroll signs for both the sense and the strengths", () => {
|
|
363
|
+
const m = mountScroller({ axis: "horizontal", fade: true });
|
|
364
|
+
m.viewport.style.direction = "rtl";
|
|
365
|
+
// Chromium RTL convention: scrollLeft runs 0 → -(max). Raw -48 sits
|
|
366
|
+
// mid-scroll; the pre-normalization sense would have read "end"
|
|
367
|
+
// (scrollPos ≤ 1) for every RTL position.
|
|
368
|
+
stubGeometry(m.viewport, { scrollWidth: 300, clientWidth: 100, scrollLeft: -48 });
|
|
369
|
+
m.instance?.refresh();
|
|
370
|
+
expect(m.root.getAttribute("data-h-overflow")).toBe("both");
|
|
371
|
+
// The mirror is the CSS slot swap's only direction truth — a mutant
|
|
372
|
+
// that hardcodes "ltr" (or inverts the ternary) would keep the
|
|
373
|
+
// depths correct and light the WRONG physical edge, so the artifact
|
|
374
|
+
// itself is pinned, not just its consequences.
|
|
375
|
+
expect(m.root.getAttribute("data-dir")).toBe("rtl");
|
|
376
|
+
// Physical start edge (visual RIGHT under RTL) hides 48px…
|
|
377
|
+
expect(varOf(m, "left")).toBe("1");
|
|
378
|
+
expect(varOf(m, "right")).toBe("0.5");
|
|
379
|
+
m.viewport.style.direction = "";
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
183
383
|
describe("HkScrollContainer scrollbar reactivity", () => {
|
|
184
384
|
it("builds and tears down the overlay tracks when the scrollbar prop flips", async () => {
|
|
185
385
|
const container = document.createElement("div");
|
|
@@ -84,6 +84,23 @@ export default defineComponent({
|
|
|
84
84
|
/** Fade the viewport's inline edges (CSS mask) on the sides where
|
|
85
85
|
* the sensed overflow (`data-h-overflow`) reports hidden content. */
|
|
86
86
|
fade: { type: Boolean, default: false },
|
|
87
|
+
/** Hidden-content distance (px) over which a lit edge fade deepens
|
|
88
|
+
* from a hard cut to the full band (the progressive fade): the
|
|
89
|
+
* component senses how much content hides past each edge and
|
|
90
|
+
* mirrors it as a 0..1 strength (--hk-scroll-fade-strength-<side>)
|
|
91
|
+
* that scales the band in CSS, so pulling the strip grows the
|
|
92
|
+
* trailing fade and scrolling back recedes it. Read live (runtime
|
|
93
|
+
* changes need no remount, like approachDistance). 0 or negative
|
|
94
|
+
* degrades to the pre-2026-11 binary shape: any lit edge jumps
|
|
95
|
+
* straight to the full band. Only read while `fade` is on.
|
|
96
|
+
*
|
|
97
|
+
* RTL normalization assumes the Chromium/WebKit convention
|
|
98
|
+
* (scrollLeft runs 0 → -max): every engine hikari deploys on is
|
|
99
|
+
* Chromium-family (the sheet's WebView notes). A legacy
|
|
100
|
+
* positive-descending engine would clamp the normalized offset to
|
|
101
|
+
* 0 — bounded degradation: the sense parks at "end" and one fade
|
|
102
|
+
* stays saturated, never inverted. */
|
|
103
|
+
fadeDistance: { type: Number, default: 96 },
|
|
87
104
|
/** Additive opt-in: while autoFollow is enabled and the user has
|
|
88
105
|
* scrolled away from the bottom, float a small jump-back FAB that
|
|
89
106
|
* snaps to the latest content and re-arms follow. */
|
|
@@ -259,6 +276,20 @@ export default defineComponent({
|
|
|
259
276
|
let lastHOverflow: OverflowSense | null = null;
|
|
260
277
|
let lastVOverflow: OverflowSense | null = null;
|
|
261
278
|
|
|
279
|
+
// Progressive fade strengths last written to the root (see the
|
|
280
|
+
// fadeDistance prop doc). Same write-only-on-change discipline as
|
|
281
|
+
// the overflow mirror: scroll redraws the mask every frame anyway,
|
|
282
|
+
// but a pass that re-senses identical geometry must not churn
|
|
283
|
+
// inline style for nothing. The mirrored direction rides the same
|
|
284
|
+
// contract — it keys the CSS band-slot swap (see the SCSS RTL
|
|
285
|
+
// rules) and MUST agree with the physical-side depths below, which
|
|
286
|
+
// is why the computed direction is mirrored instead of CSS
|
|
287
|
+
// sniffing [dir] itself: a computed rtl under a missing/mismatched
|
|
288
|
+
// ancestor attribute (dir on the container, dir="auto") would
|
|
289
|
+
// light the wrong physical edge.
|
|
290
|
+
const lastFadeStrengths = new Map<string, string>();
|
|
291
|
+
let lastPublishedDir: string | null = null;
|
|
292
|
+
|
|
262
293
|
// Sensed selection edge hint (see the selectionHint prop doc),
|
|
263
294
|
// mirrored onto the root as data-selection-beyond — the same
|
|
264
295
|
// write-only-on-change discipline as the overflow mirror.
|
|
@@ -618,13 +649,29 @@ export default defineComponent({
|
|
|
618
649
|
}
|
|
619
650
|
|
|
620
651
|
/** Mirror the live scroll geometry onto the root element as
|
|
621
|
-
* data-h-overflow / data-v-overflow
|
|
622
|
-
*
|
|
652
|
+
* data-h-overflow / data-v-overflow, and while `fade` is on publish
|
|
653
|
+
* the per-edge fade strengths (hidden-content depth saturating over
|
|
654
|
+
* fadeDistance). Runs on every scheduled update (scroll, resize,
|
|
655
|
+
* content resize via the aligner).
|
|
656
|
+
*
|
|
657
|
+
* Scroll offsets are normalized to LTR convention first: Chromium
|
|
658
|
+
* reports negative scrollLeft under RTL (0 at the inline start down
|
|
659
|
+
* to -(max) at the inline end), so both the overflow sense and the
|
|
660
|
+
* strengths would read as "always at start" without it. Strengths
|
|
661
|
+
* are PHYSICAL-side (left/right), so the direction-neutral
|
|
662
|
+
* start/end state rules and their RTL width swap below consume the
|
|
663
|
+
* same vars unchanged. */
|
|
623
664
|
function senseOverflow(vp: HTMLElement) {
|
|
624
665
|
const root = vp.parentElement;
|
|
625
666
|
if (!root) return;
|
|
626
|
-
const
|
|
627
|
-
const
|
|
667
|
+
const rtl = getComputedStyle(vp).direction === "rtl";
|
|
668
|
+
const hMax = Math.max(0, vp.scrollWidth - vp.clientWidth);
|
|
669
|
+
const vMax = Math.max(0, vp.scrollHeight - vp.clientHeight);
|
|
670
|
+
const clampPos = (pos: number, max: number) => Math.min(max, Math.max(0, pos));
|
|
671
|
+
const hPos = clampPos(hasH() ? (rtl ? -vp.scrollLeft : vp.scrollLeft) : 0, hMax);
|
|
672
|
+
const vPos = clampPos(hasV() ? vp.scrollTop : 0, vMax);
|
|
673
|
+
const h = hasH() ? computeOverflow(vp.scrollWidth, vp.clientWidth, hPos) : "none";
|
|
674
|
+
const v = hasV() ? computeOverflow(vp.scrollHeight, vp.clientHeight, vPos) : "none";
|
|
628
675
|
if (h !== lastHOverflow) {
|
|
629
676
|
lastHOverflow = h;
|
|
630
677
|
root.setAttribute("data-h-overflow", h);
|
|
@@ -633,6 +680,78 @@ export default defineComponent({
|
|
|
633
680
|
lastVOverflow = v;
|
|
634
681
|
root.setAttribute("data-v-overflow", v);
|
|
635
682
|
}
|
|
683
|
+
if (props.fade) {
|
|
684
|
+
// Physical-side depths: the normalized hPos counts the inline
|
|
685
|
+
// START, which is the visual RIGHT edge under RTL — swap there
|
|
686
|
+
// so --hk-scroll-fade-strength-left always means the left edge.
|
|
687
|
+
// Axis-gated edges never hide anything this container can fade,
|
|
688
|
+
// so they publish the unlit "1" rather than live geometry.
|
|
689
|
+
publishFadeStrengths(root, {
|
|
690
|
+
left: hasH() ? (rtl ? hMax - hPos : hPos) : 0,
|
|
691
|
+
right: hasH() ? (rtl ? hPos : hMax - hPos) : 0,
|
|
692
|
+
top: hasV() ? vPos : 0,
|
|
693
|
+
bottom: hasV() ? vMax - vPos : 0,
|
|
694
|
+
});
|
|
695
|
+
// Mirror the COMPUTED direction for the CSS band-slot swap (the
|
|
696
|
+
// SCSS RTL rules key on this attribute, not on [dir]): the
|
|
697
|
+
// depths above are physical, so the slot mapping must come from
|
|
698
|
+
// the same resolved direction the normalization used — an
|
|
699
|
+
// ancestor [dir] the selector happens to miss (dir on this
|
|
700
|
+
// element, dir="auto", an explicit deeper override) would
|
|
701
|
+
// otherwise light the wrong edge. Lands the same pass as the
|
|
702
|
+
// overflow attrs, so band slots and lit attrs are never from
|
|
703
|
+
// different frames. Known trade (2026-11-06 review): a pure
|
|
704
|
+
// direction change at rest (locale flip, scrollLeft 0, no
|
|
705
|
+
// layout delta) schedules no sensing pass, so the mirror and
|
|
706
|
+
// the depths sit stale until the next scroll / resize /
|
|
707
|
+
// refresh / prop touch — wrong physical edge in that window,
|
|
708
|
+
// self-healing; a dir MutationObserver was rejected as
|
|
709
|
+
// over-engineering for a narrow trigger.
|
|
710
|
+
const dir = rtl ? "rtl" : "ltr";
|
|
711
|
+
if (lastPublishedDir !== dir) {
|
|
712
|
+
lastPublishedDir = dir;
|
|
713
|
+
root.setAttribute("data-dir", dir);
|
|
714
|
+
}
|
|
715
|
+
} else {
|
|
716
|
+
clearPublishedFade(root);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Drop every fade state the mirror published (fade prop off):
|
|
721
|
+
* the four strength vars, their cache and the mirrored direction —
|
|
722
|
+
* a stale inline value must not outlive the feature that wrote it
|
|
723
|
+
* (same contract shape as stopSelectionSensing). */
|
|
724
|
+
function clearPublishedFade(root: HTMLElement) {
|
|
725
|
+
if (lastFadeStrengths.size === 0 && lastPublishedDir === null) return;
|
|
726
|
+
for (const side of ["left", "right", "top", "bottom"]) {
|
|
727
|
+
root.style.removeProperty(`--hk-scroll-fade-strength-${side}`);
|
|
728
|
+
}
|
|
729
|
+
lastFadeStrengths.clear();
|
|
730
|
+
if (lastPublishedDir !== null) {
|
|
731
|
+
lastPublishedDir = null;
|
|
732
|
+
root.removeAttribute("data-dir");
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Progressive fade strengths (see the fadeDistance prop doc): the
|
|
737
|
+
* hidden px past an edge saturates linearly over fadeDistance into
|
|
738
|
+
* the band's 0..1 depth, so the fade grows with the pull and caps
|
|
739
|
+
* at the full band. An edge hiding nothing (the same ≤1px deadband
|
|
740
|
+
* computeOverflow uses) publishes 1 — with its 0px band that is
|
|
741
|
+
* invisible, and it keeps dock-fed edges (which share the vars,
|
|
742
|
+
* no fade prop → never published) at their designed full band. */
|
|
743
|
+
function publishFadeStrengths(root: HTMLElement, hidden: { left: number; right: number; top: number; bottom: number }) {
|
|
744
|
+
const dist = Math.max(0, props.fadeDistance);
|
|
745
|
+
for (const [side, depth] of Object.entries(hidden) as Array<[keyof typeof hidden, number]>) {
|
|
746
|
+
const strength =
|
|
747
|
+
depth <= 1 || dist === 0
|
|
748
|
+
? "1"
|
|
749
|
+
: String(Math.min(1, Math.round((depth / dist) * 1000) / 1000));
|
|
750
|
+
if (lastFadeStrengths.get(side) !== strength) {
|
|
751
|
+
lastFadeStrengths.set(side, strength);
|
|
752
|
+
root.style.setProperty(`--hk-scroll-fade-strength-${side}`, strength);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
636
755
|
}
|
|
637
756
|
|
|
638
757
|
/** Selection edge hint (see the selectionHint prop doc): does any
|
|
@@ -705,6 +824,13 @@ export default defineComponent({
|
|
|
705
824
|
|
|
706
825
|
watch(() => props.selectionSelector, () => scheduleUpdate());
|
|
707
826
|
|
|
827
|
+
// Runtime fade changes re-sense immediately: flipping `fade` on with
|
|
828
|
+
// content already hidden must not wait for a scroll, and a different
|
|
829
|
+
// fadeDistance re-derives every strength without any DOM motion.
|
|
830
|
+
watch(() => [props.fade, props.fadeDistance] as const, () => {
|
|
831
|
+
scheduleUpdate();
|
|
832
|
+
});
|
|
833
|
+
|
|
708
834
|
function onScroll() {
|
|
709
835
|
if (!viewportRef.value) return;
|
|
710
836
|
scheduleUpdate();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source contract for the range-ribbon reset affordance (user direction
|
|
3
|
+
* 2026-11-06: the waterfall minimap's reset control must be the STANDARD
|
|
4
|
+
* icon button, not a per-component hand-rolled `<button>` chip).
|
|
5
|
+
*
|
|
6
|
+
* HkRangeRibbon renders its reset through HkIconButton (the shared icon
|
|
7
|
+
* button: one box ladder, one glyph channel, one hover/press/focus chrome)
|
|
8
|
+
* carrying the a directly-imported `RotateCcw` glyph in the icon slot — the same "named-icon contract" the unified window-close wave
|
|
9
|
+
* pinned (window-close.contract.test.ts). The positioning class
|
|
10
|
+
* `hk-range-ribbon-reset` survives ONLY as the seat hook the component
|
|
11
|
+
* sheet pins (it must keep clearing the edge handle).
|
|
12
|
+
*
|
|
13
|
+
* Pinned here so a refactor cannot silently re-sprout a bespoke chip —
|
|
14
|
+
* the same "looks unified but drifted" failure class the window-close
|
|
15
|
+
* contract guards against.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it } from "vitest";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const ribbon = readFileSync(join(here, "HkRangeRibbon.tsx"), "utf-8");
|
|
24
|
+
|
|
25
|
+
describe("range-ribbon reset control contract", () => {
|
|
26
|
+
it("renders the reset affordance through the standard HkIconButton", () => {
|
|
27
|
+
expect(ribbon, "the reset must ride HkIconButton chrome").toContain("HIconButton");
|
|
28
|
+
expect(ribbon, "the seat class survives as the positioning hook").toContain(
|
|
29
|
+
'class="hk-range-ribbon-reset"',
|
|
30
|
+
);
|
|
31
|
+
expect(
|
|
32
|
+
ribbon,
|
|
33
|
+
"the glyph rides the house-normal lucide slot (every in-tree HkIconButton slots its glyph)",
|
|
34
|
+
).toContain("<RotateCcw");
|
|
35
|
+
expect(ribbon, "no hand-rolled <button> may carry the reset class").not.toMatch(
|
|
36
|
+
/<button[^>]*hk-range-ribbon-reset/,
|
|
37
|
+
);
|
|
38
|
+
// The three things a partial revert silently drops while still passing
|
|
39
|
+
// the substring checks above.
|
|
40
|
+
expect(ribbon, "the reset keeps its touch-floor size").toMatch(
|
|
41
|
+
/<HIconButton[\s\S]{0,200}?size=\{24\}/,
|
|
42
|
+
);
|
|
43
|
+
// The non-submit guarantee moved upstream into HkIconButton (one
|
|
44
|
+
// implementation, not a per-call-site spread); it is pinned in
|
|
45
|
+
// HkIconButton.test.tsx and asserted on the rendered DOM in
|
|
46
|
+
// HkRangeRibbon.test.tsx.
|
|
47
|
+
expect(ribbon, "the reset passes the standard variant explicitly").toContain('variant="ghost"');
|
|
48
|
+
expect(ribbon, "the glyph rides the slot form, not the icon prop").toMatch(
|
|
49
|
+
/\{\{\s*icon:\s*\(\)\s*=>\s*<RotateCcw/,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -978,7 +978,7 @@ export type {
|
|
|
978
978
|
} from "./composables/useInfinitePages";
|
|
979
979
|
export { default as HkFilterChip } from "./components/HkFilterChip";
|
|
980
980
|
export { default as HkPillToggleGroup } from "./components/HkPillToggleGroup";
|
|
981
|
-
export type { HkPillToggleGroupOption } from "./components/HkPillToggleGroup";
|
|
981
|
+
export type { HkPillToggleGroupOption, HkPillTone } from "./components/HkPillToggleGroup";
|
|
982
982
|
export { HkIconChip as HkIconChip } from "./components/HkIconChip";
|
|
983
983
|
export { HkIconChip as HIconChip } from "./components/HkIconChip";
|
|
984
984
|
export { HkCardList, HkListRow } from "./components/HkCardList";
|