Files
speckle-server/packages/frontend-2/components/form/select/Base.vue
T
Kristaps Fabians Geikins b02a07e2b6 feat: Frontend 2.0 MVP
2023-05-08 10:47:01 +03:00

521 lines
15 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div>
<Listbox
v-model="wrappedValue"
:name="name"
:multiple="multiple"
:by="by"
:disabled="isDisabled"
as="div"
>
<ListboxLabel
class="block label text-foreground"
:class="{ 'sr-only': !showLabel }"
>
{{ label }}
</ListboxLabel>
<div :class="buttonsWrapperClasses">
<!-- <div class="relative flex"> -->
<ListboxButton v-slot="{ open }" :class="buttonClasses">
<div class="flex items-center justify-between w-full">
<div class="block truncate grow text-left">
<template
v-if="!wrappedValue || (isArray(wrappedValue) && !wrappedValue.length)"
>
<slot name="nothing-selected">
{{ 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">
<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>
</ListboxButton>
<!-- </div> -->
<!-- Clear Button -->
<button
v-if="renderClearButton"
v-tippy="'Clear'"
:class="clearButtonClasses"
:disabled="disabled"
@click="clearValue()"
>
<XMarkIcon class="w-3 h-3" />
</button>
<Transition
leave-active-class="transition ease-in duration-100"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<ListboxOptions
class="absolute top-[100%] z-10 mt-1 w-full rounded-md bg-foundation-2 py-1 label label--light outline outline-2 outline-primary-muted focus:outline-none shadow"
@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-5 w-5 text-foreground" />
</div>
<input
ref="searchInput"
v-model="searchValue"
type="text"
class="pl-9 w-full border-0 bg-foundation-page rounded placeholder:font-normal normal placeholder:text-foreground-2 focus:outline-none focus:ring-1 focus:border-outline-1 focus:ring-outline-1"
:placeholder="searchPlaceholder"
@change="triggerSearch"
@keydown.stop
/>
</div>
</label>
<div
class="overflow-auto simple-scrollbar"
:class="[hasSearch ? 'max-h-52' : 'max-h-60']"
>
<div v-if="isAsyncSearchMode && isAsyncLoading" class="px-1">
<CommonLoadingBar :loading="true" />
</div>
<div v-else-if="isAsyncSearchMode && !currentItems.length">
<slot name="nothing-found">
<div class="text-foreground-2 text-center">Nothing found 🤷</div>
</slot>
</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"
>
<li
:class="[
active ? 'text-primary' : 'text-foreground',
'relative transition cursor-pointer select-none py-1.5 pl-3',
!hideCheckmarks ? 'pr-9' : ''
]"
>
<span :class="['block truncate']">
<slot
name="option"
:item="item"
:active="active"
:selected="selected"
>
{{ 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>
</Transition>
</div>
</Listbox>
<p
v-if="helpTipId"
:id="helpTipId"
class="mt-2 ml-3 text-sm"
:class="helpTipClasses"
>
{{ helpTip }}
</p>
</div>
</template>
<script setup lang="ts">
// Vue components don't support generic props, so having to rely on any
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
import {
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
ListboxLabel
} from '@headlessui/vue'
import {
ChevronDownIcon,
CheckIcon,
ChevronUpIcon,
MagnifyingGlassIcon,
XMarkIcon
} from '@heroicons/vue/24/solid'
import { debounce, isArray } from 'lodash-es'
import { PropType } from 'vue'
import { MaybeAsync, Nullable, Optional } from '@speckle/shared'
import { RuleExpression, useField } from 'vee-validate'
import { nanoid } from 'nanoid'
type ButtonStyle = 'base' | 'simple'
type SingleItem = any
type ValueType = SingleItem | SingleItem[] | undefined
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
},
/**
* 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
},
/**
* 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<string>>,
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
}
})
const { value, errorMessage: error } = useField<ValueType>(props.name, props.rules, {
validateOnMount: props.validateOnMount,
validateOnValueUpdate: props.validateOnValueUpdate,
initialValue: props.modelValue
})
const searchInput = ref(null as Nullable<HTMLInputElement>)
const searchValue = ref('')
const currentItems = ref([] as SingleItem[])
const isAsyncLoading = ref(false)
const internalHelpTipId = ref(nanoid())
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', props.showLabel ? 'mt-1' : '']
if (props.buttonStyle !== 'simple') {
classParts.push('hover:shadow rounded-md')
classParts.push('outline outline-2 outline-primary-muted')
}
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',
hasValueSelected.value ? `w-6 ${commonButtonClasses.value}` : 'w-0'
]
if (!isDisabled.value) {
classParts.push(
'bg-primary-muted hover:bg-primary hover:text-foreground-on-primary'
)
}
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('py-2 px-3')
if (!isDisabled.value) {
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
}
if (props.multiple) {
value.value = newVal || []
} else {
const currentVal = value.value
const isUnset =
props.allowUnset &&
currentVal &&
newVal &&
itemKey(currentVal as SingleItem) === itemKey(newVal as SingleItem)
value.value = isUnset ? undefined : newVal
}
}
})
const hasValueSelected = computed(() => {
if (props.multiple) 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 simpleDisplayText = (v: ValueType) => JSON.stringify(v)
const itemKey = (v: SingleItem): string | number =>
props.by ? (v[props.by] as string) : 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)
watch(
() => props.items,
(newItems) => {
currentItems.value = newItems.slice()
},
{ immediate: true }
)
watch(searchValue, () => {
if (!isAsyncSearchMode.value) return
debouncedSearch()
})
onMounted(() => {
if (isAsyncSearchMode.value && !props.items.length) {
triggerSearch()
}
})
</script>