mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-18 05:58:07 +01:00
feat: migrate to @nuxtjs/tailwindcss (#32)
This commit is contained in:
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<div v-if="isLeading" :class="iconLeadingWrapperClass">
|
||||
<Icon :name="iconName" :class="iconClass" />
|
||||
</div>
|
||||
<input
|
||||
:id="name"
|
||||
ref="input"
|
||||
@@ -21,6 +18,9 @@
|
||||
@blur="$emit('blur', $event)"
|
||||
>
|
||||
<slot />
|
||||
<div v-if="isLeading" :class="iconLeadingWrapperClass">
|
||||
<Icon :name="iconName" :class="iconClass" />
|
||||
</div>
|
||||
<div v-if="isTrailing" :class="iconTrailingWrapperClass">
|
||||
<Icon :name="iconName" :class="iconClass" />
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<div v-if="icon" :class="iconWrapperClass">
|
||||
<Icon :name="icon" :class="iconClass" />
|
||||
</div>
|
||||
|
||||
<select
|
||||
:id="name"
|
||||
:name="name"
|
||||
@@ -36,6 +32,10 @@
|
||||
/>
|
||||
</template>
|
||||
</select>
|
||||
|
||||
<div v-if="icon" :class="iconWrapperClass">
|
||||
<Icon :name="icon" :class="iconClass" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -189,7 +189,7 @@ export default {
|
||||
)
|
||||
})
|
||||
|
||||
const iconWrapperClass = $ui.select.icon.leading.base
|
||||
const iconWrapperClass = $ui.select.icon.leading.wrapper
|
||||
|
||||
return {
|
||||
select,
|
||||
|
||||
@@ -1,448 +1,165 @@
|
||||
<template>
|
||||
<div ref="container">
|
||||
<input :value="value" :required="required" class="absolute inset-0 w-px opacity-0 cursor-default">
|
||||
<Listbox
|
||||
:model-value="modelValue"
|
||||
as="div"
|
||||
:class="wrapperClass"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<ListboxButton :class="selectCustomClass">
|
||||
<span class="block truncate">{{ modelValue[textAttribute] }}</span>
|
||||
<span :class="iconWrapperClass">
|
||||
<Icon name="heroicons-solid:selector" :class="iconClass" aria-hidden="true" />
|
||||
</span>
|
||||
</ListboxButton>
|
||||
|
||||
<slot :toggle="toggle" :open="open">
|
||||
<TwButton
|
||||
icon="solid/selector"
|
||||
icon-class="u-text-gray-400"
|
||||
trailing
|
||||
:size="size"
|
||||
:variant="variant"
|
||||
base-class="w-full cursor-default focus:outline-none disabled:cursor-not-allowed disabled:opacity-75"
|
||||
:disabled="disabled || !options || !options.length"
|
||||
@click.native="!disabled && options && options.length && toggle()"
|
||||
>
|
||||
<div v-if="selectedOptions && selectedOptions.length" class="inline-flex w-full px-3 py-2 -my-2 -ml-3 truncate">
|
||||
<span v-for="(selectedOption, index) of selectedOptions" :key="index" class="inline-flex items-center pr-2">
|
||||
<slot name="label" :option="selectedOption">
|
||||
<span class="u-text-gray-700">{{ selectedOption[textAttribute] }}</span>
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="inline-flex w-full u-text-gray-400">
|
||||
{{ placeholder || '' }}
|
||||
</div>
|
||||
</TwButton>
|
||||
</slot>
|
||||
|
||||
<transition
|
||||
enter-class=""
|
||||
enter-active-class=""
|
||||
enter-to-class=""
|
||||
leave-class="opacity-100"
|
||||
leave-active-class="transition duration-100 ease-in"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-show="open" ref="tooltip" class="z-10 overflow-hidden bg-white rounded-md shadow-lg dark:bg-gray-800 ring-1 u-ring-gray-200" :class="dropdownClass">
|
||||
<div v-if="searchable" class="w-full border-b u-border-gray-200">
|
||||
<TwInput
|
||||
ref="search"
|
||||
v-model="q"
|
||||
type="search"
|
||||
:name="`select-search-${name}`"
|
||||
block
|
||||
autocomplete="off"
|
||||
appearance="none"
|
||||
:placeholder="placeholderSearch"
|
||||
/>
|
||||
</div>
|
||||
<ul
|
||||
ref="options"
|
||||
tabindex="-1"
|
||||
role="listbox"
|
||||
class="overflow-y-auto max-h-60 sm:text-sm focus:outline-none"
|
||||
<transition leave-active-class="transition ease-in duration-100" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<ListboxOptions class="absolute z-10 mt-1 w-full bg-white shadow-lg max-h-60 rounded-md py-1 text-base ring-1 u-ring-gray-200 overflow-auto focus:outline-none sm:text-sm">
|
||||
<ListboxOption
|
||||
v-for="(option, index) in options"
|
||||
v-slot="{ active, selected, disabled }"
|
||||
:key="index"
|
||||
as="template"
|
||||
:value="option"
|
||||
:disabled="option.disabled"
|
||||
>
|
||||
<li
|
||||
v-if="showNewOption"
|
||||
ref="option-new"
|
||||
role="option"
|
||||
class="relative pl-3 pr-12 cursor-default select-none group hover:text-white hover:bg-primary-600"
|
||||
:class="{
|
||||
'bg-primary-600 text-white': active === -1,
|
||||
'u-text-gray-900': active !== -1,
|
||||
'py-2': dropdownSize === 'md',
|
||||
'py-1 text-sm': dropdownSize === 'sm'
|
||||
}"
|
||||
@mouseover="active = -1"
|
||||
@click="active === -1 && newOption()"
|
||||
>
|
||||
<slot name="newOption" :optionName="q">
|
||||
<span class="block truncate">Add new option: "{{ q }}"</span>
|
||||
</slot>
|
||||
</li>
|
||||
<li
|
||||
v-for="(option, index) in filteredNormalizedOptions"
|
||||
:key="index"
|
||||
:ref="`option-${index}`"
|
||||
role="option"
|
||||
class="relative pl-3 pr-12 cursor-default select-none group hover:text-white hover:bg-primary-600"
|
||||
:class="{
|
||||
'font-semibold': isOptionSelected(option),
|
||||
'bg-primary-600 text-white': active === index,
|
||||
'u-text-gray-900': active !== index,
|
||||
'py-2': dropdownSize === 'md',
|
||||
'py-1 text-sm': dropdownSize === 'sm'
|
||||
}"
|
||||
@mouseover="active = index"
|
||||
@click.prevent="active === index && selectOption(option)"
|
||||
>
|
||||
<slot name="option" :option="option">
|
||||
<span class="block truncate">{{ option[textAttribute] }}</span>
|
||||
</slot>
|
||||
<span class="absolute inset-y-0 right-0 flex items-center pr-3">
|
||||
<Icon
|
||||
v-if="isOptionSelected(option)"
|
||||
name="solid/check"
|
||||
class=" group-hover:text-white"
|
||||
:class="{
|
||||
'text-white': active === index,
|
||||
'text-primary-600': active !== index,
|
||||
'h-5 w-5': dropdownSize === 'md',
|
||||
'h-4 w-4': dropdownSize === 'sm'
|
||||
}"
|
||||
/>
|
||||
<li :class="resolveOptionClass({ active, disabled })">
|
||||
<span :class="[selected ? 'font-semibold' : 'font-normal', 'block truncate']">
|
||||
<slot name="option" :option="option">
|
||||
{{ option[textAttribute] }}
|
||||
</slot>
|
||||
</span>
|
||||
|
||||
<span v-if="selected" :class="resolveOptionIconClass({ active })">
|
||||
<Icon name="heroicons-solid:check" :class="listOptionIconSizeClass" aria-hidden="true" />
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ListboxOption>
|
||||
</ListboxOptions>
|
||||
</transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { get } from 'lodash-es'
|
||||
import { createPopper } from '@popperjs/core'
|
||||
// import { directive as onClickaway } from 'vue-clickaway'
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOptions,
|
||||
ListboxOption
|
||||
} from '@headlessui/vue'
|
||||
import Icon from '../elements/Icon'
|
||||
import { classNames } from '../../utils'
|
||||
import $ui from '#build/ui'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Icon
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number, Object],
|
||||
default: ''
|
||||
},
|
||||
// directives: {
|
||||
// onClickaway
|
||||
// },
|
||||
shortcuts: {
|
||||
disabled () {
|
||||
return !this.open
|
||||
},
|
||||
up: 'prev',
|
||||
down: 'next',
|
||||
enter: 'enter',
|
||||
esc: {
|
||||
handler: 'close',
|
||||
stop: true,
|
||||
prevent: true
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator (value) {
|
||||
return Object.keys($ui.selectCustom.size).includes(value)
|
||||
}
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Number, Object, Array],
|
||||
default: ''
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
textAttribute: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
valueAttribute: {
|
||||
type: String,
|
||||
default: 'value'
|
||||
},
|
||||
searchAttributes: {
|
||||
type: Array,
|
||||
default: null
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
placeholderSearch: {
|
||||
type: String,
|
||||
default: 'Search...'
|
||||
},
|
||||
searchable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
newEnabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator (value) {
|
||||
return ['xxs', 'xs', 'sm', 'md', 'lg', 'xl'].includes(value)
|
||||
}
|
||||
},
|
||||
dropdownClass: {
|
||||
type: String,
|
||||
default: 'w-full'
|
||||
},
|
||||
dropdownSize: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator (value) {
|
||||
return ['sm', 'md'].includes(value)
|
||||
}
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'gray'
|
||||
},
|
||||
strategy: {
|
||||
type: String,
|
||||
default: 'absolute'
|
||||
},
|
||||
placement: {
|
||||
type: String,
|
||||
default: 'bottom-start'
|
||||
},
|
||||
unselectable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
wrapperClass: {
|
||||
type: String,
|
||||
default: () => $ui.selectCustom.wrapper
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
open: false,
|
||||
active: 0,
|
||||
q: '',
|
||||
instance: null
|
||||
}
|
||||
baseClass: {
|
||||
type: String,
|
||||
default: () => $ui.selectCustom.base
|
||||
},
|
||||
computed: {
|
||||
showNewOption () {
|
||||
return this.newEnabled && this.q && !this.filteredNormalizedOptions.find(option => option[this.textAttribute].toLowerCase() === this.q.toLowerCase())
|
||||
},
|
||||
selectedOptions () {
|
||||
if (this.multiple) {
|
||||
return this.value.map(value => this.normalizedOptions.find(option => option[this.valueAttribute] === value)).filter(Boolean)
|
||||
} else {
|
||||
return [this.normalizedOptions.find(option => option[this.valueAttribute] === this.value)].filter(Boolean)
|
||||
}
|
||||
},
|
||||
normalizedOptions () {
|
||||
return this.options.map(option => this.normalizeOption(option))
|
||||
},
|
||||
filteredNormalizedOptions () {
|
||||
let filteredNormalizedOptions = this.normalizedOptions
|
||||
|
||||
if (!this.q) {
|
||||
return filteredNormalizedOptions
|
||||
}
|
||||
|
||||
try {
|
||||
filteredNormalizedOptions = this.normalizedOptions.filter((option) => {
|
||||
return (this.searchAttributes?.length ? this.searchAttributes : [this.textAttribute]).some((searchAttribute) => {
|
||||
return option[searchAttribute] && option[searchAttribute].search(new RegExp(this.q, 'i')) !== -1
|
||||
})
|
||||
})
|
||||
} catch (e) {}
|
||||
|
||||
return filteredNormalizedOptions
|
||||
}
|
||||
iconBaseClass: {
|
||||
type: String,
|
||||
default: () => $ui.selectCustom.icon.base
|
||||
},
|
||||
watch: {
|
||||
disabled (value) {
|
||||
if (value && open) { this.close() }
|
||||
},
|
||||
open (value) {
|
||||
this.$emit('open', value)
|
||||
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.searchable) {
|
||||
this.$nextTick(() => {
|
||||
this.$refs.search.$refs.input.focus()
|
||||
this.$refs.search.$refs.input.select()
|
||||
})
|
||||
}
|
||||
|
||||
if (this.multiple) {
|
||||
if (this.value.length) {
|
||||
this.active = this.filteredNormalizedOptions.findIndex(option => this.value.includes(option[this.valueAttribute]))
|
||||
}
|
||||
} else if (this.value) {
|
||||
this.active = this.filteredNormalizedOptions.findIndex(option => option[this.valueAttribute] === this.value)
|
||||
}
|
||||
|
||||
if (this.instance) {
|
||||
this.instance.destroy()
|
||||
this.instance = null
|
||||
}
|
||||
|
||||
this.instance = createPopper(this.$refs.container, this.$refs.tooltip, {
|
||||
strategy: this.strategy,
|
||||
placement: this.placement,
|
||||
modifiers: [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, 8]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'computeStyles',
|
||||
options: {
|
||||
gpuAcceleration: false,
|
||||
adaptive: false
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'preventOverflow',
|
||||
options: {
|
||||
padding: 8
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.scrollIntoView()
|
||||
})
|
||||
},
|
||||
filteredNormalizedOptions () {
|
||||
this.updateActive()
|
||||
},
|
||||
q () {
|
||||
this.updateActive()
|
||||
}
|
||||
customClass: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
beforeDestroy () {
|
||||
if (this.instance) {
|
||||
this.instance.destroy()
|
||||
this.instance = null
|
||||
}
|
||||
listBaseClass: {
|
||||
type: String,
|
||||
default: () => $ui.selectCustom.list.base
|
||||
},
|
||||
methods: {
|
||||
toggle () {
|
||||
this.open = !this.open
|
||||
},
|
||||
close () {
|
||||
this.open = false
|
||||
},
|
||||
newOption () {
|
||||
this.$emit('new', this.q)
|
||||
},
|
||||
isOptionSelected (option) {
|
||||
if (this.multiple) {
|
||||
return this.value && this.value.find(it => it === option[this.valueAttribute])
|
||||
}
|
||||
|
||||
return this.value && this.value === option[this.valueAttribute]
|
||||
},
|
||||
selectOption (option) {
|
||||
if (this.multiple) {
|
||||
const value = [...this.value]
|
||||
const index = value.findIndex(it => it === option[this.valueAttribute])
|
||||
if (index > -1) {
|
||||
value.splice(index, 1)
|
||||
} else {
|
||||
value.push(option[this.valueAttribute])
|
||||
}
|
||||
this.$emit('input', value)
|
||||
} else {
|
||||
if (this.isOptionSelected(option)) {
|
||||
if (this.unselectable) {
|
||||
this.$emit('input', null)
|
||||
}
|
||||
} else {
|
||||
this.$emit('input', option[this.valueAttribute])
|
||||
}
|
||||
this.open = false
|
||||
}
|
||||
},
|
||||
guessOptionValue (option) {
|
||||
return get(option, this.valueAttribute, get(option, this.textAttribute))
|
||||
},
|
||||
guessOptionText (option) {
|
||||
return get(option, this.textAttribute, get(option, this.valueAttribute))
|
||||
},
|
||||
normalizeOption (option) {
|
||||
if (['string', 'number', 'boolean'].includes(typeof option)) {
|
||||
return {
|
||||
[this.valueAttribute]: option,
|
||||
[this.textAttribute]: option
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...option,
|
||||
[this.valueAttribute]: this.guessOptionValue(option),
|
||||
[this.textAttribute]: this.guessOptionText(option)
|
||||
}
|
||||
},
|
||||
prev () {
|
||||
if (this.active - 1 >= (this.showNewOption ? -1 : 0)) {
|
||||
this.active--
|
||||
}
|
||||
|
||||
this.scrollIntoView()
|
||||
},
|
||||
next () {
|
||||
if (this.active + 1 <= (this.filteredNormalizedOptions.length - 1)) {
|
||||
this.active++
|
||||
}
|
||||
|
||||
this.scrollIntoView()
|
||||
},
|
||||
enter () {
|
||||
if (this.active === -1) {
|
||||
if (this.showNewOption) {
|
||||
this.newOption()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const option = this.filteredNormalizedOptions[this.active]
|
||||
if (!option) {
|
||||
return
|
||||
}
|
||||
|
||||
this.selectOption(option)
|
||||
},
|
||||
scrollIntoView () {
|
||||
let child
|
||||
if (this.active === -1) {
|
||||
child = this.$refs['option-new']
|
||||
} else {
|
||||
child = this.$refs[`option-${this.active}`][0]
|
||||
}
|
||||
|
||||
if (!child) {
|
||||
return
|
||||
}
|
||||
|
||||
child.scrollIntoView({ block: 'nearest' })
|
||||
},
|
||||
updateActive () {
|
||||
this.active = this.showNewOption && !this.filteredNormalizedOptions.length ? -1 : 0
|
||||
}
|
||||
listOptionBaseClass: {
|
||||
type: String,
|
||||
default: () => $ui.selectCustom.list.option.base
|
||||
},
|
||||
listOptionActiveClass: {
|
||||
type: String,
|
||||
default: () => $ui.selectCustom.list.option.active
|
||||
},
|
||||
listOptionInactiveClass: {
|
||||
type: String,
|
||||
default: () => $ui.selectCustom.list.option.inactive
|
||||
},
|
||||
listOptionDisabledClass: {
|
||||
type: String,
|
||||
default: () => $ui.selectCustom.list.option.disabled
|
||||
},
|
||||
listOptionIconBaseClass: {
|
||||
type: String,
|
||||
default: () => $ui.selectCustom.list.option.icon.base
|
||||
},
|
||||
listOptionIconActiveClass: {
|
||||
type: String,
|
||||
default: () => $ui.selectCustom.list.option.icon.active
|
||||
},
|
||||
listOptionIconInactiveClass: {
|
||||
type: String,
|
||||
default: () => $ui.selectCustom.list.option.icon.inactive
|
||||
},
|
||||
listOptionIconSizeClass: {
|
||||
type: String,
|
||||
default: () => $ui.selectCustom.list.option.icon.size
|
||||
},
|
||||
textAttribute: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const selectCustomClass = computed(() => {
|
||||
return classNames(
|
||||
props.baseClass,
|
||||
$ui.selectCustom.size[props.size],
|
||||
$ui.selectCustom.spacing[props.size],
|
||||
$ui.selectCustom.appearance.default,
|
||||
$ui.selectCustom.trailing.spacing[props.size],
|
||||
props.customClass
|
||||
)
|
||||
})
|
||||
|
||||
const iconClass = computed(() => {
|
||||
return classNames(
|
||||
props.iconBaseClass,
|
||||
$ui.selectCustom.icon.size[props.size],
|
||||
$ui.selectCustom.icon.trailing.spacing[props.size]
|
||||
)
|
||||
})
|
||||
|
||||
const iconWrapperClass = $ui.selectCustom.icon.trailing.wrapper
|
||||
|
||||
function resolveOptionClass ({ active, disabled }) {
|
||||
return classNames(
|
||||
props.listOptionBaseClass,
|
||||
active ? props.listOptionActiveClass : props.listOptionInactiveClass,
|
||||
disabled && props.listOptionDisabledClass
|
||||
)
|
||||
}
|
||||
|
||||
function resolveOptionIconClass ({ active }) {
|
||||
return classNames(
|
||||
props.listOptionIconBaseClass,
|
||||
active ? props.listOptionIconActiveClass : props.listOptionIconInactiveClass
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user