feat: reorder space tabs by dragging one to a new position

The order of config.spaces used to be whatever order the spaces were created
in, and there was no way back other than deleting a space and drawing it
again.

The gesture is deliberately narrow — mouse, editors only. The same tabs are
the primary way to switch spaces in View, where touch is first class, so a
drag there would compete with the tap that switches. Recorded in the spec as
"Touch editor: not exposed".

The part that needed care is not the drag. Position in the array feeds three
things: the marker placement fallback, the swipe neighbour and a positional
`floor`. So the write that stores the new order also writes down the
placement that used to depend on it: a marker with neither an explicit space
nor an area that names one gets the space it has right now. Both changes go in
one save; splitting them would leave a window in which markers move on their
own. The positional `floor` cannot be fixed from here, so the card says so
once.

Issue: #220
User-Visible: yes
This commit is contained in:
Codex
2026-08-21 00:27:35 +03:00
parent 30bf18f508
commit 8369c0e839
16 changed files with 718 additions and 128 deletions
File diff suppressed because one or more lines are too long
+149
View File
@@ -0,0 +1,149 @@
// 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.
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 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;
};
// 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 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,
}));
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));
}
};
await settle();
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.
const firstBefore = c._model[0].id;
c._serverCfg.markers = [
...(c._serverCfg.markers || []),
{ id: 'smoke-dangling', binding: 'virtual', name: 'dangling' },
];
const dangling = () => (c._serverCfg.markers || [])
.find((marker) => marker.id === 'smoke-dangling');
out.plantedMarkerStartsWithoutSpace = !dangling().space;
const before = ids();
out.enoughTabsToReorder = before.length >= 2;
out.reorderableInEditor = tabs()[0].hasAttribute('data-reorderable');
// --- 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];
// --- 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;
// --- 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',
clientX: rect.x + rect.width / 2, clientY: rect.y + rect.height / 2, bubbles: 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;
// --- 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,
}));
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);
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);
c._writeConfig = realWrite;
return out;
});
checkAll(res);
await finish(browser, res);
File diff suppressed because one or more lines are too long
+51 -42
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -2,6 +2,11 @@
## Unreleased
- Space tabs can be reordered: grab a tab with the mouse in any editor mode and
drop it where it belongs. The order is saved, and swipe and carousel
navigation follow it. Markers stay exactly where they were
([#220](https://github.com/Matysh/houseplan-card/issues/220)).
- “Optimize plans” now removes microscopic floating-point noise from stored
grid coordinates even when nothing visibly moves. Its preview separately
reports updated spaces and cleaned coordinate values, and a second run is an
+5
View File
@@ -8,6 +8,11 @@
## Не выпущено
- Порядок вкладок пространств теперь можно менять: в режиме редактора возьмите
вкладку мышью и перетащите на нужное место. Порядок сохраняется, свайп и
карусель следуют ему. Устройства при этом остаются на своих местах
([#220](https://github.com/Matysh/houseplan-card/issues/220)).
- «Оптимизировать планы» теперь устраняет микроскопический floating-point шум
сохранённых координат сетки, даже если визуально ничего не сдвигается. В
предпросмотре отдельно показаны обновлённые пространства и очищенные
+17
View File
@@ -235,6 +235,23 @@ The plan image keeps its proportions initially. Background can later move,
scale or rotate it. Detaching a plan never deletes its server file; deletion
requires an explicit user action.
### Tab order
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.
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
primary way to navigate, and a gesture must not compete with a plain tap. Order
is changed on a computer, like the rest of the plan work.
If a card anywhere pins its floor **by number** (`floor: 0`), remember that the
number means a position: after a reorder such a card shows a different floor.
The card warns about this once. Pin the floor by space id instead of a number to
avoid it entirely.
### Display settings
A space can show/hide room borders, names, LQI, Background and openings. It can
+18
View File
@@ -269,6 +269,24 @@ desktop: для точного рисования, Resize, модификато
них, последующая смена источника больше не сбрасывает ни один выбор. В мастере
этажей каждый следующий этаж получает собственные чистые defaults.
### Порядок вкладок
Вкладки пространств стоят в том порядке, в котором пространства заведены, и
этот порядок можно изменить: в любом режиме редактора возьмите вкладку мышью и
перетащите на новое место. Порядок сохраняется сразу и действует везде —
вкладки, свайп между этажами в киоске, стрелки карусели.
Перетаскивание работает **только мышью и только в редакторах**. В обычном
просмотре и на сенсорных экранах вкладка по-прежнему только переключает
пространство: там это основной способ навигации, и жест не должен мешать
обычному нажатию. Порядок меняется на компьютере — как и остальная работа с
планом.
Если где-то карточка закреплена за этажом **по номеру** (`floor: 0`), помните,
что номер означает позицию: после перестановки такая карточка покажет другой
этаж. Карточка предупредит об этом один раз. Чтобы этого не случалось, задавайте
этаж идентификатором пространства, а не номером.
### Настройки пространства
| Раздел | Настройка | Результат |
+44
View File
@@ -717,6 +717,50 @@ export const MUTANTS = [
replace: ' .dev:not(.unavail):hover {',
}],
},
{
id: 'tab-reorder-not-persisted',
guard: 'node demo/smoke_space_tab_reorder.mjs',
because: 'перестановка вкладок, оставшаяся только в памяти, выглядит рабочей ровно до '
+ 'перезагрузки страницы — смок обязан требовать запись на сервер',
patches: [{
file: 'src/houseplan-card.ts',
find: ' cfg.spaces = applySpaceOrder(cfg.spaces || [], order);\n this._saveConfig();',
replace: ' cfg.spaces = applySpaceOrder(cfg.spaces || [], order);',
}],
},
{
id: 'reorder-skips-materialization',
guard: 'node demo/smoke_space_tab_reorder.mjs',
because: 'без материализации привязки маркер без space и area уезжает вслед за порядком: '
+ 'его пространство решает firstSpaceId, а тот после перестановки другой (#220 §8.3)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' const byId = new Map(pinned.map((entry) => [entry.id, entry.space]));',
replace: ' const byId = new Map();',
}],
},
{
id: 'tab-reorder-eats-click',
guard: 'node --test --test-name-pattern="issue 220" test/space-order.test.mjs',
because: 'нулевой порог превращает обычный клик по вкладке в перетаскивание, и '
+ 'переключение пространств — основное действие панели — перестаёт работать',
patches: [{
file: 'src/space-order.ts',
find: 'export const TAB_DRAG_THRESHOLD_PX = 4;',
replace: 'export const TAB_DRAG_THRESHOLD_PX = 0;',
}],
},
{
id: 'tab-reorder-ignores-pointer-type',
guard: 'node --test --test-name-pattern="issue 220" test/space-order.test.mjs',
because: 'на тач-устройстве вкладки — это навигация View, где тап обязан оставаться тапом; '
+ 'решение владельца «Touch editor: not exposed» держится именно этой проверкой',
patches: [{
file: 'src/space-order.ts',
find: " if (ctx.pointerType !== 'mouse') return false;",
replace: ' if (false) return false;',
}],
},
{
id: 'internal-path-ignores-query',
guard: 'node scripts/backend-test-guard.mjs issue_225',
+116 -2
View File
@@ -261,6 +261,10 @@ import {
type OpeningPlacementCore, type OpeningPlacementPreset, type OpeningPlacementType,
} from './opening-placement';
import { safeStoredColor } from './color';
import {
applySpaceOrder, canStartTabDrag, markersNeedingPlacement, passedDragThreshold,
reorderSpaceIds,
} from './space-order';
const CARD_VERSION = '1.66.0';
const DISPLAY_LABEL_KEYS: Record<DeviceDisplayMode, I18nKey> = {
@@ -1238,6 +1242,99 @@ class HouseplanCard extends LitElement {
}
/** Direct space tabs use the same motion as swipe/carousel navigation. */
// ---- reordering the space tabs (issue #220) ------------------------------
//
// Mouse only, editors only: the same tabs switch spaces in View, where touch
// is a first-class citizen, so a gesture here would compete with that tap.
// docs/specs/220-space-tab-reorder.md §4.1, "Touch editor: not exposed".
private get _canReorderTabs(): boolean {
return canStartTabDrag({
canEdit: this._canEdit,
kiosk: this._kiosk,
mode: this._mode,
pointerType: 'mouse',
spaceCount: this._model.length,
fixedFloor: this._hasFixedFloor,
});
}
private _tabPointerDown(event: PointerEvent, id: string): void {
if (!canStartTabDrag({
canEdit: this._canEdit,
kiosk: this._kiosk,
mode: this._mode,
pointerType: event.pointerType,
spaceCount: this._model.length,
fixedFloor: this._hasFixedFloor,
})) return;
this._tabDrag = {
id, pointerId: event.pointerId, x: event.clientX, y: event.clientY,
moved: false, overId: id,
};
}
private _tabPointerMove(event: PointerEvent, overId: string): 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 };
}
private _tabPointerUp(event: PointerEvent): void {
const drag = this._tabDrag;
this._tabDrag = null;
if (!drag || drag.pointerId !== event.pointerId || !drag.moved) return;
this._commitTabOrder(drag.id, drag.overId);
}
/** A click that followed a real drag must not also switch the space. */
private _tabClick(id: string): void {
if (this._tabDrag?.moved) return;
this._pickSpace(id);
}
/**
* Write the new order — and, in the same write, the placement that used to
* depend on it.
*
* A marker with neither an explicit space nor an area that names one renders
* in whatever space sits first. Reordering would silently hand it to another
* space, so the answer it has right now is written down first. This is the
* whole reason the two changes may not be split into two saves.
*/
private _commitTabOrder(movedId: string, targetId: string): void {
const cfg = this._serverCfg;
if (!cfg || !this._canReorderTabs) return;
const ids = this._model.map((space) => space.id);
const order = reorderSpaceIds(ids, movedId, targetId);
if (order === ids) return;
const pinned = markersNeedingPlacement(
cfg.markers || [],
Object.fromEntries(
Object.entries(this._areaToSpace).map(([area, value]) => [area, value.space]),
),
ids[0] || '',
);
if (pinned.length) {
const byId = new Map(pinned.map((entry) => [entry.id, entry.space]));
for (const marker of cfg.markers || []) {
const space = byId.get(String((marker as any).id));
if (space) (marker as any).space = space;
}
}
cfg.spaces = applySpaceOrder(cfg.spaces || [], order);
this._saveConfig();
if (!this._tabOrderWarned) {
this._tabOrderWarned = true;
this._showToast(this._t('toast.space_order_changed'));
}
}
private _pickSpace(id: string): void {
if (id === this._space) return;
const ids = this._model.map((sp) => sp.id);
@@ -1827,6 +1924,14 @@ class HouseplanCard extends LitElement {
private _cycleTimer?: number;
private _cyclePausedUntil = 0;
private _swipeStart: { x: number; y: number; id: number } | null = null;
/** 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;
} | null = null;
/** The positional-`floor` warning is worth saying once, not on every drop. */
private _tabOrderWarned = false;
private _lastTap = 0;
private get _labsIso(): boolean {
return this._labs.active.includes('iso');
@@ -1916,6 +2021,7 @@ class HouseplanCard extends LitElement {
private _holdFired = false;
static properties = {
_tabDrag: { state: true },
_hdrH: { state: true },
_booting: { state: true },
_bootFading: { state: true },
@@ -15746,8 +15852,16 @@ class HouseplanCard extends LitElement {
${navigationSpaces.map(
(s) => html`<button
data-hp="space-tab" data-id="${s.id}"
class="tab ${this._space === s.id ? 'active' : ''}"
@click=${() => this._pickSpace(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' : ''}"
?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._tabDrag = null; }}
@click=${() => this._tabClick(s.id)}
>
${s.title}${this._norm && this._canEdit
? html`<ha-icon class="tabedit" icon="mdi:cog-outline"
+1
View File
@@ -287,6 +287,7 @@
"toast.ha_disabled_add": "A disabled Home Assistant object cannot be added to the plan. Enable it in Home Assistant first.",
"toast.ha_binding_unverified": "The object status could not be verified through the Home Assistant registry. Display and actions are temporarily unavailable.",
"toast.markup_needs_server": "Markup is available after the config is moved to the server",
"toast.space_order_changed": "Order changed. If any card pins its floor by number, check those panels.",
"toast.conflict": "Config was changed in another window — data refreshed, repeat your last action",
"toast.cfg_save_failed": "Failed to save config: {err}",
"toast.room_overlap": "The outline overlaps room “{name}” — rooms must not overlap",
+1
View File
@@ -287,6 +287,7 @@
"toast.ha_disabled_add": "Деактивированный объект Home Assistant нельзя добавить на план. Сначала активируйте его в Home Assistant.",
"toast.ha_binding_unverified": "Статус объекта не удалось подтвердить по реестру Home Assistant. Отображение и действия временно недоступны.",
"toast.markup_needs_server": "Разметка доступна после переноса конфига на сервер",
"toast.space_order_changed": "Порядок изменён. Если где-то этаж карточки задан номером, проверьте такие панели.",
"toast.conflict": "Конфиг изменён в другом окне — данные обновлены, повторите последнее действие",
"toast.cfg_save_failed": "Не удалось сохранить конфиг: {err}",
"toast.room_overlap": "Контур накладывается на комнату «{name}» — комнаты не должны накладываться",
+119
View File
@@ -0,0 +1,119 @@
/**
* Reordering the space tabs.
*
* The order of `config.spaces` is not decoration: it feeds the marker
* placement fallback (`firstSpaceId`), the swipe neighbour and the positional
* `floor` of a fixed-floor card. Moving a tab must therefore move nothing else
* — see docs/specs/220-space-tab-reorder.md §8.
*
* Everything here is pure so the rules can be tested without a browser.
*/
/** How far the pointer must travel before a click becomes a drag. */
export const TAB_DRAG_THRESHOLD_PX = 4;
export interface TabDragContext {
/** The card allows editing at all. */
canEdit: boolean;
/** A wall panel never reorders anything. */
kiosk: boolean;
/** Current mode; reordering lives in the editors only (owner, 2026-08-20). */
mode: 'view' | 'plan' | 'devices' | 'decor';
/** Pointer that started the gesture. */
pointerType: string;
/** How many spaces the panel shows right now. */
spaceCount: number;
/** A card pinned to one floor shows a single tab and must not reorder. */
fixedFloor: boolean;
}
/**
* Whether a pointerdown on a tab may begin a reorder.
*
* Touch is deliberately excluded rather than degraded: the tabs live in View
* as well, where switching spaces is a fully supported touch interaction, and
* any gesture on a tab would compete with the tap that switches. The product
* decision is recorded as `Touch editor: not exposed`.
*/
export function canStartTabDrag(ctx: TabDragContext): boolean {
if (!ctx.canEdit || ctx.kiosk || ctx.fixedFloor) return false;
if (ctx.mode === 'view') return false;
if (ctx.pointerType !== 'mouse') return false;
return ctx.spaceCount > 1;
}
/** Has the pointer moved far enough to mean "drag" rather than "click"? */
export function passedDragThreshold(dx: number, dy: number): boolean {
return Math.hypot(dx, dy) >= TAB_DRAG_THRESHOLD_PX;
}
/**
* Move `movedId` so that it sits where `targetId` is, keeping every other id
* in its relative order. Returns the same array instance when nothing moves,
* so a caller can skip the write without comparing element by element.
*/
export function reorderSpaceIds(
ids: readonly string[], movedId: string, targetId: string,
): string[] {
const from = ids.indexOf(movedId);
const to = ids.indexOf(targetId);
if (from < 0 || to < 0 || from === to) return ids as string[];
const next = ids.slice();
next.splice(from, 1);
next.splice(to, 0, movedId);
return next;
}
/** Reorder the stored spaces to match `order`; ids missing from it keep their tail. */
export function applySpaceOrder<T extends { id?: unknown }>(
spaces: readonly T[], order: readonly string[],
): T[] {
const rank = new Map(order.map((id, index) => [id, index]));
// A stable sort keeps unknown ids (there should be none) in their old order
// instead of shuffling them by an accidental comparison result.
return spaces
.map((space, index) => ({ space, index }))
.sort((a, b) => {
const ra = rank.get(String(a.space?.id)) ?? Number.MAX_SAFE_INTEGER;
const rb = rank.get(String(b.space?.id)) ?? Number.MAX_SAFE_INTEGER;
return ra - rb || a.index - b.index;
})
.map((entry) => entry.space);
}
export interface PlacementMarker {
id?: unknown;
space?: unknown;
area?: unknown;
removed?: unknown;
}
/**
* Markers whose space is decided by the "first space" fallback, and where that
* fallback currently lands.
*
* Such a marker has neither an explicit `space` nor an `area` that names a
* space. Today it renders in whichever space happens to sit first; after a
* reorder that would be a different one — the marker would move on its own,
* which is the one thing a reorder may never do. Writing the answer it has
* right now makes the placement explicit and independent of order for good.
*/
export function markersNeedingPlacement(
markers: readonly PlacementMarker[],
areaToSpace: Readonly<Record<string, string>>,
firstSpaceId: string,
): { id: string; space: string }[] {
if (!firstSpaceId) return [];
const out: { id: string; space: string }[] = [];
for (const marker of markers) {
if (!marker || marker.removed === true) continue;
const id = typeof marker.id === 'string' ? marker.id : '';
if (!id) continue;
const explicit = typeof marker.space === 'string' ? marker.space : '';
if (explicit) continue;
const area = typeof marker.area === 'string' ? marker.area : '';
if (area && areaToSpace[area]) continue;
out.push({ id, space: firstSpaceId });
}
return out;
}
+4
View File
@@ -1692,6 +1692,10 @@ export const cardStyles = css`
}
.modetab:active { transform: scale(0.97); }
.modetab ha-icon { --mdc-icon-size: 15px; }
/* 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); }
.modetab .closex {
--mdc-icon-size: 13px;
box-sizing: border-box;
+85
View File
@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
TAB_DRAG_THRESHOLD_PX, applySpaceOrder, canStartTabDrag, markersNeedingPlacement,
passedDragThreshold, reorderSpaceIds,
} from '../test-build/space-order.js';
const ctx = (over = {}) => ({
canEdit: true, kiosk: false, mode: 'plan', pointerType: 'mouse',
spaceCount: 3, fixedFloor: false, ...over,
});
// --- AC5: где перетаскивание вообще включается -------------------------------
test('issue 220 tab drag is available to an editor with a mouse', () => {
assert.equal(canStartTabDrag(ctx()), true);
assert.equal(canStartTabDrag(ctx({ mode: 'devices' })), true);
assert.equal(canStartTabDrag(ctx({ mode: 'decor' })), true);
});
test('issue 220 tab drag is not exposed to touch, View, kiosk or a fixed floor', () => {
// Touch is excluded by product decision, not by omission: the same tabs
// switch spaces in View, where a tap must stay a tap.
assert.equal(canStartTabDrag(ctx({ pointerType: 'touch' })), false);
assert.equal(canStartTabDrag(ctx({ pointerType: 'pen' })), false);
assert.equal(canStartTabDrag(ctx({ mode: 'view' })), false);
assert.equal(canStartTabDrag(ctx({ kiosk: true })), false);
assert.equal(canStartTabDrag(ctx({ canEdit: false })), false);
assert.equal(canStartTabDrag(ctx({ fixedFloor: true })), false);
assert.equal(canStartTabDrag(ctx({ spaceCount: 1 })), false);
});
test('issue 220 a click stays a click until the pointer really travels', () => {
assert.equal(passedDragThreshold(0, 0), false);
assert.equal(passedDragThreshold(TAB_DRAG_THRESHOLD_PX - 1, 0), false);
assert.equal(passedDragThreshold(TAB_DRAG_THRESHOLD_PX, 0), true);
assert.equal(passedDragThreshold(0, TAB_DRAG_THRESHOLD_PX), true);
});
// --- AC1: сама перестановка --------------------------------------------------
test('issue 220 a tab lands where it was dropped and the rest keep their order', () => {
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'c', 'a'), ['c', 'a', 'b']);
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'a', 'c'), ['b', 'c', 'a']);
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'b', 'b'), ['a', 'b', 'c']);
assert.deepEqual(reorderSpaceIds(['a', 'b', 'c'], 'b', 'zz'), ['a', 'b', 'c']);
});
test('issue 220 stored spaces follow the new order', () => {
const spaces = [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
assert.deepEqual(applySpaceOrder(spaces, ['c', 'a', 'b']).map((s) => s.id), ['c', 'a', 'b']);
// an id the order does not mention keeps its tail position instead of moving
assert.deepEqual(
applySpaceOrder([...spaces, { id: 'd' }], ['c', 'a', 'b']).map((s) => s.id),
['c', 'a', 'b', 'd'],
);
});
// --- AC3: маркеры не двигаются ----------------------------------------------
test('issue 220 only order-dependent markers are pinned, and to where they are now', () => {
const markers = [
{ id: 'dangling' }, // no space, no area
{ id: 'by-area', area: 'kitchen' }, // area names a space
{ id: 'explicit', space: 'f2' }, // already explicit
{ id: 'area-unknown', area: 'nowhere' }, // area names nothing
{ id: 'gone', removed: true }, // tombstone
];
const pinned = markersNeedingPlacement(markers, { kitchen: 'f1' }, 'f1');
assert.deepEqual(pinned, [
{ id: 'dangling', space: 'f1' },
{ id: 'area-unknown', space: 'f1' },
]);
});
test('issue 220 pinning writes the space the marker has before the reorder', () => {
// The fallback is the FIRST space of the current order. If the write used
// the order after the move, the marker would follow the reorder — the very
// thing this pinning exists to prevent.
const markers = [{ id: 'dangling' }];
assert.deepEqual(markersNeedingPlacement(markers, {}, 'garden'), [
{ id: 'dangling', space: 'garden' },
]);
assert.deepEqual(markersNeedingPlacement(markers, {}, ''), []);
});
+1
View File
@@ -20,6 +20,7 @@
"src/virtual-light-state.ts",
"src/types.ts",
"src/space-geometry.ts",
"src/space-order.ts",
"src/signing.ts", "src/initial-load.ts", "src/space-model-selection.ts", "src/space-dialog.ts",
"src/visual-continuity.ts", "src/mode-transition.ts", "src/pointer-modality.ts",
"src/render-device-snapshot.ts",