Current section
Files
Jump to
Current section
Files
src/groups.gleam
import gleam/dynamic/decode
import gleam/float
import gleam/list
import gleam/option.{type Option, None, Some}
import lustre/attribute.{type Attribute}
import lustre/event
// TYPES -----------------------------------------------------------------------
pub type Position {
Position(x: Float, y: Float)
}
/// Determines when list modifications should be applied during drag operations.
///
/// This controls the timing of when your list gets updated - either continuously
/// while dragging for real-time visual feedback, or only when the item is dropped
/// for final placement. In the groups module, this applies to both same-group
/// and cross-group operations independently.
///
/// ## Variants
///
/// - `OnDrag`: Apply list changes immediately as the item is dragged over drop targets.
/// Provides real-time visual feedback but triggers more frequent updates.
/// - `OnDrop`: Apply list changes only when the drag operation completes.
/// More performant for complex lists but less visual feedback during dragging.
///
/// ## Group Context
///
/// You can configure different listen modes for same-group vs cross-group operations:
/// - Main `listen`: Controls when same-group operations are applied
/// - `groups.listen`: Controls when cross-group operations are applied
///
/// ## Example
///
/// ```gleam
/// let config = groups.Config(
/// listen: groups.OnDrag, // Same-group: immediate updates
/// groups: groups.GroupsConfig(
/// listen: groups.OnDrop, // Cross-group: only on drop
/// // ... other group options
/// ),
/// // ... other options
/// )
/// ```
pub type Listen {
OnDrag
OnDrop
}
/// Defines how the list should be modified when an item is dragged and dropped.
///
/// Each operation provides a different way of rearranging items in the list,
/// affecting how the dragged item and other items are repositioned. In the context
/// of groups, operations work differently for same-group vs cross-group transfers.
///
/// ## Variants
///
/// - `InsertAfter`: Move the dragged item to the position immediately after the drop target
/// - `InsertBefore`: Move the dragged item to the position immediately before the drop target
/// - `Rotate`: Perform a circular shift of all items between the drag and drop positions (includes both endpoints)
/// - `Swap`: Exchange the positions of the dragged item and the drop target item
/// - `Unaltered`: Do not modify the list (useful for custom logic or visual feedback only)
///
/// ## Group Behavior
///
/// For **same-group** operations, this behaves identically to the basic dnd module.
/// For **cross-group** operations, the dragged item's group membership is updated using
/// the `setter` function before applying the positional change.
///
/// ## Examples
///
/// Given items with groups `[A₁, B₁, C₂, D₂, E₁]` (subscript = group) and dragging `C₂` to `E₁`:
///
/// - `InsertAfter`: `[A₁, B₁, D₂, E₁, C₁]` - C changes to group 1 and moves after E
/// - `InsertBefore`: `[A₁, B₁, D₂, C₁, E₁]` - C changes to group 1 and moves before E
/// - `Rotate`: Group-aware rotation with group membership updates
/// - `Swap`: `[A₁, B₁, E₂, D₂, C₁]` - C and E exchange both positions and groups
pub type Operation {
InsertAfter
InsertBefore
Rotate
Swap
Unaltered
}
/// Configuration for cross-group drag-and-drop operations.
///
/// This type defines how items should behave when dragged between different logical groups.
/// It works alongside the main Config to provide different behaviors for same-group vs
/// cross-group transfers.
///
/// ## Fields
///
/// - `listen`: When to apply cross-group changes (OnDrag for real-time, OnDrop for final placement)
/// - `operation`: What operation to perform when transferring between groups (InsertAfter, InsertBefore, Rotate, Swap, or Unaltered)
/// - `comparator`: Function to determine if two items belong to the same group. Return `True` if items are in the same group.
/// - `setter`: Function to update the dragged item when it's moved to a different group. Receives (target_item, drag_item) and returns the updated drag_item.
///
/// ## Example
///
/// ```gleam
/// type Group { Left | Right }
/// type Item { Item(group: Group, value: String) }
///
/// let groups_config = groups.GroupsConfig(
/// listen: groups.OnDrag,
/// operation: groups.InsertBefore,
/// comparator: fn(a, b) { a.group == b.group }, // Same group if group field matches
/// setter: fn(target, drag) { Item(..drag, group: target.group) }, // Update group membership
/// )
/// ```
pub type GroupsConfig(a) {
GroupsConfig(
listen: Listen,
operation: Operation,
comparator: fn(a, a) -> Bool,
setter: fn(a, a) -> a,
)
}
/// Configuration for group-aware drag-and-drop behavior.
///
/// This extends the basic drag-and-drop configuration to support items organized in logical groups.
/// It defines separate behaviors for same-group operations (using the main config) and
/// cross-group operations (using the groups config).
///
/// ## Fields
///
/// - `before_update`: A callback function that allows you to modify the list before any operation is applied
/// - `listen`: When to apply same-group changes (OnDrag for real-time, OnDrop for final placement)
/// - `operation`: What operation to perform for same-group transfers
/// - `groups`: Configuration for cross-group operations (see GroupsConfig)
///
/// ## Behavior
///
/// - When dragging within the same group: uses `listen` and `operation`
/// - When dragging between different groups: uses `groups.listen` and `groups.operation`
/// - The `groups.comparator` determines whether items are in the same group
/// - The `groups.setter` updates the item when it changes groups
///
/// ## Example
///
/// ```gleam
/// let config = groups.Config(
/// before_update: fn(_, _, list) { list },
/// listen: groups.OnDrag, // Same-group: update while dragging
/// operation: groups.Rotate, // Same-group: rotate items
/// groups: groups.GroupsConfig(
/// listen: groups.OnDrag, // Cross-group: update while dragging
/// operation: groups.InsertBefore, // Cross-group: insert before target
/// comparator: fn(a, b) { a.group == b.group },
/// setter: fn(target, drag) { Item(..drag, group: target.group) },
/// ),
/// )
/// ```
pub type Config(a) {
Config(
before_update: fn(Int, Int, List(a)) -> List(a),
listen: Listen,
operation: Operation,
groups: GroupsConfig(a),
)
}
pub type State {
State(
drag_index: Int,
drop_index: Int,
drag_counter: Int,
start_position: Position,
current_position: Position,
drag_element_id: String,
drop_element_id: String,
)
}
pub type Model {
Model(Option(State))
}
/// Information about the current drag operation when an item is being dragged.
///
/// This type provides access to all relevant details about an active drag operation,
/// including positions, indices, and element identifiers. In the groups context,
/// this information applies regardless of whether the drag is within the same group
/// or between different groups.
///
/// ## Fields
///
/// - `drag_index`: List index of the item being dragged (original position)
/// - `drop_index`: List index of the current drop target (where it would be placed)
/// - `drag_element_id`: DOM element ID of the dragged item
/// - `drop_element_id`: DOM element ID of the current drop target
/// - `start_position`: Mouse coordinates where the drag began
/// - `current_position`: Current mouse coordinates during drag
///
/// ## Group Operations
///
/// The indices provided here are absolute positions in the full list, regardless
/// of group membership. The system internally handles group logic using the
/// comparator function to determine same-group vs cross-group behavior.
///
/// ## Usage
///
/// ```gleam
/// // Check if currently dragging and get info
/// case model.system.info(model.system.model) {
/// Some(info) -> {
/// // Currently dragging - can access both drag and drop information
/// let drag_item = list.drop(model.items, info.drag_index) |> list.take(1)
/// let drop_item = list.drop(model.items, info.drop_index) |> list.take(1)
/// // Use for group-aware styling, validation, etc.
/// }
/// None -> {
/// // Not currently dragging
/// }
/// }
/// ```
///
/// ## Availability
///
/// This information is only available during active drag operations (returns `Some(Info)`).
/// When no drag is in progress, `system.info()` returns `None`.
pub type Info {
Info(
drag_index: Int,
drop_index: Int,
drag_element_id: String,
drop_element_id: String,
start_position: Position,
current_position: Position,
)
}
/// The group-aware drag-and-drop system containing all necessary functions and state.
///
/// This type extends the basic drag-and-drop system to support items organized in logical groups.
/// It provides intelligent behavior for same-group vs cross-group operations.
/// Create it using `groups.create()` and integrate it into your Lustre application.
///
/// ## Type Parameters
///
/// - `a`: The type of items in your draggable list (must support group operations)
/// - `msg`: Your application's message type
///
/// ## Fields
///
/// - `model`: Internal drag state (opaque - use `info()` to access current state)
/// - `update`: Function to handle drag messages with group-aware logic for both same-group and cross-group operations
/// - `drag_events`: Function to generate mouse event attributes for draggable elements
/// - `drop_events`: Function to generate mouse event attributes for drop targets
/// - `ghost_styles`: Function to generate CSS styling for the ghost element
/// - `info`: Function to extract current drag information (positions, indices, etc.)
///
/// ## Group Behavior
///
/// The system automatically detects whether a drag operation is within the same group
/// or between different groups, and applies the appropriate configuration:
/// - Same group: Uses main `operation` and `listen` settings
/// - Cross group: Uses `groups.operation` and `groups.listen` settings, plus `setter` to update group membership
///
/// ## Usage
///
/// ```gleam
/// // In your update function:
/// DndMsg(dnd_msg) -> {
/// let #(new_dnd, new_items) =
/// model.system.update(dnd_msg, model.system.model, model.items)
/// let updated_system = groups.System(..model.system, model: new_dnd)
/// Model(system: updated_system, items: new_items)
/// }
///
/// // In your view function:
/// html.div(
/// [..model.system.drag_events(index, element_id)],
/// [html.text(item.value)]
/// )
/// ```
pub type System(a, msg) {
System(
model: Model,
update: fn(DndMsg, Model, List(a)) -> #(Model, List(a)),
drag_events: fn(Int, String) -> List(Attribute(msg)),
drop_events: fn(Int, String) -> List(Attribute(msg)),
ghost_styles: fn(Model) -> List(Attribute(msg)),
info: fn(Model) -> Option(Info),
)
}
pub type DndMsg {
DragStart(Int, String, Position)
Drag(Position)
DragOver(Int, String)
DragEnter(Int)
DragLeave
DragEnd
}
// SYSTEM CREATION -------------------------------------------------------------
pub fn create(config: Config(a), step_msg: fn(DndMsg) -> msg) -> System(a, msg) {
System(
model: Model(None),
update: system_update(config),
drag_events: drag_events(step_msg),
drop_events: drop_events(step_msg),
ghost_styles: ghost_styles(),
info: get_info,
)
}
// SYSTEM UPDATE ---------------------------------------------------------------
fn system_update(
config: Config(a),
) -> fn(DndMsg, Model, List(a)) -> #(Model, List(a)) {
fn(msg: DndMsg, model: Model, list: List(a)) -> #(Model, List(a)) {
case msg {
DragStart(drag_index, drag_element_id, position) -> {
let new_state =
State(
drag_index: drag_index,
drop_index: drag_index,
drag_counter: 0,
start_position: position,
current_position: position,
drag_element_id: drag_element_id,
drop_element_id: drag_element_id,
)
#(Model(Some(new_state)), list)
}
Drag(position) -> {
case model {
Model(Some(state)) -> {
let updated_state =
State(
..state,
current_position: position,
drag_counter: state.drag_counter + 1,
)
#(Model(Some(updated_state)), list)
}
Model(None) -> #(model, list)
}
}
DragOver(drop_index, drop_element_id) -> {
case model {
Model(Some(state)) -> {
let updated_state =
State(
..state,
drop_index: drop_index,
drop_element_id: drop_element_id,
)
#(Model(Some(updated_state)), list)
}
Model(None) -> #(model, list)
}
}
DragEnter(drop_index) -> {
case model {
Model(Some(state)) -> {
case state.drag_counter > 1 && state.drag_index != drop_index {
True -> {
let equal_groups =
groups_equal(
config.groups.comparator,
state.drag_index,
drop_index,
list,
)
case equal_groups, config.listen, config.groups.listen {
// Same group operations
True, OnDrag, _ -> {
let updated_state =
update_state_for_operation(
config.operation,
drop_index,
state,
)
let updated_list =
sublist_update(
config.operation,
state.drag_index,
drop_index,
list,
)
#(
Model(Some(updated_state)),
config.before_update(
state.drag_index,
drop_index,
updated_list,
),
)
}
// Different group operations
False, _, OnDrag -> {
let updated_state =
update_state_for_operation(
config.groups.operation,
drop_index,
state,
)
let updated_list =
groups_list_update(
config.groups.operation,
config.groups.comparator,
config.groups.setter,
state.drag_index,
drop_index,
list,
)
#(
Model(Some(updated_state)),
config.before_update(
state.drag_index,
drop_index,
updated_list,
),
)
}
// Reset drag counter for other cases
_, _, _ -> {
let updated_state = State(..state, drag_counter: 0)
#(Model(Some(updated_state)), list)
}
}
}
False -> #(model, list)
}
}
Model(None) -> #(model, list)
}
}
DragLeave -> {
case model {
Model(Some(state)) -> {
let updated_state = State(..state, drop_index: state.drag_index)
#(Model(Some(updated_state)), list)
}
Model(None) -> #(model, list)
}
}
DragEnd -> {
case model {
Model(Some(state)) -> {
case state.drag_index != state.drop_index {
True -> {
let equal_groups =
groups_equal(
config.groups.comparator,
state.drag_index,
state.drop_index,
list,
)
case equal_groups, config.listen, config.groups.listen {
// Same group operations
True, OnDrop, _ -> {
let updated_list =
sublist_update(
config.operation,
state.drag_index,
state.drop_index,
list,
)
#(
Model(None),
config.before_update(
state.drag_index,
state.drop_index,
updated_list,
),
)
}
// Different group operations
False, _, OnDrop -> {
let updated_list =
groups_list_update(
config.groups.operation,
config.groups.comparator,
config.groups.setter,
state.drag_index,
state.drop_index,
list,
)
#(
Model(None),
config.before_update(
state.drag_index,
state.drop_index,
updated_list,
),
)
}
// No operation for other cases
_, _, _ -> #(Model(None), list)
}
}
False -> #(Model(None), list)
}
}
Model(None) -> #(Model(None), list)
}
}
}
}
}
// GROUP HELPERS ---------------------------------------------------------------
fn groups_equal(
comparator: fn(a, a) -> Bool,
drag_index: Int,
drop_index: Int,
list: List(a),
) -> Bool {
let drag_items = get_items_at_index(drag_index, list)
let drop_items = get_items_at_index(drop_index, list)
case drag_items, drop_items {
[drag_item], [drop_item] -> comparator(drag_item, drop_item)
_, _ -> False
}
}
fn get_items_at_index(index: Int, list: List(a)) -> List(a) {
list |> list.drop(index) |> list.take(1)
}
fn drag_group_update(
setter: fn(a, a) -> a,
drag_index: Int,
drop_index: Int,
list: List(a),
) -> List(a) {
let drop_items = get_items_at_index(drop_index, list)
list.index_map(list, fn(item, index) {
case index == drag_index {
True -> {
case drop_items {
[drop_item] -> [setter(drop_item, item)]
_ -> [item]
}
}
False -> [item]
}
})
|> list.flatten()
}
fn drag_and_drop_group_update(
setter: fn(a, a) -> a,
drag_index: Int,
drop_index: Int,
list: List(a),
) -> List(a) {
let drag_items = get_items_at_index(drag_index, list)
let drop_items = get_items_at_index(drop_index, list)
list.index_map(list, fn(item, index) {
case index {
i if i == drag_index -> {
case drop_items {
[drop_item] -> [setter(drop_item, item)]
_ -> [item]
}
}
i if i == drop_index -> {
case drag_items {
[drag_item] -> [setter(drag_item, item)]
_ -> [item]
}
}
_ -> [item]
}
})
|> list.flatten()
}
fn all_group_update(
fn_transform: fn(List(a)) -> List(a),
start_index: Int,
end_index: Int,
list: List(a),
) -> List(a) {
let beginning = list.take(list, start_index)
let middle =
list |> list.drop(start_index) |> list.take(end_index - start_index + 1)
let end = list.drop(list, end_index + 1)
list.append(list.append(beginning, fn_transform(middle)), end)
}
fn bubble_group_recursive(
comparator: fn(a, a) -> Bool,
setter: fn(a, a) -> a,
items: List(a),
) -> List(a) {
case items {
[] -> []
[x] -> [x]
[x, ..xs] -> {
let sublist = sublist_by_first_item(comparator, items)
case sublist {
[] -> [x, ..xs]
_ -> {
let sublist_length = list.length(sublist)
let reversed_tail = sublist |> list.drop(1) |> list.reverse()
let first_sublist = list.take(sublist, 1)
let next_item = xs |> list.drop(sublist_length - 1) |> list.take(1)
case first_sublist, next_item {
[first], [next] -> {
let updated_first = setter(next, first)
let updated_next = setter(first, next)
let remaining = list.drop(xs, sublist_length)
list.append(
list.append(reversed_tail, [updated_first]),
bubble_group_recursive(
comparator,
setter,
list.append([updated_next], remaining),
),
)
}
_, _ -> [x, ..xs]
}
}
}
}
}
}
fn sublist_by_first_item(
comparator: fn(a, a) -> Bool,
items: List(a),
) -> List(a) {
case items {
[] -> []
[first, ..] -> list.filter(items, fn(item) { comparator(first, item) })
}
}
// STATE AND LIST UPDATE HELPERS ----------------------------------------------
fn update_state_for_operation(
operation: Operation,
drop_index: Int,
state: State,
) -> State {
case operation {
InsertAfter -> {
let new_drag_index = case drop_index < state.drag_index {
True -> drop_index + 1
False -> drop_index
}
State(..state, drag_index: new_drag_index, drag_counter: 0)
}
InsertBefore -> {
let new_drag_index = case state.drag_index < drop_index {
True -> drop_index - 1
False -> drop_index
}
State(..state, drag_index: new_drag_index, drag_counter: 0)
}
Rotate -> State(..state, drag_index: drop_index, drag_counter: 0)
Swap -> State(..state, drag_index: drop_index, drag_counter: 0)
Unaltered -> State(..state, drag_counter: 0)
}
}
// Reuse the operations from dnd.gleam for same-group operations
fn sublist_update(
operation: Operation,
drag_index: Int,
drop_index: Int,
list: List(a),
) -> List(a) {
case operation {
InsertAfter -> insert_after(drag_index, drop_index, list)
InsertBefore -> insert_before(drag_index, drop_index, list)
Rotate -> rotate_items(drag_index, drop_index, list)
Swap -> swap_items(drag_index, drop_index, list)
Unaltered -> list
}
}
// Group-specific list operations for cross-group transfers
fn groups_list_update(
operation: Operation,
comparator: fn(a, a) -> Bool,
setter: fn(a, a) -> a,
drag_index: Int,
drop_index: Int,
list: List(a),
) -> List(a) {
case operation {
InsertAfter -> {
let updated_list = drag_group_update(setter, drag_index, drop_index, list)
insert_after(drag_index, drop_index, updated_list)
}
InsertBefore -> {
let updated_list = drag_group_update(setter, drag_index, drop_index, list)
insert_before(drag_index, drop_index, updated_list)
}
Rotate -> {
case drag_index < drop_index {
True -> {
let transform_fn = fn(items) {
let reversed = list.reverse(items)
let bubbled = bubble_group_recursive(comparator, setter, reversed)
list.reverse(bubbled)
}
let updated_list =
all_group_update(transform_fn, drag_index, drop_index, list)
rotate_items(drag_index, drop_index, updated_list)
}
False -> {
let transform_fn = fn(items) {
bubble_group_recursive(comparator, setter, items)
}
let updated_list =
all_group_update(transform_fn, drop_index, drag_index, list)
rotate_items(drag_index, drop_index, updated_list)
}
}
}
Swap -> {
let updated_list =
drag_and_drop_group_update(setter, drag_index, drop_index, list)
swap_items(drag_index, drop_index, updated_list)
}
Unaltered -> list
}
}
// Import the list operation functions from dnd module logic
// Helper function to split a list at a given index
fn split_at(index: Int, list: List(a)) -> #(List(a), List(a)) {
#(list.take(list, index), list.drop(list, index))
}
// InsertAfter operation
fn insert_after(drag_index: Int, drop_index: Int, list: List(a)) -> List(a) {
case drag_index < drop_index {
True -> after_forward(drag_index, drop_index, list)
False ->
case drop_index < drag_index {
True -> after_backward(drag_index, drop_index, list)
False -> list
}
}
}
// InsertBefore operation
fn insert_before(drag_index: Int, drop_index: Int, list: List(a)) -> List(a) {
case drag_index < drop_index {
True -> before_forward(drag_index, drop_index, list)
False ->
case drop_index < drag_index {
True -> before_backward(drag_index, drop_index, list)
False -> list
}
}
}
// Rotate operation
fn rotate_items(drag_index: Int, drop_index: Int, list: List(a)) -> List(a) {
case drag_index < drop_index {
True -> after_forward(drag_index, drop_index, list)
False ->
case drop_index < drag_index {
True -> before_backward(drag_index, drop_index, list)
False -> list
}
}
}
// Swap operation
fn swap_items(drag_index: Int, drop_index: Int, list: List(a)) -> List(a) {
case drag_index != drop_index {
True -> swap_at(drag_index, drop_index, list)
False -> list
}
}
// Helper functions for different movement directions
fn after_backward(i: Int, j: Int, list: List(a)) -> List(a) {
let #(beginning, rest) = split_at(j + 1, list)
let #(middle, end) = split_at(i - j - 1, rest)
let #(head, tail) = split_at(1, end)
list.append(list.append(list.append(beginning, head), middle), tail)
}
fn after_forward(i: Int, j: Int, list: List(a)) -> List(a) {
let #(beginning, rest) = split_at(i, list)
let #(middle, end) = split_at(j - i + 1, rest)
let #(head, tail) = split_at(1, middle)
list.append(list.append(list.append(beginning, tail), head), end)
}
fn before_backward(i: Int, j: Int, list: List(a)) -> List(a) {
let #(beginning, rest) = split_at(j, list)
let #(middle, end) = split_at(i - j, rest)
let #(head, tail) = split_at(1, end)
list.append(list.append(list.append(beginning, head), middle), tail)
}
fn before_forward(i: Int, j: Int, list: List(a)) -> List(a) {
let #(beginning, rest) = split_at(i, list)
let #(middle, end) = split_at(j - i, rest)
let #(head, tail) = split_at(1, middle)
list.append(list.append(list.append(beginning, tail), head), end)
}
fn swap_at(i: Int, j: Int, list: List(a)) -> List(a) {
let item_i = list.drop(list, i) |> list.take(1)
let item_j = list.drop(list, j) |> list.take(1)
list.index_map(list, fn(item, index) {
case index {
idx if idx == i -> item_j
idx if idx == j -> item_i
_ -> [item]
}
})
|> list.flatten()
}
// EVENT HANDLERS --------------------------------------------------------------
fn drag_events(
step_msg: fn(DndMsg) -> msg,
) -> fn(Int, String) -> List(Attribute(msg)) {
fn(drag_index: Int, drag_element_id: String) -> List(Attribute(msg)) {
[
event.on("mousedown", {
use client_x <- decode.field("clientX", decode.float)
use client_y <- decode.field("clientY", decode.float)
decode.success(
step_msg(DragStart(
drag_index,
drag_element_id,
Position(client_x, client_y),
)),
)
}),
]
}
}
fn drop_events(
step_msg: fn(DndMsg) -> msg,
) -> fn(Int, String) -> List(Attribute(msg)) {
fn(drop_index: Int, drop_element_id: String) -> List(Attribute(msg)) {
[
event.on(
"mouseover",
decode.success(step_msg(DragOver(drop_index, drop_element_id))),
),
event.on("mouseenter", decode.success(step_msg(DragEnter(drop_index)))),
event.on("mouseleave", decode.success(step_msg(DragLeave))),
]
}
}
// GHOST STYLES ---------------------------------------------------------------
fn ghost_styles() -> fn(Model) -> List(Attribute(msg)) {
fn(model: Model) -> List(Attribute(msg)) {
case model {
Model(Some(state)) -> {
[
attribute.style("position", "fixed"),
attribute.style("pointer-events", "none"),
attribute.style("z-index", "1000"),
attribute.style("user-select", "none"),
attribute.style("-webkit-user-select", "none"),
attribute.style(
"left",
float.to_string(state.current_position.x) <> "px",
),
attribute.style(
"top",
float.to_string(state.current_position.y) <> "px",
),
]
}
Model(None) -> []
}
}
}
// INFO EXTRACTION -------------------------------------------------------------
fn get_info(model: Model) -> Option(Info) {
case model {
Model(Some(state)) -> {
Some(Info(
drag_index: state.drag_index,
drop_index: state.drop_index,
drag_element_id: state.drag_element_id,
drop_element_id: state.drop_element_id,
start_position: state.start_position,
current_position: state.current_position,
))
}
Model(None) -> None
}
}