Files
speckle-server/packages/ui-components/src/components/form/select/Base.vue
T
Benjamin Ottensten fc76fd9f4f Various polishing around the product (#2427)
* Update header navigation

Logo, share button color, breadcrumb colors, spacings

* Updates to main button component

Shadows, border on secondary button, less spacings to icons

* Update spacings in dialog after bew button styling

* Use secondary button in embed dialog

* Update select inputs

Spacing, icon, border on dropdown, smaller avatars

* Update inputs to use the new styling

* Various copy updates

* Update icons

Smaller icons, outline instead of solid, removed some icons that were unnecessary

* Switch order of actions in Delete dialog

* Update styling of inline New model action

* Remove strange BG effect on comments component

* Update styling of hide/isolate actions in viewer

Was necessary after the button styling change. But new copy also makes it more usable.

* Fix alignment issue in selection info panel

* Align styling in Viewer panel component

* Clean up measure usage tips

A permanent "Right click to cancel measurement" tip isn't needed

* Panel spacing

* Update actions in the add model to viewer dialog

* Update permissions input in new project dialog

* Two minor things

* Remove unnecessary flex classes

---------

Co-authored-by: andrewwallacespeckle <andrew@speckle.systems>
2024-06-25 14:43:50 +02:00

715 lines
20 KiB
Vue

<template>
<div>
<Listbox
:key="forceUpdateKey"
v-model="wrappedValue"
:name="name"
:multiple="multiple"
:by="by"
:disabled="isDisabled"
as="div"
>
<ListboxLabel
:id="labelId"
class="flex label text-foreground mb-1.5"
:class="{ 'sr-only': !showLabel }"
:for="buttonId"
>
{{ label }}
<div v-if="showRequired" class="text-danger text-xs opacity-80">*</div>
</ListboxLabel>
<div :class="buttonsWrapperClasses">
<!-- <div class="relative flex"> -->
<ListboxButton
:id="buttonId"
ref="listboxButton"
v-slot="{ open }"
:class="buttonClasses"
>
<div class="flex items-center justify-between w-full">
<div
class="block truncate grow text-left text-xs sm:text-sm"
:class="[hasValueSelected ? 'text-foreground' : 'text-foreground-2']"
>
<template
v-if="!wrappedValue || (isArray(wrappedValue) && !wrappedValue.length)"
>
<slot name="nothing-selected">
{{ placeholder ? placeholder : label }}
</slot>
</template>
<template v-else>
<slot name="something-selected" :value="wrappedValue">
{{ simpleDisplayText(wrappedValue) }}
</slot>
</template>
</div>
<div class="pointer-events-none shrink-0 ml-1 flex items-center space-x-2">
<ExclamationCircleIcon
v-if="errorMessage"
class="h-4 w-4 text-danger"
aria-hidden="true"
/>
<div
v-else-if="!showLabel && showRequired"
class="text-4xl text-danger opacity-50 h-4 w-4 leading-6"
>
*
</div>
<ChevronUpIcon
v-if="open"
class="h-4 w-4 text-foreground"
aria-hidden="true"
/>
<ChevronDownIcon
v-else
class="h-4 w-4 text-foreground"
aria-hidden="true"
/>
</div>
</div>
<!-- Sync isOpen with dropdown open state -->
<template v-if="(isOpen = open)"></template>
</ListboxButton>
<!-- </div> -->
<!-- Clear Button -->
<button
v-if="renderClearButton"
:class="clearButtonClasses"
:disabled="disabled"
@click="clearValue()"
>
<XMarkIcon class="w-3 h-3" />
</button>
<Transition
v-if="isMounted"
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<Teleport to="body" :disabled="!mountMenuOnBody">
<ListboxOptions
ref="menuEl"
:class="listboxOptionsClasses"
:style="listboxOptionsStyle"
@focus="searchInput?.focus()"
>
<label v-if="hasSearch" class="flex flex-col mx-1 mb-1">
<span class="sr-only label text-foreground">Search</span>
<div class="relative">
<div
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-2"
>
<MagnifyingGlassIcon class="h-4 w-4 text-foreground-2" />
</div>
<input
ref="searchInput"
v-model="searchValue"
type="text"
class="py-1 pl-7 w-full bg-foundation-page rounded-[5px] placeholder:font-normal normal placeholder:text-foreground-2 focus:outline-none focus:ring-1 border-outline-3 focus:border-outline-1 focus:ring-outline-1 text-sm"
:placeholder="searchPlaceholder"
@keydown.stop
/>
</div>
</label>
<div
class="overflow-auto simple-scrollbar"
:class="[hasSearch ? 'max-h-52' : 'max-h-40']"
>
<div v-if="isAsyncSearchMode && isAsyncLoading" class="px-1">
<CommonLoadingBar :loading="true" />
</div>
<div v-else-if="isAsyncSearchMode && !currentItems.length">
<div class="text-foreground-2 text-center">
<slot name="nothing-found">Nothing found</slot>
</div>
</div>
<template v-if="!isAsyncSearchMode || !isAsyncLoading">
<ListboxOption
v-for="item in finalItems"
:key="itemKey(item)"
v-slot="{
active,
selected
}: {
active: boolean,
selected: boolean
}"
:value="item"
:disabled="disabledItemPredicate?.(item) || false"
>
<li
:class="
listboxOptionClasses({
active,
disabled: disabledItemPredicate?.(item) || false
})
"
>
<span :class="['block truncate']">
<slot
name="option"
:item="item"
:active="active"
:selected="selected"
:disabled="disabledItemPredicate?.(item) || false"
>
{{ simpleDisplayText(item) }}
</slot>
</span>
<span
v-if="!hideCheckmarks && selected"
:class="[
active ? 'text-primary' : 'text-foreground',
'absolute inset-y-0 right-0 flex items-center pr-4'
]"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ListboxOption>
</template>
</div>
</ListboxOptions>
</Teleport>
</Transition>
</div>
</Listbox>
<p v-if="helpTipId" :id="helpTipId" class="mt-2 text-xs" :class="helpTipClasses">
{{ helpTip }}
</p>
</div>
</template>
<script
setup
lang="ts"
generic="SingleItem extends Record<string, unknown> | string | number"
>
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
ListboxLabel
} from '@headlessui/vue'
import {
ChevronDownIcon,
CheckIcon,
ChevronUpIcon,
MagnifyingGlassIcon,
XMarkIcon,
ExclamationCircleIcon
} from '@heroicons/vue/20/solid'
import { debounce, isArray, isObjectLike } from 'lodash'
import type { CSSProperties, PropType, Ref } from 'vue'
import { computed, onMounted, ref, unref, watch } from 'vue'
import type { MaybeAsync, Nullable, Optional } from '@speckle/shared'
import { useField } from 'vee-validate'
import type { RuleExpression } from 'vee-validate'
import { nanoid } from 'nanoid'
import CommonLoadingBar from '~~/src/components/common/loading/Bar.vue'
import { useElementBounding, useMounted, useIntersectionObserver } from '@vueuse/core'
type ButtonStyle = 'base' | 'simple' | 'tinted'
type ValueType = SingleItem | SingleItem[] | undefined
const isObjectLikeType = (v: unknown): v is Record<string, unknown> => isObjectLike(v)
const emit = defineEmits<{
(e: 'update:modelValue', v: ValueType): void
}>()
const props = defineProps({
multiple: {
type: Boolean,
default: false
},
items: {
type: Array as PropType<SingleItem[]>,
default: () => []
},
modelValue: {
type: [Object, Array, String] as PropType<ValueType>,
default: undefined
},
/**
* Whether to enable the search bar. You must also set one of the following:
* * filterPredicate - to allow filtering passed in `items` based on search bar
* * getSearchResults - to allow asynchronously loading items from server (props.items no longer required in this case,
* but can be used to prefill initial values)
*/
search: {
type: Boolean,
default: false
},
/**
* If search=true and this is set, you can use this to filter passed in items based on whatever
* the user enters in the search bar
*/
filterPredicate: {
type: Function as PropType<
Optional<(item: SingleItem, searchString: string) => boolean>
>,
default: undefined
},
/**
* Set this to disable certain items in the list
*/
disabledItemPredicate: {
type: Function as PropType<Optional<(item: SingleItem) => boolean>>,
default: undefined
},
/**
* If search=true and this is set, you can use this to load data asynchronously depending
* on the search query
*/
getSearchResults: {
type: Function as PropType<
Optional<(searchString: string) => MaybeAsync<SingleItem[]>>
>,
default: undefined
},
searchPlaceholder: {
type: String,
default: 'Search'
},
/**
* Label is required at the very least for screen-readers
*/
label: {
type: String,
required: true
},
/**
* Optional text that replaces the label as the placeholder when set.
*/
placeholder: {
type: String
},
/**
* Whether to show the label visually
*/
showLabel: {
type: Boolean,
default: false
},
name: {
type: String,
required: true
},
/**
* Objects will be compared by the values in the specified prop
*/
by: {
type: String,
required: false
},
disabled: {
type: Boolean as PropType<Optional<boolean>>,
default: false
},
buttonStyle: {
type: String as PropType<Optional<ButtonStyle>>,
default: 'base'
},
hideCheckmarks: {
type: Boolean as PropType<Optional<boolean>>,
default: false
},
allowUnset: {
type: Boolean as PropType<Optional<boolean>>,
default: true
},
clearable: {
type: Boolean,
default: false
},
/**
* Validation stuff
*/
rules: {
type: [String, Object, Function, Array] as PropType<RuleExpression<ValueType>>,
default: undefined
},
/**
* vee-validate validation() on component mount
*/
validateOnMount: {
type: Boolean,
default: false
},
/**
* Whether to trigger validation whenever the value changes
*/
validateOnValueUpdate: {
type: Boolean,
default: false
},
/**
* Will replace the generic "Value" text with the name of the input in error messages
*/
useLabelInErrors: {
type: Boolean,
default: true
},
/**
* Optional help text
*/
help: {
type: String as PropType<Optional<string>>,
default: undefined
},
fixedHeight: {
type: Boolean,
default: false
},
/**
* By default component holds its own internal value state so that even if you don't have it tied up to a real `modelValue` ref somewhere
* it knows its internal state and can report it on form submits.
*
* If you set this to true, its only going to rely on `modelValue` as its primary source of truth so that you can reject updates etc.
*/
fullyControlValue: {
type: Boolean,
default: false
},
/**
* Whether to show the red "required" asterisk
*/
showRequired: {
type: Boolean,
default: false
},
/**
* Whether to mount the menu on the body instead of inside the component. Useful when select box is mounted within
* dialog windows and the menu causes unnecessary overflow.
*/
mountMenuOnBody: {
type: Boolean,
default: false
},
labelId: {
type: String,
default: undefined
},
buttonId: {
type: String,
default: undefined
}
})
const { value, errorMessage: error } = useField<ValueType>(props.name, props.rules, {
validateOnMount: props.validateOnMount,
validateOnValueUpdate: props.validateOnValueUpdate,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
initialValue: props.modelValue as ValueType
})
const isMounted = useMounted()
const searchInput = ref(null as Nullable<HTMLInputElement>)
const menuEl = ref(null as Nullable<{ el: Nullable<HTMLElement> }>)
const listboxButton = ref(null as Nullable<{ el: Nullable<HTMLButtonElement> }>)
const searchValue = ref('')
const currentItems = ref([]) as Ref<SingleItem[]>
const isAsyncLoading = ref(false)
const forceUpdateKey = ref(1)
const internalHelpTipId = ref(nanoid())
const isOpen = ref(false)
const listboxButtonBounding = useElementBounding(
computed(() => listboxButton.value?.el),
{ windowResize: true, windowScroll: true, immediate: true }
)
useIntersectionObserver(
computed(() => menuEl.value?.el),
([{ isIntersecting }]) => {
if (isIntersecting && props.mountMenuOnBody) {
listboxButtonBounding.update()
}
}
)
const title = computed(() => unref(props.label) || unref(props.name))
const errorMessage = computed(() => {
const base = error.value
if (!base || !unref(props.useLabelInErrors)) return base
return base.replace('Value', title.value)
})
const helpTip = computed(() => errorMessage.value || unref(props.help))
const hasHelpTip = computed(() => !!helpTip.value)
const helpTipId = computed(() =>
hasHelpTip.value ? `${unref(props.name)}-${internalHelpTipId.value}` : undefined
)
const helpTipClasses = computed((): string =>
error.value ? 'text-danger' : 'text-foreground-2'
)
const renderClearButton = computed(
() => props.buttonStyle !== 'simple' && props.clearable && !props.disabled
)
const buttonsWrapperClasses = computed(() => {
const classParts: string[] = ['relative flex group']
if (error.value) {
classParts.push('hover:shadow rounded-md')
classParts.push('text-danger-darker focus:border-danger focus:ring-danger')
if (props.buttonStyle !== 'simple') {
classParts.push('outline outline-2 outline-danger')
}
} else if (props.buttonStyle !== 'simple') {
classParts.push('rounded-md border')
if (isOpen.value) {
classParts.push('border-outline-1')
} else {
classParts.push('border-outline-3')
}
}
if (props.fixedHeight) {
classParts.push('h-8')
}
return classParts.join(' ')
})
const commonButtonClasses = computed(() => {
const classParts: string[] = []
if (props.buttonStyle !== 'simple') {
// classParts.push('group-hover:shadow')
// classParts.push('outline outline-2 outline-primary-muted ')
classParts.push(
isDisabled.value ? 'bg-foundation-disabled text-foreground-disabled' : ''
)
}
if (isDisabled.value) classParts.push('cursor-not-allowed')
return classParts.join(' ')
})
const clearButtonClasses = computed(() => {
const classParts = [
'relative z-[1]',
'flex items-center justify-center text-center shrink-0',
'rounded-r-md overflow-hidden transition-all',
'text-foreground',
hasValueSelected.value ? `w-6 ${commonButtonClasses.value}` : 'w-0'
]
if (!isDisabled.value) {
classParts.push(
'hover:bg-primary hover:text-foreground-on-primary dark:text-foreground-on-primary'
)
if (props.buttonStyle === 'tinted') {
classParts.push('bg-outline-3')
} else {
classParts.push('bg-primary-muted')
}
}
return classParts.join(' ')
})
const buttonClasses = computed(() => {
const classParts = [
'relative z-[2]',
'normal rounded-md cursor-pointer transition truncate flex-1',
'flex items-center',
commonButtonClasses.value
]
if (props.buttonStyle !== 'simple') {
classParts.push('p-2')
if (!isDisabled.value) {
if (props.buttonStyle === 'tinted') {
classParts.push('bg-foundation-page text-foreground')
} else {
classParts.push('bg-foundation text-foreground')
}
}
}
if (renderClearButton.value && hasValueSelected.value) {
classParts.push('rounded-r-none')
}
return classParts.join(' ')
})
const hasSearch = computed(
() => !!(props.search && (props.filterPredicate || props.getSearchResults))
)
const isAsyncSearchMode = computed(() => hasSearch.value && props.getSearchResults)
const isDisabled = computed(
() => props.disabled || (!props.items.length && !isAsyncSearchMode.value)
)
const wrappedValue = computed({
get: () => {
const currentValue = value.value
if (props.multiple) {
return isArray(currentValue) ? currentValue : []
} else {
return isArray(currentValue) ? undefined : currentValue
}
},
set: (newVal) => {
if (props.multiple && !isArray(newVal)) {
console.warn('Attempting to set non-array value in selector w/ multiple=true')
return
} else if (!props.multiple && isArray(newVal)) {
console.warn('Attempting to set array value in selector w/ multiple=false')
return
}
let finalValue: typeof value.value
if (props.multiple) {
finalValue = newVal || []
} else {
const currentVal = value.value
const isUnset =
props.allowUnset &&
currentVal &&
newVal &&
itemKey(currentVal as SingleItem) === itemKey(newVal as SingleItem)
finalValue = isUnset ? undefined : newVal
}
if (props.fullyControlValue) {
// Not setting value.value, cause then we don't give a chance for the parent
// component to reject the update
emit('update:modelValue', finalValue)
} else {
value.value = finalValue
}
// hacky, but there's no other way to force ListBox to re-read the modelValue prop which
// we need in case the update was rejected and ListBox still thinks the value is the one
// that was clicked on
forceUpdateKey.value += 1
}
})
const hasValueSelected = computed(() => {
if (props.multiple && isArray(wrappedValue.value))
return wrappedValue.value.length !== 0
else return !!wrappedValue.value
})
const clearValue = () => {
if (props.multiple) wrappedValue.value = []
else wrappedValue.value = undefined
}
const finalItems = computed(() => {
const searchVal = searchValue.value
if (!hasSearch.value || !searchVal?.length) return currentItems.value
if (props.filterPredicate) {
return currentItems.value.filter(
(i) => props.filterPredicate?.(i, searchVal) || false
)
}
return currentItems.value
})
const listboxOptionsClasses = computed(() => {
const classParts = [
'rounded-md bg-foundation-2 py-1 label label--light border border-outline-3 shadow-md mt-1 '
]
if (props.mountMenuOnBody) {
classParts.push('fixed z-50')
} else {
classParts.push('absolute top-[100%] w-full z-10')
}
return classParts.join(' ')
})
const listboxOptionsStyle = computed(() => {
const style: CSSProperties = {}
if (props.mountMenuOnBody) {
const top = listboxButtonBounding.top.value
const left = listboxButtonBounding.left.value
const width = listboxButtonBounding.width.value
const height = listboxButtonBounding.height.value
style.top = `${top + height}px`
style.left = `${left}px`
style.width = `${width}px`
}
return style
})
const simpleDisplayText = (v: ValueType) => JSON.stringify(v)
const itemKey = (v: SingleItem): string | number => {
if (isObjectLikeType(v)) {
return v[props.by || 'id'] as string
} else {
return v
}
}
const triggerSearch = async () => {
if (!isAsyncSearchMode.value || !props.getSearchResults) return
isAsyncLoading.value = true
try {
currentItems.value = await props.getSearchResults(searchValue.value)
} finally {
isAsyncLoading.value = false
}
}
const debouncedSearch = debounce(triggerSearch, 1000)
const listboxOptionClasses = (params: { active: boolean; disabled: boolean }) => {
const { active, disabled } = params || {}
const { hideCheckmarks } = props
const classParts = [
'relative transition cursor-pointer select-none py-1.5 pl-3',
!hideCheckmarks ? 'pr-9' : ''
]
if (disabled) {
classParts.push('opacity-50 cursor-not-allowed')
} else {
classParts.push(active ? 'text-primary' : 'text-foreground')
}
return classParts.join(' ')
}
watch(
() => props.items,
(newItems) => {
currentItems.value = newItems.slice()
},
{ immediate: true }
)
watch(searchValue, () => {
if (!isAsyncSearchMode.value) return
void debouncedSearch()
})
onMounted(() => {
if (isAsyncSearchMode.value && !props.items.length) {
void triggerSearch()
}
})
defineExpose({ triggerSearch })
</script>