mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-20 15:01:46 +01:00
feat: module improvements
This commit is contained in:
113
src/runtime/components/forms/Checkbox.vue
Normal file
113
src/runtime/components/forms/Checkbox.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
:id="name"
|
||||
v-model="isChecked"
|
||||
:name="name"
|
||||
:required="required"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
type="checkbox"
|
||||
:class="inputClass"
|
||||
@focus="$emit('focus', $event)"
|
||||
@blur="$emit('blur', $event)"
|
||||
>
|
||||
</div>
|
||||
<div v-if="label" class="ml-3 text-sm">
|
||||
<label :for="name" :class="labelClass">
|
||||
{{ label }}
|
||||
<span v-if="required" :class="requiredClass">*</span>
|
||||
</label>
|
||||
<p v-if="help" :class="helpClass">
|
||||
{{ help }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
import { classNames } from '../../utils'
|
||||
import $ui from '#build/ui'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Number, Boolean],
|
||||
default: null
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean, Array],
|
||||
default: null
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
help: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
wrapperClass: {
|
||||
type: String,
|
||||
default: () => $ui.checkbox.wrapper
|
||||
},
|
||||
baseClass: {
|
||||
type: String,
|
||||
default: () => $ui.checkbox.base
|
||||
},
|
||||
labelClass: {
|
||||
type: String,
|
||||
default: () => $ui.checkbox.label
|
||||
},
|
||||
requiredClass: {
|
||||
type: String,
|
||||
default: () => $ui.checkbox.required
|
||||
},
|
||||
helpClass: {
|
||||
type: String,
|
||||
default: () => $ui.checkbox.help
|
||||
},
|
||||
customClass: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'focus', 'blur'],
|
||||
setup (props, { emit }) {
|
||||
const isChecked = computed({
|
||||
get () {
|
||||
return props.modelValue
|
||||
},
|
||||
set (value) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
})
|
||||
|
||||
const inputClass = computed(() => {
|
||||
return classNames(
|
||||
props.baseClass,
|
||||
props.customClass
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
isChecked,
|
||||
inputClass
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
86
src/runtime/components/forms/FormGroup.vue
Normal file
86
src/runtime/components/forms/FormGroup.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div>
|
||||
<slot name="label">
|
||||
<div class="flex content-center justify-between">
|
||||
<label
|
||||
v-if="label"
|
||||
:for="name"
|
||||
:class="labelClass"
|
||||
@click="onLabelClick"
|
||||
>
|
||||
{{ label }}
|
||||
<span v-if="required" :class="requiredClass">*</span>
|
||||
</label>
|
||||
<span v-if="$slots.hint || hint" :class="hintClass">
|
||||
<slot name="hint">{{ hint }}</slot>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="description" :class="descriptionClass">
|
||||
{{ description }}
|
||||
</p>
|
||||
</slot>
|
||||
<div :class="!!label && containerClass">
|
||||
<slot />
|
||||
<p v-if="help" :class="helpClass">
|
||||
{{ help }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import $ui from '#build/ui'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
help: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
hint: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
containerClass: {
|
||||
type: String,
|
||||
default: () => $ui.formGroup.container
|
||||
},
|
||||
labelClass: {
|
||||
type: String,
|
||||
default: () => $ui.formGroup.label
|
||||
},
|
||||
descriptionClass: {
|
||||
type: String,
|
||||
default: () => $ui.formGroup.description
|
||||
},
|
||||
requiredClass: {
|
||||
type: String,
|
||||
default: () => $ui.formGroup.required
|
||||
},
|
||||
hintClass: {
|
||||
type: String,
|
||||
default: () => $ui.formGroup.hint
|
||||
},
|
||||
helpClass: {
|
||||
type: String,
|
||||
default: () => $ui.formGroup.help
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
206
src/runtime/components/forms/Input.vue
Normal file
206
src/runtime/components/forms/Input.vue
Normal file
@@ -0,0 +1,206 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<div v-if="isLeading" :class="iconLeadingWrapperClass">
|
||||
<Icon :name="iconName" :class="iconClass" />
|
||||
</div>
|
||||
<input
|
||||
:id="name"
|
||||
ref="input"
|
||||
:name="name"
|
||||
:value="modelValue"
|
||||
:type="type"
|
||||
:required="required"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:autocomplete="autocomplete"
|
||||
:spellcheck="spellcheck"
|
||||
:class="inputClass"
|
||||
@input="onInput($event.target.value)"
|
||||
@focus="$emit('focus', $event)"
|
||||
@blur="$emit('blur', $event)"
|
||||
>
|
||||
<slot />
|
||||
<div v-if="isTrailing" :class="iconTrailingWrapperClass">
|
||||
<Icon :name="iconName" :class="iconClass" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import Icon from '../elements/Icon'
|
||||
import { classNames } from '../../utils'
|
||||
import $ui from '#build/ui'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Icon
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
autocomplete: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
spellcheck: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
loadingIcon: {
|
||||
type: String,
|
||||
default: () => $ui.input.icon.loading
|
||||
},
|
||||
trailing: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
leading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator (value) {
|
||||
return Object.keys($ui.input.size).includes(value)
|
||||
}
|
||||
},
|
||||
wrapperClass: {
|
||||
type: String,
|
||||
default: () => $ui.input.wrapper
|
||||
},
|
||||
baseClass: {
|
||||
type: String,
|
||||
default: () => $ui.input.base
|
||||
},
|
||||
iconBaseClass: {
|
||||
type: String,
|
||||
default: () => $ui.input.icon.base
|
||||
},
|
||||
customClass: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
appearance: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator (value) {
|
||||
return Object.keys($ui.input.appearance).includes(value)
|
||||
}
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'focus', 'blur'],
|
||||
setup (props, { emit }) {
|
||||
const input = ref(null)
|
||||
|
||||
const autoFocus = () => {
|
||||
if (props.autofocus) {
|
||||
input.value.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const onInput = (value) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autoFocus()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
const isLeading = computed(() => {
|
||||
return (props.icon && props.leading) || (props.icon && !props.trailing) || (props.loading && !props.trailing)
|
||||
})
|
||||
|
||||
const isTrailing = computed(() => {
|
||||
return (props.icon && props.trailing) || (props.loading && props.trailing)
|
||||
})
|
||||
|
||||
const inputClass = computed(() => {
|
||||
return classNames(
|
||||
props.baseClass,
|
||||
$ui.input.size[props.size],
|
||||
$ui.input.spacing[props.size],
|
||||
$ui.input.appearance[props.appearance],
|
||||
isLeading.value && $ui.input.leading.spacing[props.size],
|
||||
isTrailing.value && $ui.input.trailing.spacing[props.size],
|
||||
props.customClass
|
||||
)
|
||||
})
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (props.loading) {
|
||||
return props.loadingIcon
|
||||
}
|
||||
|
||||
return props.icon
|
||||
})
|
||||
|
||||
const iconClass = computed(() => {
|
||||
return classNames(
|
||||
props.iconBaseClass,
|
||||
$ui.input.icon.size[props.size],
|
||||
isLeading.value && $ui.input.icon.leading.spacing[props.size],
|
||||
isTrailing.value && $ui.input.icon.trailing.spacing[props.size],
|
||||
props.loading && 'animate-spin'
|
||||
)
|
||||
})
|
||||
|
||||
const iconLeadingWrapperClass = $ui.input.icon.leading.wrapper
|
||||
const iconTrailingWrapperClass = $ui.input.icon.trailing.wrapper
|
||||
|
||||
return {
|
||||
input,
|
||||
onInput,
|
||||
inputClass,
|
||||
iconName,
|
||||
iconClass,
|
||||
iconLeadingWrapperClass,
|
||||
iconTrailingWrapperClass,
|
||||
isLeading,
|
||||
isTrailing
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
113
src/runtime/components/forms/Radio.vue
Normal file
113
src/runtime/components/forms/Radio.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<div class="flex items-center h-5">
|
||||
<input
|
||||
:id="`${name}-${value}`"
|
||||
v-model="isChecked"
|
||||
:name="name"
|
||||
:required="required"
|
||||
:value="value"
|
||||
:disabled="disabled"
|
||||
type="radio"
|
||||
:class="radioClass"
|
||||
@focus="$emit('focus', $event)"
|
||||
@blur="$emit('blur', $event)"
|
||||
>
|
||||
</div>
|
||||
<div v-if="label" class="ml-3 text-sm">
|
||||
<label :for="`${name}-${value}`" :class="labelClass">
|
||||
{{ label }}
|
||||
<span v-if="required" :class="requiredClass">*</span>
|
||||
</label>
|
||||
<p v-if="help" :class="helpClass">
|
||||
{{ help }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { computed } from 'vue'
|
||||
import { classNames } from '../../utils'
|
||||
import $ui from '#build/ui'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Number, Boolean],
|
||||
default: null
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean, Object],
|
||||
default: null
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
help: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
wrapperClass: {
|
||||
type: String,
|
||||
default: () => $ui.radio.wrapper
|
||||
},
|
||||
baseClass: {
|
||||
type: String,
|
||||
default: () => $ui.radio.base
|
||||
},
|
||||
labelClass: {
|
||||
type: String,
|
||||
default: () => $ui.radio.label
|
||||
},
|
||||
requiredClass: {
|
||||
type: String,
|
||||
default: () => $ui.radio.required
|
||||
},
|
||||
helpClass: {
|
||||
type: String,
|
||||
default: () => $ui.radio.help
|
||||
},
|
||||
customClass: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'focus', 'blur'],
|
||||
setup (props, { emit }) {
|
||||
const isChecked = computed({
|
||||
get () {
|
||||
return props.modelValue
|
||||
},
|
||||
set (value) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
})
|
||||
|
||||
const radioClass = computed(() => {
|
||||
return classNames(
|
||||
props.baseClass,
|
||||
props.customClass
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
isChecked,
|
||||
radioClass
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
209
src/runtime/components/forms/Select.vue
Normal file
209
src/runtime/components/forms/Select.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<div v-if="icon" :class="iconWrapperClass">
|
||||
<Icon :name="icon" :class="iconClass" />
|
||||
</div>
|
||||
|
||||
<select
|
||||
:id="name"
|
||||
:name="name"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:class="selectClass"
|
||||
@input="onInput($event.target.value)"
|
||||
>
|
||||
<template v-for="(option, index) in normalizedOptionsWithPlaceholder">
|
||||
<optgroup
|
||||
v-if="option.children"
|
||||
:key="`${option[valueAttribute]}-optgroup-${index}`"
|
||||
:value="option[valueAttribute]"
|
||||
:label="option[textAttribute]"
|
||||
>
|
||||
<option
|
||||
v-for="(childOption, index2) in option.children"
|
||||
:key="`${childOption[valueAttribute]}-${index}-${index2}`"
|
||||
:value="childOption[valueAttribute]"
|
||||
:selected="childOption[valueAttribute] === normalizedValue"
|
||||
v-text="childOption[textAttribute]"
|
||||
/>
|
||||
</optgroup>
|
||||
<option
|
||||
v-else
|
||||
:key="`${option[valueAttribute]}-${index}`"
|
||||
:value="option[valueAttribute]"
|
||||
:selected="option[valueAttribute] === normalizedValue"
|
||||
v-text="option[textAttribute]"
|
||||
/>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed } from 'vue'
|
||||
import { get } from 'lodash-es'
|
||||
import Icon from '../elements/Icon'
|
||||
import { classNames } from '../../utils'
|
||||
import $ui from '#build/ui'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Icon
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Number, Object],
|
||||
default: ''
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator (value) {
|
||||
return Object.keys($ui.select.size).includes(value)
|
||||
}
|
||||
},
|
||||
wrapperClass: {
|
||||
type: String,
|
||||
default: () => $ui.select.wrapper
|
||||
},
|
||||
baseClass: {
|
||||
type: String,
|
||||
default: () => $ui.select.base
|
||||
},
|
||||
iconBaseClass: {
|
||||
type: String,
|
||||
default: () => $ui.select.icon.base
|
||||
},
|
||||
customClass: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
textAttribute: {
|
||||
type: String,
|
||||
default: 'text'
|
||||
},
|
||||
valueAttribute: {
|
||||
type: String,
|
||||
default: 'value'
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
setup (props, { emit }) {
|
||||
const select = ref(null)
|
||||
|
||||
const onInput = (value) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
const guessOptionValue = (option) => {
|
||||
return get(option, props.valueAttribute, get(option, props.textAttribute))
|
||||
}
|
||||
|
||||
const guessOptionText = (option) => {
|
||||
return get(option, props.textAttribute, get(option, props.valueAttribute))
|
||||
}
|
||||
|
||||
const normalizeOption = (option) => {
|
||||
if (['string', 'number', 'boolean'].includes(typeof option)) {
|
||||
return {
|
||||
[props.valueAttribute]: option,
|
||||
[props.textAttribute]: option
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...option,
|
||||
[props.valueAttribute]: guessOptionValue(option),
|
||||
[props.textAttribute]: guessOptionText(option)
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedOptions = computed(() => {
|
||||
return props.options.map(option => normalizeOption(option))
|
||||
})
|
||||
|
||||
const normalizedOptionsWithPlaceholder = computed(() => {
|
||||
if (!props.placeholder) {
|
||||
return normalizedOptions.value
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
[props.valueAttribute]: null,
|
||||
[props.textAttribute]: props.placeholder
|
||||
},
|
||||
...normalizedOptions.value
|
||||
]
|
||||
})
|
||||
|
||||
const normalizedValue = computed(() => {
|
||||
const foundOption = normalizedOptionsWithPlaceholder.value.find(option => option.value === props.modelValue)
|
||||
if (!foundOption) {
|
||||
return null
|
||||
}
|
||||
|
||||
return foundOption.value
|
||||
})
|
||||
|
||||
const selectClass = computed(() => {
|
||||
return classNames(
|
||||
props.baseClass,
|
||||
$ui.select.size[props.size],
|
||||
$ui.select.spacing[props.size],
|
||||
$ui.select.appearance.default,
|
||||
!!props.icon && $ui.select.leading.spacing[props.size],
|
||||
$ui.select.trailing.spacing[props.size],
|
||||
props.customClass
|
||||
)
|
||||
})
|
||||
|
||||
const iconClass = computed(() => {
|
||||
return classNames(
|
||||
props.iconBaseClass,
|
||||
$ui.select.icon.size[props.size],
|
||||
!!props.icon && $ui.select.icon.leading.spacing[props.size]
|
||||
)
|
||||
})
|
||||
|
||||
const iconWrapperClass = $ui.select.icon.leading.base
|
||||
|
||||
return {
|
||||
select,
|
||||
onInput,
|
||||
guessOptionValue,
|
||||
guessOptionText,
|
||||
normalizeOption,
|
||||
normalizedOptions,
|
||||
normalizedOptionsWithPlaceholder,
|
||||
normalizedValue,
|
||||
selectClass,
|
||||
iconClass,
|
||||
iconWrapperClass
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
448
src/runtime/components/forms/SelectCustom.vue
Normal file
448
src/runtime/components/forms/SelectCustom.vue
Normal file
@@ -0,0 +1,448 @@
|
||||
<template>
|
||||
<div ref="container">
|
||||
<input :value="value" :required="required" class="absolute inset-0 w-px opacity-0 cursor-default">
|
||||
|
||||
<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"
|
||||
>
|
||||
<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'
|
||||
}"
|
||||
/>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { get } from 'lodash-es'
|
||||
import { createPopper } from '@popperjs/core'
|
||||
// import { directive as onClickaway } from 'vue-clickaway'
|
||||
|
||||
import Icon from '../elements/Icon'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Icon
|
||||
},
|
||||
// directives: {
|
||||
// onClickaway
|
||||
// },
|
||||
shortcuts: {
|
||||
disabled () {
|
||||
return !this.open
|
||||
},
|
||||
up: 'prev',
|
||||
down: 'next',
|
||||
enter: 'enter',
|
||||
esc: {
|
||||
handler: 'close',
|
||||
stop: true,
|
||||
prevent: true
|
||||
}
|
||||
},
|
||||
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
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
open: false,
|
||||
active: 0,
|
||||
q: '',
|
||||
instance: null
|
||||
}
|
||||
},
|
||||
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
|
||||
}
|
||||
},
|
||||
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()
|
||||
}
|
||||
},
|
||||
beforeDestroy () {
|
||||
if (this.instance) {
|
||||
this.instance.destroy()
|
||||
this.instance = null
|
||||
}
|
||||
},
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
150
src/runtime/components/forms/Textarea.vue
Normal file
150
src/runtime/components/forms/Textarea.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<textarea
|
||||
:id="name"
|
||||
ref="textarea"
|
||||
:value="modelValue"
|
||||
:name="name"
|
||||
:rows="rows"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:placeholder="placeholder"
|
||||
:autocomplete="autocomplete"
|
||||
:class="textareaClass"
|
||||
@input="onInput($event.target.value)"
|
||||
@focus="$emit('focus', $event)"
|
||||
@blur="$emit('blur', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { classNames } from '../../utils'
|
||||
import $ui from '#build/ui'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
autoresize: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
autofocus: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
autocomplete: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
appearance: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator (value) {
|
||||
return Object.keys($ui.textarea.appearance).includes(value)
|
||||
}
|
||||
},
|
||||
resize: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'md',
|
||||
validator (value) {
|
||||
return Object.keys($ui.textarea.size).includes(value)
|
||||
}
|
||||
},
|
||||
wrapperClass: {
|
||||
type: String,
|
||||
default: () => $ui.textarea.wrapper
|
||||
},
|
||||
baseClass: {
|
||||
type: String,
|
||||
default: () => $ui.textarea.base
|
||||
},
|
||||
customClass: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update:modelValue', 'focus', 'blur'],
|
||||
setup (props, { emit }) {
|
||||
const textarea = ref(null)
|
||||
|
||||
const autoFocus = () => {
|
||||
if (props.autofocus) {
|
||||
textarea.value.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const autoResize = () => {
|
||||
if (props.autoresize) {
|
||||
const styles = window.getComputedStyle(textarea.value)
|
||||
const paddingTop = parseInt(styles.paddingTop)
|
||||
const paddingBottom = parseInt(styles.paddingBottom)
|
||||
const padding = paddingTop + paddingBottom
|
||||
const initialHeight = (parseInt(styles.height) - padding) / textarea.value.rows
|
||||
const scrollHeight = textarea.value.scrollHeight - padding
|
||||
const newRows = Math.ceil(scrollHeight / initialHeight)
|
||||
|
||||
textarea.value.rows = newRows
|
||||
}
|
||||
}
|
||||
|
||||
const onInput = (value) => {
|
||||
autoResize()
|
||||
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
autoFocus()
|
||||
autoResize()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
const textareaClass = computed(() => {
|
||||
return classNames(
|
||||
props.baseClass,
|
||||
$ui.textarea.size[props.size],
|
||||
$ui.textarea.spacing[props.size],
|
||||
$ui.textarea.appearance[props.appearance],
|
||||
!props.resize && 'resize-none',
|
||||
props.customClass
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
textarea,
|
||||
onInput,
|
||||
textareaClass
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
47
src/runtime/components/forms/Toggle.vue
Normal file
47
src/runtime/components/forms/Toggle.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<Switch
|
||||
v-model="enabled"
|
||||
:class="[enabled ? 'bg-primary-600' : 'u-bg-gray-200', 'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary-500']"
|
||||
>
|
||||
<span :class="[enabled ? 'translate-x-5' : 'translate-x-0', 'pointer-events-none relative inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200']">
|
||||
<span :class="[enabled ? 'opacity-0 ease-out duration-100' : 'opacity-100 ease-in duration-200', 'absolute inset-0 h-full w-full flex items-center justify-center transition-opacity']" aria-hidden="true">
|
||||
<Icon :name="iconOff" class="h-3 w-3 u-text-gray-400" />
|
||||
</span>
|
||||
<span :class="[enabled ? 'opacity-100 ease-in duration-200' : 'opacity-0 ease-out duration-100', 'absolute inset-0 h-full w-full flex items-center justify-center transition-opacity']" aria-hidden="true">
|
||||
<Icon :name="iconOn" class="h-3 w-3 text-primary-600" />
|
||||
</span>
|
||||
</span>
|
||||
</Switch>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { Switch } from '@headlessui/vue'
|
||||
import Icon from '../elements/Icon'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
iconOn: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
iconOff: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const enabled = computed({
|
||||
get () {
|
||||
return props.modelValue
|
||||
},
|
||||
set (value) {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user