composer: pin the edge panel properly
Three bugs in wheel.half, all found by looking at it: The hub was centred on the edge, so the edge sliced it in half -- half a cancel target is not a target. It is pushed inward by its own radius now, and the wedges start beyond it. The ring is an inset shadow rather than a border, because a border pushes every absolutely positioned child inward by its own width, which put the hub a pixel past the screen edge. Highlighting fired anywhere the pointer went, including well outside the wheel, promising a pick that release would not deliver. The hit test is bounded by the same rIn/rOut the wedges are drawn from, so the lit shape is the region. An edge panel did not follow its edge: it kept the pixel position it opened at and drifted inward when the window grew. It re-derives its anchor on resize, and on visualViewport resize for pinch-zoom and mobile chrome. One GEOMETRY object per variant now drives the wedges, the labels, the hub and the hit test together, so they cannot disagree.
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
/* The ring is an INSET shadow, not a border. A border pushes every absolutely
|
||||
* positioned child inward by its own width, which put the hub a pixel past the
|
||||
* screen edge on an edge panel -- the one place a pixel shows. */
|
||||
.cx-wheel {
|
||||
position: fixed; pointer-events: auto; border-radius: var(--r-pill);
|
||||
background: color-mix(in srgb, var(--elev-3) 92%, transparent);
|
||||
border: var(--bw) solid var(--line);
|
||||
box-shadow: var(--shadow-3);
|
||||
box-shadow: var(--shadow-3), inset 0 0 0 var(--bw) var(--line);
|
||||
backdrop-filter: blur(6px);
|
||||
animation: cx-wheel-in var(--dur-1) var(--ease-standard);
|
||||
}
|
||||
@@ -36,9 +38,11 @@
|
||||
|
||||
/* Sized to the dead zone exactly (radius 42), so the cancel target you can see
|
||||
* is the cancel target you can hit. */
|
||||
/* left/top/size come from the geometry so the drawn hub and the cancel region
|
||||
* are the same circle. */
|
||||
.cx-wheel-hub {
|
||||
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
|
||||
width: 84px; height: 84px; border-radius: var(--r-pill);
|
||||
position: absolute; transform: translate(-50%, -50%);
|
||||
border-radius: var(--r-pill);
|
||||
border: var(--bw) dashed var(--line);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
color: var(--faint);
|
||||
@@ -56,7 +60,3 @@
|
||||
* viewport, which is what gives the edge-panel silhouette. The flat side gets a
|
||||
* straight edge so it reads as pinned rather than cropped by accident. */
|
||||
.cx-wheel-half { backdrop-filter: blur(8px); }
|
||||
.cx-wheel-right { border-radius: var(--r-pill) 0 0 var(--r-pill); border-right: 0; }
|
||||
.cx-wheel-left { border-radius: 0 var(--r-pill) var(--r-pill) 0; border-left: 0; }
|
||||
.cx-wheel-bottom { border-radius: var(--r-pill) var(--r-pill) 0 0; border-bottom: 0; }
|
||||
.cx-wheel-top { border-radius: 0 0 var(--r-pill) var(--r-pill); border-top: 0; }
|
||||
|
||||
+144
-64
@@ -1,9 +1,13 @@
|
||||
// The radial picker.
|
||||
// Radial pickers: the full wheel, and the half wheel pinned to a screen edge.
|
||||
//
|
||||
// Nothing in v4 resembles this. It is for the handful of choices made over and
|
||||
// over during editing, where a menu costs a read-and-aim every time: press,
|
||||
// Nothing in v4 resembles these. They are for the handful of choices made over
|
||||
// and over during editing, where a menu costs a read-and-aim every time: press,
|
||||
// flick toward the wedge you already know the position of, release. The muscle
|
||||
// memory is the feature -- the same choice always lives at the same angle.
|
||||
//
|
||||
// One GEOMETRY object per variant drives the drawn wedges, the label placement,
|
||||
// the hub and the hit test, so what you see and what you can hit cannot drift
|
||||
// apart.
|
||||
|
||||
import { define } from "../internals/register.js";
|
||||
import { el, svg } from "../internals/el.js";
|
||||
@@ -11,16 +15,83 @@ import { mount, unmount } from "../internals/portal.js";
|
||||
import { claim } from "../internals/zlayer.js";
|
||||
import { push } from "../internals/dismiss.js";
|
||||
|
||||
const SIZE = 240; // diameter in px
|
||||
const DEAD_ZONE = 42; // centre radius that means "cancel"; matches the hub exactly
|
||||
const MIN = 2;
|
||||
const MAX = 12;
|
||||
|
||||
/** The full circle, opened wherever the pointer is. */
|
||||
export const FULL = {
|
||||
size: 240,
|
||||
rIn: 42, rOut: 117, rLabel: 84,
|
||||
hub: { r: 42, x: 0, y: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Half a circle, centred ON an edge.
|
||||
*
|
||||
* The hub is pushed inward by its own radius so it sits fully on screen -- a hub
|
||||
* centred on the edge is sliced in half by it, and half a cancel target is not
|
||||
* a target. Everything else is sized around that: the wedges start beyond the
|
||||
* hub so the two never overlap.
|
||||
*/
|
||||
export const HALF = {
|
||||
size: 300,
|
||||
rIn: 84, rOut: 147, rLabel: 115,
|
||||
hub: { r: 40, x: 0, y: 0 }, // x/y filled in per edge, below
|
||||
};
|
||||
|
||||
// Which half faces inward from each edge, where a panel pinned there sits, and
|
||||
// which way "inward" points. An edge panel opens AWAY from its edge, which is
|
||||
// the whole reason it works with a thumb: the arc lands where the thumb is.
|
||||
export const EDGES = {
|
||||
right: { startDeg: 180, spanDeg: 180, inward: [-1, 0], anchor: (w, h, at) => [w, at ?? h / 2] },
|
||||
left: { startDeg: 0, spanDeg: 180, inward: [1, 0], anchor: (w, h, at) => [0, at ?? h / 2] },
|
||||
bottom: { startDeg: 270, spanDeg: 180, inward: [0, -1], anchor: (w, h, at) => [at ?? w / 2, h] },
|
||||
top: { startDeg: 90, spanDeg: 180, inward: [0, 1], anchor: (w, h, at) => [at ?? w / 2, 0] },
|
||||
};
|
||||
|
||||
const point = (cx, cy, radius, degrees) => {
|
||||
const rad = (degrees - 90) * (Math.PI / 180);
|
||||
return [cx + Math.cos(rad) * radius, cy + Math.sin(rad) * radius];
|
||||
};
|
||||
|
||||
/**
|
||||
* Which sector of an arc a point falls in; -1 for the hub or anywhere off the
|
||||
* ring.
|
||||
*
|
||||
* Angles run clockwise from straight up, so 0 is 12 o'clock and the arrangement
|
||||
* matches how people describe it ("the one at the top"). Everything radial goes
|
||||
* through here, so a full wheel and an edge panel cannot disagree about which
|
||||
* item a direction means.
|
||||
*
|
||||
* The bounds matter as much as the angle: the lit wedge IS the region that
|
||||
* picks the item, so a pointer beyond the rim -- or over the hub -- is pointing
|
||||
* at nothing, and highlighting an item there would promise a pick that release
|
||||
* would not deliver.
|
||||
*/
|
||||
export function sectorInArc(dx, dy, count, {
|
||||
startDeg = 0, spanDeg = 360,
|
||||
rIn = FULL.rIn, rOut = FULL.rOut,
|
||||
hubX = 0, hubY = 0, hubR = rIn,
|
||||
bounded = true,
|
||||
} = {}) {
|
||||
if (Math.hypot(dx - hubX, dy - hubY) < hubR) return -1; // over the hub: cancel
|
||||
const distance = Math.hypot(dx, dy);
|
||||
if (bounded && (distance < rIn || distance > rOut)) return -1;
|
||||
|
||||
const angle = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360;
|
||||
const rel = (angle - startDeg + 720) % 360;
|
||||
if (rel > spanDeg) return -1; // outside a partial arc
|
||||
return Math.min(Math.floor(rel / (spanDeg / count)), count - 1);
|
||||
}
|
||||
|
||||
/** The full circle: item 0 centred at the top. */
|
||||
export function sectorAt(dx, dy, count, deadZone = FULL.hub.r) {
|
||||
return sectorInArc(dx, dy, count, {
|
||||
startDeg: -(360 / count) / 2, spanDeg: 360,
|
||||
rIn: deadZone, rOut: FULL.rOut, hubR: deadZone,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An annular wedge: the actual area that picks this item.
|
||||
*
|
||||
@@ -28,10 +99,13 @@ const point = (cx, cy, radius, degrees) => {
|
||||
* target legible -- the lit shape IS the region your pointer has to be in, so
|
||||
* aiming stops being a guess about where one item's zone ends.
|
||||
*/
|
||||
export function wedgePath(index, count, {
|
||||
cx = SIZE / 2, cy = SIZE / 2, rIn = DEAD_ZONE, rOut = SIZE / 2 - 3,
|
||||
startDeg = -(360 / count) / 2, spanDeg = 360,
|
||||
} = {}) {
|
||||
export function wedgePath(index, count, geo = {}) {
|
||||
const {
|
||||
size = FULL.size, rIn = FULL.rIn, rOut = FULL.rOut,
|
||||
startDeg = -(360 / count) / 2, spanDeg = 360,
|
||||
} = geo;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
const slice = spanDeg / count;
|
||||
const start = startDeg + index * slice;
|
||||
const end = start + slice;
|
||||
@@ -47,68 +121,42 @@ export function wedgePath(index, count, {
|
||||
`L ${f(x3)} ${f(y3)} A ${rIn} ${rIn} 0 ${largeArc} 0 ${f(x4)} ${f(y4)} Z`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which sector of an arc a point falls in; -1 for the dead zone or outside it.
|
||||
*
|
||||
* Angles run clockwise from straight up, so 0 is 12 o'clock and the arrangement
|
||||
* matches how people describe it ("the one at the top"). Everything radial in
|
||||
* the kit goes through here, so a full wheel and an edge panel cannot disagree
|
||||
* about which item a direction means.
|
||||
*/
|
||||
export function sectorInArc(dx, dy, count, { startDeg = 0, spanDeg = 360, deadZone = DEAD_ZONE } = {}) {
|
||||
if (Math.hypot(dx, dy) < deadZone) return -1;
|
||||
const angle = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360;
|
||||
const rel = (angle - startDeg + 720) % 360;
|
||||
if (rel > spanDeg) return -1; // outside a partial arc
|
||||
return Math.min(Math.floor(rel / (spanDeg / count)), count - 1);
|
||||
}
|
||||
|
||||
/** The full circle: item 0 centred at the top. */
|
||||
export function sectorAt(dx, dy, count, deadZone = DEAD_ZONE) {
|
||||
return sectorInArc(dx, dy, count, { startDeg: -(360 / count) / 2, spanDeg: 360, deadZone });
|
||||
}
|
||||
|
||||
// Which half of the circle faces inward from each edge, and where a panel
|
||||
// pinned there sits. An edge panel opens AWAY from its edge, which is the whole
|
||||
// reason it works with a thumb: the arc lands where the thumb already is.
|
||||
export const EDGES = {
|
||||
right: { startDeg: 180, spanDeg: 180, anchor: (w, h, at) => [w, at ?? h / 2] },
|
||||
left: { startDeg: 0, spanDeg: 180, anchor: (w, h, at) => [0, at ?? h / 2] },
|
||||
bottom: { startDeg: 270, spanDeg: 180, anchor: (w, h, at) => [at ?? w / 2, h] },
|
||||
top: { startDeg: 90, spanDeg: 180, anchor: (w, h, at) => [at ?? w / 2, 0] },
|
||||
};
|
||||
|
||||
function buildWheel({
|
||||
items, onPick, onClose, centre, arc, cancelOnCentre, cls, hubText,
|
||||
}) {
|
||||
function buildWheel({ items, onPick, onClose, geo, recentre, cancelOnCentre, cls, hubText }) {
|
||||
if (items.length < MIN || items.length > MAX) {
|
||||
throw new RangeError(`A picker takes ${MIN}-${MAX} items; got ${items.length}. More than that and nobody can aim.`);
|
||||
}
|
||||
|
||||
const { size } = geo;
|
||||
const layer = claim("popover");
|
||||
const centre = recentre();
|
||||
|
||||
const node = el("div", { cls, attrs: { role: "menu", "aria-label": "Quick pick" } });
|
||||
node.style.zIndex = String(layer.z);
|
||||
node.style.left = `${centre.x - SIZE / 2}px`;
|
||||
node.style.top = `${centre.y - SIZE / 2}px`;
|
||||
node.style.width = `${SIZE}px`;
|
||||
node.style.height = `${SIZE}px`;
|
||||
node.style.width = `${size}px`;
|
||||
node.style.height = `${size}px`;
|
||||
|
||||
const place = () => {
|
||||
node.style.left = `${centre.x - size / 2}px`;
|
||||
node.style.top = `${centre.y - size / 2}px`;
|
||||
};
|
||||
place();
|
||||
|
||||
let active = -1;
|
||||
|
||||
// The wedges are drawn behind the labels, so the highlight is the pickable
|
||||
// region itself rather than a box around some text.
|
||||
const canvas = svg("svg", { class: "cx-wheel-canvas", viewBox: `0 0 ${SIZE} ${SIZE}`,
|
||||
width: SIZE, height: SIZE, "aria-hidden": "true" });
|
||||
const canvas = svg("svg", { class: "cx-wheel-canvas", viewBox: `0 0 ${size} ${size}`,
|
||||
width: size, height: size, "aria-hidden": "true" });
|
||||
const shapes = items.map((_, i) => {
|
||||
const path = svg("path", { class: "cx-wheel-wedge", d: wedgePath(i, items.length, arc) });
|
||||
const path = svg("path", { class: "cx-wheel-wedge", d: wedgePath(i, items.length, geo) });
|
||||
canvas.append(path);
|
||||
return path;
|
||||
});
|
||||
node.append(canvas);
|
||||
|
||||
const slice = arc.spanDeg / items.length;
|
||||
const slice = geo.spanDeg / items.length;
|
||||
const wedges = items.map((item, i) => {
|
||||
const angle = arc.startDeg + slice * (i + 0.5);
|
||||
const angle = geo.startDeg + slice * (i + 0.5);
|
||||
const wedge = el("button", {
|
||||
cls: ["cx-wheel-item", "cx-focusable"],
|
||||
attrs: { type: "button", role: "menuitem", title: item.label },
|
||||
@@ -119,10 +167,9 @@ function buildWheel({
|
||||
});
|
||||
// Placed on a circle rather than drawn as a pie slice: a round label that
|
||||
// stays upright is far easier to read than text bent around a wedge.
|
||||
const radius = SIZE / 2 - 34;
|
||||
const rad = (angle - 90) * (Math.PI / 180);
|
||||
wedge.style.left = `${SIZE / 2 + Math.cos(rad) * radius}px`;
|
||||
wedge.style.top = `${SIZE / 2 + Math.sin(rad) * radius}px`;
|
||||
const [lx, ly] = point(size / 2, size / 2, geo.rLabel, angle);
|
||||
wedge.style.left = `${lx}px`;
|
||||
wedge.style.top = `${ly}px`;
|
||||
wedge.addEventListener("click", () => commit(i));
|
||||
node.append(wedge);
|
||||
return wedge;
|
||||
@@ -135,6 +182,10 @@ function buildWheel({
|
||||
attrs: cancelOnCentre ? { role: "img", "aria-label": hubText, title: hubText } : { "aria-hidden": "true" },
|
||||
children: cancelOnCentre ? el("span", { cls: "cx-wheel-hub-glyph", text: "✕" }) : null,
|
||||
});
|
||||
hub.style.width = `${geo.hub.r * 2}px`;
|
||||
hub.style.height = `${geo.hub.r * 2}px`;
|
||||
hub.style.left = `${size / 2 + geo.hub.x}px`;
|
||||
hub.style.top = `${size / 2 + geo.hub.y}px`;
|
||||
node.append(hub);
|
||||
|
||||
function highlight(index) {
|
||||
@@ -161,7 +212,7 @@ function buildWheel({
|
||||
|
||||
const at = (event) => sectorInArc(
|
||||
event.clientX - centre.x, event.clientY - centre.y, items.length,
|
||||
{ ...arc, deadZone: cancelOnCentre ? DEAD_ZONE : 0 });
|
||||
{ ...geo, hubX: geo.hub.x, hubY: geo.hub.y, hubR: cancelOnCentre ? geo.hub.r : 0 });
|
||||
|
||||
const onMove = (event) => { armed = true; highlight(at(event)); };
|
||||
const onUp = (event) => {
|
||||
@@ -171,6 +222,19 @@ function buildWheel({
|
||||
if (index >= 0) commit(index);
|
||||
};
|
||||
|
||||
// An edge panel is pinned to an edge, so the edge moving is the one thing it
|
||||
// must follow. Without this it keeps the pixel position it opened at and
|
||||
// drifts into the middle of the window on a resize.
|
||||
const onResize = () => {
|
||||
Object.assign(centre, recentre());
|
||||
place();
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
// visualViewport covers what window.resize misses: pinch-zoom, and mobile
|
||||
// browser chrome sliding in and out, both of which move the edge without
|
||||
// changing innerWidth.
|
||||
window.visualViewport?.addEventListener("resize", onResize);
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
const n = items.length;
|
||||
if (/^[1-9]$/.test(event.key)) {
|
||||
@@ -197,6 +261,7 @@ function buildWheel({
|
||||
node,
|
||||
isOverlay: true,
|
||||
get active() { return active; },
|
||||
get centre() { return { ...centre }; },
|
||||
highlight,
|
||||
close(reason = "manual") {
|
||||
if (closed) return;
|
||||
@@ -204,6 +269,8 @@ function buildWheel({
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("resize", onResize);
|
||||
window.visualViewport?.removeEventListener("resize", onResize);
|
||||
dismissal.release();
|
||||
layer.release();
|
||||
unmount(node);
|
||||
@@ -219,11 +286,11 @@ define("wheel", "picker", ({ items = [], onPick, onClose, x, y, cancelOnCentre =
|
||||
items, onPick, onClose, cancelOnCentre,
|
||||
cls: "cx-wheel",
|
||||
hubText: "Release here to cancel",
|
||||
centre: {
|
||||
geo: { ...FULL, startDeg: -(360 / Math.max(items.length, 1)) / 2, spanDeg: 360 },
|
||||
recentre: () => ({
|
||||
x: x ?? Math.round(window.innerWidth / 2),
|
||||
y: y ?? Math.round(window.innerHeight / 2),
|
||||
},
|
||||
arc: { startDeg: -(360 / Math.max(items.length, 1)) / 2, spanDeg: 360 },
|
||||
}),
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -235,17 +302,30 @@ define("wheel", "picker", ({ items = [], onPick, onClose, x, y, cancelOnCentre =
|
||||
* arc lands exactly where the thumb already is and every item is in reach.
|
||||
*
|
||||
* `at` is the position along that edge (y for left/right, x for top/bottom);
|
||||
* it defaults to the middle.
|
||||
* it defaults to the middle, and is re-derived whenever the window resizes.
|
||||
*/
|
||||
define("wheel", "half", ({ items = [], edge = "right", at, onPick, onClose, cancelOnCentre = true } = {}) => {
|
||||
const spec = EDGES[edge];
|
||||
if (!spec) throw new RangeError(`Unknown edge "${edge}". Known: ${Object.keys(EDGES).join(", ")}.`);
|
||||
const [cx, cy] = spec.anchor(window.innerWidth, window.innerHeight, at);
|
||||
|
||||
const [ix, iy] = spec.inward;
|
||||
const geo = {
|
||||
...HALF,
|
||||
startDeg: spec.startDeg,
|
||||
spanDeg: spec.spanDeg,
|
||||
// Pushed inward by its own radius, so the whole hub is on screen instead of
|
||||
// being sliced down the middle by the edge it sits on.
|
||||
hub: { r: HALF.hub.r, x: ix * HALF.hub.r, y: iy * HALF.hub.r },
|
||||
};
|
||||
|
||||
return buildWheel({
|
||||
items, onPick, onClose, cancelOnCentre,
|
||||
cls: `cx-wheel cx-wheel-half cx-wheel-${edge}`,
|
||||
hubText: "Release here to cancel",
|
||||
centre: { x: cx, y: cy },
|
||||
arc: { startDeg: spec.startDeg, spanDeg: spec.spanDeg },
|
||||
geo,
|
||||
recentre: () => {
|
||||
const [cx, cy] = spec.anchor(window.innerWidth, window.innerHeight, at);
|
||||
return { x: cx, y: cy };
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -275,16 +275,31 @@ test("the click that opens the wheel does not also pick", () => {
|
||||
onPick: (item) => { picked = item.label; },
|
||||
x: 400, y: 300,
|
||||
});
|
||||
window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 400, clientY: 100, bubbles: true }));
|
||||
// 80px straight up from the centre: inside the ring, pointing at item 0.
|
||||
window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 400, clientY: 220, bubbles: true }));
|
||||
assert.equal(picked, null, "a release with no movement is not a pick");
|
||||
assert.ok(portalRoot().childElementCount > 0, "the wheel is still open");
|
||||
|
||||
window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 100, bubbles: true }));
|
||||
window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 400, clientY: 100, bubbles: true }));
|
||||
window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 220, bubbles: true }));
|
||||
window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 400, clientY: 220, bubbles: true }));
|
||||
assert.equal(picked, "Split", "after moving, a release picks");
|
||||
w.destroy();
|
||||
});
|
||||
|
||||
test("a pointer beyond the rim points at nothing", () => {
|
||||
// The lit wedge IS the region that picks the item. Highlighting one from
|
||||
// outside the ring promises a pick that release would not deliver.
|
||||
const w = composer.wheel.picker({
|
||||
items: [{ label: "Split" }, { label: "Duplicate" }, { label: "Rate" }],
|
||||
x: 400, y: 300,
|
||||
});
|
||||
window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 60, bubbles: true }));
|
||||
assert.equal(w.active, -1, "240px out is off the wheel");
|
||||
window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 220, bubbles: true }));
|
||||
assert.equal(w.active, 0, "80px out is on it");
|
||||
w.destroy();
|
||||
});
|
||||
|
||||
test("releasing in the dead zone cancels without picking", () => {
|
||||
let picked = null;
|
||||
const w = composer.wheel.picker({
|
||||
@@ -341,6 +356,58 @@ test("a half wheel opens on its edge and picks by key", () => {
|
||||
w.destroy();
|
||||
});
|
||||
|
||||
test("an edge panel's hub sits fully on screen, not sliced by the edge", () => {
|
||||
// A hub centred on the edge is cut in half by it, and half a cancel target is
|
||||
// not a target. It is pushed inward by its own radius.
|
||||
const w = composer.wheel.half({ edge: "right", at: 300, items: [{ label: "a" }, { label: "b" }] });
|
||||
const hub = w.node.querySelector(".cx-wheel-hub");
|
||||
const size = Number(w.node.style.width.replace("px", ""));
|
||||
assert.ok(parseFloat(hub.style.left) < size / 2, "the hub leans into the screen");
|
||||
const radius = parseFloat(hub.style.width) / 2;
|
||||
assert.ok(parseFloat(hub.style.left) + radius <= size / 2 + 0.01,
|
||||
"and its far edge does not cross the screen edge");
|
||||
w.destroy();
|
||||
});
|
||||
|
||||
test("cancel is where the hub actually is, not where the centre is", () => {
|
||||
const spec = EDGES.right;
|
||||
const hubR = 40;
|
||||
const arc = { ...spec, rIn: 84, rOut: 147, hubX: -hubR, hubY: 0, hubR };
|
||||
// A point over the drawn hub cancels...
|
||||
assert.equal(sectorInArc(-hubR, 0, 4, arc), -1);
|
||||
// ...while the geometric centre, which is now outside the hub, is simply off
|
||||
// the ring rather than a pick.
|
||||
assert.equal(sectorInArc(0, 0, 4, arc), -1);
|
||||
});
|
||||
|
||||
test("an edge panel follows its edge when the window resizes", () => {
|
||||
// It is pinned to an edge; the edge moving is the one thing it must follow.
|
||||
const w = composer.wheel.half({ edge: "right", items: [{ label: "a" }, { label: "b" }] });
|
||||
const before = w.centre.x;
|
||||
|
||||
window.innerWidth = window.innerWidth + 400;
|
||||
window.dispatchEvent(new window.Event("resize"));
|
||||
|
||||
assert.equal(w.centre.x, before + 400, "the centre moved with the edge");
|
||||
const size = Number(w.node.style.width.replace("px", ""));
|
||||
assert.equal(parseFloat(w.node.style.left), w.centre.x - size / 2);
|
||||
window.innerWidth = window.innerWidth - 400;
|
||||
w.destroy();
|
||||
});
|
||||
|
||||
test("edge panels stay pinned at any viewport shape", () => {
|
||||
for (const [w, h] of [[320, 640], [1920, 1080], [2560, 1080], [800, 2000]]) {
|
||||
window.innerWidth = w;
|
||||
window.innerHeight = h;
|
||||
for (const edge of Object.keys(EDGES)) {
|
||||
const p = composer.wheel.half({ edge, items: [{ label: "a" }, { label: "b" }, { label: "c" }] });
|
||||
const [ex, ey] = EDGES[edge].anchor(w, h);
|
||||
assert.deepEqual(p.centre, { x: ex, y: ey }, `${edge} at ${w}x${h}`);
|
||||
p.destroy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("an unknown edge is refused rather than guessed", () => {
|
||||
assert.throws(() => composer.wheel.half({ edge: "diagonal", items: [{ label: "a" }, { label: "b" }] }), RangeError);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user