start combobox component

This commit is contained in:
Jordan Blasenhauer 2024-06-09 21:49:41 +02:00
parent 195f292599
commit 197436c9d5
9 changed files with 583 additions and 73 deletions

View file

@ -229,6 +229,14 @@ body {
@apply pointer-events-none transition-transform h-4 w-4 fill-gray-600 dark:fill-gray-500;
}
.select-combobox-list {
@apply flex flex-col;
}
.combobox-no-match {
@apply text-[0.75rem] font-semibold mb-0 mt-0.5;
}
.select-dropdown-container {
@apply flex fixed h-full max-h-[200px] overflow-x-hidden overflow-y-auto flex-col w-fit mt-2;
}

File diff suppressed because one or more lines are too long

View file

@ -4,6 +4,7 @@ import { contentIndex } from "@utils/tabindex.js";
import Container from "@components/Widget/Container.vue";
import Header from "@components/Forms/Header/Field.vue";
import ErrorField from "@components/Forms/Error/Field.vue";
import { v4 as uuidv4 } from "uuid";
/**
@name Forms/Field/Checkbox.vue
@ -29,7 +30,7 @@ import ErrorField from "@components/Forms/Error/Field.vue";
},
]
}
@param {string} id
@param {string} [id=uuidv4()] - Unique id
@param {string} label - The label of the field. Can be a translation key or by default raw text.
@param {string} name - The name of the field. Case no label, this is the fallback. Can be a translation key or by default raw text.
@param {string} value
@ -49,7 +50,8 @@ const props = defineProps({
// id && value && method
id: {
type: String,
required: true,
required: false,
default: uuidv4(),
},
columns: {
type: [Object, Boolean],

View file

@ -0,0 +1,463 @@
<script setup>
import { ref, reactive, watch, onMounted, defineEmits, defineProps } from "vue";
import { contentIndex } from "@utils/tabindex.js";
import Container from "@components/Widget/Container.vue";
import Header from "@components/Forms/Header/Field.vue";
import ErrorField from "@components/Forms/Error/Field.vue";
import { v4 as uuidv4 } from "uuid";
/**
@name Forms/Field/Combobox.vue
@description This component is used to create a complete combobox field input with error handling and label.
We can be more precise by adding values that need to be selected to be valid.
We can also add popover to display more information.
@example
{
id: 'test-input',
value: 'yes',
values : ['yes', 'no'],
name: 'test-input',
disabled: false,
required: true,
requiredValues : ['no'], // need required to be checked
label: 'Test select',
inpType: "select",
popovers : [
{
text: "This is a popover text",
iconName: "info",
iconColor: "info",
},]
}
@param {string} [id=uuidv4()] - Unique id
@param {string} label - The label of the field. Can be a translation key or by default raw text.
@param {string} name - The name of the field. Case no label, this is the fallback. Can be a translation key or by default raw text.
@param {string} value
@param {array} values
@param {array} [popovers] - List of popovers to display more information
@param {string} [inpType="select"] - The type of the field, useful when we have multiple fields in the same container to display the right field
@param {boolean} [disabled=false]
@param {boolean} [required=false]
@param {array} [requiredValues=[]] - values that need to be selected to be valid, works only if required is true
@param {object} [columns={"pc": "12", "tablet": "12", "mobile": "12}] - Field has a grid system. This allow to get multiple field in the same row if needed.
@param {boolean} [hideLabel=false]
@param {string} [containerClass=""]
@param {string} [inpClass=""]
@param {string} [headerClass=""]
@param {string|number} [tabId=contentIndex] - The tabindex of the field, by default it is the contentIndex
*/
const props = defineProps({
// id && value && method
id: {
type: String,
required: false,
default: uuidv4(),
},
columns: {
type: [Object, Boolean],
required: false,
default: false,
},
value: {
type: String,
required: true,
},
values: {
type: Array,
required: true,
},
inpType: {
type: String,
required: false,
default: "select",
},
disabled: {
type: Boolean,
required: false,
},
required: {
type: Boolean,
required: false,
},
requiredValues: {
type: Array,
required: false,
default: [],
},
label: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
popovers: {
type: Array,
required: false,
default: [],
},
hideLabel: {
type: Boolean,
required: false,
},
containerClass: {
type: String,
required: false,
default: "",
},
headerClass: {
type: String,
required: false,
default: "",
},
inpClass: {
type: String,
required: false,
default: "",
},
tabId: {
type: [String, Number],
required: false,
default: contentIndex,
},
});
// When mounted or when props changed, we want select to display new props values
// When component value change itself, we want to switch to select.value
// To avoid component to send and stick to props values (bad behavior)
// Trick is to use select.value || props.value on template
watch(props, (newProp, oldProp) => {
if (newProp.value !== select.value) {
select.value = "";
}
});
const inp = reactive({
value: props.value,
isValid: false,
});
const inputEl = ref();
const select = reactive({
isOpen: false,
// On mounted value is null to display props value
// Then on new select we will switch to select.value
// If we use select.value : props.value
// Component will not re-render after props.value change
value: "",
isValid: !props.required
? true
: props.requiredValues.length <= 0
? true
: props.requiredValues.includes(props.value)
? true
: false,
});
const selectBtn = ref();
const selectWidth = ref("");
const selectDropdown = ref();
// EVENTS
function toggleSelect() {
select.isOpen = select.isOpen ? false : true;
// Position dropdown relative to btn on open on fixed position
if (select.isOpen) {
// focus on combobox and reset value
inp.value = "";
inp.isValid = inputEl.value.checkValidity();
inputEl.value.focus();
// setup dropdown position
const selectBtnRect = selectBtn.value.getBoundingClientRect();
const selectDropdownRect = selectDropdown.value.getBoundingClientRect();
// We need to take care of parent padding and margin that will affect dropdown position but aren't calculate in rect
const parents = [selectBtn.value.parentElement];
let isParent = selectBtn.value.parentElement ? true : false;
while (isParent) {
parents.push(parents[parents.length - 1].parentElement);
isParent = parents[parents.length - 1].parentElement ? true : false;
}
let noRectParentHeight = 0;
for (let i = 0; i < parents.length; i++) {
try {
noRectParentHeight += +window
.getComputedStyle(parents[i], null)
.getPropertyValue("padding-top")
.replace("px", "");
} catch (e) {}
try {
noRectParentHeight += +window
.getComputedStyle(parents[i], null)
.getPropertyValue("margin-top")
.replace("px", "");
} catch (e) {}
}
// If dropdown is too close to bottom, we need to drop it up
const canDropBeDown =
selectBtnRect.top + selectDropdownRect.height + 20 < window.innerHeight
? true
: false;
if (canDropBeDown) {
selectDropdown.value.style.top = `${
window.scrollY +
selectBtnRect.bottom +
selectBtnRect.height * 2 -
selectDropdownRect.height -
noRectParentHeight +
16
}px`;
}
if (!canDropBeDown) {
selectDropdown.value.style.top = `${
window.scrollY +
selectBtnRect.top +
selectBtnRect.height * 2 -
selectDropdownRect.height * 2 -
noRectParentHeight -
24
}px`;
}
}
}
function closeSelect() {
select.isOpen = false;
}
function changeValue(newValue) {
// Allow on template to switch from prop value to component own value
// Then send the new value to parent
select.value = newValue;
// Check if value is required and if it is in requiredValues
select.isValid = !props.required
? true
: props.requiredValues.length <= 0
? true
: props.requiredValues.includes(newValue)
? true
: false;
closeSelect();
return newValue;
}
// Close select when clicked outside logic
function closeOutside(e) {
try {
if (e.target !== selectBtn.value && e.target !== inputEl.value) {
select.isOpen = false;
}
} catch (err) {
select.isOpen = false;
}
}
function closeScroll(e) {
if (!e.target) return;
// Case not a DOM element (like the document itself)
if (e.target.nodeType !== 1) return (select.isOpen = false);
// Case DOM, check if it is the select dropdown
if (e.target.hasAttribute("data-select-dropdown")) return;
select.isOpen = false;
}
// Close select dropdown when clicked outside element
watch(select, () => {
if (select.isOpen) {
window.addEventListener("click", closeOutside);
window.addEventListener("scroll", closeScroll, true);
} else {
window.removeEventListener("click", closeOutside);
window.removeEventListener("scroll", closeScroll, true);
}
});
onMounted(() => {
selectWidth.value = `${selectBtn.value.clientWidth}px`;
window.addEventListener("resize", () => {
try {
selectWidth.value = `${selectBtn.value.clientWidth}px`;
} catch (err) {}
});
});
const emits = defineEmits(["inp"]);
</script>
<template>
<Container
:containerClass="`field-container ${props.containerClass}`"
:columns="props.columns"
>
<Header
:popovers="props.popovers"
:required="props.required"
:name="props.name"
:label="props.label"
:hideLabel="props.hideLabel"
:headerClass="props.headerClass"
/>
<select :name="props.name" class="hidden">
<option
v-for="(value, id) in props.values"
:key="id"
:value="value"
@click="$emit('inp', changeValue(value))"
:selected="
(select.value && select.value === value) ||
(!select.value && value === props.value)
? true
: false
"
>
{{ value }}
</option>
</select>
<!-- end default select -->
<!--custom-->
<div class="relative">
<button
:name="`${props.name}-custom`"
:tabindex="props.tabId"
ref="selectBtn"
:aria-controls="`${props.id}-custom`"
:aria-expanded="select.isOpen ? 'true' : 'false'"
:aria-description="$t('inp_select_dropdown_button_desc')"
data-select-dropdown
:disabled="props.disabled || false"
@click.prevent="toggleSelect()"
:class="[
'select-btn',
select.isValid ? 'valid' : 'invalid',
props.inpClass,
]"
>
<span :id="`${props.id}-text`" class="select-btn-name">
{{ select.value || props.value }}
</span>
<!-- chevron -->
<svg
role="img"
aria-hidden="true"
:class="[select.isOpen ? '-rotate-180' : '']"
class="select-btn-svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
>
<path
d="M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"
/>
</svg>
<!-- end chevron -->
</button>
<!-- dropdown-->
<div
ref="selectDropdown"
:style="{ width: selectWidth }"
:id="`${props.id}-custom`"
:class="[select.isOpen ? 'open' : 'close']"
class="select-dropdown-container"
:aria-hidden="select.isOpen ? 'false' : 'true'"
:aria-expanded="select.isOpen ? 'true' : 'false'"
:aria-description="$t('inp_select_dropdown_desc')"
>
<div>
<label :class="['sr-only']" :for="`${props.id}-combobox`">
{{ $t("inp_combobox", "inp_combobox") }}
</label>
<input
:tabindex="select.isOpen ? props.tabId : '-1'"
ref="inputEl"
v-model="inp.value"
@input="
() => {
inp.isValid = inputEl.checkValidity();
$emit('inp', inp.value);
}
"
:aria-controls="`${props.id}-list`"
:id="`${props.id}-combobox`"
:class="[
'input-regular',
inp.isValid ? 'valid' : 'invalid',
props.inpClass,
]"
:placeholder="props.placeholder || ''"
:pattern="props.pattern || '(?s).*'"
:name="`${props.id}-combobox`"
:value="inp.value"
:type="'text'"
/>
<div
class="select-dropdown-btn"
v-if="!props.values.some((str) => str.includes(inp.value))"
:aria-hidden="
!props.values.some((str) => str.includes(inp.value))
? 'true'
: 'false'
"
role="alert"
>
<p class="combobox-no-match">
{{ $t("inp_combobox_no_match", "inp_combobox_no_match") }}
</p>
</div>
</div>
<div
:id="`${props.id}-list`"
:aria-hidden="select.isOpen ? 'false' : 'true'"
role="radiogroup"
class="select-combobox-list"
>
<template v-for="(value, id) in props.values">
<button
v-if="value.includes(inp.value)"
:aria-hidden="value.includes(inp.value) ? 'false' : 'true'"
:tabindex="
select.isOpen
? value.includes(inp.value)
? props.tabId
: '-1'
: '-1'
"
role="radio"
@click.prevent="$emit('inp', changeValue(value))"
:class="[
id === 0 ? 'first' : '',
id === props.values.length - 1 ? 'last' : '',
(value === select.value && select.value === value) ||
(!select.value && value === props.value)
? 'active'
: '',
'select-dropdown-btn',
]"
:aria-controls="`${props.id}-text`"
:aria-checked="
(select.value && select.value === value) ||
(!select.value && value === props.value)
? 'true'
: 'false'
"
>
{{ value }}
</button>
</template>
</div>
</div>
<ErrorField :isValid="select.isValid" :isValue="true" />
<!-- end dropdown-->
</div>
<!-- end custom-->
</Container>
</template>

View file

@ -4,6 +4,7 @@ import { contentIndex } from "@utils/tabindex.js";
import Container from "@components/Widget/Container.vue";
import Header from "@components/Forms/Header/Field.vue";
import ErrorField from "@components/Forms/Error/Field.vue";
import { v4 as uuidv4 } from "uuid";
import flatpickr from "flatpickr";
@ -36,7 +37,7 @@ import "@assets/css/flatpickr.dark.css";
},
],
}
@param {string} id
@param {string} [id=uuidv4()] - Unique id
@param {string} label - The label of the field. Can be a translation key or by default raw text.
@param {string} name - The name of the field. Case no label, this is the fallback. Can be a translation key or by default raw text.
@param {array} popovers - List of popovers to display more information
@ -57,7 +58,8 @@ const props = defineProps({
// id && type && disabled && required && value
id: {
type: String,
required: true,
required: false,
default: uuidv4(),
},
name: {
type: String,

View file

@ -4,6 +4,7 @@ import { contentIndex } from "@utils/tabindex.js";
import Container from "@components/Widget/Container.vue";
import Header from "@components/Forms/Header/Field.vue";
import ErrorField from "@components/Forms/Error/Field.vue";
import { v4 as uuidv4 } from "uuid";
/**
@name Forms/Field/Input.vue
@ -31,7 +32,7 @@ import ErrorField from "@components/Forms/Error/Field.vue";
},
],
}
@param {string} id
@param {string} [id=uuidv4()] - Unique id
@param {string} type - text, email, password, number, tel, url
@param {string} label - The label of the field. Can be a translation key or by default raw text.
@param {string} name - The name of the field. Case no label, this is the fallback. Can be a translation key or by default raw text. @param {string} label
@ -56,7 +57,8 @@ const props = defineProps({
// id && value && method
id: {
type: String,
required: true,
required: false,
default: uuidv4(),
},
columns: {
type: [Object, Boolean],
@ -146,7 +148,6 @@ const inp = reactive({
showInp: false,
isClipAllow: false,
isValid: false,
isIndexUp: false,
});
const emits = defineEmits(["inp"]);

View file

@ -4,6 +4,7 @@ import { contentIndex } from "@utils/tabindex.js";
import Container from "@components/Widget/Container.vue";
import Header from "@components/Forms/Header/Field.vue";
import ErrorField from "@components/Forms/Error/Field.vue";
import { v4 as uuidv4 } from "uuid";
/**
@name Forms/Field/Select.vue
@ -29,7 +30,7 @@ import ErrorField from "@components/Forms/Error/Field.vue";
iconColor: "info",
},]
}
@param {string} id
@param {string} [id=uuidv4()] - Unique id
@param {string} label - The label of the field. Can be a translation key or by default raw text.
@param {string} name - The name of the field. Case no label, this is the fallback. Can be a translation key or by default raw text.
@param {string} value
@ -51,7 +52,8 @@ const props = defineProps({
// id && value && method
id: {
type: String,
required: true,
required: false,
default: uuidv4(),
},
columns: {
type: [Object, Boolean],
@ -147,7 +149,6 @@ const select = reactive({
: props.requiredValues.includes(props.value)
? true
: false,
isIndexUp: false,
});
const selectBtn = ref();
@ -352,6 +353,7 @@ const emits = defineEmits(["inp"]);
<div
data-select-dropdown
:aria-hidden="select.isOpen ? 'false' : 'true'"
:aria-expanded="select.isOpen ? 'true' : 'false'"
ref="selectDropdown"
role="radiogroup"
:style="{ width: selectWidth }"

View file

@ -85,6 +85,8 @@
"inp_input_error": "input is invalid",
"inp_popover_multisite": "This setting is multisite.",
"inp_popover_global": "This setting is global.",
"inp_combobox": "Combobox input for relate select radio group.",
"inp_combobox_no_match": "No match found.",
"action_send": "send {name}",
"action_start": "start {name}",
"action_disable": "disable {name}",

View file

@ -4,97 +4,127 @@ import Checkbox from "@components/Forms/Field/Checkbox.vue";
import Select from "@components/Forms/Field/Select.vue";
import Input from "@components/Forms/Field/Input.vue";
import Datepicker from "@components/Forms/Field/Datepicker.vue";
import Combobox from "@components/Forms/Field/Combobox.vue";
import Button from "@components/Widget/Button.vue";
const checkboxData = {
id: 'test-checkbox',
value: 'yes',
name: 'test-checkbox',
id: "test-checkbox",
value: "yes",
name: "test-checkbox",
disabled: false,
required: true,
label: 'Test checkbox',
tabId: '1',
}
label: "Test checkbox",
tabId: "1",
};
const selectData = {
id: 'test-select',
value: 'yes',
values: ['yes', 'no'],
name: 'test-select',
id: "test-select",
value: "yes",
values: ["yes", "no"],
name: "test-select",
disabled: false,
required: false,
requiredValues : ["no"],
label: 'Test select',
tabId: '1',
}
requiredValues: ["no"],
label: "Test select",
tabId: "1",
};
const inputData = {
id: 'test-input',
value: 'yes',
id: "test-input",
value: "yes",
type: "text",
name: 'test-input',
name: "test-input",
disabled: false,
required: true,
label: 'Test input',
pattern : "(test)",
tabId: '1',
}
label: "Test input",
pattern: "(test)",
tabId: "1",
};
const datepickerData = {
id: 'test-datepicker',
name: 'test-datepicker',
id: "test-datepicker",
name: "test-datepicker",
disabled: false,
required: true,
label: 'Test datepicker',
tabId: '1',
}
label: "Test datepicker",
tabId: "1",
};
const buttonData = {
id: 'test-button',
text: 'Test button',
type: 'button',
id: "test-button",
text: "Test button",
type: "button",
disabled: false,
eventAttr: {"store" : "modal", "default" : "close", "value" : "open", "target" : "modal_id", "valueExpanded" : "open"},
columns: {"pc": 12, "tablet": 12, "mobile": 12},
tabId: '1',
}
eventAttr: {
store: "modal",
default: "close",
value: "open",
target: "modal_id",
valueExpanded: "open",
},
columns: { pc: 12, tablet: 12, mobile: 12 },
tabId: "1",
};
const buttonSVGData = {
id: 'test-button',
text: 'Test button',
type: 'button',
id: "test-button",
text: "Test button",
type: "button",
disabled: false,
eventAttr: {"store" : "modal", "default" : "close", "value" : "open", "target" : "modal_id", "valueExpanded" : "open"},
columns: {"pc": 12, "tablet": 12, "mobile": 12},
tabId: '1',
iconName : "plus"
}
eventAttr: {
store: "modal",
default: "close",
value: "open",
target: "modal_id",
valueExpanded: "open",
},
columns: { pc: 12, tablet: 12, mobile: 12 },
tabId: "1",
iconName: "plus",
};
const buttonOnlySVGData = {
id: 'test-button',
text: 'Test button',
hideText : true,
type: 'button',
id: "test-button",
text: "Test button",
hideText: true,
type: "button",
disabled: false,
eventAttr: {"store" : "modal", "default" : "close", "value" : "open", "target" : "modal_id", "valueExpanded" : "open"},
columns: {"pc": 12, "tablet": 12, "mobile": 12},
tabId: '1',
iconName : "plus"
}
eventAttr: {
store: "modal",
default: "close",
value: "open",
target: "modal_id",
valueExpanded: "open",
},
columns: { pc: 12, tablet: 12, mobile: 12 },
tabId: "1",
iconName: "plus",
};
const comboboxData = {
id: "test-combobox",
value: "yes",
values: ["yes", "no"],
name: "test-combobox",
disabled: false,
required: false,
requiredValues: ["no"],
label: "Test combobox",
tabId: "1",
};
</script>
<template>
<div class="bg-secondary flex flex-col items-center justify-center h-full">
<div style="width: 300px;">
<Checkbox v-bind="checkboxData" />
<Select v-bind="selectData" />
<Input v-bind="inputData" />
<Datepicker v-bind="datepickerData" />
<Button v-bind="buttonData" />
<Button v-bind="buttonSVGData" />
<Button v-bind="buttonOnlySVGData" />
</div>
<div class="bg-secondary flex flex-col items-center justify-center h-full">
<div style="width: 300px">
<Checkbox v-bind="checkboxData" />
<Select v-bind="selectData" />
<Input v-bind="inputData" />
<Datepicker v-bind="datepickerData" />
<Combobox v-bind="comboboxData" />
<Button v-bind="buttonData" />
<Button v-bind="buttonSVGData" />
<Button v-bind="buttonOnlySVGData" />
</div>
</div>
</template>