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.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

View File

@ -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')

View File

@ -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'])
})

View File

@ -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 {

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 { 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"
>
,&nbsp;
v-if="entry.index != props.modelValue.length - 1"
class="token widgetApplyPadding"
>
,&nbsp;
</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>