Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
158 lines
4.7 KiB
Svelte
158 lines
4.7 KiB
Svelte
<script lang="ts">
|
|
// Reorderable list of search result groups.
|
|
//
|
|
// Drag-and-drop alone would leave this unusable with a screen reader or any
|
|
// pointerless input, so every row also carries labelled move up/down buttons
|
|
// which are the primary, always-available mechanism.
|
|
//
|
|
// TRACES: UR-050 | DR-066
|
|
import { searchGroupOrder } from "$lib/stores/searchGroupOrder";
|
|
import { GROUP_LABELS, type SearchGroupId } from "$lib/utils/searchScope";
|
|
|
|
let dragIndex = $state<number | null>(null);
|
|
let overIndex = $state<number | null>(null);
|
|
// Announced to assistive tech after a move, since the list itself reorders
|
|
// silently.
|
|
let announcement = $state("");
|
|
|
|
function announce(id: SearchGroupId) {
|
|
const position = $searchGroupOrder.indexOf(id) + 1;
|
|
announcement = `${GROUP_LABELS[id]} moved to position ${position} of ${$searchGroupOrder.length}`;
|
|
}
|
|
|
|
function move(id: SearchGroupId, delta: number) {
|
|
searchGroupOrder.move(id, delta);
|
|
announce(id);
|
|
}
|
|
|
|
function onDragStart(event: DragEvent, index: number) {
|
|
dragIndex = index;
|
|
event.dataTransfer?.setData("text/plain", String(index));
|
|
if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
|
|
}
|
|
|
|
function onDragOver(event: DragEvent, index: number) {
|
|
if (dragIndex === null) return;
|
|
event.preventDefault();
|
|
overIndex = index;
|
|
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
|
}
|
|
|
|
function onDrop(event: DragEvent, index: number) {
|
|
event.preventDefault();
|
|
if (dragIndex !== null && dragIndex !== index) {
|
|
const id = $searchGroupOrder[dragIndex];
|
|
searchGroupOrder.reorder(dragIndex, index);
|
|
if (id) announce(id);
|
|
}
|
|
dragIndex = null;
|
|
overIndex = null;
|
|
}
|
|
|
|
function onDragEnd() {
|
|
dragIndex = null;
|
|
overIndex = null;
|
|
}
|
|
</script>
|
|
|
|
<ul class="space-y-2">
|
|
{#each $searchGroupOrder as id, i (id)}
|
|
<li
|
|
draggable="true"
|
|
ondragstart={(e) => onDragStart(e, i)}
|
|
ondragover={(e) => onDragOver(e, i)}
|
|
ondrop={(e) => onDrop(e, i)}
|
|
ondragend={onDragEnd}
|
|
class="flex items-center gap-3 px-3 py-2 rounded-lg bg-gray-800 border transition-colors {overIndex ===
|
|
i && dragIndex !== i
|
|
? 'border-[var(--color-jellyfin)]'
|
|
: 'border-gray-700'} {dragIndex === i ? 'opacity-50' : ''}"
|
|
>
|
|
<!-- Decorative: dragging is the mouse affordance, the buttons below are
|
|
the accessible path. -->
|
|
<svg
|
|
class="w-4 h-4 text-gray-500 flex-shrink-0 cursor-grab"
|
|
fill="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
d="M9 4h2v2H9V4zm4 0h2v2h-2V4zM9 9h2v2H9V9zm4 0h2v2h-2V9zm-4 5h2v2H9v-2zm4 0h2v2h-2v-2zm-4 5h2v2H9v-2zm4 0h2v2h-2v-2z"
|
|
/>
|
|
</svg>
|
|
|
|
<span class="text-sm text-gray-400 w-5 flex-shrink-0">{i + 1}</span>
|
|
<span class="flex-1 text-white">{GROUP_LABELS[id]}</span>
|
|
|
|
<button
|
|
type="button"
|
|
onclick={() => move(id, -1)}
|
|
disabled={i === 0}
|
|
aria-label="Move {GROUP_LABELS[id]} up"
|
|
class="p-2 rounded text-gray-300 hover:bg-gray-700 hover:text-white transition-colors disabled:opacity-30 disabled:hover:bg-transparent"
|
|
>
|
|
<svg
|
|
class="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onclick={() => move(id, 1)}
|
|
disabled={i === $searchGroupOrder.length - 1}
|
|
aria-label="Move {GROUP_LABELS[id]} down"
|
|
class="p-2 rounded text-gray-300 hover:bg-gray-700 hover:text-white transition-colors disabled:opacity-30 disabled:hover:bg-transparent"
|
|
>
|
|
<svg
|
|
class="w-4 h-4"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
viewBox="0 0 24 24"
|
|
aria-hidden="true"
|
|
>
|
|
<path
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
stroke-width="2"
|
|
d="M19 9l-7 7-7-7"
|
|
/>
|
|
</svg>
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
|
|
<div class="sr-only" role="status" aria-live="polite">{announcement}</div>
|
|
|
|
<div class="flex justify-end pt-3">
|
|
<button
|
|
type="button"
|
|
onclick={() => {
|
|
searchGroupOrder.reset();
|
|
announcement = "Search group order reset to default";
|
|
}}
|
|
class="text-sm text-gray-400 hover:text-white transition-colors"
|
|
>
|
|
Reset to default
|
|
</button>
|
|
</div>
|
|
|
|
<style>
|
|
.sr-only {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0, 0, 0, 0);
|
|
white-space: nowrap;
|
|
border-width: 0;
|
|
}
|
|
</style>
|