mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix(tabs): restore captured mouse reordering
Issue: #243 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -861,6 +861,74 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
} } : {}),
|
||||
};
|
||||
}, { fixture, scenario });
|
||||
if (scenario.tabDrag) {
|
||||
const drag = await page.evaluate((placement) => {
|
||||
const card = window.__goldenCard;
|
||||
const tabs = [...card.renderRoot.querySelectorAll('[data-hp="space-tab"]')];
|
||||
if (tabs.length < 3) throw new Error('golden tab drag requires at least three spaces');
|
||||
const source = placement === 'before' ? tabs.at(-1) : tabs[0];
|
||||
const target = placement === 'before' ? tabs[0] : tabs.at(-1);
|
||||
const point = (element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
};
|
||||
window.__goldenTabTrustedMove = false;
|
||||
window.__goldenTabCapturedTargetId = null;
|
||||
card.renderRoot.querySelector('.tabs').addEventListener('pointermove', (event) => {
|
||||
if (!event.buttons || !event.isTrusted) return;
|
||||
window.__goldenTabTrustedMove = true;
|
||||
window.__goldenTabCapturedTargetId = event.target
|
||||
?.closest?.('[data-hp="space-tab"]')?.dataset?.id || null;
|
||||
}, { capture: true });
|
||||
return {
|
||||
sourceId: source.dataset.id,
|
||||
targetId: target.dataset.id,
|
||||
source: point(source),
|
||||
target: point(target),
|
||||
};
|
||||
}, scenario.tabDrag);
|
||||
await page.mouse.move(drag.source.x, drag.source.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(drag.target.x, drag.target.y, { steps: 5 });
|
||||
const state = await page.evaluate(async ({ expected, sourceId, targetId }) => {
|
||||
const card = window.__goldenCard;
|
||||
await card.updateComplete;
|
||||
await new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
|
||||
const source = [...card.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
|
||||
.find((tab) => tab.dataset.id === sourceId);
|
||||
const target = [...card.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
|
||||
.find((tab) => tab.dataset.id === targetId);
|
||||
const targetRect = target?.getBoundingClientRect();
|
||||
const shadow = getComputedStyle(target).boxShadow;
|
||||
const insetX = Number(shadow.match(
|
||||
/([+-]?\d+(?:\.\d+)?)px\s+0px\s+0px\s+0px\s+inset/,
|
||||
)?.[1] || 0);
|
||||
const ok = window.__goldenTabTrustedMove
|
||||
&& window.__goldenTabCapturedTargetId === sourceId
|
||||
&& card._tabDrag?.id === sourceId
|
||||
&& card._tabDrag?.targetId === targetId
|
||||
&& card._tabDrag?.placement === expected
|
||||
&& source?.classList.contains('dragging')
|
||||
&& target?.classList.contains(`drop-${expected}`)
|
||||
&& !target?.classList.contains(`drop-${expected === 'before' ? 'after' : 'before'}`)
|
||||
&& targetRect?.width > 0 && targetRect?.height > 0
|
||||
&& (expected === 'before' ? insetX > 0 : insetX < 0);
|
||||
return {
|
||||
ok,
|
||||
trusted: window.__goldenTabTrustedMove,
|
||||
capturedTargetId: window.__goldenTabCapturedTargetId,
|
||||
sourceId,
|
||||
targetId,
|
||||
placement: card._tabDrag?.placement || null,
|
||||
targetSize: targetRect ? [targetRect.width, targetRect.height] : null,
|
||||
insetX,
|
||||
};
|
||||
}, { expected: scenario.tabDrag, sourceId: drag.sourceId, targetId: drag.targetId });
|
||||
if (!state.ok) {
|
||||
throw new Error(`semantic golden tab drag failed: ${JSON.stringify(state)}`);
|
||||
}
|
||||
result.tabDrag = state;
|
||||
}
|
||||
if (scenario.hoverDevice) {
|
||||
const point = await page.evaluate((id) => {
|
||||
const marker = window.__goldenCard?.renderRoot?.querySelector(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 35;
|
||||
export const GOLDEN_MATRIX_VERSION = 36;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -101,6 +101,12 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'space-tab-drop-before-light', fixture: 'visual', applianceLifecycle: true,
|
||||
space: 'golden-geometry', mode: 'plan', tabDrag: 'before',
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'space-tab-drop-after-dark', fixture: 'visual', applianceLifecycle: true,
|
||||
space: 'golden-geometry', mode: 'plan', tabDrag: 'after',
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'plan-snap-endpoint-light', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
|
||||
planSnap: { tool: 'draw', pointer: [0.48, 0.48], expectedKind: 'endpoint' },
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
|
||||
@@ -721,6 +721,13 @@ try {
|
||||
}
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
// Tab-divider goldens deliberately hold the real pointer through the
|
||||
// screenshot. Always release it before the shared page is reused, even
|
||||
// when semantic validation or capture failed.
|
||||
if (scenario.tabDrag) {
|
||||
try { await page.mouse.up(); } catch { /* the browser may already be closing */ }
|
||||
}
|
||||
}
|
||||
results.push(result);
|
||||
console.log(`${result.status.padEnd(17)} ${scenario.id}`);
|
||||
|
||||
+295
-200
@@ -1,224 +1,319 @@
|
||||
// Issue #220: the order of the space tabs is changed by dragging one of them.
|
||||
//
|
||||
// The demo fixture has two spaces, which is all the panel needs to prove the
|
||||
// contract. What this smoke protects is not the animation but the three
|
||||
// promises around it: the
|
||||
// new order survives a save, an ordinary click still switches the space, and
|
||||
// nothing of the sort exists in View, where the same tabs are a touch-first
|
||||
// navigation control.
|
||||
// Issues #220/#243: space tabs reorder through the browser's real mouse input.
|
||||
// Positive drag assertions must never dispatch pointermove at a chosen target:
|
||||
// pointer capture is the mechanism under test and owns event delivery.
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1100, height: 900 }, 1);
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const out = {};
|
||||
|
||||
const settle = async () => page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const tabs = () => [...sr().querySelectorAll('[data-hp="space-tab"]')];
|
||||
const ids = () => tabs().map((tab) => tab.dataset.id);
|
||||
const settle = async () => {
|
||||
const started = performance.now();
|
||||
do { await new Promise((r) => requestAnimationFrame(r)); }
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
};
|
||||
const started = performance.now();
|
||||
do { await new Promise((done) => requestAnimationFrame(done)); }
|
||||
while (c._modeTransitionBusy && performance.now() - started < 1500);
|
||||
await c.updateComplete;
|
||||
});
|
||||
|
||||
// A save must reach the server exactly once per drop, and carry the order.
|
||||
const writes = [];
|
||||
const realWrite = c._writeConfig.bind(c);
|
||||
c._writeConfig = () => {
|
||||
writes.push((c._serverCfg.spaces || []).map((space) => space.id));
|
||||
return Promise.resolve();
|
||||
};
|
||||
const ids = () => page.evaluate(() => [...window.__card.renderRoot
|
||||
.querySelectorAll('[data-hp="space-tab"]')].map((tab) => tab.dataset.id));
|
||||
|
||||
const drag = async (fromId, toId, { travel = 40 } = {}) => {
|
||||
const from = tabs().find((tab) => tab.dataset.id === fromId);
|
||||
const to = tabs().find((tab) => tab.dataset.id === toId);
|
||||
const a = from.getBoundingClientRect();
|
||||
const b = to.getBoundingClientRect();
|
||||
const event = (type, x, y, target) => target.dispatchEvent(new PointerEvent(type, {
|
||||
pointerId: 7, pointerType: 'mouse', clientX: x, clientY: y, bubbles: true, composed: true,
|
||||
}));
|
||||
event('pointerdown', a.x + a.width / 2, a.y + a.height / 2, from);
|
||||
// one intermediate move on the source keeps the gesture honest: the drag
|
||||
// must begin from travel, not from merely touching a second tab
|
||||
event('pointermove', a.x + a.width / 2 + travel, a.y + a.height / 2, from);
|
||||
event('pointermove', b.x + b.width / 2, b.y + b.height / 2, to);
|
||||
event('pointerup', b.x + b.width / 2, b.y + b.height / 2, to);
|
||||
await settle();
|
||||
// The write is debounced (~500 ms). Waiting for it is the point: a smoke
|
||||
// that checks the panel and leaves proves the DOM, not the save.
|
||||
const deadline = performance.now() + 1500;
|
||||
const seen = writes.length;
|
||||
while (writes.length === seen && performance.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 25));
|
||||
}
|
||||
};
|
||||
const tabPoint = (id) => page.evaluate((tabId) => {
|
||||
const tab = [...window.__card.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
|
||||
.find((item) => item.dataset.id === tabId);
|
||||
if (!tab) throw new Error(`space tab missing: ${tabId}`);
|
||||
const rect = tab.getBoundingClientRect();
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
|
||||
}, id);
|
||||
|
||||
const writeCount = () => page.evaluate(() => window.__tabReorderWrites.length);
|
||||
const insetX = (shadow) => Number(shadow.match(
|
||||
/([+-]?\d+(?:\.\d+)?)px\s+0px\s+0px\s+0px\s+inset/,
|
||||
)?.[1] || 0);
|
||||
const waitForWrites = async (count) => page.waitForFunction(
|
||||
(expected) => window.__tabReorderWrites.length >= expected, count, { timeout: 1800 },
|
||||
);
|
||||
|
||||
const holdDrag = async (fromId, toId) => {
|
||||
const from = await tabPoint(fromId);
|
||||
const to = await tabPoint(toId);
|
||||
await page.mouse.move(from.x, from.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(to.x, to.y, { steps: 5 });
|
||||
await settle();
|
||||
return page.evaluate(({ fromId: sourceId, toId: targetId }) => {
|
||||
const c = window.__card;
|
||||
const tabs = [...c.renderRoot.querySelectorAll('[data-hp="space-tab"]')];
|
||||
const source = tabs.find((tab) => tab.dataset.id === sourceId);
|
||||
const target = tabs.find((tab) => tab.dataset.id === targetId);
|
||||
const style = target ? getComputedStyle(target) : null;
|
||||
return {
|
||||
drag: c._tabDrag ? { ...c._tabDrag } : null,
|
||||
sourceClass: source?.className || '',
|
||||
targetClass: target?.className || '',
|
||||
targetShadow: style?.boxShadow || '',
|
||||
trustedMoveSeen: window.__tabReorderTrustedMove === true,
|
||||
};
|
||||
}, { fromId, toId });
|
||||
};
|
||||
|
||||
const releaseDrag = async (expectedWrites) => {
|
||||
await page.mouse.up();
|
||||
await waitForWrites(expectedWrites);
|
||||
await settle();
|
||||
};
|
||||
|
||||
const setup = await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._mode = 'plan';
|
||||
c.requestUpdate();
|
||||
await settle();
|
||||
|
||||
// A marker with neither an explicit space nor an area that names one is the
|
||||
// whole reason this feature has to be careful: today it renders in whichever
|
||||
// space sits first, so a reorder would hand it to another one. The fixture
|
||||
// has no such marker, so the smoke plants it — otherwise the guarantee would
|
||||
// be tested only as a pure function, never as applied behaviour.
|
||||
await c.updateComplete;
|
||||
// The directional contract needs a source that can travel across both
|
||||
// sides. The demo fixture has two spaces, so add a third in-memory space to
|
||||
// the card and its write model without changing the shared demo fixture.
|
||||
if (!c._model.some((space) => space.id === 'smoke-third')) {
|
||||
const modelSpace = structuredClone(c._model.at(-1));
|
||||
modelSpace.id = 'smoke-third';
|
||||
modelSpace.title = 'Smoke third';
|
||||
const configSpace = structuredClone(c._serverCfg.spaces.at(-1));
|
||||
configSpace.id = modelSpace.id;
|
||||
configSpace.title = modelSpace.title;
|
||||
c._model = [...c._model, modelSpace];
|
||||
c._serverCfg.spaces = [...c._serverCfg.spaces, configSpace];
|
||||
}
|
||||
const firstBefore = c._model[0].id;
|
||||
c._space = firstBefore;
|
||||
c._serverCfg.markers = [
|
||||
...(c._serverCfg.markers || []),
|
||||
...(c._serverCfg.markers || []).filter((marker) => marker.id !== 'smoke-dangling'),
|
||||
{ id: 'smoke-dangling', binding: 'virtual', name: 'dangling' },
|
||||
];
|
||||
const dangling = () => (c._serverCfg.markers || [])
|
||||
.find((marker) => marker.id === 'smoke-dangling');
|
||||
out.plantedMarkerStartsWithoutSpace = !dangling().space;
|
||||
window.__tabReorderWrites = [];
|
||||
window.__tabReorderOriginalWrite = c._writeConfig.bind(c);
|
||||
c._writeConfig = () => {
|
||||
window.__tabReorderWrites.push((c._serverCfg.spaces || []).map((space) => space.id));
|
||||
return Promise.resolve();
|
||||
};
|
||||
window.__tabReorderTrustedMove = false;
|
||||
c.renderRoot.querySelector('.tabs')?.addEventListener('pointermove', (event) => {
|
||||
if (event.buttons && event.isTrusted) window.__tabReorderTrustedMove = true;
|
||||
}, { capture: true });
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
return {
|
||||
firstBefore,
|
||||
markerHasNoSpace: !c._serverCfg.markers.find((marker) => marker.id === 'smoke-dangling')?.space,
|
||||
reorderable: c.renderRoot.querySelector('[data-hp="space-tab"]')
|
||||
?.hasAttribute('data-reorderable') === true,
|
||||
};
|
||||
});
|
||||
|
||||
const before = ids();
|
||||
out.enoughTabsToReorder = before.length >= 2;
|
||||
out.reorderableInEditor = tabs()[0].hasAttribute('data-reorderable');
|
||||
out.plantedMarkerStartsWithoutSpace = setup.markerHasNoSpace;
|
||||
out.reorderableInEditor = setup.reorderable;
|
||||
const initial = await ids();
|
||||
out.enoughTabsToReorder = initial.length >= 3;
|
||||
|
||||
// --- AC1: the drop changes the order and asks for a save -------------------
|
||||
const moved = before[before.length - 1];
|
||||
const target = before[0];
|
||||
const active = c._space;
|
||||
await drag(moved, target);
|
||||
const after = ids();
|
||||
out.tabMovedToTheFront = after[0] === moved;
|
||||
out.otherTabsKeptOrder = JSON.stringify(after.filter((id) => id !== moved))
|
||||
=== JSON.stringify(before.filter((id) => id !== moved));
|
||||
out.orderReachedTheServer = writes.length >= 1;
|
||||
out.savedOrderMatchesPanel = JSON.stringify(writes[writes.length - 1])
|
||||
=== JSON.stringify(after);
|
||||
out.activeSpaceUnchanged = c._space === active;
|
||||
// AC3: the order-dependent marker keeps the space it had, written down.
|
||||
out.danglingMarkerPinnedToItsOldSpace = dangling().space === firstBefore;
|
||||
out.danglingMarkerDidNotFollowTheOrder = dangling().space !== ids()[0]
|
||||
|| firstBefore === ids()[0];
|
||||
// AC1/AC2: same tab moves left and then right through trusted browser input.
|
||||
const moved = initial.at(-1);
|
||||
const leftTarget = initial[0];
|
||||
const activeBefore = await page.evaluate(() => window.__card._space);
|
||||
const beforeHold = await holdDrag(moved, leftTarget);
|
||||
out.realMouseEventsAreTrusted = beforeHold.trustedMoveSeen;
|
||||
out.captureKeptButTargetResolved = beforeHold.drag?.targetId === leftTarget
|
||||
&& beforeHold.drag?.id === moved;
|
||||
out.leftDropUsesBeforeSide = beforeHold.drag?.placement === 'before'
|
||||
&& beforeHold.targetClass.includes('drop-before')
|
||||
&& !beforeHold.targetClass.includes('drop-after');
|
||||
out.heldTabShowsDragging = beforeHold.sourceClass.includes('dragging');
|
||||
out.beforeDividerHasPositiveInset = insetX(beforeHold.targetShadow) > 0;
|
||||
await releaseDrag(1);
|
||||
const afterLeft = await ids();
|
||||
out.realMouseMovedTabLeft = afterLeft[0] === moved;
|
||||
out.firstDropWroteOnce = await writeCount() === 1;
|
||||
out.savedLeftOrderMatchesPanel = await page.evaluate(() => {
|
||||
const panel = [...window.__card.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
|
||||
.map((tab) => tab.dataset.id);
|
||||
return JSON.stringify(window.__tabReorderWrites[0]) === JSON.stringify(panel);
|
||||
});
|
||||
out.activeSpaceUnchanged = await page.evaluate((active) => window.__card._space === active, activeBefore);
|
||||
out.danglingMarkerPinnedToItsOldSpace = await page.evaluate(
|
||||
(first) => window.__card._serverCfg.markers
|
||||
.find((marker) => marker.id === 'smoke-dangling')?.space === first,
|
||||
setup.firstBefore,
|
||||
);
|
||||
out.warnedAboutPositionalFloor = await page.evaluate(() => typeof window.__card._toast === 'string'
|
||||
&& window.__card._toast.length > 0);
|
||||
|
||||
// --- AC7: the positional-floor warning is said once ------------------------
|
||||
out.warnedAboutPositionalFloor = typeof c._toast === 'string' && c._toast.length > 0;
|
||||
c._toast = '';
|
||||
const second = ids();
|
||||
await drag(second[second.length - 1], second[0]);
|
||||
out.secondDropAlsoReordered = ids()[0] === second[second.length - 1];
|
||||
out.warningNotRepeated = !c._toast;
|
||||
await page.evaluate(() => { window.__card._toast = ''; });
|
||||
const rightTarget = afterLeft.at(-1);
|
||||
const afterHold = await holdDrag(moved, rightTarget);
|
||||
out.rightDropUsesAfterSide = afterHold.drag?.placement === 'after'
|
||||
&& afterHold.targetClass.includes('drop-after')
|
||||
&& !afterHold.targetClass.includes('drop-before');
|
||||
out.afterDividerHasNegativeInset = insetX(afterHold.targetShadow) < 0;
|
||||
await releaseDrag(2);
|
||||
const afterRight = await ids();
|
||||
out.sameTabMovedRight = afterRight.at(-1) === moved;
|
||||
out.secondDropWroteOnce = await writeCount() === 2;
|
||||
out.warningNotRepeated = await page.evaluate(() => !window.__card._toast);
|
||||
|
||||
// --- AC2: a click without travel still switches the space ------------------
|
||||
const other = ids().find((id) => id !== c._space);
|
||||
const writesBeforeClick = writes.length;
|
||||
await new Promise((r) => setTimeout(r, 700)); // let any pending debounce land
|
||||
const tab = tabs().find((t) => t.dataset.id === other);
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const at = (type) => tab.dispatchEvent(new PointerEvent(type, {
|
||||
pointerId: 8, pointerType: 'mouse', composed: true,
|
||||
clientX: rect.x + rect.width / 2, clientY: rect.y + rect.height / 2, bubbles: true,
|
||||
// AC3: below-threshold movement remains a click and cannot save config.
|
||||
const currentActive = await page.evaluate(() => window.__card._space);
|
||||
const smallTarget = (await ids()).find((id) => id !== currentActive);
|
||||
const smallPoint = await tabPoint(smallTarget);
|
||||
const writesBeforeSmall = await writeCount();
|
||||
await page.mouse.move(smallPoint.x, smallPoint.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(smallPoint.x + 2, smallPoint.y);
|
||||
const smallState = await page.evaluate(() => window.__card._tabDrag && !window.__card._tabDrag.moved);
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
await page.waitForTimeout(550);
|
||||
out.subThresholdStayedClick = smallState;
|
||||
out.subThresholdDidNotWrite = await writeCount() === writesBeforeSmall;
|
||||
out.clickStillSwitchesSpace = await page.evaluate((id) => window.__card._space === id, smallTarget);
|
||||
|
||||
// AC4: a previously valid target is cleared outside tabs; release cancels.
|
||||
const outsideIds = await ids();
|
||||
const outsideFrom = outsideIds.at(-1);
|
||||
const outsideTarget = outsideIds[0];
|
||||
const writesBeforeOutside = await writeCount();
|
||||
await holdDrag(outsideFrom, outsideTarget);
|
||||
const stagePoint = await page.evaluate(() => {
|
||||
const rect = window.__card.renderRoot.querySelector('.stage').getBoundingClientRect();
|
||||
return { x: rect.left + rect.width / 2, y: rect.top + Math.min(rect.height / 2, 300) };
|
||||
});
|
||||
await page.mouse.move(stagePoint.x, stagePoint.y, { steps: 3 });
|
||||
await settle();
|
||||
out.outsideClearsDropTarget = await page.evaluate(() => window.__card._tabDrag?.moved === true
|
||||
&& window.__card._tabDrag.targetId === null
|
||||
&& window.__card._tabDrag.placement === null
|
||||
&& !window.__card.renderRoot.querySelector('.drop-before, .drop-after'));
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
await page.waitForTimeout(550);
|
||||
out.outsideReleaseEndedDrag = await page.evaluate(() => window.__card._tabDrag === null);
|
||||
out.outsideReleaseDidNotWrite = await writeCount() === writesBeforeOutside;
|
||||
const activeBeforeRecovery = await page.evaluate(() => window.__card._space);
|
||||
const recoveryTarget = (await ids()).find((id) => id !== activeBeforeRecovery);
|
||||
const recoveryPoint = await tabPoint(recoveryTarget);
|
||||
await page.mouse.click(recoveryPoint.x, recoveryPoint.y);
|
||||
await settle();
|
||||
out.nextClickWorksAfterOutsideRelease = await page.evaluate(
|
||||
(id) => window.__card._space === id, recoveryTarget,
|
||||
);
|
||||
|
||||
// Navigation cannot leave a window-level drag listener behind.
|
||||
const modeIds = await ids();
|
||||
const writesBeforeModeChange = await writeCount();
|
||||
await holdDrag(modeIds.at(-1), modeIds[0]);
|
||||
await page.evaluate(() => window.__card._setMode('devices', false));
|
||||
await settle();
|
||||
out.modeChangeEndsDrag = await page.evaluate(() => window.__card._tabDrag === null);
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
out.modeChangeDidNotWrite = await writeCount() === writesBeforeModeChange;
|
||||
await page.evaluate(() => window.__card._setMode('plan', false));
|
||||
await settle();
|
||||
|
||||
// AC5: pointercancel is an explicit cancellation, never a drop.
|
||||
const cancelIds = await ids();
|
||||
const cancelFrom = cancelIds.at(-1);
|
||||
const cancelTarget = cancelIds[0];
|
||||
const writesBeforeCancel = await writeCount();
|
||||
await holdDrag(cancelFrom, cancelTarget);
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const drag = c._tabDrag;
|
||||
const source = [...c.renderRoot.querySelectorAll('[data-hp="space-tab"]')]
|
||||
.find((tab) => tab.dataset.id === drag?.id);
|
||||
source?.dispatchEvent(new PointerEvent('pointercancel', {
|
||||
pointerId: drag?.pointerId || 1, pointerType: 'mouse', bubbles: true, composed: true,
|
||||
}));
|
||||
at('pointerdown'); at('pointermove'); at('pointerup');
|
||||
tab.click();
|
||||
await settle();
|
||||
out.clickStillSwitchesSpace = c._space === other;
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
out.clickDidNotReorder = writes.length === writesBeforeClick;
|
||||
});
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
await page.waitForTimeout(550);
|
||||
out.pointerCancelEndedDrag = await page.evaluate(() => window.__card._tabDrag === null);
|
||||
out.pointerCancelDidNotWrite = await writeCount() === writesBeforeCancel;
|
||||
|
||||
// --- AC5: touch and View never start a drag -------------------------------
|
||||
const touchOrder = ids();
|
||||
const src = tabs()[tabs().length - 1];
|
||||
const dst = tabs()[0];
|
||||
const ra = src.getBoundingClientRect();
|
||||
const rb = dst.getBoundingClientRect();
|
||||
const touch = (type, x, y, target) => target.dispatchEvent(new PointerEvent(type, {
|
||||
pointerId: 9, pointerType: 'touch', clientX: x, clientY: y, bubbles: true, composed: true,
|
||||
// Regression from #220 review: detached cards release listeners and never save.
|
||||
const detachIds = await ids();
|
||||
const detachFrom = detachIds.at(-1);
|
||||
const detachTarget = detachIds[0];
|
||||
const writesBeforeDetach = await writeCount();
|
||||
await holdDrag(detachFrom, detachTarget);
|
||||
out.dragWasActiveBeforeDetach = await page.evaluate(() => window.__card._tabDrag?.moved === true);
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
window.__tabReorderParent = c.parentNode;
|
||||
window.__tabReorderNext = c.nextSibling;
|
||||
c.remove();
|
||||
});
|
||||
out.detachEndedTheDrag = await page.evaluate(() => window.__card._tabDrag === null);
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(550);
|
||||
out.detachedCardDidNotWrite = await writeCount() === writesBeforeDetach;
|
||||
await page.evaluate(async () => {
|
||||
window.__tabReorderParent.insertBefore(window.__card, window.__tabReorderNext);
|
||||
await window.__card.updateComplete;
|
||||
});
|
||||
out.orderSurvivedDetach = JSON.stringify(await ids()) === JSON.stringify(detachIds);
|
||||
|
||||
// AC6: negative boundaries stay negative. Synthetic touch is intentional here:
|
||||
// the assertion is the pointerType guard, not browser mouse delivery.
|
||||
const touchBefore = await ids();
|
||||
await page.evaluate(() => {
|
||||
const c = window.__card;
|
||||
const tabs = [...c.renderRoot.querySelectorAll('[data-hp="space-tab"]')];
|
||||
const source = tabs.at(-1);
|
||||
const target = tabs[0];
|
||||
const a = source.getBoundingClientRect();
|
||||
const b = target.getBoundingClientRect();
|
||||
const fire = (type, x, y, node) => node.dispatchEvent(new PointerEvent(type, {
|
||||
pointerId: 91, pointerType: 'touch', clientX: x, clientY: y, bubbles: true, composed: true,
|
||||
}));
|
||||
touch('pointerdown', ra.x + ra.width / 2, ra.y + ra.height / 2, src);
|
||||
touch('pointermove', rb.x + rb.width / 2, rb.y + rb.height / 2, dst);
|
||||
touch('pointerup', rb.x + rb.width / 2, rb.y + rb.height / 2, dst);
|
||||
await settle();
|
||||
out.touchDidNotReorder = JSON.stringify(ids()) === JSON.stringify(touchOrder);
|
||||
fire('pointerdown', a.x + a.width / 2, a.y + a.height / 2, source);
|
||||
fire('pointermove', b.x + b.width / 2, b.y + b.height / 2, target);
|
||||
fire('pointerup', b.x + b.width / 2, b.y + b.height / 2, target);
|
||||
});
|
||||
await settle();
|
||||
out.touchDidNotReorder = JSON.stringify(await ids()) === JSON.stringify(touchBefore);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._mode = 'view';
|
||||
c.requestUpdate();
|
||||
await settle();
|
||||
out.notReorderableInView = !tabs()[0].hasAttribute('data-reorderable');
|
||||
const viewOrder = ids();
|
||||
await drag(viewOrder[viewOrder.length - 1], viewOrder[0]);
|
||||
out.viewDidNotReorder = JSON.stringify(ids()) === JSON.stringify(viewOrder);
|
||||
|
||||
// --- review r1 M1: the mouse is released away from the panel ---------------
|
||||
//
|
||||
// A horizontal drag that ends a few pixels below the tabs is ordinary hand
|
||||
// imprecision. Without pointer capture no tab ever sees the release, the
|
||||
// gesture stays stuck with moved:true, and the next click is swallowed by
|
||||
// _tabClick — the panel simply stops switching spaces.
|
||||
c._mode = 'plan';
|
||||
c.requestUpdate();
|
||||
await settle();
|
||||
const strayTabs = tabs();
|
||||
const strayFrom = strayTabs[strayTabs.length - 1];
|
||||
const strayRect = strayFrom.getBoundingClientRect();
|
||||
const stray = (type, x, y) => strayFrom.dispatchEvent(new PointerEvent(type, {
|
||||
pointerId: 11, pointerType: 'mouse', clientX: x, clientY: y, bubbles: true, composed: true,
|
||||
}));
|
||||
stray('pointerdown', strayRect.x + strayRect.width / 2, strayRect.y + strayRect.height / 2);
|
||||
stray('pointermove', strayRect.x + strayRect.width / 2 + 40, strayRect.y + strayRect.height / 2);
|
||||
// released far below the panel, where no tab lives
|
||||
// released on the stage, not on a tab: without pointer capture no tab
|
||||
// handler ever runs and the gesture stays stuck
|
||||
(sr().querySelector('.stage') || document.body).dispatchEvent(new PointerEvent('pointerup', {
|
||||
pointerId: 11, pointerType: 'mouse', composed: true,
|
||||
clientX: strayRect.x + 400, clientY: strayRect.y + 400, bubbles: true,
|
||||
}));
|
||||
await settle();
|
||||
out.strayReleaseEndedTheDrag = c._tabDrag === null;
|
||||
const strayTarget = ids().find((id) => id !== c._space);
|
||||
const strayNext = tabs().find((tab) => tab.dataset.id === strayTarget);
|
||||
strayNext.dispatchEvent(new PointerEvent('pointerdown', {
|
||||
pointerId: 12, pointerType: 'mouse', bubbles: true, composed: true,
|
||||
}));
|
||||
strayNext.dispatchEvent(new PointerEvent('pointerup', {
|
||||
pointerId: 12, pointerType: 'mouse', bubbles: true, composed: true,
|
||||
}));
|
||||
strayNext.click();
|
||||
await settle();
|
||||
out.panelStillSwitchesAfterStrayRelease = c._space === strayTarget;
|
||||
|
||||
// --- review r2/r3 F1: the card is destroyed mid-drag -----------------------
|
||||
//
|
||||
// Lovelace rebuilds its tree, or the user leaves the view with the button
|
||||
// still down. The window listeners the gesture installed must not outlive
|
||||
// the card: they hold the instance alive and would let an invisible card
|
||||
// write its order on the next pointerup anywhere on the page.
|
||||
c._mode = 'plan';
|
||||
c.requestUpdate();
|
||||
await settle();
|
||||
const orderBeforeDetach = ids();
|
||||
const detachTabs = tabs();
|
||||
const held = detachTabs[detachTabs.length - 1];
|
||||
const heldRect = held.getBoundingClientRect();
|
||||
const heldEvent = (type, x, y) => held.dispatchEvent(new PointerEvent(type, {
|
||||
pointerId: 13, pointerType: 'mouse', clientX: x, clientY: y,
|
||||
bubbles: true, composed: true,
|
||||
}));
|
||||
heldEvent('pointerdown', heldRect.x + heldRect.width / 2, heldRect.y + heldRect.height / 2);
|
||||
heldEvent('pointermove', heldRect.x + heldRect.width / 2 + 40, heldRect.y + heldRect.height / 2);
|
||||
out.dragWasActiveBeforeDetach = c._tabDrag !== null && c._tabDrag.moved === true;
|
||||
|
||||
const parent = c.parentNode;
|
||||
const next = c.nextSibling;
|
||||
const writesBeforeDetach = writes.length;
|
||||
c.remove();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
out.detachEndedTheDrag = c._tabDrag === null;
|
||||
// the release the detached card must no longer hear
|
||||
window.dispatchEvent(new PointerEvent('pointerup', {
|
||||
pointerId: 13, pointerType: 'mouse', bubbles: true, composed: true,
|
||||
}));
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
out.detachedCardDidNotWrite = writes.length === writesBeforeDetach;
|
||||
parent.insertBefore(c, next);
|
||||
await settle();
|
||||
out.orderSurvivedDetach = JSON.stringify(ids()) === JSON.stringify(orderBeforeDetach);
|
||||
|
||||
c._writeConfig = realWrite;
|
||||
return out;
|
||||
await c.updateComplete;
|
||||
});
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
out.notReorderableInView = await page.evaluate(() => !window.__card.renderRoot
|
||||
.querySelector('[data-hp="space-tab"]')?.hasAttribute('data-reorderable'));
|
||||
const viewBefore = await ids();
|
||||
const viewFromPoint = await tabPoint(viewBefore.at(-1));
|
||||
const viewTargetPoint = await tabPoint(viewBefore[0]);
|
||||
await page.mouse.move(viewFromPoint.x, viewFromPoint.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(viewTargetPoint.x, viewTargetPoint.y, { steps: 4 });
|
||||
await page.mouse.up();
|
||||
await settle();
|
||||
out.viewDidNotReorder = JSON.stringify(await ids()) === JSON.stringify(viewBefore);
|
||||
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
c._mode = 'plan';
|
||||
c._config = { ...c._config, floor: c._model[0].id };
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
out.fixedFloorNotReorderable = await page.evaluate(() => !window.__card.renderRoot
|
||||
.querySelector('[data-hp="space-tab"]')?.hasAttribute('data-reorderable'));
|
||||
await page.evaluate(async () => {
|
||||
const c = window.__card;
|
||||
const { floor: _floor, ...rest } = c._config;
|
||||
c._config = rest;
|
||||
c._mode = 'plan';
|
||||
c._writeConfig = window.__tabReorderOriginalWrite;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
});
|
||||
|
||||
checkAll(out);
|
||||
await finish(browser, out);
|
||||
|
||||
File diff suppressed because one or more lines are too long
Vendored
+7
-7
File diff suppressed because one or more lines are too long
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Space tabs can once again be reordered with a real mouse drag in an editor.
|
||||
Browser pointer capture no longer traps the drop target on the held tab; a
|
||||
divider now marks the exact side where the space will be inserted, and
|
||||
releasing outside the tab strip leaves the saved order unchanged
|
||||
([#243](https://github.com/Matysh/houseplan-card/issues/243)).
|
||||
|
||||
- Decorative lines, shapes, text and furniture are now drawn above opaque room
|
||||
fills, room hover, doorway fill and the dark Glow base, so the floor no
|
||||
longer hides them. Live light and sun, walls, opening symbols, devices and
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Вкладки пространств снова можно переставлять реальным перетаскиванием мышью
|
||||
в редакторе. Захват указателя браузером больше не удерживает цель на исходной
|
||||
вкладке; разделитель показывает точную сторону вставки, а отпускание мыши вне
|
||||
панели вкладок не меняет сохранённый порядок
|
||||
([#243](https://github.com/Matysh/houseplan-card/issues/243)).
|
||||
|
||||
- Декоративные линии, фигуры, текст и мебель теперь рисуются поверх непрозрачной
|
||||
заливки комнат, hover-подсветки, заливки проёмов и тёмной основы Glow, поэтому
|
||||
пол больше не скрывает декор. Живой свет и солнечные лучи, стены, символы
|
||||
|
||||
@@ -556,6 +556,19 @@ separately promised workflows:
|
||||
- [ ] View header: space tabs + count + zoom + editor tabs, the general-settings
|
||||
cog and the per-space gears (visible in EVERY mode since v1.30.1/v1.30.3
|
||||
for users who may edit); no editor toolbars [auto: smoke_modes]
|
||||
- [ ] Space-tab reorder (#243): in an editor with at least three spaces, use a
|
||||
real mouse drag while browser pointer capture remains on the held tab;
|
||||
moving left resolves the tab under the cursor and paints its left divider,
|
||||
moving right paints the right divider, and each valid drop saves exactly
|
||||
once [auto: smoke_space_tab_reorder; golden: space-tab-drop-before-light,
|
||||
space-tab-drop-after-dark]
|
||||
- [ ] Move a held space from a valid target out over the plan: the divider
|
||||
clears immediately and release does not save. `pointercancel` and removing
|
||||
the card mid-drag also end the gesture without changing order
|
||||
[auto: smoke_space_tab_reorder]
|
||||
- [ ] A sub-threshold mouse gesture remains a tab click; the next click after an
|
||||
outside release still works. Touch, View and a card fixed to one `floor`
|
||||
never expose reordering [auto: smoke_space_tab_reorder]
|
||||
- [ ] Plan: markup toolbar, space gears, +space, ⚙ palette; device icons hidden,
|
||||
labels/openings draggable; orange stage frame [manual]
|
||||
- [ ] Devices: icon drag works, click opens the marker editor directly; +/👁/↺/⬡
|
||||
|
||||
+3
-1
@@ -253,7 +253,9 @@ uses the 5 cm compatibility fallback and is not silently migrated.
|
||||
Space tabs follow the order in which the spaces were created, and that order can
|
||||
be changed: in any editor mode, grab a tab with the mouse and drag it to a new
|
||||
position. The new order is saved immediately and applies everywhere — the tabs,
|
||||
the kiosk swipe between floors and the carousel arrows.
|
||||
the kiosk swipe between floors and the carousel arrows. While dragging, a thin
|
||||
divider shows the exact insertion side; release outside the tab strip to keep
|
||||
the existing order.
|
||||
|
||||
Dragging works **with a mouse and in the editors only**. In ordinary View and on
|
||||
touch screens a tab still does one thing: it switches the space. There it is the
|
||||
|
||||
@@ -276,7 +276,9 @@ desktop: для точного рисования, Resize, модификато
|
||||
Вкладки пространств стоят в том порядке, в котором пространства заведены, и
|
||||
этот порядок можно изменить: в любом режиме редактора возьмите вкладку мышью и
|
||||
перетащите на новое место. Порядок сохраняется сразу и действует везде —
|
||||
вкладки, свайп между этажами в киоске, стрелки карусели.
|
||||
вкладки, свайп между этажами в киоске, стрелки карусели. Во время перетаскивания
|
||||
тонкий разделитель показывает точную сторону вставки; отпустите мышь вне панели
|
||||
вкладок, чтобы оставить прежний порядок.
|
||||
|
||||
Перетаскивание работает **только мышью и только в редакторах**. В обычном
|
||||
просмотре и на сенсорных экранах вкладка по-прежнему только переключает
|
||||
|
||||
@@ -1211,6 +1211,49 @@ export const MUTANTS = [
|
||||
replace: ' cfg.spaces = applySpaceOrder(cfg.spaces || [], order);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'tab-drag-target-follows-captured-source',
|
||||
guard: 'node demo/smoke_space_tab_reorder.mjs',
|
||||
because: 'pointer capture keeps pointermove targeted at the held tab; resolving the drop '
|
||||
+ 'from that event target reproduces #243, where a real mouse can never reach another tab',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' const target = this._tabDropTargetAt(event.clientX, event.clientY, drag.id);',
|
||||
replace: ' const target = this._tabDropTargetAt(drag.x, drag.y, drag.id);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'tab-drop-indicator-always-before',
|
||||
guard: 'node demo/smoke_space_tab_reorder.mjs',
|
||||
because: 'a single undirected marker cannot tell whether the held space will land before '
|
||||
+ 'or after the target; #243 requires the divider on the actual insertion side',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: " return { targetId, placement: targetIndex < sourceIndex ? 'before' : 'after' };",
|
||||
replace: " return { targetId, placement: 'before' };",
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'tab-drop-outside-commits-last-target',
|
||||
guard: 'node demo/smoke_space_tab_reorder.mjs',
|
||||
because: 'leaving the tab strip must clear the preview and make release a no-op; retaining '
|
||||
+ 'the last target makes an outside drop reorder spaces unexpectedly',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ` targetId: target?.targetId || null,
|
||||
placement: target?.placement || null,`,
|
||||
replace: ` targetId: target?.targetId || drag.targetId,
|
||||
placement: target?.placement || drag.placement,`,
|
||||
}, {
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ` const target = event.type === 'pointerup' && drag?.moved
|
||||
? this._tabDropTargetAt(event.clientX, event.clientY, drag.id)
|
||||
: null;`,
|
||||
replace: ` const target = event.type === 'pointerup' && drag?.moved
|
||||
? drag.targetId ? { targetId: drag.targetId } : null
|
||||
: null;`,
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'reorder-skips-materialization',
|
||||
guard: 'node demo/smoke_space_tab_reorder.mjs',
|
||||
@@ -1253,8 +1296,9 @@ export const MUTANTS = [
|
||||
+ '(ревью r2/r3, F1)',
|
||||
patches: [{
|
||||
file: 'src/houseplan-card.ts',
|
||||
find: ' this._endTabDrag();\n clearInterval(this._cycleTimer);',
|
||||
replace: ' clearInterval(this._cycleTimer);',
|
||||
find: ` this._endTabDrag();
|
||||
clearTimeout(this._tabSuppressClickTimer);`,
|
||||
replace: ' clearTimeout(this._tabSuppressClickTimer);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
+66
-12
@@ -1324,27 +1324,59 @@ class HouseplanCard extends LitElement {
|
||||
window.addEventListener('pointercancel', this._tabDragRelease);
|
||||
this._tabDrag = {
|
||||
id, pointerId: event.pointerId, x: event.clientX, y: event.clientY,
|
||||
moved: false, overId: id,
|
||||
moved: false, targetId: null, placement: null,
|
||||
};
|
||||
}
|
||||
|
||||
private _tabPointerMove(event: PointerEvent, overId: string): void {
|
||||
/** Resolve a drop from screen coordinates, independent of captured event.target. */
|
||||
private _tabDropTargetAt(clientX: number, clientY: number, sourceId: string): {
|
||||
targetId: string; placement: 'before' | 'after';
|
||||
} | null {
|
||||
const ids = this._model.map((space) => space.id);
|
||||
const sourceIndex = ids.indexOf(sourceId);
|
||||
if (sourceIndex < 0) return null;
|
||||
const tabs = this.renderRoot.querySelectorAll<HTMLElement>('[data-hp="space-tab"]');
|
||||
for (const tab of tabs) {
|
||||
const targetId = tab.dataset.id || '';
|
||||
if (!targetId || targetId === sourceId) continue;
|
||||
const rect = tab.getBoundingClientRect();
|
||||
if (clientX < rect.left || clientX > rect.right
|
||||
|| clientY < rect.top || clientY > rect.bottom) continue;
|
||||
const targetIndex = ids.indexOf(targetId);
|
||||
if (targetIndex < 0) return null;
|
||||
return { targetId, placement: targetIndex < sourceIndex ? 'before' : 'after' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private _tabPointerMove(event: PointerEvent): void {
|
||||
const drag = this._tabDrag;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
if (!drag.moved
|
||||
&& !passedDragThreshold(event.clientX - drag.x, event.clientY - drag.y)) return;
|
||||
// Past the threshold the gesture is a drag: the click that would otherwise
|
||||
// follow is suppressed in _tabClick, and the panel shows where it lands.
|
||||
if (drag.moved && drag.overId === overId) return;
|
||||
this._tabDrag = { ...drag, moved: true, overId };
|
||||
const target = this._tabDropTargetAt(event.clientX, event.clientY, drag.id);
|
||||
if (drag.moved && drag.targetId === target?.targetId
|
||||
&& drag.placement === target?.placement) return;
|
||||
this._tabDrag = {
|
||||
...drag,
|
||||
moved: true,
|
||||
targetId: target?.targetId || null,
|
||||
placement: target?.placement || null,
|
||||
};
|
||||
}
|
||||
|
||||
private _tabPointerUp(event: PointerEvent): void {
|
||||
const drag = this._tabDrag;
|
||||
if (drag && drag.pointerId !== event.pointerId) return;
|
||||
const target = event.type === 'pointerup' && drag?.moved
|
||||
? this._tabDropTargetAt(event.clientX, event.clientY, drag.id)
|
||||
: null;
|
||||
if (event.type === 'pointerup' && drag?.moved) this._suppressNextTabClick();
|
||||
this._endTabDrag();
|
||||
if (!drag || !drag.moved) return;
|
||||
this._commitTabOrder(drag.id, drag.overId);
|
||||
if (!drag?.moved || !target) return;
|
||||
this._commitTabOrder(drag.id, target.targetId);
|
||||
}
|
||||
|
||||
/** Drop the gesture and its window listeners, wherever the release happened. */
|
||||
@@ -1358,10 +1390,25 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** A click that followed a real drag must not also switch the space. */
|
||||
private _tabClick(id: string): void {
|
||||
if (this._tabDrag?.moved) return;
|
||||
if (this._tabSuppressClick) {
|
||||
this._tabSuppressClick = false;
|
||||
clearTimeout(this._tabSuppressClickTimer);
|
||||
this._tabSuppressClickTimer = undefined;
|
||||
return;
|
||||
}
|
||||
this._pickSpace(id);
|
||||
}
|
||||
|
||||
/** Browser click follows pointerup in the same task; clear if none arrived. */
|
||||
private _suppressNextTabClick(): void {
|
||||
this._tabSuppressClick = true;
|
||||
clearTimeout(this._tabSuppressClickTimer);
|
||||
this._tabSuppressClickTimer = window.setTimeout(() => {
|
||||
this._tabSuppressClick = false;
|
||||
this._tabSuppressClickTimer = undefined;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the new order — and, in the same write, the placement that used to
|
||||
* depend on it.
|
||||
@@ -1407,6 +1454,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _pickSpace(id: string): void {
|
||||
this._endTabDrag();
|
||||
if (id === this._space) return;
|
||||
const ids = this._model.map((sp) => sp.id);
|
||||
const from = ids.indexOf(this._space);
|
||||
@@ -2010,11 +2058,14 @@ class HouseplanCard extends LitElement {
|
||||
|
||||
/** Live tab reorder: which tab is held, where it started, where it would land. */
|
||||
private _tabDrag: {
|
||||
id: string; pointerId: number; x: number; y: number; moved: boolean; overId: string;
|
||||
id: string; pointerId: number; x: number; y: number; moved: boolean;
|
||||
targetId: string | null; placement: 'before' | 'after' | null;
|
||||
} | null = null;
|
||||
|
||||
/** Window-level release handler while a tab is held; see _tabPointerDown. */
|
||||
private _tabDragRelease: ((event: PointerEvent) => void) | null = null;
|
||||
private _tabSuppressClick = false;
|
||||
private _tabSuppressClickTimer?: number;
|
||||
|
||||
/** The positional-`floor` warning is worth saying once, not on every drop. */
|
||||
private _tabOrderWarned = false;
|
||||
@@ -2296,6 +2347,9 @@ class HouseplanCard extends LitElement {
|
||||
// pointerup anywhere on the page would make an invisible card write its
|
||||
// order (review CODE-REVIEW-220-r2/r3, F1).
|
||||
this._endTabDrag();
|
||||
clearTimeout(this._tabSuppressClickTimer);
|
||||
this._tabSuppressClickTimer = undefined;
|
||||
this._tabSuppressClick = false;
|
||||
clearInterval(this._cycleTimer);
|
||||
clearTimeout(this._kioskDotsTimer);
|
||||
clearTimeout(this._kioskHoldTimer);
|
||||
@@ -6351,6 +6405,7 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
private _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): void {
|
||||
this._endTabDrag();
|
||||
// A mode command is newer than the editor remembered by a same-route warm
|
||||
// remount. Clear it before the same-mode early return: while can_write is
|
||||
// pending, Lit may still be presenting the previous editor DOM even though
|
||||
@@ -16413,17 +16468,16 @@ class HouseplanCard extends LitElement {
|
||||
<ha-icon icon="mdi:home-city"></ha-icon>
|
||||
${this._config.title || this._t('card.title')}
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<div class="tabs" @pointermove=${(e: PointerEvent) => this._tabPointerMove(e)}>
|
||||
${navigationSpaces.map(
|
||||
(s) => html`<button
|
||||
data-hp="space-tab" data-id="${s.id}"
|
||||
class="tab ${this._space === s.id ? 'active' : ''}${
|
||||
this._tabDrag?.moved && this._tabDrag.id === s.id ? ' dragging' : ''}${
|
||||
this._tabDrag?.moved && this._tabDrag.overId === s.id
|
||||
&& this._tabDrag.id !== s.id ? ' droptarget' : ''}"
|
||||
this._tabDrag?.moved && this._tabDrag.targetId === s.id
|
||||
? ` drop-${this._tabDrag.placement}` : ''}"
|
||||
?data-reorderable=${this._canReorderTabs}
|
||||
@pointerdown=${(e: PointerEvent) => this._tabPointerDown(e, s.id)}
|
||||
@pointermove=${(e: PointerEvent) => this._tabPointerMove(e, s.id)}
|
||||
@pointerup=${(e: PointerEvent) => this._tabPointerUp(e)}
|
||||
@pointercancel=${() => this._endTabDrag()}
|
||||
@click=${() => this._tabClick(s.id)}
|
||||
|
||||
+2
-1
@@ -1725,7 +1725,8 @@ export const cardStyles = css`
|
||||
/* issue #220: a tab can be dragged to a new position in the editors */
|
||||
.tab[data-reorderable] { cursor: grab; }
|
||||
.tab.dragging { cursor: grabbing; opacity: 0.55; }
|
||||
.tab.droptarget { box-shadow: inset 2px 0 0 0 var(--primary-color, #03a9f4); }
|
||||
.tab.drop-before { box-shadow: inset 2px 0 0 0 var(--primary-color, #03a9f4); }
|
||||
.tab.drop-after { box-shadow: inset -2px 0 0 0 var(--primary-color, #03a9f4); }
|
||||
.modetab .closex {
|
||||
--mdc-icon-size: 13px;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -104,6 +104,12 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
|
||||
assert.equal(scenario.capture, 'page', scenario.id);
|
||||
assert.ok(scenario.wallJunctionPreview.path.length >= 1, scenario.id);
|
||||
}
|
||||
if (scenario.tabDrag) {
|
||||
assert.equal(['before', 'after'].includes(scenario.tabDrag), true, scenario.id);
|
||||
assert.equal(scenario.applianceLifecycle, true, scenario.id);
|
||||
assert.equal(scenario.mode, 'plan', scenario.id);
|
||||
assert.equal(scenario.capture, 'page', scenario.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -117,7 +123,8 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
|
||||
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
|
||||
'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint',
|
||||
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions',
|
||||
'washer-active-cycle', 'washer-idle-cycle', 'decor-over-opaque-hover',
|
||||
'washer-active-cycle', 'washer-idle-cycle', 'space-tab-drop-before',
|
||||
'space-tab-drop-after', 'decor-over-opaque-hover',
|
||||
'decor-over-glow-base'])
|
||||
assert.equal(ids.includes(token), true, token);
|
||||
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('plan'), true);
|
||||
@@ -145,6 +152,18 @@ test('room-label parity goldens pair View and Plan in light and dark themes', ()
|
||||
}
|
||||
});
|
||||
|
||||
test('space-tab drop goldens hold both insertion sides in light and dark', () => {
|
||||
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.tabDrag);
|
||||
assert.equal(scenarios.length, 2);
|
||||
assert.deepEqual(new Set(scenarios.map((scenario) => scenario.tabDrag)),
|
||||
new Set(['before', 'after']));
|
||||
assert.deepEqual(new Set(scenarios.map((scenario) => scenario.theme)),
|
||||
new Set(['light', 'dark']));
|
||||
for (const scenario of scenarios) {
|
||||
assert.equal(prepareGoldenFixture(scenario).config.spaces.length >= 3, true, scenario.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('washer lifecycle goldens pair active and idle snapshots of one composite fixture', () => {
|
||||
const active = GOLDEN_SCENARIOS.find((scenario) => scenario.id === 'washer-active-cycle-dark');
|
||||
const idle = GOLDEN_SCENARIOS.find((scenario) => scenario.id === 'washer-idle-cycle-dark');
|
||||
@@ -282,7 +301,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
|
||||
assert.ok(scenario);
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 35);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 36);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
|
||||
Reference in New Issue
Block a user