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:
Kaz Wesley 2024-11-22 06:55:23 -08:00 committed by GitHub
parent dcabf6d0ae
commit a2e87d37a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 257 additions and 169 deletions

View File

@ -34,6 +34,7 @@
- [Table Input Widget is now matched for Table.input method instead of - [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 Table.new. Values must be string literals, and their content is parsed to the
suitable type][11612]. suitable type][11612].
- [New design for vector-editing widget][11620]
[11151]: https://github.com/enso-org/enso/pull/11151 [11151]: https://github.com/enso-org/enso/pull/11151
[11271]: https://github.com/enso-org/enso/pull/11271 [11271]: https://github.com/enso-org/enso/pull/11271
@ -55,6 +56,7 @@
[11564]: https://github.com/enso-org/enso/pull/11564 [11564]: https://github.com/enso-org/enso/pull/11564
[11597]: https://github.com/enso-org/enso/pull/11597 [11597]: https://github.com/enso-org/enso/pull/11597
[11612]: https://github.com/enso-org/enso/pull/11612 [11612]: https://github.com/enso-org/enso/pull/11612
[11620]: https://github.com/enso-org/enso/pull/11620
#### Enso Standard Library #### Enso Standard Library

View File

@ -121,6 +121,16 @@ export function bottomDock(page: Page) {
return page.getByTestId('bottomDock') 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 navBreadcrumb = componentLocator('.NavBreadcrumb')
export const componentBrowserInput = componentLocator('.ComponentEditor') export const componentBrowserInput = componentLocator('.ComponentEditor')

View File

@ -105,7 +105,7 @@ test('Multi-selection widget', async ({ page }) => {
await dropDown.expectVisibleWithOptions(['Column A', 'Column B']) await dropDown.expectVisibleWithOptions(['Column A', 'Column B'])
await expect(dropDown.rootWidget).toHaveClass(/multiSelect/) await expect(dropDown.rootWidget).toHaveClass(/multiSelect/)
const vector = node.locator('.WidgetVector') 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(vector).toBeVisible()
await expect(dropDown.selectedItems).toHaveCount(0) await expect(dropDown.selectedItems).toHaveCount(0)
await expect(vectorItems).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. // Add-item button opens dropdown, after closing with escape.
await page.keyboard.press('Escape') await page.keyboard.press('Escape')
await dropDown.expectNotVisible() await dropDown.expectNotVisible()
await vector.locator('.add-item').click() await locate.addItemButton(vector).click()
await expect(dropDown.items).toHaveCount(2) await expect(dropDown.items).toHaveCount(2)
await expect(dropDown.selectedItems).toHaveCount(1) await expect(dropDown.selectedItems).toHaveCount(1)
@ -180,7 +180,7 @@ test('Multi-selection widget: Item edits', async ({ page }) => {
.graphNodeByBinding(page, 'selected') .graphNodeByBinding(page, 'selected')
.locator('.WidgetTopLevelArgument') .locator('.WidgetTopLevelArgument')
.filter({ has: page.getByText('columns') }) .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) const dropDown = new DropDownLocator(columnsArg)
await dropDown.clickWidget() await dropDown.clickWidget()
await dropDown.clickOption('Column A') await dropDown.clickOption('Column A')
@ -195,6 +195,46 @@ test('Multi-selection widget: Item edits', async ({ page }) => {
await expect(dropDown.selectedItem('Column B')).toExist() 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> { async function dataReadNodeWithMethodCallInfo(page: Page): Promise<Locator> {
await mockMethodCallInfo(page, 'data', { await mockMethodCallInfo(page, 'data', {
methodPointer: { methodPointer: {
@ -374,7 +414,7 @@ test('Manage aggregates in `aggregate` node', async ({ page }) => {
// Add first aggregate // Add first aggregate
const columnsArg = topLevelArgs.filter({ has: page.getByText('columns') }) 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([ await expect(columnsArg.locator('.WidgetToken')).toContainText([
'Aggregate_Column', 'Aggregate_Column',
'.', '.',
@ -423,7 +463,10 @@ test('Manage aggregates in `aggregate` node', async ({ page }) => {
) )
// Set column // 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) const firstItemDropdown = new DropDownLocator(firstItem)
await firstItemDropdown.clickWidget() await firstItemDropdown.clickWidget()
await firstItemDropdown.expectVisibleWithOptions(['column 1', 'column 2']) 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') await expect(columnsArg.locator('.WidgetText > input').first()).toHaveValue('column 1')
// Add another aggregate // Add another aggregate
await columnsArg.locator('.add-item').click() await locate.addItemButton(columnsArg).click()
await expect(columnsArg.locator('.WidgetToken')).toContainText([ await expect(columnsArg.locator('.WidgetToken')).toContainText([
'Aggregate_Column', 'Aggregate_Column',
'.', '.',
@ -462,7 +505,10 @@ test('Manage aggregates in `aggregate` node', async ({ page }) => {
) )
// Set new aggregate's column // 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) const secondItemDropdown = new DropDownLocator(secondItem)
await secondItemDropdown.clickWidget() await secondItemDropdown.clickWidget()
await secondItemDropdown.expectVisibleWithOptions(['column 1', 'column 2']) await secondItemDropdown.expectVisibleWithOptions(['column 1', 'column 2'])
@ -533,7 +579,7 @@ test('Autoscoped constructors', async ({ page }) => {
await node.click() await node.click()
await expect(topLevelArgs).toHaveCount(3) 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).toBeVisible()
await expect(groupBy.locator('.WidgetArgumentName')).toContainText(['column', 'new_name']) await expect(groupBy.locator('.WidgetArgumentName')).toContainText(['column', 'new_name'])
}) })

View File

@ -238,7 +238,7 @@ const innerWidgetInput = computed<WidgetInput>(() => {
const parentSelectionArrow = injectSelectionArrow(true) const parentSelectionArrow = injectSelectionArrow(true)
const arrowSuppressed = ref(false) const arrowSuppressed = ref(false)
const showArrow = computed(() => isHovered.value && !arrowSuppressed.value) const showArrow = computed(() => !arrowSuppressed.value && (tree.extended || isHovered.value))
provideSelectionArrow( provideSelectionArrow(
proxyRefs({ proxyRefs({
id: computed(() => { id: computed(() => {
@ -464,7 +464,11 @@ declare module '@/providers/widgetRegistry' {
> >
<NodeWidget :input="innerWidgetInput" /> <NodeWidget :input="innerWidgetInput" />
<Teleport v-if="showArrow" defer :disabled="!arrowLocation" :to="arrowLocation"> <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>
<Teleport v-if="tree.nodeElement" :to="tree.nodeElement"> <Teleport v-if="tree.nodeElement" :to="tree.nodeElement">
<div ref="dropdownElement" :style="floatingStyles" class="widgetOutOfLayout floatingElement"> <div ref="dropdownElement" :style="floatingStyles" class="widgetOutOfLayout floatingElement">
@ -515,6 +519,9 @@ svg.arrow {
opacity: 0.5; opacity: 0.5;
/* Prevent the parent from receiving a pointerout event if the mouse is over the arrow, which causes flickering. */ /* Prevent the parent from receiving a pointerout event if the mouse is over the arrow, which causes flickering. */
pointer-events: none; pointer-events: none;
&.hovered {
opacity: 0.9;
}
} }
.activityElement { .activityElement {

View File

@ -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 SvgIcon from '@/components/SvgIcon.vue'
import { useRaf } from '@/composables/animation' import { useRaf } from '@/composables/animation'
import { useEvent } from '@/composables/events' import { useEvent } from '@/composables/events'
import { useAppClass } from '@/providers/appClass' import { useAppClass } from '@/providers/appClass'
import { injectWidgetTree } from '@/providers/widgetTree'
import { Range } from '@/util/data/range' import { Range } from '@/util/data/range'
import { Vec2 } from '@/util/data/vec2' import { Vec2 } from '@/util/data/vec2'
import { uuidv4 } from 'lib0/random' import { uuidv4 } from 'lib0/random'
import { computed, ref, shallowReactive, watchEffect, watchPostEffect } from 'vue' import { computed, type Ref, ref, shallowReactive, watchEffect, watchPostEffect } from 'vue'
</script>
<script setup lang="ts" generic="T">
const props = defineProps<{ const props = defineProps<{
modelValue: T[] modelValue: T[]
newItem: () => T | undefined newItem: () => T | undefined
@ -44,6 +45,8 @@ const emit = defineEmits<{
'update:modelValue': [modelValue: T[]] 'update:modelValue': [modelValue: T[]]
}>() }>()
const tree = injectWidgetTree()
const listUuid = uuidv4() const listUuid = uuidv4()
const mimeType = computed(() => props.dragMimeType ?? 'application/octet-stream') const mimeType = computed(() => props.dragMimeType ?? 'application/octet-stream')
@ -74,30 +77,34 @@ function decodeMetadataFromMime(mime: string): DropMetadata | undefined {
const draggedIndex = ref<number>() const draggedIndex = ref<number>()
type DragItem = interface BaseItem {
| { key: string | number
type: 'item' }
index: number
item: T interface NonPlaceholderItem extends BaseItem {
key: string | number type: 'item'
} index: number
| { item: T
type: 'placeholder' hintDeletable: Ref<boolean>
width: number }
key: string | number
} interface PlaceholderItem extends BaseItem {
type: 'placeholder'
width: number
}
type DragItem = NonPlaceholderItem | PlaceholderItem
const defaultPlaceholderKey = '__placeholder_key__' const defaultPlaceholderKey = '__placeholder_key__'
const mappedItems = computed(() => { const mappedItems = computed<DragItem[]>(() => {
return props.modelValue.map( return props.modelValue.map((item, index) => ({
(item, index): DragItem => ({ type: 'item',
type: 'item', index,
index, item,
item, key: props.getKey?.(item) ?? index,
key: props.getKey?.(item) ?? index, hintDeletable: ref(false),
}), }))
)
}) })
const dropInfo = ref<DropHoverInfo>() const dropInfo = ref<DropHoverInfo>()
@ -126,8 +133,7 @@ const displayedChildren = computed(() => {
key, key,
} as const) } as const)
} }
const x = items.filter((item) => item.type !== 'item' || item.index !== draggedIndex.value) return items.filter((item) => item.type !== 'item' || item.index !== draggedIndex.value)
return x
}) })
const rootNode = ref<HTMLElement>() const rootNode = ref<HTMLElement>()
@ -135,69 +141,66 @@ const rootNode = ref<HTMLElement>()
const cssPropsToCopy = ['--color-node-primary', '--node-color-port', '--node-border-radius'] const cssPropsToCopy = ['--color-node-primary', '--node-color-port', '--node-border-radius']
function onDragStart(event: DragEvent, index: number) { function onDragStart(event: DragEvent, index: number) {
if ( if (!event.dataTransfer) return
!(event.target instanceof HTMLElement && event.target.nextElementSibling instanceof HTMLElement) if (!(event.target instanceof HTMLElement)) return
) // The element that will be shown following the mouse.
return 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) { // Create a fake offscreen DOM element to use as the drag "ghost" image. It will hold a visual
// The dragged widget contents is the next sibling of the drag handle. // clone of the widget being dragged. The ghost style is modified to add a background color
const previewElement = event.target.nextElementSibling // 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 event.dataTransfer.effectAllowed = 'move'
// clone of the widget being dragged. The ghost style is modified to add a background color // `dropEffect: none` does not work for removing an element - it disables drop completely.
// and additional border, as well as apply appropriate element scaling in cross-browser way. event.dataTransfer.dropEffect = 'move'
const elementOffsetWidth = previewElement.offsetWidth const dragItem = props.modelValue[index]!
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' const meta: DropMetadata = {
// `dropEffect: none` does not work for removing an element - it disables drop completely. list: listUuid,
event.dataTransfer.dropEffect = 'move' key: props.getKey?.(dragItem) ?? index,
const dragItem = props.modelValue[index]! width: elementOffsetWidth,
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 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 { interface DropMetadata {
@ -219,6 +222,7 @@ function areaDragOver(e: DragEvent) {
const metaMime = e.dataTransfer?.types.find((ty) => ty.startsWith(dragMetaMimePrefix)) const metaMime = e.dataTransfer?.types.find((ty) => ty.startsWith(dragMetaMimePrefix))
const typesMatch = e.dataTransfer?.types.includes(mimeType.value) const typesMatch = e.dataTransfer?.types.includes(mimeType.value)
if (!metaMime || !typesMatch) return if (!metaMime || !typesMatch) return
e.preventDefault()
const meta = decodeMetadataFromMime(metaMime) const meta = decodeMetadataFromMime(metaMime)
if (meta == null) return if (meta == null) return
@ -367,6 +371,11 @@ function addItem() {
const item = props.newItem() const item = props.newItem()
if (item) emit('update:modelValue', [...props.modelValue, item]) 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> </script>
<template> <template>
@ -388,21 +397,49 @@ function addItem() {
> >
<template v-for="entry in displayedChildren" :key="entry.key"> <template v-for="entry in displayedChildren" :key="entry.key">
<template v-if="entry.type === 'item'"> <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 <div
class="handle" v-if="entry.index != props.modelValue.length - 1"
draggable="true" class="token widgetApplyPadding"
@dragstart="onDragStart($event, entry.index)" >
@dragend="onDragEnd" ,&nbsp;
></div> </div>
<slot :item="entry.item"></slot>
</li>
<li
v-show="entry.index != props.modelValue.length - 1"
:ref="patchBoundingClientRectScaling"
class="token widgetApplyPadding"
>
,&nbsp;
</li> </li>
</template> </template>
<template v-else> <template v-else>
@ -414,7 +451,18 @@ function addItem() {
</template> </template>
</template> </template>
</TransitionGroup> </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> <span class="token widgetApplyPadding">]</span>
</div> </div>
<div <div
@ -489,10 +537,10 @@ div {
align-items: center; align-items: center;
} }
.item .preview { .draggableContent {
background: var(--color-node-primary); display: flex;
padding: 4px; flex-direction: row;
border-radius: var(--node-border-radius); align-items: center;
} }
.token { .token {
@ -514,85 +562,60 @@ div {
} }
.handle { .handle {
position: absolute; transition: color 0.2s ease;
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;
cursor: grab; cursor: grab;
color: var(--color-widget-unfocus);
&:hover {
color: var(--color-widget-focus);
}
} }
.item:hover { .item:hover {
z-index: 0; z-index: 0;
} }
.item:hover .handle { .item-button {
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 {
transition-property: opacity; transition-property: opacity;
transition-duration: 150ms; transition-duration: 150ms;
transition-timing-function: ease-in-out; transition-timing-function: ease-in-out;
opacity: 0.5; opacity: 0.5;
margin-left: 4px;
transition: margin 0.2s ease-in-out; transition: margin 0.2s ease-in-out;
.items:empty + & { .items:empty + & {
margin: 0 2px; margin: 0 2px;
} }
&:hover {
opacity: 1;
}
} }
.add-item:hover { .after-last-item {
opacity: 1; margin-left: 4px;
} }
:global(.ListWidget-drag-ghost) { :global(.ListWidget-drag-ghost) {
position: absolute; position: absolute;
left: -5000px; left: -5000px;
}
:global(.ListWidget-drag-ghost > div) {
background-color: var(--color-node-primary); background-color: var(--color-node-primary);
border-radius: var(--node-border-radius); border-radius: var(--node-border-radius);
padding: 4px; padding: 4px;
color: white; 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> </style>