forked from yvv/decision
387 lines
11 KiB
Svelte
387 lines
11 KiB
Svelte
<script lang="ts">
|
|
// Reordering a section, in place (Yvv, 2026-11-02: « en fin de section, qui ajoute des
|
|
// "poignées" aux modules de la section. Et permette de voter. »): the articles of the
|
|
// section fold to their titles, a handle before each. One drags a handle (mouse, pen or
|
|
// finger: pointer events), or — without dragging — clicks an article then its new place,
|
|
// or uses its arrows; with the keyboard, the up and down arrows move the article that has
|
|
// the focus, and the new place is said. Then « Proposer cet ordre »: the order goes to the
|
|
// vote (ADR 0009, `POST /units/{id}/order`) and its vote unfolds at once, here. An order
|
|
// already proposed is voted, never proposed twice. While the order is the one in force,
|
|
// the button stays off and says why, beside it (lot U2, UX-lecteur.md).
|
|
import { tick, untrack } from 'svelte';
|
|
import { flip } from 'svelte/animate';
|
|
import { api } from '$lib/api';
|
|
import type { DocState } from '$lib/doc.svelte';
|
|
import { t } from '$lib/i18n/index.svelte';
|
|
import { inertiaLevel } from '$lib/labels';
|
|
import { moved, place, sameOrder, step } from '$lib/order';
|
|
import { toasts } from '$lib/toast.svelte';
|
|
import type { Unit } from '$lib/types';
|
|
import { errorText } from '$lib/vote';
|
|
import Icon from './Icon.svelte';
|
|
|
|
let {
|
|
doc,
|
|
unit,
|
|
nameOf,
|
|
ondone,
|
|
oncancel,
|
|
}: {
|
|
doc: DocState;
|
|
/** The order module of the section. */
|
|
unit: Unit;
|
|
/** The title of an article, in the language it is read in. */
|
|
nameOf: (unitId: string) => string;
|
|
/** The order proposed, or the same one already proposed: its vote unfolds. */
|
|
ondone: (proposalId: string) => void;
|
|
oncancel: () => void;
|
|
} = $props();
|
|
|
|
let list = $state<string[]>([]);
|
|
let chosen = $state<string | null>(null);
|
|
let dragging = $state<string | null>(null);
|
|
let said = $state('');
|
|
let busy = $state(false);
|
|
let error = $state('');
|
|
let root: HTMLElement | undefined = $state();
|
|
const start = $derived(unit.members ?? []);
|
|
const changed = $derived(moved(start, list));
|
|
const unchanged = $derived(sameOrder(start, list));
|
|
/** The same order, already proposed: one votes it rather than proposing a double. */
|
|
const existing = $derived(
|
|
unit.proposals.find((p) => p.status === 'open' && p.kind === 'order' && sameOrder(p.order?.ids ?? [], list)),
|
|
);
|
|
/** An article of the section only proposed, not in force: said as such. */
|
|
const proposed = (id: string) => !doc.unit(id)?.inForce;
|
|
const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
|
|
// Started again only for another section: a reload never wipes what is being moved.
|
|
$effect(() => {
|
|
void unit.id;
|
|
untrack(() => {
|
|
list = [...(unit.members ?? [])];
|
|
chosen = null;
|
|
error = '';
|
|
});
|
|
});
|
|
|
|
async function focus(id: string) {
|
|
await tick();
|
|
root?.querySelector<HTMLButtonElement>(`button.item[data-id="${CSS.escape(id)}"]`)?.focus();
|
|
}
|
|
|
|
function tell(id: string) {
|
|
said = t('order.edit.moved', { title: nameOf(id), n: list.indexOf(id) + 1, total: list.length });
|
|
}
|
|
|
|
function pick(id: string) {
|
|
error = '';
|
|
if (chosen === null) {
|
|
chosen = id;
|
|
said = t('order.edit.chosen', { title: nameOf(id) });
|
|
return;
|
|
}
|
|
if (chosen === id) {
|
|
chosen = null;
|
|
said = '';
|
|
return;
|
|
}
|
|
const which = chosen;
|
|
list = place(list, which, id);
|
|
chosen = null;
|
|
tell(which);
|
|
void focus(which);
|
|
}
|
|
|
|
function shift(id: string, by: -1 | 1) {
|
|
error = '';
|
|
list = step(list, id, by);
|
|
tell(id);
|
|
void focus(id);
|
|
}
|
|
|
|
function key(e: KeyboardEvent, id: string) {
|
|
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
shift(id, e.key === 'ArrowUp' ? -1 : 1);
|
|
} else if (e.key === 'Escape' && chosen) {
|
|
e.stopPropagation();
|
|
chosen = null;
|
|
said = '';
|
|
}
|
|
}
|
|
|
|
// Dragging by the handle: the article takes the place of the row under the pointer.
|
|
function grab(e: PointerEvent, id: string) {
|
|
if (busy || e.button !== 0) return;
|
|
e.preventDefault();
|
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
dragging = id;
|
|
chosen = null;
|
|
error = '';
|
|
}
|
|
|
|
function drag(e: PointerEvent) {
|
|
const id = dragging;
|
|
if (!id || !root) return;
|
|
for (const row of root.querySelectorAll<HTMLElement>('li[data-id]')) {
|
|
const box = row.getBoundingClientRect();
|
|
if (e.clientY < box.top || e.clientY >= box.bottom) continue;
|
|
const target = row.dataset.id;
|
|
if (target && target !== id) list = place(list, id, target);
|
|
return;
|
|
}
|
|
}
|
|
|
|
function drop() {
|
|
const id = dragging;
|
|
if (!id) return;
|
|
dragging = null;
|
|
tell(id);
|
|
void focus(id);
|
|
}
|
|
|
|
async function submit() {
|
|
if (unchanged) return;
|
|
if (existing) {
|
|
ondone(existing.id);
|
|
return;
|
|
}
|
|
busy = true;
|
|
error = '';
|
|
try {
|
|
const r = await api.post<{ id: string }>(`/units/${unit.id}/order`, { order: list });
|
|
await doc.reload();
|
|
toasts.show(t('order.submitted'));
|
|
ondone(r.id);
|
|
} catch (e) {
|
|
error = errorText(e);
|
|
} finally {
|
|
busy = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="reorder">
|
|
<p class="faint small" id="order-help-{unit.id}">{t('order.edit.help')}</p>
|
|
<ol class="list" aria-label={t('order.edit.label')} aria-describedby="order-help-{unit.id}" bind:this={root}>
|
|
{#each list as id, i (id)}
|
|
{@const name = nameOf(id)}
|
|
<li data-id={id} class:chosen={chosen === id} class:moved={changed.has(id)} class:dragging={dragging === id} animate:flip={{ duration: reduce ? 0 : 140 }}>
|
|
<span class="grip" aria-hidden="true" onpointerdown={(e) => grab(e, id)} onpointermove={drag} onpointerup={drop} onpointercancel={drop}>
|
|
<Icon name="grip" size={18} />
|
|
</span>
|
|
<button type="button" class="item" data-id={id} aria-pressed={chosen === id} disabled={busy} onclick={() => pick(id)} onkeydown={(e) => key(e, id)}>
|
|
<span class="n" aria-hidden="true">{i + 1}</span>
|
|
<span class="what">
|
|
<span class="name">{name}</span>
|
|
{#if proposed(id)}<span class="tag">{t('order.edit.proposed')}</span>{/if}
|
|
</span>
|
|
</button>
|
|
<span class="arrows">
|
|
<button type="button" class="arrow up" aria-label={t('order.edit.up', { title: name })} disabled={i === 0 || busy} onclick={() => shift(id, -1)}><Icon name="chevron" size={16} /></button>
|
|
<button type="button" class="arrow down" aria-label={t('order.edit.down', { title: name })} disabled={i === list.length - 1 || busy} onclick={() => shift(id, 1)}><Icon name="chevron" size={16} /></button>
|
|
</span>
|
|
</li>
|
|
{/each}
|
|
</ol>
|
|
<p class="visually-hidden" aria-live="polite">{said}</p>
|
|
<p class="faint small">{t('order.edit.note', { level: inertiaLevel(unit.inertia) })} {t('module.order.rule')}</p>
|
|
{#if existing && !unchanged}<p class="note" role="status">{t('order.edit.exists')}</p>{/if}
|
|
{#if error}<p class="warn" role="alert">{error}</p>{/if}
|
|
<div class="actions">
|
|
{#if unchanged}<p class="why" id="order-why-{unit.id}">{t('order.edit.unchanged')}</p>{/if}
|
|
<button class="btn quiet small" type="button" disabled={busy} onclick={oncancel}>{t('common.cancel')}</button>
|
|
<button
|
|
class="btn primary small"
|
|
type="button"
|
|
disabled={busy || unchanged}
|
|
aria-describedby={unchanged ? `order-why-${unit.id}` : undefined}
|
|
onclick={submit}>{t(existing ? 'order.edit.vote' : 'order.edit.submit')}</button
|
|
>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.reorder {
|
|
display: grid;
|
|
gap: 0.7rem;
|
|
min-width: 0;
|
|
animation: unfold 0.2s var(--ease);
|
|
}
|
|
.list {
|
|
list-style: none;
|
|
margin: 0;
|
|
padding: 0;
|
|
display: grid;
|
|
gap: 0.3rem;
|
|
}
|
|
.list li {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.2rem;
|
|
border-radius: var(--r-input);
|
|
background: var(--surface);
|
|
box-shadow: var(--shadow);
|
|
transition: background-color 0.15s var(--ease);
|
|
}
|
|
.list li.moved {
|
|
background: color-mix(in srgb, var(--surface) 55%, var(--moss-wash));
|
|
}
|
|
.list li.chosen,
|
|
.list li.dragging {
|
|
background: var(--water);
|
|
color: var(--on-water);
|
|
}
|
|
.list li.dragging {
|
|
box-shadow: var(--shadow-strong);
|
|
}
|
|
.grip {
|
|
display: grid;
|
|
place-items: center;
|
|
flex: none;
|
|
width: 2.4rem;
|
|
min-height: 2.75rem;
|
|
align-self: stretch;
|
|
color: var(--ink-faint);
|
|
cursor: grab;
|
|
touch-action: none;
|
|
border-radius: var(--r-input) 0 0 var(--r-input);
|
|
}
|
|
.grip:hover {
|
|
color: var(--water);
|
|
}
|
|
.dragging .grip,
|
|
.chosen .grip {
|
|
color: inherit;
|
|
cursor: grabbing;
|
|
}
|
|
.item {
|
|
flex: 1;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.6rem;
|
|
min-width: 0;
|
|
min-height: 2.75rem;
|
|
padding: 0.45rem 0.3rem;
|
|
border: 0;
|
|
border-radius: var(--r-input);
|
|
background: transparent;
|
|
color: inherit;
|
|
text-align: left;
|
|
cursor: pointer;
|
|
}
|
|
.n {
|
|
flex: none;
|
|
min-width: 1.2rem;
|
|
font-weight: 800;
|
|
font-variant-numeric: tabular-nums;
|
|
color: var(--water);
|
|
}
|
|
.chosen .n,
|
|
.dragging .n {
|
|
color: inherit;
|
|
}
|
|
.what {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.25rem 0.6rem;
|
|
min-width: 0;
|
|
}
|
|
.name {
|
|
font-family: var(--font-text);
|
|
font-weight: 600;
|
|
overflow-wrap: anywhere;
|
|
}
|
|
.chosen .tag,
|
|
.dragging .tag {
|
|
background: color-mix(in srgb, var(--on-water) 22%, transparent);
|
|
color: inherit;
|
|
}
|
|
.arrows {
|
|
display: flex;
|
|
flex: none;
|
|
gap: 0.1rem;
|
|
padding-right: 0.25rem;
|
|
}
|
|
.arrow {
|
|
display: grid;
|
|
place-items: center;
|
|
width: 2.25rem;
|
|
height: 2.25rem;
|
|
border: 0;
|
|
border-radius: var(--r-icon);
|
|
background: transparent;
|
|
color: inherit;
|
|
}
|
|
.arrow:hover:not(:disabled) {
|
|
background: var(--water-wash);
|
|
}
|
|
.arrow:disabled {
|
|
opacity: 0.3;
|
|
}
|
|
.arrow.up :global(svg) {
|
|
transform: rotate(-90deg);
|
|
}
|
|
.arrow.down :global(svg) {
|
|
transform: rotate(90deg);
|
|
}
|
|
.small {
|
|
font-size: 0.84rem;
|
|
}
|
|
.note {
|
|
font-size: 0.88rem;
|
|
font-weight: 700;
|
|
color: var(--water);
|
|
}
|
|
.warn {
|
|
color: var(--ochre);
|
|
font-weight: 700;
|
|
font-size: 0.9rem;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
justify-content: flex-end;
|
|
}
|
|
/* Why the button is off, on its left: read before one reaches for it. */
|
|
.why {
|
|
flex: 1 1 14rem;
|
|
margin: 0;
|
|
font-size: 0.84rem;
|
|
font-weight: 650;
|
|
color: var(--ink-soft);
|
|
}
|
|
/* On a phone: every target is big enough for a finger. */
|
|
@media (max-width: 899px) {
|
|
.arrow {
|
|
width: 2.75rem;
|
|
height: 2.75rem;
|
|
}
|
|
.actions .btn {
|
|
min-height: 2.75rem;
|
|
}
|
|
/* Its own line, above the two buttons kept together. */
|
|
.why {
|
|
flex-basis: 100%;
|
|
}
|
|
}
|
|
@keyframes unfold {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-4px);
|
|
}
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.reorder {
|
|
animation: none;
|
|
}
|
|
.list li {
|
|
transition: none;
|
|
}
|
|
}
|
|
</style>
|