mirror of
https://github.com/enso-org/enso.git
synced 2024-11-26 17:06:48 +03:00
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:
parent
e5425d35a0
commit
e875781e29
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ProjectView from './views/ProjectView.vue'
|
||||
import ProjectView from '@/views/ProjectView.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
@ -33,9 +33,16 @@
|
||||
--color-heading: 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-dim: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
/* non-color variables */
|
||||
@ -62,6 +69,8 @@ body {
|
||||
/* TEMPORARY. Will be replaced with actual background when it is integrated with the dashboard. */
|
||||
background: #e4d4be;
|
||||
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;
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
|
||||
@ -82,6 +91,14 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.button.disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
@ -975,4 +975,37 @@
|
||||
<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" />
|
||||
</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>
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 86 KiB |
8
app/gui2/src/assets/icons/sort_descending.svg
Normal file
8
app/gui2/src/assets/icons/sort_descending.svg
Normal 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 |
@ -22,7 +22,7 @@ const emit = defineEmits<{
|
||||
|
||||
const rootNode = ref<HTMLElement>()
|
||||
const nodeSize = useResizeObserver(rootNode)
|
||||
const editableRoot = ref<HTMLElement>()
|
||||
const editableRootNode = ref<HTMLElement>()
|
||||
|
||||
watchEffect(() => {
|
||||
const size = nodeSize.value
|
||||
@ -157,7 +157,7 @@ interface SavedSelections {
|
||||
let selectionToRecover: SavedSelections | null = null
|
||||
|
||||
function saveSelections() {
|
||||
const root = editableRoot.value
|
||||
const root = editableRootNode.value
|
||||
const selection = window.getSelection()
|
||||
if (root == null || selection == null || !selection.containsNode(root, true)) return
|
||||
const ranges: ContentRange[] = Array.from({ length: selection.rangeCount }, (_, i) =>
|
||||
@ -186,9 +186,9 @@ function saveSelections() {
|
||||
}
|
||||
|
||||
onUpdated(() => {
|
||||
if (selectionToRecover != null && editableRoot.value != null) {
|
||||
if (selectionToRecover != null && editableRootNode.value != null) {
|
||||
const saved = selectionToRecover
|
||||
const root = editableRoot.value
|
||||
const root = editableRootNode.value
|
||||
selectionToRecover = null
|
||||
const selection = window.getSelection()
|
||||
if (selection == null) return
|
||||
@ -264,7 +264,7 @@ function handleClick(e: PointerEvent) {
|
||||
<div class="icon" @pointerdown="handleClick">@ </div>
|
||||
<div class="binding" @pointerdown.stop>{{ node.binding }}</div>
|
||||
<div
|
||||
ref="editableRoot"
|
||||
ref="editableRootNode"
|
||||
class="editable"
|
||||
contenteditable
|
||||
spellcheck="false"
|
||||
@ -308,6 +308,8 @@ function handleClick(e: PointerEvent) {
|
||||
|
||||
.editable {
|
||||
outline: none;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
@ -4,7 +4,7 @@ const emit = defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
|
||||
<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>
|
||||
|
||||
<style scoped>
|
||||
|
28
app/gui2/src/components/widgets/CheckboxWidget.vue
Normal file
28
app/gui2/src/components/widgets/CheckboxWidget.vue
Normal 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>
|
156
app/gui2/src/components/widgets/DropdownWidget.vue
Normal file
156
app/gui2/src/components/widgets/DropdownWidget.vue
Normal 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>
|
5
app/gui2/src/components/widgets/PlaceholderWidget.vue
Normal file
5
app/gui2/src/components/widgets/PlaceholderWidget.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div class="Placeholder">
|
||||
<SvgIcon name="underscore" />
|
||||
</div>
|
||||
</template>
|
82
app/gui2/src/components/widgets/SliderWidget.vue
Normal file
82
app/gui2/src/components/widgets/SliderWidget.vue
Normal 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>
|
@ -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 platform = hasWindow ? window.navigator?.platform ?? '' : ''
|
||||
// const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(platform)
|
||||
|
Loading…
Reference in New Issue
Block a user