-
Notifications
You must be signed in to change notification settings - Fork 1
feat: implement drag-and-drop for access management #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| }" | ||
| @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 | ||
|
|
@@ -20,7 +38,7 @@ | |
| @click="deletePermission(id)" | ||
| /> | ||
| </div> | ||
| <div class="permission"> | ||
| <div class="permission new-permission"> | ||
| <LInput | ||
| v-model="newPermission" | ||
| borderless | ||
|
|
@@ -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({ | ||
|
|
@@ -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
|
||
| 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
|
||
|
|
||
| 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> | ||
|
|
@@ -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> | ||
There was a problem hiding this comment.
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=trueon the entire row) is not keyboard-operable, and the new drag handle is a non-focusable with only atitletooltip. 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 ontitle).