Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 157 additions & 3 deletions src/components/general/panels/access/LAccessPermissions.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
<template>
<div class="permissions">
<div
class="permissions"
:class="{ 'drop-at-end': dropAtEnd }"
>
<h3>Permissions</h3>
<div
v-for="(permission, id) of internals.permissions"
v-for="(permission, id, index) of internals.permissions"
:key="id"
class="permission"
draggable="true"
:class="{
dragging: draggingPermissionId === id,
'drop-before': dropBeforePermissionId === id && draggingPermissionId !== id
Comment on lines 10 to +14
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Native HTML5 drag-and-drop (draggable=true on the entire row) is not keyboard-operable, and the new drag handle is a non-focusable with only a title tooltip. If this feature needs to meet WCAG 2.1 AA (and the PR description mentions a keyboard fallback), add an explicit keyboard-accessible reorder mechanism (e.g., Move up/down buttons or a focusable handle with key bindings) and expose accessible labels/instructions via ARIA (don’t rely on title).

Copilot uses AI. Check for mistakes.
}"
@dragstart="onPermissionDragStart($event, id)"
@dragend="onPermissionDragEnd"
@dragover.prevent="onPermissionDragOver($event, index)"
@drop.prevent="onPermissionDrop"
>
<span
class="drag-handle"
title="Drag to reorder"
>
::
</span>
<LInput
borderless
disabled
Expand All @@ -20,7 +38,7 @@
@click="deletePermission(id)"
/>
</div>
<div class="permission">
<div class="permission new-permission">
<LInput
v-model="newPermission"
borderless
Expand Down Expand Up @@ -49,6 +67,9 @@ import { ref } from "vue";
import { addPermission, internals } from "@/internals";

const newPermission = ref("");
const draggingPermissionId = ref<string | null>(null);
const dropBeforePermissionId = ref<string | null>(null);
const dropAtEnd = ref(false);

function addNewPermission() {
addPermission({
Expand All @@ -64,6 +85,84 @@ function deletePermission(permissionId: string) {

delete internals.permissions[permissionId];
}

function onPermissionDragStart(event: DragEvent, permissionId: string) {
draggingPermissionId.value = permissionId;

const dataTransfer = event.dataTransfer;
if (!dataTransfer) {
return;
}

dataTransfer.setData("text/plain", permissionId);
dataTransfer.setDragImage(event.currentTarget as Element, 16, 16);
dataTransfer.dropEffect = "move";
dataTransfer.effectAllowed = "move";
}

function onPermissionDragEnd() {
draggingPermissionId.value = null;
dropBeforePermissionId.value = null;
dropAtEnd.value = false;
}

function onPermissionDragOver(event: DragEvent, targetIndex: number) {
const permissionIds = Object.keys(internals.permissions);
const target = event.currentTarget as HTMLElement | null;

if (!target || !permissionIds.length) {
return;
}

const { height, top } = target.getBoundingClientRect();
const insertAfter = event.clientY > top + height / 2;
const insertionIndex = insertAfter ? targetIndex + 1 : targetIndex;
const nextPermissionId = permissionIds[insertionIndex] ?? null;

dropBeforePermissionId.value = nextPermissionId;
dropAtEnd.value = nextPermissionId === null;
}

function onPermissionDrop() {
if (!draggingPermissionId.value) {
return;
}

moveRecordEntryBefore(internals.permissions, draggingPermissionId.value, dropBeforePermissionId.value);
draggingPermissionId.value = null;
dropBeforePermissionId.value = null;
dropAtEnd.value = false;
}

function moveRecordEntryBefore<T extends { id: string }>(
record: Record<string, T>,
sourceId: string,
beforeId: string | null
) {
if (sourceId === beforeId) {
return;
}

Comment on lines +137 to +145
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

moveRecordEntryBefore (and associated drag logic) is duplicated in both LAccessPermissions and LAccessRoles. This will be easy to accidentally diverge over time; consider extracting it into a shared utility/composable (e.g., useRecordDragReorder) used by both components.

Copilot uses AI. Check for mistakes.
const entries = Object.entries(record);
const sourceIndex = entries.findIndex(([id]) => id === sourceId);
if (sourceIndex === -1) {
return;
}

const [sourceEntry] = entries.splice(sourceIndex, 1);
const insertionIndex = beforeId ? entries.findIndex(([id]) => id === beforeId) : entries.length;
const safeInsertionIndex = insertionIndex === -1 ? entries.length : insertionIndex;
Comment on lines +146 to +154
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reordering relies on JavaScript object property enumeration order (Object.entries / Object.keys) as the source of truth. That order is not purely insertion-based for integer-like keys (e.g., an id of "1" will be sorted ahead of non-integer keys), which can make drag-reorder behave incorrectly. Consider storing an explicit ordered array (or order field) instead of relying on Record key order, or validate/normalize ids to avoid integer-like keys.

Copilot uses AI. Check for mistakes.

entries.splice(safeInsertionIndex, 0, sourceEntry);

for (const id of Object.keys(record)) {
delete record[id];
}

for (const [id, value] of entries) {
record[id] = value;
}
}
</script>

<style scoped>
Expand All @@ -85,10 +184,65 @@ function deletePermission(permissionId: string) {
.permission {
display: flex;
gap: var(--length-xs);
cursor: grab;
position: relative;
border-radius: var(--length-radius-xs);
transition: background-color 0.12s ease;

&.dragging {
opacity: 0.45;
}

&.drop-before {
background-color: color-mix(in srgb, #3f8cff 10%, transparent);

&::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: -4px;
height: 3px;
border-radius: 999px;
background: linear-gradient(90deg, #3f8cff 0%, #66a3ff 100%);
}
}

.drag-handle {
align-items: center;
color: var(--color-content-softer);
display: inline-flex;
font-size: var(--font-size-s);
justify-content: center;
min-width: 16px;
user-select: none;
}

&.new-permission {
cursor: default;
position: relative;

.drag-handle {
display: none;
}
}

&:deep(.input) {
flex: 1 1 0;
}
}

&.drop-at-end {
.new-permission::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: -4px;
height: 3px;
border-radius: 999px;
background: linear-gradient(90deg, #3f8cff 0%, #66a3ff 100%);
}
}
}
</style>
Loading
Loading