The charging tabs drag, like a car's do

Reordering the bar meant editing the template, which is a poor way to ask for
Home chargers first. The tabs now drag into either order on the same native
drag events as a car's tabs and the garage, down to the live reorder as the
pointer crosses a tab, the grab cursor, and the rail's lock holding the bar
still for anyone who would rather not nudge it on the way to a tab.

The arrangement is saved on the profile as charger_tab_order, beside the garage
order and for the same reason: it is a layout choice that should follow the
account rather than the browser, unlike which cards are folded. The field is
reconciled onto the users collection at boot, so no migration step. Its
normalizer is the garage's, which now takes the field name and cap as
arguments instead of being copied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-01 21:10:52 +02:00
co-authored by Claude Opus 5
parent 2a5d21d328
commit 1418a566fd
8 changed files with 150 additions and 19 deletions
+1
View File
@@ -48,6 +48,7 @@
"eyebrow": "Opladning og kort",
"title": "Ladere i nærheden",
"tabs": {
"dragHint": "Træk en fane for at ændre rækkefølgen af opladningsfanerne.",
"public": "Offentlige ladere",
"home": "Hjemmeladere"
},
+1
View File
@@ -48,6 +48,7 @@
"eyebrow": "Charging & map",
"title": "Nearby chargers",
"tabs": {
"dragHint": "Drag a tab to rearrange the charging tabs.",
"public": "Public chargers",
"home": "Home chargers"
},
+1
View File
@@ -48,6 +48,7 @@
"eyebrow": "Ładowanie i mapa",
"title": "Ładowarki w pobliżu",
"tabs": {
"dragHint": "Przeciągnij kartę, aby zmienić kolejność kart ładowania.",
"public": "Ładowarki publiczne",
"home": "Ładowarki domowe"
},
+5
View File
@@ -12,6 +12,10 @@ export const prefs = reactive({
// rows, the provider's readings. Nothing to apply to the document — the views
// read it to decide whether their elements are draggable at all.
dragLocked: false,
// The charging page's tab arrangement. Kept here rather than fetched by the
// view because it arrives with the profile anyway, and the bar has to render
// in the right order on the first paint.
chargerTabOrder: [],
});
const FONT_SCALE = { small: "93.75%", medium: "100%", large: "112.5%" };
@@ -49,6 +53,7 @@ export function applyProfilePrefs(profile) {
prefs.currency = profile.currency || "USD";
prefs.fontSize = profile.fontSize || "medium";
prefs.dragLocked = !!profile.dragLocked;
prefs.chargerTabOrder = Array.isArray(profile.chargerTabOrder) ? profile.chargerTabOrder : [];
applyTheme();
applyFontSize();
}
+96 -5
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { t } from "../i18n";
import { prefs } from "../prefs";
import { askConfirm } from "../lib/confirm.js";
import { api } from "../api";
import { formatDateTime } from "../lib/format.js";
@@ -27,6 +28,83 @@ const stations = [
// still placeholder data; the home half is not — those are records the user
// imported from a service they connected.
const chargerTab = ref("public"); // "public" | "home"
// --- Arranging the tab bar ---
//
// The bar drags into either order, on the same native drag events as the
// garage and a car's tabs — so pointer-only, as touch browsers don't fire
// these. The arrangement is saved on the profile rather than in this browser:
// it is a layout choice that should follow the account, the way the garage
// order does, and unlike which cards are folded.
const ALL_CHARGER_TABS = ["public", "home"];
const tabKeys = ref([...ALL_CHARGER_TABS]);
watch(
() => prefs.chargerTabOrder,
(order) => {
const arranged = [];
for (const key of order || []) {
if (ALL_CHARGER_TABS.includes(key) && !arranged.includes(key)) arranged.push(key);
}
// A tab the stored arrangement doesn't mention — one added in a later
// release — follows the arranged ones, the same rule the car's tabs use.
tabKeys.value = [...arranged, ...ALL_CHARGER_TABS.filter((k) => !arranged.includes(k))];
},
{ immediate: true }
);
// The rail's lock holds the bar still for someone who would rather not nudge it
// on the way to a tab.
const canArrangeTabs = computed(() => !prefs.dragLocked && tabKeys.value.length > 1);
const dragTab = ref(""); // tab being dragged
const dropTab = ref(""); // tab it is currently hovering over
const tabOrderError = ref("");
let tabsMoved = false; // the bar changed during this drag and isn't saved yet
function onTabDragStart(key, e) {
dragTab.value = key;
tabsMoved = false;
e.dataTransfer.effectAllowed = "move";
// Firefox only starts a drag once something is on the transfer.
e.dataTransfer.setData("text/plain", key);
}
// Reorder live as the pointer crosses tabs, so the bar shows the arrangement
// you are about to get. dragenter fires again for every child element inside
// the same button, so the tab being hovered is remembered and only a genuinely
// new one moves anything.
function onTabDragEnter(key) {
if (!dragTab.value || key === dragTab.value || dropTab.value === key) return;
dropTab.value = key;
const list = tabKeys.value;
const from = list.indexOf(dragTab.value);
const to = list.indexOf(key);
if (from < 0 || to < 0) return;
list.splice(to, 0, ...list.splice(from, 1));
tabsMoved = true;
}
// Save whatever the bar now shows. Called from both drop and dragend: a tab
// released beside the bar never produces a drop, and leaving that arrangement
// unsaved would quietly undo itself on the next load.
async function commitTabOrder() {
dragTab.value = "";
dropTab.value = "";
if (!tabsMoved) return;
tabsMoved = false;
tabOrderError.value = "";
const arranged = [...tabKeys.value];
try {
await api.updateMe({ chargerTabOrder: arranged });
prefs.chargerTabOrder = arranged;
} catch (e) {
// The arrangement didn't stick; say so and put the stored one back rather
// than leaving the bar showing an order the server does not have.
tabOrderError.value = e.message;
tabKeys.value = [...prefs.chargerTabOrder.filter((k) => ALL_CHARGER_TABS.includes(k)),
...ALL_CHARGER_TABS.filter((k) => !prefs.chargerTabOrder.includes(k))];
}
}
const publicStations = stations;
const selected = ref("sc");
@@ -623,16 +701,29 @@ onMounted(async () => {
<h1 class="text-3xl font-bold tracking-[-0.03em] text-strong">{{ t("charging.title") }}</h1>
</div>
<!-- Tabs: public network vs. the user's own home charger(s) -->
<!-- Tabs: public network vs. the user's own home charger(s). They drag into
either order, like a car's tabs and the garage. -->
<p v-if="tabOrderError" class="mb-4 rounded-control bg-danger-soft px-4 py-3 text-sm font-medium text-danger">{{ tabOrderError }}</p>
<div class="mb-6 flex gap-2 border-b border-subtle">
<button
v-for="tab in ['public', 'home']"
v-for="tab in tabKeys"
:key="tab"
:draggable="canArrangeTabs"
:title="canArrangeTabs ? t('charging.tabs.dragHint') : ''"
class="-mb-px border-b-2 px-1 pb-3 text-sm font-semibold transition-colors"
:class="chargerTab === tab
? 'border-accent text-strong'
: 'border-transparent text-muted hover:text-body'"
:class="[
chargerTab === tab
? 'border-accent text-strong'
: 'border-transparent text-muted hover:text-body',
canArrangeTabs ? 'cursor-grab active:cursor-grabbing' : '',
dragTab === tab ? 'opacity-50' : '',
]"
@click="chargerTab = tab"
@dragstart="onTabDragStart(tab, $event)"
@dragenter="onTabDragEnter(tab)"
@dragover.prevent
@drop.prevent="commitTabOrder"
@dragend="commitTabOrder"
>
{{ t(`charging.tabs.${tab}`) }}
</button>