Select Design Changes (#409)

* Select Design Changes

* update theme file for SelectInput

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Chirag Chhatrala 2024-05-16 18:20:40 +05:30 committed by GitHub
parent 12e5546ff3
commit 795fcfc973
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 197 additions and 180 deletions

View File

@ -3,14 +3,17 @@
<template #label> <template #label>
<slot name="label" /> <slot name="label" />
</template> </template>
<v-select <v-select
v-model="compVal" v-model="compVal"
:dusk="name"
:data="finalOptions" :data="finalOptions"
:label="label" :label="label"
:option-key="optionKey" :option-key="optionKey"
:emit-key="emitKey" :emit-key="emitKey"
:required="required" :required="required"
:multiple="multiple" :multiple="multiple"
:clearable="clearable"
:searchable="searchable" :searchable="searchable"
:loading="loading" :loading="loading"
:color="color" :color="color"
@ -19,57 +22,52 @@
:theme="theme" :theme="theme"
:has-error="hasError" :has-error="hasError"
:allow-creation="allowCreation" :allow-creation="allowCreation"
:disabled="disabled ? true : null" :disabled="disabled"
:help="help" :help="help"
:help-position="helpPosition" :help-position="helpPosition"
:remote="remote"
:dropdown-class="dropdownClass"
@update-options="updateOptions" @update-options="updateOptions"
@update:model-value="updateModelValue" @update:model-value="updateModelValue"
> >
<template #selected="{ option }"> <template #selected="{ option }">
<template v-if="multiple">
<div class="flex items-center truncate mr-6">
<span class="truncate">
{{ getOptionNames(selectedValues).join(', ') }}
</span>
</div>
</template>
<template v-else>
<slot <slot
name="selected" name="selected"
:option="option" :option="option"
:option-name="getOptionName(option)" :option-name="getOptionName(option)"
> >
<template v-if="multiple">
<div class="flex items-center truncate mr-6">
<span class="truncate">
{{ getOptionNames(selectedValues).join(", ") }}
</span>
</div>
</template>
<template v-else>
<div class="flex items-center truncate mr-6"> <div class="flex items-center truncate mr-6">
<div>{{ getOptionName(option) }}</div> <div>{{ getOptionName(option) }}</div>
</div> </div>
</template>
</slot> </slot>
</template> </template>
</template>
<template #option="{ option, selected }"> <template #option="{ option, selected }">
<slot <slot
name="option" name="option"
:option="option" :option="option"
:selected="selected" :selected="selected"
> >
<span class="flex group-hover:text-white"> <span class="flex">
<p class="flex-grow group-hover:text-white"> <p class="flex-grow">
{{ option.name }} {{ option.name }}
</p> </p>
<span <span
v-if="selected" v-if="selected"
class="absolute inset-y-0 right-0 flex items-center pr-4 dark:text-white" class="absolute inset-y-0 right-0 flex items-center pr-4 dark:text-white"
> >
<svg <Icon
class="h-5 w-5" name="heroicons:check-16-solid"
viewBox="0 0 20 20" class="w-5 h-5"
fill="currentColor"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
/> />
</svg>
</span> </span>
</span> </span>
</slot> </slot>
@ -87,45 +85,58 @@
</template> </template>
<script> <script>
import { inputProps, useFormInput } from "./useFormInput.js" import { inputProps, useFormInput } from './useFormInput.js'
import InputWrapper from "./components/InputWrapper.vue" import InputWrapper from './components/InputWrapper.vue'
/** /**
* Options: {name,value} objects * Options: {name,value} objects
*/ */
export default { export default {
name: "SelectInput", name: 'SelectInput',
components: { InputWrapper }, components: { InputWrapper },
props: { props: {
...inputProps, ...inputProps,
options: { type: Array, required: true }, options: { type: Array, required: true },
optionKey: { type: String, default: "value" }, optionKey: { type: String, default: 'value' },
emitKey: { type: String, default: "value" }, emitKey: { type: String, default: 'value' },
displayKey: { type: String, default: "name" }, displayKey: { type: String, default: 'name' },
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
multiple: { type: Boolean, default: false }, multiple: { type: Boolean, default: false },
searchable: { type: Boolean, default: false }, searchable: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
allowCreation: { type: Boolean, default: false }, allowCreation: { type: Boolean, default: false },
dropdownClass: { type: String, default: 'w-full' },
remote: { type: Function, default: null }
}, },
setup (props, context) { setup (props, context) {
return { return {
...useFormInput(props, context), ...useFormInput(props, context)
} }
}, },
data () { data () {
return { return {
additionalOptions: [], additionalOptions: [],
selectedValues: [], selectedValues: []
} }
}, },
computed: { computed: {
finalOptions () { finalOptions () {
return this.options.concat(this.additionalOptions) return this.options.concat(this.additionalOptions)
}
}, },
watch: {
compVal: {
handler (newVal, oldVal) {
if (!oldVal) {
this.handleCompValChanged()
}
},
immediate: false
}
},
mounted () {
this.handleCompValChanged()
}, },
methods: { methods: {
getOptionName (val) { getOptionName (val) {
@ -136,7 +147,7 @@ export default {
return null return null
}, },
getOptionNames (values) { getOptionNames (values) {
return values.map((val) => { return values.map(val => {
return this.getOptionName(val) return this.getOptionName(val)
}) })
}, },
@ -149,6 +160,11 @@ export default {
this.additionalOptions.push(newItem) this.additionalOptions.push(newItem)
} }
}, },
}, handleCompValChanged () {
if (this.compVal) {
this.selectedValues = this.compVal
}
}
}
} }
</script> </script>

View File

@ -4,24 +4,18 @@
class="v-select relative" class="v-select relative"
:class="[{ 'w-0': multiple, 'min-w-full': multiple }]" :class="[{ 'w-0': multiple, 'min-w-full': multiple }]"
> >
<span class="inline-block w-full rounded-md"> <div
class="inline-block w-full flex overflow-hidden"
:style="inputStyle"
:class="[theme.SelectInput.input, { '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200': disabled }, inputClass]"
>
<button <button
type="button" type="button"
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded="true" aria-expanded="true"
aria-labelledby="listbox-label" aria-labelledby="listbox-label"
class="cursor-pointer" class="cursor-pointer w-full flex-grow relative"
:style="inputStyle" :class="[{'py-2': !multiple || loading, 'py-1': multiple},theme.default.inputSpacing.horizontal]"
:class="[
theme.SelectInput.input,
{
'py-2': !multiple || loading,
'py-1': multiple,
'!ring-red-500 !ring-2 !border-transparent': hasError,
'!cursor-not-allowed !bg-gray-200': disabled,
},
inputClass,
]"
@click="toggleDropdown" @click="toggleDropdown"
> >
<div :class="{ 'h-6': !multiple, 'min-h-8': multiple && !loading }"> <div :class="{ 'h-6': !multiple, 'min-h-8': multiple && !loading }">
@ -43,6 +37,7 @@
<slot <slot
name="selected" name="selected"
:option="modelValue" :option="modelValue"
:toggle="select"
/> />
</div> </div>
<div <div
@ -60,66 +55,73 @@
</div> </div>
</transition> </transition>
</div> </div>
<span <span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none" <Icon
> name="heroicons:chevron-up-down-16-solid"
<svg class="h-5 w-5 text-gray-500"
class="h-5 w-5 text-gray-400"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
>
<path
d="M7 7l3-3 3 3m0 6l-3 3-3-3"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/> />
</svg>
</span> </span>
</button> </button>
</span> <button
v-if="clearable && !isEmpty"
class="hover:bg-gray-50 dark:hover:bg-gray-900 border-l px-2"
:class="[theme.default.inputSpacing.vertical]"
@click.prevent="clear()"
>
<Icon
name="heroicons:x-mark-20-solid"
class="w-5 h-5 text-gray-500"
width="2em"
dynamic
/>
</button>
</div>
<collapsible <collapsible
v-model="isOpen" v-model="isOpen"
class="absolute mt-1 rounded-md bg-white dark:bg-notion-dark-light shadow-xl z-10" class="absolute mt-1 bg-white overflow-auto dark:bg-notion-dark-light shadow-xl z-10"
:class="dropdownClass" :class="[dropdownClass,theme.SelectInput.dropdown]"
@click-away="onClickAway" @click-away="onClickAway"
> >
<ul <ul
tabindex="-1" tabindex="-1"
role="listbox" role="listbox"
class="rounded-md text-base leading-6 shadow-xs overflow-auto focus:outline-none sm:text-sm sm:leading-5 relative" class="text-base leading-6 shadow-xs overflow-auto focus:outline-none sm:text-sm sm:leading-5 relative"
:class="{ :class="{ 'max-h-42': !isSearchable, 'max-h-48': isSearchable }"
'max-h-42 py-1': !isSearchable,
'max-h-48 pb-1': isSearchable,
}"
> >
<div <div
v-if="isSearchable" v-if="isSearchable"
class="px-2 pt-2 sticky top-0 bg-white dark-bg-notion-dark-light z-10" class="sticky top-0 z-10 flex border-b border-gray-300"
> >
<text-input <input
v-model="searchTerm" v-model="searchTerm"
name="search" type="text"
:color="color" class="flex-grow pl-3 pr-7 py-3 w-full focus:outline-none dark:text-white"
:theme="theme" placeholder="Search"
placeholder="Search..." >
<div class="flex absolute right-0 inset-y-0 items-center px-2 justify-center pointer-events-none">
<Icon
name="heroicons:magnifying-glass-solid"
class="h-5 w-5 text-gray-500 dark:text-gray-400"
/> />
</div> </div>
</div>
<div <div
v-if="loading" v-if="loading"
class="w-full py-2 flex justify-center" class="w-full py-2 flex justify-center"
> >
<Loader class="h-6 w-6 text-nt-blue mx-auto" /> <Loader class="h-6 w-6 text-nt-blue mx-auto" />
</div> </div>
<template v-if="filteredOptions.length > 0"> <div
v-if="filteredOptions.length > 0"
class="p-1"
>
<li <li
v-for="item in filteredOptions" v-for="item in filteredOptions"
:key="item[optionKey]" :key="item[optionKey]"
role="option" role="option"
:style="optionStyle" :style="optionStyle"
:class="{ 'px-3 pr-9': multiple, 'px-3': !multiple }" :class="[{ 'px-3 pr-9': multiple, 'px-3': !multiple },dropdownClass,theme.SelectInput.option]"
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue" class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:bg-gray-100 dark:hover:bg-gray-900 rounded focus:outline-none"
@click="select(item)" @click="select(item)"
> >
<slot <slot
@ -128,84 +130,82 @@
:selected="isSelected(item)" :selected="isSelected(item)"
/> />
</li> </li>
</template> </div>
<p <p
v-else-if="!loading && !(allowCreation && searchTerm)" v-else-if="!loading && !(allowCreation && searchTerm)"
class="w-full text-gray-500 text-center py-2" class="w-full text-gray-500 text-center py-2"
> >
{{ {{ (allowCreation ? 'Type something to add an option' : 'No option available') }}.
allowCreation
? "Type something to add an option"
: "No option available"
}}.
</p> </p>
<li <div
v-if="allowCreation && searchTerm" v-if="allowCreation && searchTerm"
class="border-t border-gray-300 p-1"
>
<li
role="option" role="option"
:style="optionStyle" :style="optionStyle"
:class="{ 'px-3 pr-9': multiple, 'px-3': !multiple }" :class="[{ 'px-3 pr-9': multiple, 'px-3': !multiple },dropdownClass,theme.SelectInput.option]"
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white dark:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue" class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:bg-gray-100 dark:hover:bg-gray-900 rounded focus:outline-none"
@click="createOption(searchTerm)" @click="createOption(searchTerm)"
> >
Create Create <span class="px-2 bg-gray-100 border border-gray-300 rounded group-hover-text-black">{{ searchTerm
<b class="px-1 bg-gray-300 rounded group-hover-text-black">{{ }}</span>
searchTerm
}}</b>
</li> </li>
</div>
</ul> </ul>
</collapsible> </collapsible>
</div> </div>
</template> </template>
<script> <script>
import Collapsible from "~/components/global/transitions/Collapsible.vue" import Collapsible from '~/components/global/transitions/Collapsible.vue'
import { themes } from "~/lib/forms/form-themes.js" import { themes } from '../../../lib/forms/form-themes.js'
import TextInput from "../TextInput.vue" import debounce from 'debounce'
import debounce from "lodash/debounce" import Fuse from 'fuse.js'
import Fuse from "fuse.js"
export default { export default {
name: "VSelect", name: 'VSelect',
components: { Collapsible, TextInput }, components: { Collapsible },
directives: {}, directives: {},
props: { props: {
data: Array, data: Array,
modelValue: { default: null, type: [String, Number, Array, Object] }, modelValue: { default: null, type: [String, Number, Array, Object] },
inputClass: { type: String, default: null }, inputClass: { type: String, default: null },
dropdownClass: { type: String, default: "w-full" }, dropdownClass: { type: String, default: 'w-full' },
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
required: { type: Boolean, default: false }, required: { type: Boolean, default: false },
multiple: { type: Boolean, default: false }, multiple: { type: Boolean, default: false },
searchable: { type: Boolean, default: false }, searchable: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
hasError: { type: Boolean, default: false }, hasError: { type: Boolean, default: false },
remote: { type: Function, default: null }, remote: { type: Function, default: null },
searchKeys: { type: Array, default: () => ["name"] }, searchKeys: { type: Array, default: () => ['name'] },
optionKey: { type: String, default: "id" }, optionKey: { type: String, default: 'id' },
emitKey: { type: String, default: null }, emitKey: { type: String, default: null },
color: { type: String, default: "#3B82F6" }, color: { type: String, default: '#3B82F6' },
placeholder: { type: String, default: null }, placeholder: { type: String, default: null },
uppercaseLabels: { type: Boolean, default: true }, uppercaseLabels: { type: Boolean, default: true },
theme: { type: Object, default: () => themes.default }, theme: { type: Object, default: () => themes.default },
allowCreation: { type: Boolean, default: false }, allowCreation: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }, disabled: { type: Boolean, default: false }
}, },
emits: ["update:modelValue", "update-options"], emits: ['update:modelValue', 'update-options'],
data () { data () {
return { return {
isOpen: false, isOpen: false,
searchTerm: "", searchTerm: '',
defaultValue: this.modelValue ?? null, defaultValue: this.modelValue ?? null
} }
}, },
computed: { computed: {
optionStyle () { optionStyle () {
return { return {
"--bg-form-color": this.color, '--bg-form-color': this.color
} }
}, },
inputStyle () { inputStyle () {
return { return {
"--tw-ring-color": this.color, '--tw-ring-color': this.color
} }
}, },
debouncedRemote () { debouncedRemote () {
@ -216,13 +216,13 @@ export default {
}, },
filteredOptions () { filteredOptions () {
if (!this.data) return [] if (!this.data) return []
if (!this.searchable || this.remote || this.searchTerm === "") { if (!this.searchable || this.remote || this.searchTerm === '') {
return this.data return this.data
} }
// Fuse search // Fuse search
const fuzeOptions = { const fuzeOptions = {
keys: this.searchKeys, keys: this.searchKeys
} }
const fuse = new Fuse(this.data, fuzeOptions) const fuse = new Fuse(this.data, fuzeOptions)
return fuse.search(this.searchTerm).map((res) => { return fuse.search(this.searchTerm).map((res) => {
@ -232,18 +232,17 @@ export default {
isSearchable () { isSearchable () {
return this.searchable || this.remote !== null || this.allowCreation return this.searchable || this.remote !== null || this.allowCreation
}, },
isEmpty () {
return this.multiple ? !this.modelValue || this.modelValue.length === 0 : !this.modelValue
}
}, },
watch: { watch: {
searchTerm (val) { searchTerm (val) {
if (!this.debouncedRemote) return if (!this.debouncedRemote) return
if ( if ((this.remote && val) || (val === '' && !this.modelValue) || (val === '' && this.isOpen)) {
(this.remote && val) ||
(val === "" && !this.modelValue) ||
(val === "" && this.isOpen)
) {
return this.debouncedRemote(val) return this.debouncedRemote(val)
} }
}, }
}, },
methods: { methods: {
onClickAway (event) { onClickAway (event) {
@ -271,7 +270,7 @@ export default {
this.isOpen = !this.isOpen this.isOpen = !this.isOpen
} }
if (!this.isOpen) { if (!this.isOpen) {
this.searchTerm = "" this.searchTerm = ''
} }
}, },
select (value) { select (value) {
@ -285,48 +284,43 @@ export default {
} }
if (this.multiple) { if (this.multiple) {
const emitValue = Array.isArray(this.modelValue) const emitValue = Array.isArray(this.modelValue) ? [...this.modelValue] : []
? [...this.modelValue]
: []
if (this.isSelected(value)) { if (this.isSelected(value)) {
this.$emit( this.$emit('update:modelValue', emitValue.filter((item) => {
"update:modelValue",
emitValue.filter((item) => {
if (this.emitKey) { if (this.emitKey) {
return item !== value return item !== value
} }
return ( return item[this.optionKey] !== value && item[this.optionKey] !== value[this.optionKey]
item[this.optionKey] !== value && }))
item[this.optionKey] !== value[this.optionKey]
)
}),
)
return return
} }
emitValue.push(value) emitValue.push(value)
this.$emit("update:modelValue", emitValue) this.$emit('update:modelValue', emitValue)
} else { } else {
if (this.modelValue === value) { if (this.modelValue === value) {
this.$emit("update:modelValue", this.defaultValue ?? null) this.$emit('update:modelValue', this.defaultValue ?? null)
} else { } else {
this.$emit("update:modelValue", value) this.$emit('update:modelValue', value)
} }
} }
}, },
clear () {
this.$emit('update:modelValue', this.multiple ? [] : null)
},
createOption (newOption) { createOption (newOption) {
if (newOption) { if (newOption) {
const newItem = { const newItem = {
name: newOption, name: newOption,
value: newOption, value: newOption,
id: newOption, id: newOption
} }
this.$emit("update-options", newItem) this.$emit('update-options', newItem)
this.select(newItem) this.select(newItem)
this.searchTerm = "" this.searchTerm = ''
}
}
} }
},
},
} }
</script> </script>

View File

@ -32,8 +32,10 @@ export const themes = {
}, },
SelectInput: { SelectInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold', label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'relative w-full rounded-lg border-gray-300 flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full px-4 bg-white text-gray-700 placeholder-gray-400 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-600 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent', input: 'relative w-full rounded-lg border-gray-300 flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 placeholder-gray-400 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-600 shadow-sm text-base focus:outline-none focus:ring-2 focus:border-transparent',
help: 'text-gray-400 dark:text-gray-500' help: 'text-gray-400 dark:text-gray-500',
dropdown: 'rounded-lg border border-gray-300 dark:border-gray-600',
option: 'rounded-md'
}, },
ScaleInput: { ScaleInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold', label: 'text-gray-700 dark:text-gray-300 font-semibold',
@ -76,8 +78,10 @@ export const themes = {
}, },
SelectInput: { SelectInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold', label: 'text-gray-700 dark:text-gray-300 font-semibold',
input: 'relative w-full flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full px-2 bg-white text-gray-700 placeholder-gray-400 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-600 text-base focus:outline-none focus:ring-2 focus:border-transparent', input: 'relative w-full flex-1 appearance-none border border-gray-300 dark:border-gray-600 w-full bg-white text-gray-700 placeholder-gray-400 dark:bg-notion-dark-light dark:text-gray-300 dark:placeholder-gray-600 text-base focus:outline-none focus:ring-2 focus:border-transparent',
help: 'text-gray-400 dark:text-gray-500' help: 'text-gray-400 dark:text-gray-500',
dropdown: 'border border-gray-300 dark:border-gray-600',
option: ''
}, },
CodeInput: { CodeInput: {
label: 'text-gray-700 dark:text-gray-300 font-semibold', label: 'text-gray-700 dark:text-gray-300 font-semibold',
@ -131,7 +135,9 @@ export const themes = {
SelectInput: { SelectInput: {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4', label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',
input: 'rounded relative w-full border-transparent flex-1 appearance-none bg-notion-input-background shadow-inner-notion w-full px-2 text-gray-900 placeholder-gray-400 dark:bg-notion-dark-light dark:placeholder-gray-500 text-base focus:outline-none focus:ring-0 focus:border-transparent focus:shadow-focus-notion', input: 'rounded relative w-full border-transparent flex-1 appearance-none bg-notion-input-background shadow-inner-notion w-full px-2 text-gray-900 placeholder-gray-400 dark:bg-notion-dark-light dark:placeholder-gray-500 text-base focus:outline-none focus:ring-0 focus:border-transparent focus:shadow-focus-notion',
help: 'text-notion-input-help dark:text-gray-500' help: 'text-notion-input-help dark:text-gray-500',
dropdown: 'rounded border border-gray-300 dark:border-gray-600',
option: 'rounded'
}, },
CodeInput: { CodeInput: {
label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4', label: 'text-gray-900 dark:text-gray-100 mb-2 block mt-4',

View File

@ -46,6 +46,7 @@
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"crisp-sdk-web": "^1.0.21", "crisp-sdk-web": "^1.0.21",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"debounce": "^1.2.1",
"fuse.js": "^6.4.6", "fuse.js": "^6.4.6",
"js-sha256": "^0.10.0", "js-sha256": "^0.10.0",
"libphonenumber-js": "^1.10.44", "libphonenumber-js": "^1.10.44",