Drag to reorder
Build a list whose items can be dragged into a new order, with Move up and Move down buttons that do the same from the keyboard.
Drag Walk the dog to the top of the list. Or Tab to its ↑ button and press Enter a few times.
module Snippets.DragReorder
open Fable.Core
open Browser.Types
open Firelight
open type Firelight.Lit
type Step = { Id: int; Text: string }
/// `item` moved to `index` in the list without it.
let moveTo (index: int) (item: Step) (steps: Step list) =
let rest = steps |> List.filter (fun s -> s.Id <> item.Id)
List.insertAt (max 0 (min index rest.Length)) item rest
[<AttachMembers>]
type ReorderList() =
inherit LitElement()
static member properties =
PropertyDeclarations.create [
"steps", PropertyDeclaration<Step list>(state = true)
"dragging", PropertyDeclaration<Step option>(state = true)
"message", PropertyDeclaration<string>(state = true)
]
static member styles =
css
$$"""
:host { display: grid; gap: 0.5rem; justify-items: start; }
ol { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.35rem; width: 18rem; }
li { display: flex; align-items: center; gap: 0.5rem; padding: 0.35rem 0.5rem; cursor: grab;
border: 1px solid var(--border); border-radius: 0.5rem; background: var(--bg); }
li.dragging { opacity: 0.5; }
.text { flex: 1; }
button { font: inherit; padding: 0 0.45rem; }
p { margin: 0; }
"""
member val steps =
[ "Wake up"; "Make coffee"; "Read the news"; "Walk the dog"; "Start work" ]
|> List.mapi (fun i text -> { Id = i; Text = text })
with get, set
member val dragging: Step option = None with get, set
member val message = "" with get, set
member this.Move(step: Step, index: int) =
this.steps <- moveTo index step this.steps
let position = 1 + (this.steps |> List.findIndex (fun s -> s.Id = step.Id))
this.message <- $"{step.Text} moved to position {position} of {this.steps.Length}."
/// From the keyboard: move, then put the focus back on the button, as moving the item's
/// element takes the focus off it.
member this.Nudge(step: Step, index: int, button: string) =
this.Move(step, index)
promise {
let! _ = this.updateComplete
this.query<HTMLElement> $"#{button}-{step.Id}" |> Option.iter _.focus()
}
|> Promise.start
member this.StepView (step: Step) (index: int) =
let dragStart =
Ev.drag (fun e ->
this.dragging <- Some step
e.dataTransfer.effectAllowed <- "move"
// Firefox only starts a drag that carries some data.
e.dataTransfer.setData ("text/plain", step.Text) |> ignore
)
// Allowing a drop means cancelling dragover.
let dragOver = Ev.drag (fun e -> if this.dragging.IsSome then e.preventDefault ())
let drop =
Ev.drag (fun e ->
e.preventDefault ()
this.dragging |> Option.iter (fun dragged -> this.Move(dragged, index))
)
html
$"""
<li draggable="true" class={if this.dragging = Some step then "dragging" else ""}
@dragstart={dragStart} @dragover={dragOver} @drop={drop} @dragend={fun _ -> this.dragging <- None}>
<span class="text">{step.Text}</span>
<button id="up-{step.Id}" aria-label="Move {step.Text} up" aria-disabled={index = 0}
@click={fun _ -> if index > 0 then this.Nudge(step, index - 1, "up")}>↑</button>
<button id="down-{step.Id}" aria-label="Move {step.Text} down" aria-disabled={index = this.steps.Length - 1}
@click={fun _ -> if index < this.steps.Length - 1 then this.Nudge(step, index + 1, "down")}>↓</button>
</li>"""
override this.render() =
html
$"""
<ol aria-label="Morning routine">{repeat (this.steps, (fun s _ -> s.Id), this.StepView)}</ol>
<p role="status">{if this.message <> "" then html $"{this.message}" else nothing}</p>"""
defineElement<ReorderList> "my-reorder-list"
- Wake up
- Make coffee
- Read the news
- Walk the dog
- Start work
How it works
The order is an F# list, and moveTo is a plain function that returns the list with one step
moved. Dragging and the buttons both end in Move, which calls it.
- Dragging.
draggable="true"lets an item be picked up. Itsdragstartrecords which step is moving, anddrop, on the item it lands on, moves it to that item's place.Ev.dragtypes each handler's event as aDragEvent, soe.dataTransferneeds no cast. - Allowing the drop. An element only accepts a drop if its
dragoverhandler callspreventDefault, which is the browser's way of saying "drop here". Without it,dropnever fires. - Firefox. Firefox doesn't start a drag that carries no data, so
dragstartputs the step's text indataTransfer, though nothing reads it. - Keyed.
repeatkeys each item by its id, so each<li>moves with its step. WithList.map, each element would stay put and show another step's text. - The keyboard. Dragging needs a pointer, so each item also has ↑ and ↓ buttons, named "Move
Walk the dog up" and so on for screen readers. Moving an element in the DOM takes the focus off
the button inside it, so
Nudgeputs the focus back once the update has rendered. At the top or the bottom, a button isaria-disabledrather than disabled, so it keeps the focus. - Saying what happened. The
role="status"line announces the new position, for anyone who can't see the list move.
Touch screens don't send these drag events. For dragging on phones, listen for pointerdown,
pointermove and pointerup instead, or use a library built on them. The buttons work everywhere.
The Kanban demo drags cards between columns the same way, with the drag state in its Elmish model.
Related
- Events: Typed handlers with Ev, for
Ev.dragand the other event types. - Animating list changes, to slide the items to their new places.