e72b193f06
* Add settings tab. Update style of component * Structuring of files/components * Updates to TexInput * Add RadioGroup * Last FE work * FE Updates * Webhooks Settings Tab * Styling updates to webhooks * Title/Description Update * General Page done * Collaborators WIP * Styling updates * Add custom message to updateProject * Radio Group Same Height * Styling updates to radio group. Disabled state * Updates pre demo * Updates to icons & post demo changes * Major Updates * Unsaved Changes Dialog * Routing WIP * Remove StatsBlock * Auto update discussions on Private * Routing/Redirects * New input style * Invite Dialog * Fix mobile radiogroup * Mobile Improvements * Fix console warning * Fix build * Disabled States * Fix console * Unhide webhooks * Updates from testing * Responsive fixes * Alignment fixes * Fix textarea mobile height * Updates to GraphQL Fragments * Fix disabled state * WIP Arrows for scroll * Update PageTabs - broken * Fix to PageTabs * PageTab fix initial scroll * Hide Scrollbar * Better underline method * Fix mobile initial underline * Webhooks Empty State * Fix input border * Fix empty state * Input Styling updates * Remove mobile smaller text * Update disabled state for disabled items * Updates disabled sates on Settings Block * Fix build. Disable Invite * Fixes to invite permissions * Disable role select when invite is disabled * Small alignment fix * Fix webhooks empty state * cleaning up unnecessary vue files * story improvement * Remove DisabledMessage prop * Fix disabled prop on Button * Move team to Leave Fragment * Remove unused Disabled Message props * Add limit to graphql query * Updates to BlockDiscussions * add formatTriggers function to webhooks * Remove md from button. Improved switch * Update RadioGroup.stories.ts * Update RadioGroup to use defineModel * Various styling and copy updates - More concise and accurate copy - More readable - Works better on mobile * Updates to Invite Dialog * Custom success Message * Update slot names * Remove md in TextInput. Set h-8 to default * Changes from call with fabs * Replace isOwner with composable * Set SettingsBlock icon as optional * Comments from PR * Updates from PR * Final Tidy Ups * Fix Title/Description * Fix spacing issue on Webhooks page * Update borders and colors to align with Automate Makes the same changes that I recently made to the unreleased Automate tab * WEB-869 * Improve styling of radiogroup component Better borders, hover effect, bigger checkmark icon, more subtle active background color, same across breakpoints. * Adjust border styling of RadioGroup component * Improve circle around checkmark in RadioGroup * Split Tabs into 2 components * Restyle overflow arrows * Adjust gap and remove icons from vertical nav Too many icons on the screen got distracting. * Ensure active item visible * Increase gap on vertical nav * Update copy for Access and Discussions settings * Input Tidyup * WEB-877 update-collaborators-block * Update inputs to new style * Fix webhooks button hover state * Fix comment copy Appears in the access settings before embedding a model * Remove hover shadow from search input on Dashboard * Small change from Benjamin * Change collaborators permissions copy * Comments from PR #1 * Comments from PR #2 * Fix condition for EditableHeader * Updates from CR --------- Co-authored-by: Kristaps Fabians Geikins <fabis94@live.com> Co-authored-by: Benjamin Ottensten <benjamin.ottensten@gmail.com>
718 lines
20 KiB
Vue
718 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">
|
||
<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"
|
||
v-tippy="'Clear'"
|
||
: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-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"
|
||
@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">
|
||
<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"
|
||
: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"
|
||
>
|
||
/* 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,
|
||
ExclamationCircleIcon
|
||
} from '@heroicons/vue/24/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 { directive as vTippy } from 'vue-tippy'
|
||
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,
|
||
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('py-2 px-3')
|
||
|
||
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 outline outline-2 outline-primary-muted focus:outline-none shadow 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
|
||
debouncedSearch()
|
||
})
|
||
|
||
onMounted(() => {
|
||
if (isAsyncSearchMode.value && !props.items.length) {
|
||
triggerSearch()
|
||
}
|
||
})
|
||
|
||
defineExpose({ triggerSearch })
|
||
</script>
|