Connection lost. Reconnecting… attempt 1 of 8
Paused. Your work is held on the server.
Could not reconnect.
This session has expired on the server.
An unhandled error has occurred.
Sedna.UI
v0.17.0 · main

Drag and drop

tier 2 — classes

Reordering a list, carrying a card to another lane, rearranging tiles — by mouse, by a finger held still, and by the keyboard. The markup and the list are the app's: data-drag-* attributes hand the input to the script, and the move arrives as a sedna-drop event. Not files: a file dropped from the desktop is the dropzone.

The script never moves a node A drag writes attributes on the app's own elements — the lifted item, the zone states, the insertion line — and clears them before it says what happened. The app moves the item in its own list and renders. In Blazor that is @onsedna-drop and a remove and an insert; @using Sedna.UI in _Imports.razor is what makes the attribute known. Every example below is a component doing exactly that.

Reorder a list

Live — drag a handle, or focus one and press Space. data-drag-zone on the list, data-drag-item on each row, and data-drag-handle on the grip, which makes the grip the only place a row drags from — the rest of the row keeps its text selectable and its controls clickable. A row with aria-disabled="true" stays put, and others can still be dropped around it.

Index counts the list without the item, so the handler is a remove and an insert. A drop back where the row started sends no event at all.

Press Space to pick up a step, the arrow keys to move it, and Space again to drop it.

  1. Freeze deploys to orders-console-01
  2. Back up src-db-14
  3. Run the schema migration
  4. Route five percent of traffic to the canary
  5. Promote the release
<p class="visually-hidden" aria-live="assertive" data-drag-live
   data-drag-pickup="Picked up {item}. Arrow keys move it, Space drops it, Escape cancels."
   data-drag-move="{item}, position {position} of {count}."
   data-drag-drop="{item} dropped at position {position} of {count}."
   data-drag-cancel="Cancelled. {item} is back at position {position}."></p>
<p class="visually-hidden" id="release-steps-help">
    Press Space to pick up a step, the arrow keys to move it, and Space again to drop it.
</p>

<ol class="drag-list" data-drag-zone="release-steps" aria-label="Release steps" @onsedna-drop="Drop">
    @foreach (var step in _steps)
    {
        <li class="drag-item" @key="step.Id" data-drag-item="@step.Id" data-drag-label="@step.Title"
            aria-disabled="@(step.Locked ? "true" : null)">
            <button class="drag-handle" type="button" data-drag-handle
                    aria-label="Move @step.Title" aria-describedby="release-steps-help">
                <i class="@(step.Locked ? "ri-lock-2-line" : "ri-draggable")" aria-hidden="true"></i>
            </button>
            <span>@step.Title</span>
        </li>
    }
</ol>

@code {
    private sealed record Step(string Id, string Title, bool Locked = false);

    private readonly List<Step> _steps =
    [
        new("freeze", "Freeze deploys to orders-console-01", Locked: true),
        new("backup", "Back up src-db-14"),
        new("migrate", "Run the schema migration"),
        new("canary", "Route five percent of traffic to the canary"),
        new("promote", "Promote the release"),
    ];

    // The list is the app's. The script says which item went where; this moves it and the
    // render puts the rows in the new order. Index counts the list without the item.
    private void Drop(SednaDropEventArgs e)
    {
        var step = _steps.Find(s => s.Id == e.Item);
        if (step is null) return;
        _steps.Remove(step);
        _steps.Insert(Math.Min(e.Index, _steps.Count), step);
    }
}

Move cards between lanes

Live. Zones that share a data-drag-group trade items; a zone with no group only reorders its own. The event bubbles from the zone the card landed in, so one handler on .drag-board hears every lane, and From and To name them.

A whole card drags, so on a phone it lifts after the same press-and-hold as a hover hint, and a finger that moves first scrolls the page as usual. From the keyboard, the arrows across the lane move the card to the next one. .drag-empty is written once in every lane and shows only while the lane has no cards.

Queued 3

  • ORD-4204 Reserve stock for the spring range
  • ORD-4209 Confirm the courier pickup
  • ORD-4187 Refund the damaged delivery
  • Nothing here. Drop a card to move it in.

In progress 2

  • ORD-4211 Re-print the customs label
  • ORD-4190 Awaiting sign-off from finance
  • Nothing here. Drop a card to move it in.

Done 0

  • Nothing here. Drop a card to move it in.
<p class="visually-hidden" aria-live="assertive" data-drag-live
   data-drag-pickup="Picked up {item}. Up and down move it, left and right change lane, Space drops it."
   data-drag-move="{item}: {zone}, position {position} of {count}."
   data-drag-drop="{item} dropped in {zone} at position {position}."
   data-drag-cancel="Cancelled. {item} is back in {zone}."></p>

<div class="drag-board" @onsedna-drop="Drop">
    @foreach (var (id, title) in Lanes)
    {
        <section class="drag-lane" aria-labelledby="lane-@id">
            <h3 class="drag-lane-head" id="lane-@id">
                @title <span class="badge">@_lanes[id].Count</span>
            </h3>
            <ul class="drag-list" data-drag-zone="@id" data-drag-group="board" data-drag-label="@title">
                @foreach (var card in _lanes[id])
                {
                    <li class="drag-item" @key="card.Id" data-drag-item="@card.Id" data-drag-label="@card.Title"
                        tabindex="0" aria-disabled="@(card.Locked ? "true" : null)">
                        <span class="text-mono text-muted text-nowrap">@card.Id</span>
                        <span>@card.Title</span>
                    </li>
                }
                <li class="drag-empty">Nothing here. Drop a card to move it in.</li>
            </ul>
        </section>
    }
</div>

@code {
    private sealed record Card(string Id, string Title, bool Locked = false);

    private static readonly (string Id, string Title)[] Lanes =
    [
        ("queued", "Queued"),
        ("active", "In progress"),
        ("done", "Done"),
    ];

    private readonly Dictionary<string, List<Card>> _lanes = new()
    {
        ["queued"] =
        [
            new("ORD-4204", "Reserve stock for the spring range"),
            new("ORD-4209", "Confirm the courier pickup"),
            new("ORD-4187", "Refund the damaged delivery"),
        ],
        ["active"] =
        [
            new("ORD-4211", "Re-print the customs label"),
            new("ORD-4190", "Awaiting sign-off from finance", Locked: true),
        ],
        ["done"] = [],
    };

    // One handler on the board hears every lane, because the event bubbles from the zone it
    // landed in. From and To name the lanes; a reorder inside one lane has both the same.
    private void Drop(SednaDropEventArgs e)
    {
        var from = _lanes[e.From];
        var card = from.Find(c => c.Id == e.Item);
        if (card is null) return;

        from.Remove(card);
        var to = _lanes[e.To];
        to.Insert(Math.Min(e.Index, to.Count), card);
    }
}

Reorder a grid of tiles

Live. .drag-grid with data-drag-axis="grid": positions run in reading order, so Index is a position in one flat list whatever the column count at this width, and the insertion line stands up beside the tile. In a grid the arrow keys move by one tile across and by one row up and down. data-drag-axis="x" is the same for a single row.

  • Orders
  • Revenue
  • Channel share
  • Sites
  • Rota
  • Team
  • Health
<p class="visually-hidden" aria-live="assertive" data-drag-live
   data-drag-pickup="Picked up {item}. Arrow keys move it, Space drops it, Escape cancels."
   data-drag-move="{item}, tile {position} of {count}."
   data-drag-drop="{item} dropped at tile {position} of {count}."
   data-drag-cancel="Cancelled. {item} is back at tile {position}."></p>

<ul class="drag-grid" data-drag-zone="dashboard" data-drag-axis="grid" aria-label="Dashboard tiles"
    @onsedna-drop="Drop">
    @foreach (var tile in _tiles)
    {
        <li class="drag-item" @key="tile.Id" data-drag-item="@tile.Id" data-drag-label="@tile.Title" tabindex="0">
            <i class="@tile.Icon" aria-hidden="true"></i>
            <span>@tile.Title</span>
        </li>
    }
</ul>

@code {
    private sealed record Tile(string Id, string Title, string Icon);

    private readonly List<Tile> _tiles =
    [
        new("orders", "Orders", "ri-inbox-line"),
        new("revenue", "Revenue", "ri-line-chart-line"),
        new("share", "Channel share", "ri-pie-chart-line"),
        new("sites", "Sites", "ri-map-pin-line"),
        new("rota", "Rota", "ri-calendar-line"),
        new("team", "Team", "ri-team-line"),
        new("health", "Health", "ri-dashboard-line"),
    ];

    // data-drag-axis="grid" counts tiles in reading order, so Index is the same position
    // in this flat list whatever the column count at the reader's width.
    private void Drop(SednaDropEventArgs e)
    {
        var tile = _tiles.Find(t => t.Id == e.Item);
        if (tile is null) return;
        _tiles.Remove(tile);
        _tiles.Insert(Math.Min(e.Index, _tiles.Count), tile);
    }
}

Carry a selection

Live — tick a few photos, then drag one of them. An item taken hold of while it is selected carries the rest of its zone's selection with it. Selected is what the markup already says: a checked input[data-drag-select] inside the item, which is a tile's own .file-tile-check, or aria-selected="true" on an item whose role allows it, such as a table row or an option. An item that is not selected travels alone.

Items lists every id carried, in document order. Index counts the destination without any of them, so the handler removes them all and inserts the run there. The others fade in place, marked data-drag-carried, and the one in hand shows the count from data-drag-count. {items} gives the count to an announcement. A zone takes the selection only if it takes every item in it.

From earlier issues 5

  • open-day-group.jpg 4.1 MB
  • new-racking-bay-4.jpg 3.4 MB
  • forklift-training.jpg 2.2 MB
  • crates-on-the-floor.jpg 2.9 MB
  • team-summer-lunch.jpg 3.8 MB
  • Nothing here yet. Tick photos above and drag one of them in.

In this issue 1

  • cover-autumn.jpg 1.6 MB
  • Nothing here yet. Tick photos above and drag one of them in.
<p class="visually-hidden" aria-live="assertive" data-drag-live
   data-drag-pickup="Picked up {items}. Arrow keys move, Space drops, Escape cancels."
   data-drag-move="{zone}, position {position} of {count}."
   data-drag-drop="{items} dropped in {zone} at position {position}."
   data-drag-cancel="Cancelled. Nothing moved."></p>

<div class="sedna-col sedna-gap-2" @onsedna-drop="Drop">
    @foreach (var (id, title) in Zones)
    {
        <section class="sedna-col sedna-gap-1" aria-labelledby="pick-@id">
            <h3 class="drag-lane-head" id="pick-@id">@title <span class="badge">@_zones[id].Count</span></h3>
            <ul class="file-grid" data-drag-zone="@id" data-drag-group="photos" data-drag-axis="grid"
                data-drag-label="@title" style="--drop-gap: var(--space-6)">
                @foreach (var photo in _zones[id])
                {
                    <li class="file-tile" @key="photo.Id" data-drag-item="@photo.Id" data-drag-label="@photo.Name" tabindex="0">
                        <label class="form-check file-tile-check">
                            <input type="checkbox" data-drag-select aria-label="Select @photo.Name"
                                   checked="@_picked.Contains(photo.Id)" @onchange="e => Toggle(photo.Id, e)" />
                        </label>
                        <span class="file-tile-thumb"><span class="file-icon file-icon--image"><i class="ri-image-line"></i></span></span>
                        <span class="file-name">@photo.Name</span>
                        <span class="file-meta"><span>@photo.Size</span></span>
                    </li>
                }
                <li class="drag-empty">Nothing here yet. Tick photos above and drag one of them in.</li>
            </ul>
        </section>
    }
</div>

@code {
    private sealed record Photo(string Id, string Name, string Size);

    private static readonly (string Id, string Title)[] Zones =
    [
        ("earlier", "From earlier issues"),
        ("issue", "In this issue"),
    ];

    private readonly Dictionary<string, List<Photo>> _zones = new()
    {
        ["earlier"] =
        [
            new("p1", "open-day-group.jpg", "4.1 MB"),
            new("p2", "new-racking-bay-4.jpg", "3.4 MB"),
            new("p3", "forklift-training.jpg", "2.2 MB"),
            new("p4", "crates-on-the-floor.jpg", "2.9 MB"),
            new("p5", "team-summer-lunch.jpg", "3.8 MB"),
        ],
        ["issue"] = [new("p6", "cover-autumn.jpg", "1.6 MB")],
    };

    private readonly HashSet<string> _picked = [];

    private void Toggle(string id, ChangeEventArgs e)
    {
        if (e.Value is true) _picked.Add(id);
        else _picked.Remove(id);
    }

    // Items is every photo carried: the ticked ones when the one taken hold of was ticked,
    // otherwise that one alone. Index counts the destination without any of them, so the
    // handler is still a remove and an insert — of a run instead of one.
    private void Drop(SednaDropEventArgs e)
    {
        var from = _zones[e.From];
        var to = _zones[e.To];
        var moving = e.Items.Select(id => from.Find(p => p.Id == id)).OfType<Photo>().ToList();
        foreach (var photo in moving) from.Remove(photo);
        to.InsertRange(Math.Min(e.Index, to.Count), moving);
        _picked.ExceptWith(e.Items);
    }
}

A nested zone that takes only some items

Live — carry a stage over another stage's tasks. data-drag-accept names the data-drag-types a zone takes. The stages take only stages and each task list only tasks, all in one group, so a task moves between stages while a stage is refused by every task list — drawn dotted, and red under the pointer — and lands in the list of stages around it instead. No item is ever offered a zone inside itself.

The stage is a .card, not a .drag-item: every state is keyed on the attributes, so any element can be the thing that is dragged. An empty data-drag-accept="" takes nothing, which is a zone items are dragged out of only.

  • Build

    2
    • Compile
    • Run the tests
    • No tasks in this stage.
  • Ship

    2
    • Package
    • Sign the package
    • No tasks in this stage.
  • Verify

    0
    • No tasks in this stage.
<p class="visually-hidden" aria-live="assertive" data-drag-live
   data-drag-pickup="Picked up {item}. Arrow keys move it, Space drops it, Escape cancels."
   data-drag-move="{item}: {zone}, position {position} of {count}."
   data-drag-drop="{item} dropped in {zone} at position {position}."
   data-drag-cancel="Cancelled. {item} is back in {zone}."></p>

<ul class="drag-list" data-drag-zone="stages" data-drag-group="plan" data-drag-accept="stage"
    data-drag-label="Stages" @onsedna-drop="Drop">
    @foreach (var stage in _stages)
    {
        <li class="card" @key="stage.Id" data-drag-item="@stage.Id" data-drag-type="stage" data-drag-label="@stage.Title">
            <div class="card-head">
                <div class="sedna-row">
                    <button class="drag-handle" type="button" data-drag-handle aria-label="Move stage @stage.Title">
                        <i class="ri-draggable" aria-hidden="true"></i>
                    </button>
                    <h3>@stage.Title</h3>
                </div>
                <span class="badge">@stage.Steps.Count</span>
            </div>
            <div class="card-body">
                <ul class="drag-list" data-drag-zone="@stage.Id" data-drag-group="plan" data-drag-accept="task"
                    data-drag-label="@stage.Title">
                    @foreach (var step in stage.Steps)
                    {
                        <li class="drag-item" @key="step.Id" data-drag-item="@step.Id" data-drag-type="task"
                            data-drag-label="@step.Title" tabindex="0">@step.Title</li>
                    }
                    <li class="drag-empty">No tasks in this stage.</li>
                </ul>
            </div>
        </li>
    }
</ul>

@code {
    private sealed record Step(string Id, string Title);
    private sealed record Stage(string Id, string Title, List<Step> Steps);

    private readonly List<Stage> _stages =
    [
        new("build", "Build", [new("compile", "Compile"), new("test", "Run the tests")]),
        new("ship", "Ship", [new("package", "Package"), new("sign", "Sign the package")]),
        new("verify", "Verify", []),
    ];

    // One zone of stages that takes only stages, and one zone of tasks per stage that takes
    // only tasks — all in one group, so a task moves between stages. A stage carried over a
    // task list is refused there and lands in the list of stages around it.
    private void Drop(SednaDropEventArgs e)
    {
        if (e.Type == "stage")
        {
            var stage = _stages.Find(s => s.Id == e.Item);
            if (stage is null) return;
            _stages.Remove(stage);
            _stages.Insert(Math.Min(e.Index, _stages.Count), stage);
            return;
        }

        var from = _stages.Find(s => s.Id == e.From)?.Steps;
        var to = _stages.Find(s => s.Id == e.To)?.Steps;
        var step = from?.Find(t => t.Id == e.Item);
        if (to is null || step is null) return;
        from!.Remove(step);
        to.Insert(Math.Min(e.Index, to.Count), step);
    }
}

Scrolling while dragging

Live — hold a row against the bottom edge. Near the edge of anything that scrolls, a pointer drag scrolls it, faster the closer it gets — this .sedna-scroll, a .drag-board sideways, and the page itself. It waits until the pointer has moved, so a row picked up near the edge does not set the list running. From the keyboard, the target row is scrolled into view instead.

  1. ORD-4101
  2. ORD-4102
  3. ORD-4103
  4. ORD-4104
  5. ORD-4105
  6. ORD-4106
  7. ORD-4107
  8. ORD-4108
  9. ORD-4109
  10. ORD-4110
  11. ORD-4111
  12. ORD-4112
  13. ORD-4113
  14. ORD-4114
  15. ORD-4115
  16. ORD-4116
  17. ORD-4117
  18. ORD-4118
  19. ORD-4119
  20. ORD-4120
  21. ORD-4121
  22. ORD-4122
  23. ORD-4123
  24. ORD-4124
  25. ORD-4125
  26. ORD-4126
  27. ORD-4127
  28. ORD-4128
  29. ORD-4129
  30. ORD-4130
<p class="visually-hidden" aria-live="assertive" data-drag-live
   data-drag-pickup="Picked up {item}. Arrow keys move it, Space drops it, Escape cancels."
   data-drag-move="{item}, position {position} of {count}."
   data-drag-drop="{item} dropped at position {position} of {count}."
   data-drag-cancel="Cancelled. {item} is back at position {position}."></p>

<div class="sedna-scroll sedna-scroll--sm">
    <ol class="drag-list" data-drag-zone="pick-queue" aria-label="Pick queue" @onsedna-drop="Drop">
        @foreach (var order in _orders)
        {
            <li class="drag-item" @key="order" data-drag-item="@order" tabindex="0">
                <span class="text-mono">@order</span>
            </li>
        }
    </ol>
</div>

@code {
    private readonly List<string> _orders = Enumerable.Range(4101, 30).Select(n => $"ORD-{n}").ToList();

    // Nothing here knows about scrolling: holding a dragged row near the top or bottom edge
    // of the .sedna-scroll scrolls it, and Index is counted where the list has scrolled to.
    private void Drop(SednaDropEventArgs e)
    {
        if (!_orders.Remove(e.Item)) return;
        _orders.Insert(Math.Min(e.Index, _orders.Count), e.Item);
    }
}

The states

Every state frozen, to see them side by side. The script writes all of them during a drag: data-dragging on the item, data-drop-state on every zone the item could reach, data-drop-over on the one under the pointer, and data-drop-edge on the item the line is drawn against. Dashed, solid and dotted outlines carry the difference as well as the colour.

The line sits in the middle of the gap between items, which it reads from --drop-gap. .drag-list and .drag-grid set it; a zone of your own with a different gap sets it too.

Items
  • Whole row drags
  • Drags from its handle
  • Cannot be moved
  • The line: a drop lands above this row
  • Picked up with Space
  • Carried by the pointer
Zones
  • Can take it
  • Can take it, and the pointer is over it
  • Refuses it
  • Refuses it, and the pointer is over it
  • Empty. Drop a card to move it in.
<div class="sedna-grid sedna-grid--sm sedna-grid--fit">
    <div class="sedna-col sedna-gap-2">
        <span class="form-label">Items</span>
        <ul class="drag-list">
            <li class="drag-item"><span>Whole row drags</span></li>
            <li class="drag-item">
                <span class="drag-handle" aria-hidden="true"><i class="ri-draggable"></i></span>
                <span>Drags from its handle</span>
            </li>
            <li class="drag-item" aria-disabled="true">
                <span class="drag-handle" aria-hidden="true"><i class="ri-lock-2-line"></i></span>
                <span>Cannot be moved</span>
            </li>
            <li class="drag-item" data-drop-edge="before"><span>The line: a drop lands above this row</span></li>
            <li class="drag-item" data-dragging="keyboard"><span>Picked up with Space</span></li>
            <li class="drag-item" data-dragging="pointer"><span>Carried by the pointer</span></li>
        </ul>
    </div>

    <div class="sedna-col sedna-gap-2">
        <span class="form-label">Zones</span>
        <ul class="drag-list" data-drop-state="valid">
            <li class="drag-item">Can take it</li>
        </ul>
        <ul class="drag-list" data-drop-state="valid" data-drop-over>
            <li class="drag-item">Can take it, and the pointer is over it</li>
        </ul>
        <ul class="drag-list" data-drop-state="invalid">
            <li class="drag-item">Refuses it</li>
        </ul>
        <ul class="drag-list" data-drop-state="invalid" data-drop-over>
            <li class="drag-item">Refuses it, and the pointer is over it</li>
        </ul>
        <ul class="drag-list" data-drag-zone="states-empty">
            <li class="drag-empty">Empty. Drop a card to move it in.</li>
        </ul>
    </div>
</div>

Without Blazor

The events are ordinary bubbling DOM events, so a page with no Blazor on it listens with addEventListener. sedna-dragstart is cancelable: calling preventDefault() keeps the item where it is.

<ol class="drag-list" id="release-steps" data-drag-zone="release-steps" aria-label="Release steps">
    <li class="drag-item" data-drag-item="backup" tabindex="0">Back up src-db-14</li>
    <li class="drag-item" data-drag-item="migrate" tabindex="0">Run the schema migration</li>
    <li class="drag-item" data-drag-item="promote" tabindex="0">Promote the release</li>
</ol>

<script>
    // The script says what moved; the page moves it. Index counts the destination
    // without the item, so a reorder and a move between zones are the same two lines.
    document.getElementById('release-steps').addEventListener('sedna-drop', function (e) {
        var move = e.detail;   // { item, type, from, to, index, fromIndex, keyboard }
        var list = steps[move.from];
        var step = list.splice(list.indexOf(move.item), 1)[0];
        steps[move.to].splice(move.index, 0, step);
        render();              // the page's own render, from its own state
    });
</script>

Attributes

AttributeOnDoes
data-drag-zone="id"a listIts direct children with data-drag-item are its items. Anything else inside it is not counted.
data-drag-item="id"an itemCan be dragged. The id is what the events carry.
data-drag-handleinside an itemThe item drags from here and nowhere else. Lifts at once under a finger.
data-drag-group="name"a zoneZones sharing a group trade items.
data-drag-type="name"an itemWhat data-drag-accept matches.
data-drag-accept="a b"a zoneThe only types it takes. Empty takes nothing.
data-drag-axisa zoney by default, x for a row, grid for tiles.
data-drag-labelan item or a zoneIts name in an announcement. An item falls back to its text, a zone to its aria-label.
aria-disabled="true"an item or a zoneNothing lifts from it or lands in it.
data-drag-livea live regionWhere a keyboard drag is announced, in the sentences below.

Events

EventOnDetail
sedna-dragstartthe itemitem, type, zone, index, keyboard. Cancelable.
sedna-dropthe zone it landed initem, type, from, to, index, fromIndex, keyboard. Only when the item moved.
sedna-dragendthe itemAs sedna-dragstart, plus dropped. Always, last.

In C# they are SednaDragEventArgs and SednaDropEventArgs. sednaUi.drag.cancel() puts back an item mid-drag, for a zone about to be torn down.

Keyboard

KeyDoes
Space EnterOn a focused item, or on its handle: picks it up. While held: drops it.
Moves it along a list, or by a row in a grid.
Moves it to the previous or next zone that takes it; along a row or a grid.
Home EndTo the first or last position.
Page Up Page DownTo the previous or next zone, in any layout.
Esc, or leaving the itemPuts it back.

Each step is read out through the app's own [data-drag-live] region, from the sentence in its data-drag-pickup, data-drag-move, data-drag-drop or data-drag-cancel, with {item}, {zone}, {position} and {count} filled in. A step with no sentence says nothing. Once the app has moved the item, focus returns to it.