mirror of
https://github.com/enso-org/enso.git
synced 2024-12-19 22:41:49 +03:00
Look and feel: Lists and dropdowns (#11620)
https://github.com/user-attachments/assets/d8c039e6-670c-4ff5-9d71-c07ee6114570 Lists: - Drag handles are icons. - List controls are shown only when component is sole selection. - Each item has delete button. - Integration tests cover dragging, adding, removing. https://github.com/user-attachments/assets/58054cb2-22bc-4279-850c-0bc4929fc246 Dropdowns: - Arrows are shown when hovered or component is sole selection. - Arrows change opacity when hovered. Implements #11533.
This commit is contained in:
parent
dcabf6d0ae
commit
a2e87d37a9
@ -34,6 +34,7 @@
|
||||
- [Table Input Widget is now matched for Table.input method instead of
|
||||
Table.new. Values must be string literals, and their content is parsed to the
|
||||
suitable type][11612].
|
||||
- [New design for vector-editing widget][11620]
|
||||
|
||||
[11151]: https://github.com/enso-org/enso/pull/11151
|
||||
[11271]: https://github.com/enso-org/enso/pull/11271
|
||||
@ -55,6 +56,7 @@
|
||||
[11564]: https://github.com/enso-org/enso/pull/11564
|
||||
[11597]: https://github.com/enso-org/enso/pull/11597
|
||||
[11612]: https://github.com/enso-org/enso/pull/11612
|
||||
[11620]: https://github.com/enso-org/enso/pull/11620
|
||||
|
||||
#### Enso Standard Library
|
||||
|
||||
|
@ -121,6 +121,16 @@ export function bottomDock(page: Page) {
|
||||
return page.getByTestId('bottomDock')
|
||||
}
|
||||
|
||||
/** Button to add an item to a vector */
|
||||
export function addItemButton(page: Locator | Page) {
|
||||
return page.getByRole('button', { name: 'new item' })
|
||||
}
|
||||
|
||||
/** Button to delete a specific item from a vector */
|
||||
export function deleteItemButton(page: Locator | Page) {
|
||||
return page.getByRole('button', { name: 'Remove item' })
|
||||
}
|
||||
|
||||
export const navBreadcrumb = componentLocator('.NavBreadcrumb')
|
||||
export const componentBrowserInput = componentLocator('.ComponentEditor')
|
||||
|
||||
|
@ -105,7 +105,7 @@ test('Multi-selection widget', async ({ page }) => {
|
||||
await dropDown.expectVisibleWithOptions(['Column A', 'Column B'])
|
||||
await expect(dropDown.rootWidget).toHaveClass(/multiSelect/)
|
||||
const vector = node.locator('.WidgetVector')
|
||||
const vectorItems = vector.locator('.item .WidgetPort input')
|
||||
const vectorItems = vector.getByTestId('list-item-content').locator('.WidgetPort input')
|
||||
await expect(vector).toBeVisible()
|
||||
await expect(dropDown.selectedItems).toHaveCount(0)
|
||||
await expect(vectorItems).toHaveCount(0)
|
||||
@ -121,7 +121,7 @@ test('Multi-selection widget', async ({ page }) => {
|
||||
// Add-item button opens dropdown, after closing with escape.
|
||||
await page.keyboard.press('Escape')
|
||||
await dropDown.expectNotVisible()
|
||||
await vector.locator('.add-item').click()
|
||||
await locate.addItemButton(vector).click()
|
||||
await expect(dropDown.items).toHaveCount(2)
|
||||
await expect(dropDown.selectedItems).toHaveCount(1)
|
||||
|
||||
@ -180,7 +180,7 @@ test('Multi-selection widget: Item edits', async ({ page }) => {
|
||||
.graphNodeByBinding(page, 'selected')
|
||||
.locator('.WidgetTopLevelArgument')
|
||||
.filter({ has: page.getByText('columns') })
|
||||
const vectorItems = columnsArg.locator('.WidgetVector .item .WidgetPort input')
|
||||
const vectorItems = columnsArg.getByTestId('list-item-content').locator('.WidgetPort input')
|
||||
const dropDown = new DropDownLocator(columnsArg)
|
||||
await dropDown.clickWidget()
|
||||
await dropDown.clickOption('Column A')
|
||||
@ -195,6 +195,46 @@ test('Multi-selection widget: Item edits', async ({ page }) => {
|
||||
await expect(dropDown.selectedItem('Column B')).toExist()
|
||||
})
|
||||
|
||||
test('Editing list', async ({ page }) => {
|
||||
await actions.goToGraph(page)
|
||||
const node = locate.graphNodeByBinding(page, 'autoscoped')
|
||||
const vector = node.locator('.WidgetVector')
|
||||
const vectorItems = vector.locator('.item')
|
||||
const vectorElements = vector.getByTestId('list-item-content')
|
||||
await expect(vectorElements).toHaveText(['..Group_By'])
|
||||
await node.click()
|
||||
|
||||
// Test add
|
||||
await locate.addItemButton(node).click()
|
||||
await locate.addItemButton(node).click()
|
||||
await expect(vectorElements).toHaveText(['..Group_By', '_', '_'])
|
||||
|
||||
// Test drag: remove item
|
||||
await vectorItems.nth(1).locator('[draggable]').dragTo(locate.graphEditor(page))
|
||||
await expect(vectorElements).toHaveText(['..Group_By', '_'])
|
||||
|
||||
// Test drag: reorder items
|
||||
await vectorItems.nth(1).locator('[draggable]').hover()
|
||||
await page.mouse.down()
|
||||
await vectorItems
|
||||
.nth(1)
|
||||
.locator('[draggable]')
|
||||
.hover({ position: { x: 10, y: 10 } })
|
||||
await expect(vectorElements).toHaveText(['..Group_By'])
|
||||
await vectorElements.first().hover({ position: { x: 10, y: 10 }, force: true })
|
||||
await page.mouse.up()
|
||||
await expect(vectorElements).toHaveText(['_', '..Group_By'])
|
||||
|
||||
// Test delete
|
||||
await locate.deleteItemButton(vectorItems.first()).click()
|
||||
await expect(vectorElements).toHaveText(['..Group_By'])
|
||||
|
||||
// Test delete: last item
|
||||
await locate.deleteItemButton(vectorItems).click()
|
||||
await expect(vectorItems).not.toExist()
|
||||
await expect(vector).toExist()
|
||||
})
|
||||
|
||||
async function dataReadNodeWithMethodCallInfo(page: Page): Promise<Locator> {
|
||||
await mockMethodCallInfo(page, 'data', {
|
||||
methodPointer: {
|
||||
@ -374,7 +414,7 @@ test('Manage aggregates in `aggregate` node', async ({ page }) => {
|
||||
// Add first aggregate
|
||||
const columnsArg = topLevelArgs.filter({ has: page.getByText('columns') })
|
||||
|
||||
await columnsArg.locator('.add-item').click()
|
||||
await locate.addItemButton(columnsArg).click()
|
||||
await expect(columnsArg.locator('.WidgetToken')).toContainText([
|
||||
'Aggregate_Column',
|
||||
'.',
|
||||
@ -423,7 +463,10 @@ test('Manage aggregates in `aggregate` node', async ({ page }) => {
|
||||
)
|
||||
|
||||
// Set column
|
||||
const firstItem = columnsArg.locator('.item > .WidgetPort > .WidgetSelection').nth(0)
|
||||
const firstItem = columnsArg
|
||||
.getByTestId('list-item-content')
|
||||
.locator('.WidgetPort > .WidgetSelection')
|
||||
.nth(0)
|
||||
const firstItemDropdown = new DropDownLocator(firstItem)
|
||||
await firstItemDropdown.clickWidget()
|
||||
await firstItemDropdown.expectVisibleWithOptions(['column 1', 'column 2'])
|
||||
@ -436,7 +479,7 @@ test('Manage aggregates in `aggregate` node', async ({ page }) => {
|
||||
await expect(columnsArg.locator('.WidgetText > input').first()).toHaveValue('column 1')
|
||||
|
||||
// Add another aggregate
|
||||
await columnsArg.locator('.add-item').click()
|
||||
await locate.addItemButton(columnsArg).click()
|
||||
await expect(columnsArg.locator('.WidgetToken')).toContainText([
|
||||
'Aggregate_Column',
|
||||
'.',
|
||||
@ -462,7 +505,10 @@ test('Manage aggregates in `aggregate` node', async ({ page }) => {
|
||||
)
|
||||
|
||||
// Set new aggregate's column
|
||||
const secondItem = columnsArg.locator('.item > .WidgetPort > .WidgetSelection').nth(1)
|
||||
const secondItem = columnsArg
|
||||
.getByTestId('list-item-content')
|
||||
.nth(1)
|
||||
.locator('.WidgetPort > .WidgetSelection')
|
||||
const secondItemDropdown = new DropDownLocator(secondItem)
|
||||
await secondItemDropdown.clickWidget()
|
||||
await secondItemDropdown.expectVisibleWithOptions(['column 1', 'column 2'])
|
||||
@ -533,7 +579,7 @@ test('Autoscoped constructors', async ({ page }) => {
|
||||
await node.click()
|
||||
await expect(topLevelArgs).toHaveCount(3)
|
||||
|
||||
const groupBy = node.locator('.item').nth(0)
|
||||
const groupBy = node.getByTestId('list-item-content').first()
|
||||
await expect(groupBy).toBeVisible()
|
||||
await expect(groupBy.locator('.WidgetArgumentName')).toContainText(['column', 'new_name'])
|
||||
})
|
||||
|
@ -238,7 +238,7 @@ const innerWidgetInput = computed<WidgetInput>(() => {
|
||||
|
||||
const parentSelectionArrow = injectSelectionArrow(true)
|
||||
const arrowSuppressed = ref(false)
|
||||
const showArrow = computed(() => isHovered.value && !arrowSuppressed.value)
|
||||
const showArrow = computed(() => !arrowSuppressed.value && (tree.extended || isHovered.value))
|
||||
provideSelectionArrow(
|
||||
proxyRefs({
|
||||
id: computed(() => {
|
||||
@ -464,7 +464,11 @@ declare module '@/providers/widgetRegistry' {
|
||||
>
|
||||
<NodeWidget :input="innerWidgetInput" />
|
||||
<Teleport v-if="showArrow" defer :disabled="!arrowLocation" :to="arrowLocation">
|
||||
<SvgIcon name="arrow_right_head_only" class="arrow widgetOutOfLayout" />
|
||||
<SvgIcon
|
||||
name="arrow_right_head_only"
|
||||
class="arrow widgetOutOfLayout"
|
||||
:class="{ hovered: isHovered }"
|
||||
/>
|
||||
</Teleport>
|
||||
<Teleport v-if="tree.nodeElement" :to="tree.nodeElement">
|
||||
<div ref="dropdownElement" :style="floatingStyles" class="widgetOutOfLayout floatingElement">
|
||||
@ -515,6 +519,9 @@ svg.arrow {
|
||||
opacity: 0.5;
|
||||
/* Prevent the parent from receiving a pointerout event if the mouse is over the arrow, which causes flickering. */
|
||||
pointer-events: none;
|
||||
&.hovered {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.activityElement {
|
||||
|
@ -1,15 +1,16 @@
|
||||
<script lang="ts">
|
||||
<script setup lang="ts" generic="T">
|
||||
import SizeTransition from '@/components/SizeTransition.vue'
|
||||
import SvgButton from '@/components/SvgButton.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { useRaf } from '@/composables/animation'
|
||||
import { useEvent } from '@/composables/events'
|
||||
import { useAppClass } from '@/providers/appClass'
|
||||
import { injectWidgetTree } from '@/providers/widgetTree'
|
||||
import { Range } from '@/util/data/range'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { uuidv4 } from 'lib0/random'
|
||||
import { computed, ref, shallowReactive, watchEffect, watchPostEffect } from 'vue'
|
||||
</script>
|
||||
import { computed, type Ref, ref, shallowReactive, watchEffect, watchPostEffect } from 'vue'
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
const props = defineProps<{
|
||||
modelValue: T[]
|
||||
newItem: () => T | undefined
|
||||
@ -44,6 +45,8 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [modelValue: T[]]
|
||||
}>()
|
||||
|
||||
const tree = injectWidgetTree()
|
||||
|
||||
const listUuid = uuidv4()
|
||||
|
||||
const mimeType = computed(() => props.dragMimeType ?? 'application/octet-stream')
|
||||
@ -74,30 +77,34 @@ function decodeMetadataFromMime(mime: string): DropMetadata | undefined {
|
||||
|
||||
const draggedIndex = ref<number>()
|
||||
|
||||
type DragItem =
|
||||
| {
|
||||
type: 'item'
|
||||
index: number
|
||||
item: T
|
||||
key: string | number
|
||||
}
|
||||
| {
|
||||
type: 'placeholder'
|
||||
width: number
|
||||
key: string | number
|
||||
}
|
||||
interface BaseItem {
|
||||
key: string | number
|
||||
}
|
||||
|
||||
interface NonPlaceholderItem extends BaseItem {
|
||||
type: 'item'
|
||||
index: number
|
||||
item: T
|
||||
hintDeletable: Ref<boolean>
|
||||
}
|
||||
|
||||
interface PlaceholderItem extends BaseItem {
|
||||
type: 'placeholder'
|
||||
width: number
|
||||
}
|
||||
|
||||
type DragItem = NonPlaceholderItem | PlaceholderItem
|
||||
|
||||
const defaultPlaceholderKey = '__placeholder_key__'
|
||||
|
||||
const mappedItems = computed(() => {
|
||||
return props.modelValue.map(
|
||||
(item, index): DragItem => ({
|
||||
type: 'item',
|
||||
index,
|
||||
item,
|
||||
key: props.getKey?.(item) ?? index,
|
||||
}),
|
||||
)
|
||||
const mappedItems = computed<DragItem[]>(() => {
|
||||
return props.modelValue.map((item, index) => ({
|
||||
type: 'item',
|
||||
index,
|
||||
item,
|
||||
key: props.getKey?.(item) ?? index,
|
||||
hintDeletable: ref(false),
|
||||
}))
|
||||
})
|
||||
|
||||
const dropInfo = ref<DropHoverInfo>()
|
||||
@ -126,8 +133,7 @@ const displayedChildren = computed(() => {
|
||||
key,
|
||||
} as const)
|
||||
}
|
||||
const x = items.filter((item) => item.type !== 'item' || item.index !== draggedIndex.value)
|
||||
return x
|
||||
return items.filter((item) => item.type !== 'item' || item.index !== draggedIndex.value)
|
||||
})
|
||||
|
||||
const rootNode = ref<HTMLElement>()
|
||||
@ -135,69 +141,66 @@ const rootNode = ref<HTMLElement>()
|
||||
const cssPropsToCopy = ['--color-node-primary', '--node-color-port', '--node-border-radius']
|
||||
|
||||
function onDragStart(event: DragEvent, index: number) {
|
||||
if (
|
||||
!(event.target instanceof HTMLElement && event.target.nextElementSibling instanceof HTMLElement)
|
||||
)
|
||||
return
|
||||
if (!event.dataTransfer) return
|
||||
if (!(event.target instanceof HTMLElement)) return
|
||||
// The element that will be shown following the mouse.
|
||||
const previewElement = event.target.parentElement
|
||||
if (!(previewElement instanceof HTMLElement)) return
|
||||
// The element being replaced with a placeholder during the operation.
|
||||
const sizeElement = previewElement.parentElement
|
||||
if (!(sizeElement instanceof HTMLElement)) return
|
||||
|
||||
if (event.dataTransfer) {
|
||||
// The dragged widget contents is the next sibling of the drag handle.
|
||||
const previewElement = event.target.nextElementSibling
|
||||
// Create a fake offscreen DOM element to use as the drag "ghost" image. It will hold a visual
|
||||
// clone of the widget being dragged. The ghost style is modified to add a background color
|
||||
// and additional border, as well as apply appropriate element scaling in cross-browser way.
|
||||
const elementOffsetWidth = sizeElement.offsetWidth
|
||||
const elementRect = originalBoundingClientRect.call(sizeElement)
|
||||
const elementScale = elementRect.width / elementOffsetWidth
|
||||
const dragGhost = document.createElement('div')
|
||||
dragGhost.classList.add('ListWidget-drag-ghost')
|
||||
const previewElementStyle = getComputedStyle(previewElement)
|
||||
const elementTopLeft = props.toDragPosition(new Vec2(elementRect.left, elementRect.top))
|
||||
const currentMousePos = props.toDragPosition(new Vec2(event.clientX, event.clientY))
|
||||
const elementRelativeOffset = currentMousePos.sub(elementTopLeft)
|
||||
// To maintain appropriate styling, we have to copy over a set of node tree CSS variables from
|
||||
// the preview element to the ghost element.
|
||||
cssPropsToCopy.forEach((prop) => {
|
||||
dragGhost.style.setProperty(prop, previewElementStyle.getPropertyValue(prop))
|
||||
})
|
||||
dragGhost.style.setProperty('transform', `scale(${elementScale})`)
|
||||
dragGhost.appendChild(previewElement.cloneNode(true))
|
||||
document.body.appendChild(dragGhost)
|
||||
event.dataTransfer.setDragImage(dragGhost, elementRelativeOffset.x, elementRelativeOffset.y)
|
||||
// Remove the ghost element after a short delay, giving the browser time to render it and set
|
||||
// the drag image.
|
||||
setTimeout(() => dragGhost.remove(), 0)
|
||||
|
||||
// Create a fake offscreen DOM element to use as the drag "ghost" image. It will hold a visual
|
||||
// clone of the widget being dragged. The ghost style is modified to add a background color
|
||||
// and additional border, as well as apply appropriate element scaling in cross-browser way.
|
||||
const elementOffsetWidth = previewElement.offsetWidth
|
||||
const elementRect = originalBoundingClientRect.call(previewElement)
|
||||
const elementScale = elementRect.width / elementOffsetWidth
|
||||
const dragGhost = document.createElement('div')
|
||||
dragGhost.classList.add('ListWidget-drag-ghost')
|
||||
const dragGhostInner = document.createElement('div')
|
||||
dragGhost.appendChild(dragGhostInner)
|
||||
const previewElementStyle = getComputedStyle(previewElement)
|
||||
const elementTopLeft = props.toDragPosition(new Vec2(elementRect.left, elementRect.top))
|
||||
const currentMousePos = props.toDragPosition(new Vec2(event.clientX, event.clientY))
|
||||
const elementRelativeOffset = currentMousePos.sub(elementTopLeft)
|
||||
// To maintain appropriate styling, we have to copy over a set of node tree CSS variables from
|
||||
// the original preview element to the ghost element.
|
||||
cssPropsToCopy.forEach((prop) => {
|
||||
dragGhostInner.style.setProperty(prop, previewElementStyle.getPropertyValue(prop))
|
||||
})
|
||||
dragGhostInner.style.setProperty('transform', `scale(${elementScale})`)
|
||||
dragGhostInner.appendChild(previewElement.cloneNode(true))
|
||||
document.body.appendChild(dragGhost)
|
||||
event.dataTransfer.setDragImage(dragGhost, elementRelativeOffset.x, elementRelativeOffset.y)
|
||||
// Remove the ghost element after a short delay, giving the browser time to render it and set
|
||||
// the drag image.
|
||||
setTimeout(() => dragGhost.remove(), 0)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
// `dropEffect: none` does not work for removing an element - it disables drop completely.
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
const dragItem = props.modelValue[index]!
|
||||
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
// `dropEffect: none` does not work for removing an element - it disables drop completely.
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
const dragItem = props.modelValue[index]!
|
||||
|
||||
const meta: DropMetadata = {
|
||||
list: listUuid,
|
||||
key: props.getKey?.(dragItem) ?? index,
|
||||
width: elementOffsetWidth,
|
||||
}
|
||||
const payload = props.toDragPayload(dragItem)
|
||||
event.dataTransfer.setData(mimeType.value, payload)
|
||||
|
||||
if (props.toPlainText) {
|
||||
event.dataTransfer.setData('text/plain', props.toPlainText(dragItem))
|
||||
}
|
||||
|
||||
const metaMime = encodeMetadataToMime(meta)
|
||||
event.dataTransfer.setData(metaMime, '')
|
||||
// The code below will remove the item from list; because doing it in the same frame ends drag
|
||||
// immediately, we need to put it in setTimeout (nextTick is not enough).
|
||||
setTimeout(() => {
|
||||
updateItemBounds()
|
||||
draggedIndex.value = index
|
||||
dropInfo.value = { meta, position: currentMousePos }
|
||||
}, 0)
|
||||
const meta: DropMetadata = {
|
||||
list: listUuid,
|
||||
key: props.getKey?.(dragItem) ?? index,
|
||||
width: elementOffsetWidth,
|
||||
}
|
||||
const payload = props.toDragPayload(dragItem)
|
||||
event.dataTransfer.setData(mimeType.value, payload)
|
||||
|
||||
if (props.toPlainText) {
|
||||
event.dataTransfer.setData('text/plain', props.toPlainText(dragItem))
|
||||
}
|
||||
|
||||
const metaMime = encodeMetadataToMime(meta)
|
||||
event.dataTransfer.setData(metaMime, '')
|
||||
// The code below will remove the item from list; because doing it in the same frame ends drag
|
||||
// immediately, we need to put it in setTimeout (nextTick is not enough).
|
||||
setTimeout(() => {
|
||||
updateItemBounds()
|
||||
draggedIndex.value = index
|
||||
dropInfo.value = { meta, position: currentMousePos }
|
||||
}, 0)
|
||||
}
|
||||
|
||||
interface DropMetadata {
|
||||
@ -219,6 +222,7 @@ function areaDragOver(e: DragEvent) {
|
||||
const metaMime = e.dataTransfer?.types.find((ty) => ty.startsWith(dragMetaMimePrefix))
|
||||
const typesMatch = e.dataTransfer?.types.includes(mimeType.value)
|
||||
if (!metaMime || !typesMatch) return
|
||||
e.preventDefault()
|
||||
const meta = decodeMetadataFromMime(metaMime)
|
||||
if (meta == null) return
|
||||
|
||||
@ -367,6 +371,11 @@ function addItem() {
|
||||
const item = props.newItem()
|
||||
if (item) emit('update:modelValue', [...props.modelValue, item])
|
||||
}
|
||||
|
||||
function deleteItem(index: number) {
|
||||
const modelValue = props.modelValue.filter((_, i) => i !== index)
|
||||
emit('update:modelValue', modelValue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -388,21 +397,49 @@ function addItem() {
|
||||
>
|
||||
<template v-for="entry in displayedChildren" :key="entry.key">
|
||||
<template v-if="entry.type === 'item'">
|
||||
<li :ref="(el) => setItemRef(el, entry.index)" class="item">
|
||||
<li :ref="patchBoundingClientRectScaling" class="item">
|
||||
<div :ref="(el) => setItemRef(el, entry.index)" class="draggableContent">
|
||||
<SizeTransition width>
|
||||
<!-- This wrapper is needed because an SVG element cannot directly be draggable. -->
|
||||
<div
|
||||
v-if="tree.extended"
|
||||
class="deletable"
|
||||
:class="{ hintDeletable: entry.hintDeletable.value }"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, entry.index)"
|
||||
@dragend="onDragEnd"
|
||||
>
|
||||
<SvgIcon name="grab" class="handle" />
|
||||
</div>
|
||||
</SizeTransition>
|
||||
<div
|
||||
class="deletable"
|
||||
:class="{ hintDeletable: entry.hintDeletable.value }"
|
||||
data-testid="list-item-content"
|
||||
>
|
||||
<slot :item="entry.item"></slot>
|
||||
</div>
|
||||
<SizeTransition width>
|
||||
<!-- This wrapper is needed to animate an `SvgButton` because it ultimately contains a `TooltipTrigger`,
|
||||
which has a fragment root. -->
|
||||
<div v-if="tree.extended" class="displayContents">
|
||||
<SvgButton
|
||||
class="item-button"
|
||||
name="close"
|
||||
title="Remove item"
|
||||
@click.stop="deleteItem(entry.index)"
|
||||
@pointerenter="entry.hintDeletable.value = true"
|
||||
@pointerleave="entry.hintDeletable.value = false"
|
||||
/>
|
||||
</div>
|
||||
</SizeTransition>
|
||||
</div>
|
||||
<div
|
||||
class="handle"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, entry.index)"
|
||||
@dragend="onDragEnd"
|
||||
></div>
|
||||
<slot :item="entry.item"></slot>
|
||||
</li>
|
||||
<li
|
||||
v-show="entry.index != props.modelValue.length - 1"
|
||||
:ref="patchBoundingClientRectScaling"
|
||||
class="token widgetApplyPadding"
|
||||
>
|
||||
,
|
||||
v-if="entry.index != props.modelValue.length - 1"
|
||||
class="token widgetApplyPadding"
|
||||
>
|
||||
,
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<template v-else>
|
||||
@ -414,7 +451,18 @@ function addItem() {
|
||||
</template>
|
||||
</template>
|
||||
</TransitionGroup>
|
||||
<SvgIcon class="add-item" name="vector_add" @click.stop="addItem" />
|
||||
<SizeTransition width>
|
||||
<!-- This wrapper is a workaround: If the `v-if` is applied to the `SvgIcon`, once the button is shown it will
|
||||
never go back to hidden. This might be a Vue bug? -->
|
||||
<div v-if="tree.extended" class="displayContents">
|
||||
<SvgButton
|
||||
class="item-button after-last-item"
|
||||
name="vector_add"
|
||||
title="Add a new item"
|
||||
@click.stop="addItem"
|
||||
/>
|
||||
</div>
|
||||
</SizeTransition>
|
||||
<span class="token widgetApplyPadding">]</span>
|
||||
</div>
|
||||
<div
|
||||
@ -489,10 +537,10 @@ div {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item .preview {
|
||||
background: var(--color-node-primary);
|
||||
padding: 4px;
|
||||
border-radius: var(--node-border-radius);
|
||||
.draggableContent {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.token {
|
||||
@ -514,85 +562,60 @@ div {
|
||||
}
|
||||
|
||||
.handle {
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: -6px;
|
||||
height: calc(100% - 12px);
|
||||
width: 2px;
|
||||
box-shadow:
|
||||
2px 0 0 transparent,
|
||||
-2px 0 0 transparent;
|
||||
transition: box-shadow 0.2s ease;
|
||||
pointer-events: none;
|
||||
transition: color 0.2s ease;
|
||||
cursor: grab;
|
||||
|
||||
color: var(--color-widget-unfocus);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-widget-focus);
|
||||
}
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.item:hover .handle {
|
||||
box-shadow:
|
||||
2px 0 0 var(--color-widget-unfocus),
|
||||
-2px 0 0 var(--color-widget-unfocus);
|
||||
|
||||
&:hover {
|
||||
box-shadow:
|
||||
2px 0 0 var(--color-widget-focus),
|
||||
-2px 0 0 var(--color-widget-focus);
|
||||
}
|
||||
|
||||
background: var(--color-node-background);
|
||||
pointer-events: all;
|
||||
|
||||
&:before {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
left: -1px;
|
||||
right: -4px;
|
||||
top: -3px;
|
||||
bottom: -3px;
|
||||
z-index: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.item:hover .handle:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.GraphEditor.draggingEdge .handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.add-item {
|
||||
.item-button {
|
||||
transition-property: opacity;
|
||||
transition-duration: 150ms;
|
||||
transition-timing-function: ease-in-out;
|
||||
opacity: 0.5;
|
||||
margin-left: 4px;
|
||||
transition: margin 0.2s ease-in-out;
|
||||
.items:empty + & {
|
||||
margin: 0 2px;
|
||||
}
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.add-item:hover {
|
||||
opacity: 1;
|
||||
.after-last-item {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
:global(.ListWidget-drag-ghost) {
|
||||
position: absolute;
|
||||
left: -5000px;
|
||||
}
|
||||
:global(.ListWidget-drag-ghost > div) {
|
||||
background-color: var(--color-node-primary);
|
||||
border-radius: var(--node-border-radius);
|
||||
padding: 4px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.deletable {
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
&.hintDeletable {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.displayContents {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.SvgButton {
|
||||
--color-menu-entry-hover-bg: transparent;
|
||||
}
|
||||
</style>
|
||||
|
Loading…
Reference in New Issue
Block a user