Umsortiert wird gezogen, nicht geklickt
Die Pfeile sind weg; eine Mediathek wird in der Seitenleiste an ihren Platz gezogen. Mit Pointer Events statt HTML5-Drag: letzteres kennt kein Telefon, zeichnet ein Geisterbild, das sich nicht gestalten lässt, und liefert während des Ziehens keine brauchbaren Koordinaten. Mit Schwelle von sechs Bildpunkten — sonst verschiebt jeder leicht wackelige Klick die Reihenfolge, und ein Klick ohne Bewegung führt weiterhin zur Mediathek, auch im Bearbeitungsmodus. Die weichenden Zeilen gleiten, die gezogene folgt dem Zeiger ohne Verzögerung; bei „Bewegung reduzieren" gleitet nichts. Die Pfeiltasten bleiben auf der fokussierten Zeile, nur ohne sichtbare Knöpfe. Ziehen ist mit der Tastatur nicht bedienbar, und eine Funktion, die nur mit der Maus erreichbar ist, ist für den, der keine benutzt, gar keine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a22fa9b58e
commit
a8bb98c44c
@@ -23,6 +23,11 @@ export const icons = {
|
|||||||
/** Winkel nach oben — eine Ebene zurück im Baum. */
|
/** Winkel nach oben — eine Ebene zurück im Baum. */
|
||||||
hoch: rahmen('<path d="m6 14 6-6 6 6"/>'),
|
hoch: rahmen('<path d="m6 14 6-6 6 6"/>'),
|
||||||
runter: rahmen('<path d="m6 10 6 6 6-6"/>'),
|
runter: rahmen('<path d="m6 10 6 6 6-6"/>'),
|
||||||
|
// Sechs Punkte — das übliche Zeichen für „hier anfassen". Gefüllt,
|
||||||
|
// weil Kreise mit Radius 1 als Umriss nur ein Fleck wären.
|
||||||
|
griff: rahmen('<circle cx="9" cy="6" r="1"/><circle cx="15" cy="6" r="1"/>'
|
||||||
|
+ '<circle cx="9" cy="12" r="1"/><circle cx="15" cy="12" r="1"/>'
|
||||||
|
+ '<circle cx="9" cy="18" r="1"/><circle cx="15" cy="18" r="1"/>', true),
|
||||||
/** Filmstreifen — für eine Filmmediathek. */
|
/** Filmstreifen — für eine Filmmediathek. */
|
||||||
filme: rahmen('<rect x="3" y="4" width="18" height="16" rx="2"/>'
|
filme: rahmen('<rect x="3" y="4" width="18" height="16" rx="2"/>'
|
||||||
+ '<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>'),
|
+ '<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>'),
|
||||||
|
|||||||
+108
-31
@@ -147,8 +147,16 @@ function leisteBauen(): void {
|
|||||||
// die Entscheidung zurück, sie zu trennen.
|
// die Entscheidung zurück, sie zu trennen.
|
||||||
if (mediatheken.length) {
|
if (mediatheken.length) {
|
||||||
gruppe('Mediatheken', istAdmin() && mediatheken.length > 1);
|
gruppe('Mediatheken', istAdmin() && mediatheken.length > 1);
|
||||||
|
|
||||||
|
// **Ein eigener Behälter für die Mediatheken.** Beim Ziehen wird gerechnet,
|
||||||
|
// die wievielte Zeile unter dem Zeiger liegt; stünden Überschriften und
|
||||||
|
// „Start" dazwischen, ginge diese Rechnung schief.
|
||||||
|
const liste = document.createElement('div');
|
||||||
|
liste.className = 'nav-liste';
|
||||||
|
leiste.appendChild(liste);
|
||||||
|
|
||||||
mediatheken.forEach((b, i) => eintrag(
|
mediatheken.forEach((b, i) => eintrag(
|
||||||
b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount, i));
|
b.name, `#/m/${b.id}`, b.kind === 'Shows' ? 'serien' : 'filme', b.itemCount, i, liste));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung', 'verwaltung'); }
|
if (istAdmin()) { gruppe('Server'); eintrag('Verwaltung', '#/verwaltung', 'verwaltung'); }
|
||||||
@@ -184,7 +192,7 @@ function leisteBauen(): void {
|
|||||||
|
|
||||||
function eintrag(
|
function eintrag(
|
||||||
text: string, ziel: string, bild: keyof typeof icons,
|
text: string, ziel: string, bild: keyof typeof icons,
|
||||||
anzahl?: number | null, platz?: number,
|
anzahl?: number | null, platz?: number, behaelter?: HTMLElement,
|
||||||
): void {
|
): void {
|
||||||
const b = document.createElement('button');
|
const b = document.createElement('button');
|
||||||
b.dataset.ziel = ziel;
|
b.dataset.ziel = ziel;
|
||||||
@@ -192,43 +200,112 @@ function leisteBauen(): void {
|
|||||||
+ `<span class="name">${esc(text)}</span>`
|
+ `<span class="name">${esc(text)}</span>`
|
||||||
+ (anzahl != null && !leisteBearbeiten ? `<span class="zahl">${anzahl}</span>` : '');
|
+ (anzahl != null && !leisteBearbeiten ? `<span class="zahl">${anzahl}</span>` : '');
|
||||||
|
|
||||||
// Im Bearbeitungsmodus führt die Zeile nirgendwohin — sonst wechselt man
|
if (leisteBearbeiten && platz != null && behaelter) {
|
||||||
// beim Zielen auf einen Pfeil versehentlich die Ansicht.
|
b.classList.add('ziehbar');
|
||||||
if (leisteBearbeiten && platz != null) {
|
|
||||||
b.classList.add('verschiebbar');
|
// Der Griff links statt eines Knopfes rechts: er sagt „hier anfassen",
|
||||||
b.appendChild(pfeil('hoch', platz, platz - 1, platz === 0, text));
|
// und er nimmt die Zeile nicht als Ganzes in Beschlag.
|
||||||
b.appendChild(pfeil('runter', platz, platz + 1, platz === mediatheken.length - 1, text));
|
const griff = document.createElement('span');
|
||||||
|
griff.className = 'nav-griff icon';
|
||||||
|
griff.innerHTML = icons.griff;
|
||||||
|
b.prepend(griff);
|
||||||
|
|
||||||
|
// **Die Tastatur bleibt bedienbar.** Ziehen geht damit nicht, und eine
|
||||||
|
// Funktion, die nur mit der Maus erreichbar ist, ist für den, der keine
|
||||||
|
// benutzt, gar keine. Sichtbare Pfeile braucht es dafür nicht.
|
||||||
|
b.setAttribute('aria-roledescription', 'Verschiebbarer Eintrag');
|
||||||
|
b.title = `„${text}" ziehen — oder mit den Pfeiltasten verschieben`;
|
||||||
|
b.onkeydown = e => {
|
||||||
|
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||||
|
e.preventDefault();
|
||||||
|
void schieben(platz, platz + (e.key === 'ArrowDown' ? 1 : -1));
|
||||||
|
};
|
||||||
|
|
||||||
|
ziehbarMachen(b, platz, behaelter);
|
||||||
} else {
|
} else {
|
||||||
b.onclick = () => { location.hash = ziel; };
|
b.onclick = () => { location.hash = ziel; };
|
||||||
}
|
}
|
||||||
|
|
||||||
leiste.appendChild(b);
|
(behaelter ?? leiste).appendChild(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Macht eine Zeile ziehbar.
|
||||||
|
*
|
||||||
|
* **Pointer Events statt HTML5-Drag.** Letzteres kennt kein Telefon, zeichnet
|
||||||
|
* ein Geisterbild, das sich nicht gestalten lässt, und liefert während des
|
||||||
|
* Ziehens keine brauchbaren Koordinaten. Pointer Events bedienen Maus, Finger
|
||||||
|
* und Stift mit demselben Code.
|
||||||
|
*
|
||||||
|
* **Mit Schwelle.** Erst nach sechs Bildpunkten gilt es als Ziehen — sonst
|
||||||
|
* verschiebt jeder leicht wackelige Klick die Reihenfolge.
|
||||||
|
*/
|
||||||
|
function ziehbarMachen(zeile: HTMLElement, platz: number, behaelter: HTMLElement): void {
|
||||||
|
zeile.addEventListener('pointerdown', start);
|
||||||
|
|
||||||
|
function start(e: PointerEvent): void {
|
||||||
|
// Nur die linke Maustaste; rechts gehört dem Kontextmenü.
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const zeilen = [...behaelter.querySelectorAll<HTMLElement>('.ziehbar')];
|
||||||
|
const hoehe = zeile.getBoundingClientRect().height
|
||||||
|
+ parseFloat(getComputedStyle(behaelter).rowGap || '0');
|
||||||
|
const startY = e.clientY;
|
||||||
|
|
||||||
|
let zieht = false;
|
||||||
|
let ziel = platz;
|
||||||
|
|
||||||
|
zeile.setPointerCapture(e.pointerId);
|
||||||
|
|
||||||
|
const bewegen = (m: PointerEvent) => {
|
||||||
|
const weg = m.clientY - startY;
|
||||||
|
|
||||||
|
if (!zieht) {
|
||||||
|
if (Math.abs(weg) < 6) return;
|
||||||
|
zieht = true;
|
||||||
|
behaelter.classList.add('zieht');
|
||||||
|
zeile.classList.add('wird-gezogen');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ein Pfeil zum Schieben.
|
// Wohin die Zeile fiele, wenn man jetzt losliesse.
|
||||||
*
|
ziel = Math.max(0, Math.min(zeilen.length - 1, platz + Math.round(weg / hoehe)));
|
||||||
* **Zwei Knöpfe statt Ziehen und Ablegen.** Ziehen ist schöner, aber mit
|
|
||||||
* der Tastatur unbedienbar und auf dem Telefon ein Glücksspiel.
|
|
||||||
*/
|
|
||||||
function pfeil(
|
|
||||||
richtung: 'hoch' | 'runter', von: number, nach: number, gesperrt: boolean, name: string,
|
|
||||||
): HTMLElement {
|
|
||||||
const p = document.createElement('span');
|
|
||||||
p.className = 'nav-pfeil' + (gesperrt ? ' gesperrt' : '');
|
|
||||||
p.setAttribute('role', 'button');
|
|
||||||
if (!gesperrt) p.setAttribute('tabindex', '0');
|
|
||||||
p.setAttribute('aria-label', `„${name}" nach ${richtung === 'hoch' ? 'oben' : 'unten'}`);
|
|
||||||
p.innerHTML = icons.hoch;
|
|
||||||
if (richtung === 'runter') p.style.transform = 'rotate(180deg)';
|
|
||||||
|
|
||||||
const los = (e: Event) => {
|
zeile.style.transform = `translateY(${weg}px)`;
|
||||||
e.stopPropagation();
|
|
||||||
if (gesperrt) return;
|
// Die anderen machen Platz — nach oben oder unten, je nachdem, auf
|
||||||
void schieben(von, nach);
|
// welcher Seite sie liegen.
|
||||||
|
zeilen.forEach((andere, i) => {
|
||||||
|
if (andere === zeile) return;
|
||||||
|
let versatz = 0;
|
||||||
|
if (platz < ziel && i > platz && i <= ziel) versatz = -hoehe;
|
||||||
|
else if (platz > ziel && i >= ziel && i < platz) versatz = hoehe;
|
||||||
|
andere.style.transform = versatz ? `translateY(${versatz}px)` : '';
|
||||||
|
});
|
||||||
};
|
};
|
||||||
p.onclick = los;
|
|
||||||
p.onkeydown = e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); los(e); } };
|
const beenden = () => {
|
||||||
return p;
|
zeile.removeEventListener('pointermove', bewegen);
|
||||||
|
zeile.removeEventListener('pointerup', beenden);
|
||||||
|
zeile.removeEventListener('pointercancel', beenden);
|
||||||
|
|
||||||
|
behaelter.classList.remove('zieht');
|
||||||
|
zeilen.forEach(z => { z.style.transform = ''; z.classList.remove('wird-gezogen'); });
|
||||||
|
|
||||||
|
// Ohne Ziehen war es ein Klick — und der führt zur Mediathek, auch im
|
||||||
|
// Bearbeitungsmodus. Sonst wäre die Leiste dort unbenutzbar.
|
||||||
|
if (!zieht) {
|
||||||
|
const weg = zeile.dataset.ziel;
|
||||||
|
if (weg) location.hash = weg;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ziel !== platz) void schieben(platz, ziel);
|
||||||
|
};
|
||||||
|
|
||||||
|
zeile.addEventListener('pointermove', bewegen);
|
||||||
|
zeile.addEventListener('pointerup', beenden);
|
||||||
|
zeile.addEventListener('pointercancel', beenden);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-9
@@ -181,16 +181,45 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
|
|||||||
.nav-bearbeiten.an { color: var(--accent); }
|
.nav-bearbeiten.an { color: var(--accent); }
|
||||||
.nav-bearbeiten svg { width: 15px; height: 15px; display: block; }
|
.nav-bearbeiten svg { width: 15px; height: 15px; display: block; }
|
||||||
|
|
||||||
/* Im Bearbeitungsmodus trägt die Zeile zwei Pfeile statt der Zahl. */
|
/* ---------- Umsortieren durch Ziehen ----------
|
||||||
#chrome button.verschiebbar { cursor: default; }
|
*
|
||||||
.nav-pfeil {
|
* Der Behälter braucht keine eigene Form; er existiert, damit beim Ziehen
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
* gerechnet werden kann, die wievielte Zeile unter dem Zeiger liegt.
|
||||||
width: 20px; height: 20px; flex: none;
|
*/
|
||||||
border-radius: var(--r-knopf); color: var(--text-muted);
|
.nav-liste { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
#chrome button.ziehbar {
|
||||||
|
cursor: grab;
|
||||||
|
/* Ohne das übernimmt der Browser die senkrechte Geste und scrollt, statt
|
||||||
|
* die Zeile zu bewegen — auf dem Telefon wäre Ziehen damit unmöglich. */
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#chrome button.ziehbar:active { cursor: grabbing; }
|
||||||
|
|
||||||
|
.nav-griff {
|
||||||
|
display: inline-flex; color: var(--text-faint); flex: none; margin-right: -2px;
|
||||||
|
}
|
||||||
|
.nav-griff svg { width: 14px; height: 14px; }
|
||||||
|
#chrome button.ziehbar:hover .nav-griff { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Die gezogene Zeile liegt über den anderen und wirft einen Schatten —
|
||||||
|
* sonst sieht man nicht, dass sie abgehoben ist. */
|
||||||
|
#chrome button.wird-gezogen {
|
||||||
|
z-index: 3; cursor: grabbing;
|
||||||
|
background: var(--elevated); box-shadow: var(--schatten-dialog);
|
||||||
|
opacity: .95;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die weichenden Zeilen gleiten, die gezogene folgt dem Zeiger ohne
|
||||||
|
* Verzögerung — eine Übergangszeit auf ihr machte sie zäh. */
|
||||||
|
#chrome .nav-liste.zieht button:not(.wird-gezogen) {
|
||||||
|
transition: transform .14s ease;
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
#chrome .nav-liste.zieht button:not(.wird-gezogen) { transition: none; }
|
||||||
}
|
}
|
||||||
.nav-pfeil:hover { background: var(--raised); color: var(--text); }
|
|
||||||
.nav-pfeil.gesperrt { opacity: .25; pointer-events: none; }
|
|
||||||
.nav-pfeil svg { width: 14px; height: 14px; }
|
|
||||||
|
|
||||||
#chrome button {
|
#chrome button {
|
||||||
display: flex; align-items: center; gap: var(--s-regular);
|
display: flex; align-items: center; gap: var(--s-regular);
|
||||||
|
|||||||
Reference in New Issue
Block a user