forked from yvv/decision
Lot U : le lecteur refait — l'ordre en une ligne au bout de sa section, poignées sur place (Reorder), le titre et l'ordre votés à leur place ; Propositions seed en tête quand rien n'est en vigueur ; Propositions en cours repliées par section ; panneau sans onglets ; oui pour passer · non pour bloquer ; quorum de l'auteur ; votes simulés dits simulés
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,357 @@
|
||||
<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.
|
||||
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) {
|
||||
error = t('order.edit.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="name">{name}</span>
|
||||
{#if proposed(id)}<span class="tag">{t('order.edit.proposed')}</span>{/if}
|
||||
</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">
|
||||
<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} 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;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem 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;
|
||||
}
|
||||
.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;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
/* On a phone: every target is big enough for a finger. */
|
||||
@media (max-width: 899px) {
|
||||
.arrow {
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
}
|
||||
@keyframes unfold {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reorder {
|
||||
animation: none;
|
||||
}
|
||||
.list li {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user