Vue widgets (#7728)

Adds widgets:
- Checkbox (with sorting)
- Numeric slider
- Dropdown (accepting a list of strings)
- Closes #7731
- Placeholder (underscore - has no actions)

# Important Notes
The widgets are currently added to every node, but are not synced with the yjs representation. This is intentional, as (afaict) the format for the AST representation is not yet finalized.

There are a number of design differences, for practical reasons:
- The dropdown now has a scrollbar.
- As a side effect, the sort button needed to be moved left, to avoid overlapping with the scrollbar.
- Note that it is *not* centered in the 8px horizontal padding. It is 4px wide, and has 1px left and 3px right padding: `.||||...`. (Note that the 8px horizontal padding from the design is retained.
- 4px of vertical padding has been inserted, so that there is *some* padding between the bubble for the selected item, and the outer dropdown container, when the first item is selected. Note that this is different to the 8px
- 16px of right margin has been inserted after every item. This is the same amount of padding that is added by the bubble. This means that the dropdown does not change in width when a long item is selected.

Design issues:
- The sort button for the dropdown overlaps the text
This commit is contained in:
somebody1234 2023-09-13 20:06:24 +10:00 committed by GitHub
parent e5425d35a0
commit e875781e29
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 359 additions and 8 deletions

View File

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ProjectView from './views/ProjectView.vue' import ProjectView from '@/views/ProjectView.vue'
</script> </script>
<template> <template>

View File

@ -33,9 +33,16 @@
--color-heading: var(--vt-c-text-light-1); --color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1); --color-text: var(--vt-c-text-light-1);
--color-dim: rgba(0, 0, 0, 0.25); --color-text-light: rgba(255, 255, 255, 0.7);
--color-widget: rgba(255, 255, 255, 0.12);
--color-widget-selected: rgba(255, 255, 255, 0.58);
--color-port-connected: rgba(255, 255, 255, 0.15);
--color-frame-bg: rgba(255, 255, 255, 0.3); --color-frame-bg: rgba(255, 255, 255, 0.3);
--color-dim: rgba(0, 0, 0, 0.25);
} }
/* non-color variables */ /* non-color variables */
@ -62,6 +69,8 @@ body {
/* TEMPORARY. Will be replaced with actual background when it is integrated with the dashboard. */ /* TEMPORARY. Will be replaced with actual background when it is integrated with the dashboard. */
background: #e4d4be; background: #e4d4be;
color: var(--color-text); color: var(--color-text);
/* TEMPORARY. Will be replaced with actual background when it is integrated with the dashboard. */
background: #e4d4be;
transition: color 0.5s, background-color 0.5s; transition: color 0.5s, background-color 0.5s;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
@ -82,6 +91,14 @@ body {
cursor: pointer; cursor: pointer;
} }
:focus {
outline: none;
}
.hidden {
display: none;
}
.button.disabled { .button.disabled {
cursor: default; cursor: default;
} }

View File

@ -975,4 +975,37 @@
<g id="workflow_play" fill="none"> <g id="workflow_play" fill="none">
<path d="M4.48489 3.22329L11.5226 7.12542C12.2094 7.50621 12.2094 8.49376 11.5226 8.87455L4.48488 12.7767C3.81837 13.1462 2.99998 12.6642 2.99998 11.9021L2.99998 4.09786C2.99998 3.33575 3.81837 2.85374 4.48489 3.22329Z" fill="currentColor" /> <path d="M4.48489 3.22329L11.5226 7.12542C12.2094 7.50621 12.2094 8.49376 11.5226 8.87455L4.48488 12.7767C3.81837 13.1462 2.99998 12.6642 2.99998 11.9021L2.99998 4.09786C2.99998 3.33575 3.81837 2.85374 4.48489 3.22329Z" fill="currentColor" />
</g> </g>
<g id="underscore" fill="none">
<svg width="20" height="4" viewBox="0 0 20 4" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="4" rx="2" fill="white" fill-opacity="0.33" />
</svg>
</g>
<g id="sort" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.32">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 3L8 7L10 7L10 14L12 14L12 7L14 7L11 3Z" fill="#111827" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 14L8 10L6 10L6 3L4 3L4 10L2 10L5 14Z" fill="#111827" />
</g>
</svg>
</g>
<g id="sort_ascending" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.8">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 3L8 7L10 7L10 14L12 14L12 7L14 7L11 3Z" fill="#111827" />
<g opacity="0.4">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 14L8 10L6 10L6 3L4 3L4 10L2 10L5 14Z" fill="#111827" />
</g>
</g>
</svg>
</g>
<g id="sort_descending" fill="none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.8">
<g opacity="0.4">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 3L8 7L10 7L10 14L12 14L12 7L14 7L11 3Z" fill="#111827" />
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 14L8 10L6 10L6 3L4 3L4 10L2 10L5 14Z" fill="#111827" />
</g>
</svg>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -0,0 +1,8 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.8">
<g opacity="0.4">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 3L8 7L10 7L10 14L12 14L12 7L14 7L11 3Z" fill="#111827" />
</g>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 14L8 10L6 10L6 3L4 3L4 10L2 10L5 14Z" fill="#111827" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@ -22,7 +22,7 @@ const emit = defineEmits<{
const rootNode = ref<HTMLElement>() const rootNode = ref<HTMLElement>()
const nodeSize = useResizeObserver(rootNode) const nodeSize = useResizeObserver(rootNode)
const editableRoot = ref<HTMLElement>() const editableRootNode = ref<HTMLElement>()
watchEffect(() => { watchEffect(() => {
const size = nodeSize.value const size = nodeSize.value
@ -157,7 +157,7 @@ interface SavedSelections {
let selectionToRecover: SavedSelections | null = null let selectionToRecover: SavedSelections | null = null
function saveSelections() { function saveSelections() {
const root = editableRoot.value const root = editableRootNode.value
const selection = window.getSelection() const selection = window.getSelection()
if (root == null || selection == null || !selection.containsNode(root, true)) return if (root == null || selection == null || !selection.containsNode(root, true)) return
const ranges: ContentRange[] = Array.from({ length: selection.rangeCount }, (_, i) => const ranges: ContentRange[] = Array.from({ length: selection.rangeCount }, (_, i) =>
@ -186,9 +186,9 @@ function saveSelections() {
} }
onUpdated(() => { onUpdated(() => {
if (selectionToRecover != null && editableRoot.value != null) { if (selectionToRecover != null && editableRootNode.value != null) {
const saved = selectionToRecover const saved = selectionToRecover
const root = editableRoot.value const root = editableRootNode.value
selectionToRecover = null selectionToRecover = null
const selection = window.getSelection() const selection = window.getSelection()
if (selection == null) return if (selection == null) return
@ -264,7 +264,7 @@ function handleClick(e: PointerEvent) {
<div class="icon" @pointerdown="handleClick">@ &nbsp;</div> <div class="icon" @pointerdown="handleClick">@ &nbsp;</div>
<div class="binding" @pointerdown.stop>{{ node.binding }}</div> <div class="binding" @pointerdown.stop>{{ node.binding }}</div>
<div <div
ref="editableRoot" ref="editableRootNode"
class="editable" class="editable"
contenteditable contenteditable
spellcheck="false" spellcheck="false"
@ -308,6 +308,8 @@ function handleClick(e: PointerEvent) {
.editable { .editable {
outline: none; outline: none;
display: flex;
gap: 4px;
} }
.icon { .icon {

View File

@ -4,7 +4,7 @@ const emit = defineEmits<{ click: [] }>()
</script> </script>
<template> <template>
<div class="NavBreadcrumb"><span v-text="text" @click="emit('click')"></span></div> <div class="NavBreadcrumb"><span @click="emit('click')" v-text="text"></span></div>
</template> </template>
<style scoped> <style scoped>

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [modelValue: boolean] }>()
</script>
<template>
<div class="Checkbox" @click="emit('update:modelValue', !modelValue)">
<div :class="{ hidden: !modelValue }"></div>
</div>
</template>
<style scoped>
.Checkbox {
cursor: pointer;
width: 24px;
height: 24px;
border-radius: var(--radius-full);
background: var(--color-widget);
}
.Checkbox > div {
margin: 6px;
width: 12px;
height: 12px;
border-radius: var(--radius-full);
background: var(--color-widget-selected);
}
</style>

View File

@ -0,0 +1,156 @@
<script setup lang="ts">
import SvgIcon from '@/components/SvgIcon.vue'
import { computed, ref } from 'vue'
enum SortDirection {
none = 'none',
ascending = 'ascending',
descending = 'descending',
}
const props = defineProps<{ color: string; selectedValue: string | null; values: string[] }>()
const emit = defineEmits<{ click: [index: number] }>()
const sortDirection = ref<SortDirection>(SortDirection.none)
const sortedValuesAndIndices = computed(() => {
const valuesAndIndices = props.values.map<[value: string, index: number]>((value, index) => [
value,
index,
])
switch (sortDirection.value) {
case SortDirection.ascending: {
return valuesAndIndices.sort((a, b) => (a[0] > b[0] ? 1 : a[0] < b[0] ? -1 : 0))
}
case SortDirection.descending: {
return valuesAndIndices.sort((a, b) => (a[0] > b[0] ? -1 : a[0] < b[0] ? 1 : 0))
}
case SortDirection.none:
default: {
return valuesAndIndices
}
}
})
const ICON_LOOKUP: Record<SortDirection, string> = {
[SortDirection.none]: 'sort',
[SortDirection.ascending]: 'sort_ascending',
[SortDirection.descending]: 'sort_descending',
}
const NEXT_SORT_DIRECTION: Record<SortDirection, SortDirection> = {
[SortDirection.none]: SortDirection.ascending,
[SortDirection.ascending]: SortDirection.descending,
[SortDirection.descending]: SortDirection.none,
}
</script>
<template>
<div class="Dropdown">
<ul class="list" :style="{ background: color }" @wheel.stop>
<template v-for="[value, index] in sortedValuesAndIndices" :key="value">
<li v-if="value === selectedValue">
<div class="selected-item"><span v-text="value"></span></div>
</li>
<li v-else class="selectable-item button" @click="emit('click', index)">
<span v-text="value"></span>
</li>
</template>
</ul>
<div class="sort button">
<div class="sort-background" :style="{ background: color }"></div>
<SvgIcon
:name="ICON_LOOKUP[sortDirection]"
@click="sortDirection = NEXT_SORT_DIRECTION[sortDirection]"
/>
</div>
</div>
</template>
<style scoped>
.Dropdown {
position: absolute;
top: 100%;
margin-top: 4px;
height: 136px;
}
.list {
position: relative;
user-select: none;
overflow: auto;
border-radius: 8px;
width: min-content;
height: 100%;
scrollbar-width: none;
scrollbar-gutter: stable both-edges;
list-style-type: none;
color: var(--color-text-light);
padding: 4px 0;
}
.list::-webkit-scrollbar {
-webkit-appearance: none;
width: 8px;
}
.list::-webkit-scrollbar-track {
-webkit-box-shadow: none;
}
.list::-webkit-scrollbar-thumb {
border: 2px solid #0000;
border-left-width: 1px;
border-right-width: 3px;
background-clip: padding-box;
border-radius: var(--radius-full);
background-color: rgba(0, 0, 0, 0.2);
}
.list::-webkit-scrollbar-corner {
background: rgba(0, 0, 0, 0);
}
.list::-webkit-scrollbar-button {
height: 4px;
}
.sort-background {
position: absolute;
border-top-left-radius: var(--radius-full);
border-bottom-left-radius: var(--radius-full);
opacity: 0.5;
left: 0;
top: 0;
height: 100%;
width: 100%;
}
.sort {
position: absolute;
border-top-left-radius: var(--radius-full);
border-bottom-left-radius: var(--radius-full);
top: 1px;
right: 6px;
padding: 2px;
padding-right: 0;
line-height: 0;
}
.sort > img {
position: relative;
}
.selected-item {
border-radius: var(--radius-full);
background-color: var(--color-port-connected);
padding-left: 8px;
padding-right: 8px;
width: min-content;
}
.selectable-item {
margin-right: 16px;
}
</style>

View File

@ -0,0 +1,5 @@
<template>
<div class="Placeholder">
<SvgIcon name="underscore" />
</div>
</template>

View File

@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { PointerButtonMask, usePointer } from '@/util/events'
const props = defineProps<{ modelValue: number; min: number; max: number }>()
const emit = defineEmits<{ 'update:modelValue': [modelValue: number] }>()
const sliderNode = ref<HTMLElement>()
const dragPointer = usePointer((position) => {
if (sliderNode.value == null) {
return
}
const rect = sliderNode.value.getBoundingClientRect()
const fractionRaw = (position.absolute.x - rect.left) / (rect.right - rect.left)
const fraction = Math.max(0, Math.min(1, fractionRaw))
const newValue = props.min + Math.round(fraction * (props.max - props.min))
emit('update:modelValue', newValue)
}, PointerButtonMask.Main)
const sliderWidth = computed(
() => `${((props.modelValue - props.min) * 100) / (props.max - props.min)}%`,
)
const inputValue = computed({
get() {
return props.modelValue
},
set(value) {
emit('update:modelValue', value)
},
})
</script>
<template>
<div ref="sliderNode" class="Slider" v-on="dragPointer.events">
<div class="fraction" :style="{ width: sliderWidth }"></div>
<input v-model.number="inputValue" type="number" :size="1" class="value" />
</div>
</template>
<style scoped>
.Slider {
clip-path: inset(0 round var(--radius-full));
position: relative;
user-select: none;
display: flex;
justify-content: space-around;
background: var(--color-widget);
border-radius: var(--radius-full);
width: 56px;
}
.fraction {
position: absolute;
height: 100%;
left: 0;
background: var(--color-widget);
}
.value {
position: relative;
display: inline-block;
background: none;
border: none;
text-align: center;
min-width: 0;
font-weight: 800;
line-height: 171.5%;
height: 24px;
padding-top: 1px;
padding-bottom: 1px;
appearance: textfield;
-moz-appearance: textfield;
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>

View File

@ -88,6 +88,26 @@ export function useWindowEventConditional<K extends keyof WindowEventMap>(
}) })
} }
/**
* Add an event listener on document for the duration of condition being true.
* @param condition the condition that determines if event is bound
* @param event name of event to register
* @param handler event handler
*/
export function useDocumentEventConditional<K extends keyof DocumentEventMap>(
event: K,
condition: WatchSource<boolean>,
handler: (e: DocumentEventMap[K]) => void,
options?: boolean | AddEventListenerOptions,
): void {
watch(condition, (conditionMet, _, onCleanup) => {
if (conditionMet) {
document.addEventListener(event, handler, options)
onCleanup(() => document.removeEventListener(event, handler, options))
}
})
}
// const hasWindow = typeof window !== 'undefined' // const hasWindow = typeof window !== 'undefined'
// const platform = hasWindow ? window.navigator?.platform ?? '' : '' // const platform = hasWindow ? window.navigator?.platform ?? '' : ''
// const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(platform) // const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(platform)