include datepicker and autocomplete

This commit is contained in:
Evert Prants 2023-01-27 18:27:14 +02:00
parent 4a696eef0d
commit 266e7194c0
Signed by: evert
GPG Key ID: 1688DA83D222D0B5
14 changed files with 490 additions and 29 deletions

56
package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": {
"@headlessui/vue": "^1.7.7",
"@heroicons/vue": "^2.0.13",
"@vuepic/vue-datepicker": "^3.6.5",
"@vueuse/core": "^9.10.0",
"jwt-decode": "^3.1.2",
"lodash.get": "^4.4.2",
@ -698,6 +699,21 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.45.tgz",
"integrity": "sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg=="
},
"node_modules/@vuepic/vue-datepicker": {
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-3.6.5.tgz",
"integrity": "sha512-axf9st9UIjxbLUL/WjbYPSzkNKfedfDqV3wQIBeCAekN2/w45RNmWfIUXIxpbfZKiRo2ie+U23IW20I33oGauw==",
"dependencies": {
"date-fns": "^2.29.3",
"date-fns-tz": "^1.3.7"
},
"engines": {
"node": ">=14"
},
"peerDependencies": {
"vue": ">=3.2.0"
}
},
"node_modules/@vueuse/core": {
"version": "9.10.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.10.0.tgz",
@ -1000,6 +1016,26 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz",
"integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w=="
},
"node_modules/date-fns": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==",
"engines": {
"node": ">=0.11"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/date-fns"
}
},
"node_modules/date-fns-tz": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-1.3.7.tgz",
"integrity": "sha512-1t1b8zyJo+UI8aR+g3iqr5fkUHWpd58VBx8J/ZSQ+w7YrGlw80Ag4sA86qkfCXRBLmMc4I2US+aPMd4uKvwj5g==",
"peerDependencies": {
"date-fns": ">=2.0.0"
}
},
"node_modules/de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
@ -2456,6 +2492,15 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.45.tgz",
"integrity": "sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg=="
},
"@vuepic/vue-datepicker": {
"version": "3.6.5",
"resolved": "https://registry.npmjs.org/@vuepic/vue-datepicker/-/vue-datepicker-3.6.5.tgz",
"integrity": "sha512-axf9st9UIjxbLUL/WjbYPSzkNKfedfDqV3wQIBeCAekN2/w45RNmWfIUXIxpbfZKiRo2ie+U23IW20I33oGauw==",
"requires": {
"date-fns": "^2.29.3",
"date-fns-tz": "^1.3.7"
}
},
"@vueuse/core": {
"version": "9.10.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.10.0.tgz",
@ -2632,6 +2677,17 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-2.6.21.tgz",
"integrity": "sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w=="
},
"date-fns": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA=="
},
"date-fns-tz": {
"version": "1.3.7",
"resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-1.3.7.tgz",
"integrity": "sha512-1t1b8zyJo+UI8aR+g3iqr5fkUHWpd58VBx8J/ZSQ+w7YrGlw80Ag4sA86qkfCXRBLmMc4I2US+aPMd4uKvwj5g==",
"requires": {}
},
"de-indent": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",

View File

@ -11,6 +11,7 @@
"dependencies": {
"@headlessui/vue": "^1.7.7",
"@heroicons/vue": "^2.0.13",
"@vuepic/vue-datepicker": "^3.6.5",
"@vueuse/core": "^9.10.0",
"jwt-decode": "^3.1.2",
"lodash.get": "^4.4.2",

View File

@ -33,7 +33,7 @@
as="h3"
class="text-lg font-bold leading-6 text-gray-900"
>
<div class="flex items-center justify-between">
<div class="mb-4 flex items-center justify-between">
<slot name="title" />
<button v-if="closeButton" @click="closeModal">
<XMarkIcon class="h-6 w-6" />

View File

@ -31,6 +31,7 @@
:class="inputClass"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
@input="onInput"
@change="onChange"
@focus="onFocus"
@ -52,26 +53,22 @@ import set from 'lodash.set';
import get from 'lodash.get';
import { computed, ref, Ref } from 'vue';
import { inject } from 'vue';
import { FormData, FormErrors } from './form.types';
import { FormData, FormErrors, InputType } from './form.types';
const props = withDefaults(
defineProps<{
type?:
| 'text'
| 'number'
| 'password'
| 'color'
| 'email'
| 'checkbox'
| 'radio';
type?: InputType;
forIdPrefix?: string;
label: string;
name: string;
disabled?: boolean;
readonly?: boolean;
placeholder?: string;
}>(),
{
type: 'text',
disabled: false,
readonly: false,
}
);
@ -90,7 +87,9 @@ const fieldName = computed(() =>
formGroup?.value ? `${formGroup.value}.${props.name}` : props.name
);
const forId = computed(() => `form-${fieldName.value}`);
const forId = computed(
() => `${props.forIdPrefix || 'form'}-${fieldName.value}`
);
const value = computed(() => get(formData?.value, fieldName.value));
const errors = computed(() => formErrors?.value[fieldName.value] || []);

View File

@ -0,0 +1,209 @@
<template>
<Combobox :modelValue="localValue" @update:modelValue="valueChanged" nullable>
<div class="relative mt-1">
<div
class="relative w-full cursor-default overflow-hidden rounded-md bg-white text-left focus:outline-none focus-visible:ring-2 sm:text-sm"
>
<ComboboxInput
:id="forId"
:class="inputClass"
:displayValue="(option: any) => getLabel(option)"
@change="query = $event.target.value"
/>
<ComboboxButton
class="absolute inset-y-0 right-0 flex items-center pr-2"
>
<ArrowPathIcon
class="h-5 w-5 animate-spin text-gray-400"
aria-hidden="true"
v-if="searching"
/>
<ChevronUpDownIcon class="h-5 w-5 text-gray-400" aria-hidden="true" />
</ComboboxButton>
</div>
<TransitionRoot
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
@after-leave="query = ''"
>
<ComboboxOptions
class="absolute mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm"
>
<div
v-if="options.length === 0 && (query !== '' || searching)"
class="relative cursor-default select-none py-2 px-4 text-gray-700"
>
<slot name="notfound" :query="query" :searching="searching">
{{ searching ? 'Loading..' : 'Nothing found.' }}
</slot>
</div>
<ComboboxOption
v-for="option in options"
as="template"
:key="option[bindValue]"
:value="option"
:disabled="option?.disabled"
v-slot="{ selected, active }"
>
<li :class="itemClass(active, selected)">
<slot
name="option"
:selected="selected"
:active="active"
:option="option"
>
<span
class="block truncate"
:class="{ 'font-medium': selected, 'font-normal': !selected }"
>
{{
typeof bindLabel === 'function'
? bindLabel(option)
: option[bindLabel]
}}
</span>
</slot>
<span
v-if="selected"
class="absolute inset-y-0 left-0 flex items-center pl-2 text-blue-600"
>
<CheckIcon class="h-5 w-5" aria-hidden="true" />
</span>
</li>
</ComboboxOption>
</ComboboxOptions>
</TransitionRoot>
</div>
</Combobox>
</template>
<script setup lang="ts">
import { ref, computed, watch, shallowRef } from 'vue';
import {
Combobox,
ComboboxInput,
ComboboxButton,
ComboboxOptions,
ComboboxOption,
TransitionRoot,
} from '@headlessui/vue';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/vue/20/solid';
import deepUnref from '../../../utils/deep-unref';
import { ArrowPathIcon } from '@heroicons/vue/24/outline';
import { useDebounceFn } from '@vueuse/shared';
const props = withDefaults(
defineProps<{
forId?: string;
modelValue: any;
initialOptions?: any[];
searchFn?: (query: string) => Promise<any[]>;
bindValue?: string;
bindLabel?: string | ((obj: any) => string);
disabled?: boolean;
invalid?: boolean;
placeholder?: string;
}>(),
{
modelValue: null,
disabled: false,
invalid: false,
bindValue: 'id',
bindLabel: 'displayName',
}
);
if (!props.initialOptions && !props.searchFn) {
throw new Error(
'[Autocomplete] you must either pass initial props or a search function!'
);
}
const options = shallowRef<any[]>(props.initialOptions || []);
const query = ref('');
const searching = ref(false);
const inputClass = computed(() => {
return [
props.invalid
? 'border-red-300 focus:border-red-500 focus:ring-red-500'
: 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
`block w-full rounded-md shadow-sm sm:text-sm transition-colors duration-200`,
'py-2 pl-3 pr-10 text-left cursor-default h-[38px] border-[1px] focus:ring-1',
];
});
const itemClass = (active: boolean, selected: boolean) => {
const classList = ['relative cursor-default select-none py-2 pl-8 pr-4'];
if (selected) {
classList.push('text-blue-900');
}
if (active && selected) {
classList.push('bg-blue-200');
} else if (active || selected) {
classList.push('bg-blue-100');
} else {
classList.push('text-gray-900');
}
return classList;
};
const localValue = shallowRef(props.modelValue);
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number | null): void;
}>();
const valueChanged = (option: any) => {
localValue.value = option;
emit('update:modelValue', deepUnref(localValue.value || null));
};
const searchFnDebounced = useDebounceFn((text: string) => {
searching.value = true;
props
.searchFn?.(text)
.then((response) => {
options.value = response;
})
.catch(console.error)
.finally(() => (searching.value = false));
}, 300);
const getLabel = (option: any) =>
option
? typeof props.bindLabel === 'function'
? props.bindLabel(option)
: option[props.bindLabel]
: '';
const localSearch = (text: string) => {
if (!props.initialOptions) return;
options.value =
text === ''
? props.initialOptions
: props.initialOptions.filter((option) =>
getLabel(option)
.toLowerCase()
.replace(/\s+/g, '')
.includes(text.toLowerCase().replace(/\s+/g, ''))
);
};
watch(
query,
(text) => {
if (props.searchFn) {
return searchFnDebounced(text);
}
localSearch(text);
},
{ immediate: true }
);
</script>

View File

@ -31,6 +31,12 @@
as="template"
>
<li :class="itemClass(active, selected)">
<slot
name="option"
:selected="selected"
:active="active"
:option="option"
>
<span
:class="[
selected ? 'font-medium' : 'font-normal',
@ -39,6 +45,7 @@
>
{{ option.name }}
</span>
</slot>
<span
v-if="selected"
class="absolute inset-y-0 left-0 flex items-center pl-2 text-blue-600"
@ -62,13 +69,7 @@ import {
ListboxOption,
} from '@headlessui/vue';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/vue/24/outline';
export interface SelectOption {
value: string | number;
name: string;
description?: string;
disabled?: boolean;
}
import { SelectOption } from '../form.types';
const props = withDefaults(
defineProps<{

View File

@ -0,0 +1,53 @@
<template>
<FormField
:name="name"
:label="label"
:placeholder="placeholder"
:disabled="disabled"
>
<template #input="{ invalid, forId, value, setValue }">
<Autocomplete
:for-id="forId"
:invalid="invalid"
:disabled="disabled"
:initialOptions="initialOptions"
:searchFn="searchFn"
:bindValue="bindValue"
:bindLabel="bindLabel"
:placeholder="placeholder"
:model-value="value"
@update:model-value="(newValue) => setValue(newValue)"
>
<template #notfound="slotProps">
<slot name="notfound" v-bind="slotProps" />
</template>
<template #option="slotProps">
<slot name="option" v-bind="slotProps" />
</template>
</Autocomplete>
</template>
<template #default><slot /></template>
</FormField>
</template>
<script setup lang="ts">
import FormField from '../FormField.vue';
import Autocomplete from '../base/Autocomplete.vue';
const props = withDefaults(
defineProps<{
label: string;
name: string;
initialOptions?: any[];
searchFn?: (query: string) => Promise<any[]>;
bindValue?: string;
bindLabel?: string | ((obj: any) => string);
disabled?: boolean;
placeholder?: string;
}>(),
{
disabled: false,
}
);
</script>

View File

@ -0,0 +1,43 @@
<template>
<FormField
:name="name"
:label="label"
:disabled="disabled"
for-id-prefix="dp-input"
>
<template #input="{ invalid, fieldName, value, setValue }">
<Datepicker
text-input
arrow-navigation
:class="[invalid ? 'dp__invalid' : '']"
:uid="fieldName"
:placeholder="placeholder"
:disabled="disabled"
:clearable="clearable"
v-bind="$attrs"
:model-value="(value as string)"
@update:model-value="setValue"
/>
</template>
<template #default><slot /></template>
</FormField>
</template>
<script setup lang="ts">
import FormField from '../FormField.vue';
import Datepicker from '@vuepic/vue-datepicker';
import { ExtractComponentProps } from '../../../utils/extract-component-props';
interface DatePickerProps extends ExtractComponentProps<typeof Datepicker> {
label: string;
name: string;
disabled?: boolean;
clearable?: boolean;
placeholder?: string;
}
// https://vue3datepicker.com/
const props = withDefaults(defineProps<DatePickerProps>(), {
disabled: false,
});
</script>

View File

@ -14,7 +14,11 @@
:placeholder="placeholder"
:model-value="(value as string)"
@update:model-value="(newValue) => setValue(newValue)"
/>
>
<template #option="slotProps">
<slot name="option" v-bind="slotProps" />
</template>
</SelectVue>
</template>
<template #default><slot /></template>
</FormField>
@ -22,7 +26,8 @@
<script setup lang="ts">
import FormField from '../FormField.vue';
import SelectVue, { SelectOption } from '../base/Select.vue';
import SelectVue from '../base/Select.vue';
import { SelectOption } from '../form.types';
const props = withDefaults(
defineProps<{

View File

@ -5,3 +5,27 @@ export interface FormSubmit {
formData: FormData;
fieldErrors: FormErrors;
}
export type InputType =
| 'text'
| 'number'
| 'password'
| 'color'
| 'email'
| 'checkbox'
| 'radio';
export interface SharedFieldProps {
name: string;
label: string;
disabled?: boolean;
placeholder?: string;
readonly?: boolean;
}
export interface SelectOption {
value: string | number;
name: string;
description?: string;
disabled?: boolean;
}

View File

@ -33,12 +33,11 @@ import {
} from '../../interfaces/storage.interfaces';
import jfetch from '../../utils/jfetch';
import takeError from '../../utils/take-error';
import { FormSubmit } from '../form/form.types';
import { FormSubmit, SelectOption } from '../form/form.types';
import Form from '../form/Form.vue';
import FormAlert from '../form/FormAlert.vue';
import FormField from '../form/FormField.vue';
import FormSelectField from '../form/fields/FormSelectField.vue';
import { SelectOption } from '../form/base/Select.vue';
import { IsRequired, MinLength } from '../form/validators';
import Modal from '../Modal.vue';
import FormColorField from '../form/fields/FormColorField.vue';

View File

@ -1,3 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import '@vuepic/vue-datepicker/src/VueDatePicker/style/main.scss';
.dp__input {
@apply block w-full rounded-md border-gray-300 shadow-sm transition-colors duration-200 focus:border-blue-500 focus:ring-1 focus:ring-blue-500 sm:text-sm;
height: 38px;
}
.dp__invalid .dp__input {
@apply border-red-300 focus:border-red-500 focus:ring-red-500;
}

View File

@ -0,0 +1,5 @@
export type ExtractComponentProps<TComponent> = TComponent extends new () => {
$props: infer P;
}
? P
: never;

View File

@ -2,6 +2,23 @@
<StandardLayout>
<h1>Dashboard</h1>
<p>Hello, {{ user.name }}</p>
<Form @submit="test" :validators="validators">
<FormDateField
name="date"
label="datepicker"
locale="en-UK"
clearable
:enable-time-picker="false"
/>
<FormField name="test" label="test" />
<FormAutocompleteField
:search-fn="searchFn"
name="autocomplete"
label="autocomplete"
/>
<button type="submit">test</button>
</Form>
</StandardLayout>
</template>
@ -9,7 +26,46 @@
import { ref } from 'vue';
import { useUserStore } from '../store/user.store';
import StandardLayout from '../components/StandardLayout.vue';
import FormDateField from '../components/form/fields/FormDateField.vue';
import Form from '../components/form/Form.vue';
import FormField from '../components/form/FormField.vue';
import { IsRequired } from '../components/form/validators';
import FormAutocompleteField from '../components/form/fields/FormAutocompleteField.vue';
const userStore = useUserStore();
const user = ref(userStore.user);
const test = console.log;
const validators = [{ field: 'date', validators: [IsRequired()] }];
const autocompleteTest = [
{
id: 1,
displayName: 'search things',
},
{
id: 2,
displayName: 'potato',
},
{
id: 3,
displayName: 'carrot',
},
{
id: 4,
displayName: 'beet',
},
];
const searchFn = async (query: string) => {
await new Promise((resolve) => setTimeout(resolve, 800));
return query === ''
? autocompleteTest
: autocompleteTest.filter((person) =>
person.displayName
.toLowerCase()
.replace(/\s+/g, '')
.includes(query.toLowerCase().replace(/\s+/g, ''))
);
};
</script>