start input list with add and delete actions

This commit is contained in:
Jordan Blasenhauer 2024-07-02 17:24:17 +02:00
parent 9f1dbef6e3
commit 9400784fe3
8 changed files with 583 additions and 23 deletions

View file

@ -179,6 +179,11 @@ body {
@apply font-bold text-red-500 absolute;
}
.input-error-dropdown-msg {
@apply text-red-500 text-sm mb-0 font-semibold leading-normal tracking-normal;
}
.input-error-msg {
@apply absolute text-red-500 text-[0.75rem] font-semibold mb-0 mt-0.5;
}
@ -211,6 +216,14 @@ body {
@apply pointer-events-none text-green-500 -z-10 opacity-0;
}
.input-list-add {
@apply transition absolute top-1 right-9 cursor-pointer transition-transform h-6 w-6 disabled:cursor-not-allowed disabled:opacity-50 disabled:dark:opacity-75;
}
.input-list-svg {
@apply absolute top-1.5 right-2 pointer-events-none transition-transform h-6 w-6 fill-gray-600 dark:fill-gray-500;
}
.checkbox-container {
@apply relative z-10 mt-1;
}
@ -256,10 +269,6 @@ body {
@apply flex flex-col max-h-[200px] overflow-x-hidden overflow-y-auto;
}
.combobox-no-match {
@apply text-[0.75rem] font-semibold mb-0 mt-0.5;
}
.select-dropdown-container {
@apply flex max-h-[200px] overflow-x-hidden overflow-y-auto flex-col w-fit mt-2;
}
@ -277,7 +286,7 @@ body {
}
.select-dropdown-btn {
@apply outline-offset-[-4px] border-b border-l border-r border-gray-300 hover:brightness-90 bg-white text-gray-700 my-0 relative px-6 py-2 text-center align-middle transition-all rounded-none cursor-pointer leading-normal text-sm ease-in tracking-normal dark:border-slate-600 dark:bg-slate-700 dark:text-gray-300;
@apply outline-offset-[-4px] border-b border-l border-r border-gray-300 hover:brightness-90 bg-white text-gray-700 my-0 relative px-2 py-2 text-left align-middle transition-all rounded-none cursor-pointer leading-normal text-sm ease-in tracking-normal dark:border-slate-600 dark:bg-slate-700 dark:text-gray-300;
}
.active.select-dropdown-btn {
@ -558,13 +567,17 @@ body {
}
.icon-container {
@apply block w-fit pointer-events-none;
@apply block w-fit pointer-events-none;
}
.stick.icon-container {
@apply absolute top-0 right-0 min-w-12 dark:brightness-90 flex justify-center items-center w-12 h-12 text-center rounded-circle;
}
.icon-input {
@apply h-6 w-6 relative pointer-events-none;
}
.icon-social {
@apply hover:opacity-80 pointer-events-none;
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,63 @@
<script setup>
/**
@name Forms/Error/Dropdown.vue
@description This component is used to display a feedback message on a dropdown field.
It is used with /Forms/Field components.
@example
{
isValid: false,
isValue: false,
}
@param {boolean} [isValid=false] - Check if the field is valid
@param {boolean} [isValue=false] - Check if the field has a value, display a different message if the field is empty or not
@param {boolean} [isValueTaken=false] - Check if input is already taken. Use with list input.
@param {string} [errorClass=""] - Additional class
*/
const props = defineProps({
isValid: {
type: Boolean,
required: false,
},
isValue: {
type: Boolean,
required: false,
},
isValueTaken: {
type: Boolean,
required: false,
default: false,
},
errorClass: {
type: String,
required: false,
default: "",
},
});
</script>
<template>
<div
:aria-hidden="props.isValid ? 'true' : 'false'"
:class="[
props.isValid ? 'hidden' : '',
'select-dropdown-btn last first',
props.errorClass,
]"
role="alert"
>
<p class="input-error-dropdown-msg">
{{
props.isValid
? $t("inp_input_valid")
: props.isNoMatch
? $t("inp_input_no_match")
: props.isValueTaken
? $t("inp_input_error_taken")
: !props.isValue
? $t("inp_input_error_required")
: $t("inp_input_error")
}}
</p>
</div>
</template>

View file

@ -10,6 +10,7 @@
}
@param {boolean} [isValid=false] - Check if the field is valid
@param {boolean} [isValue=false] - Check if the field has a value, display a different message if the field is empty or not
@param {boolean} [isValueTaken=false] - Check if input is already taken. Use with list input.
@param {string} [errorClass=""] - Additional class
*/
@ -22,6 +23,11 @@ const props = defineProps({
type: Boolean,
required: false,
},
isValueTaken: {
type: Boolean,
required: false,
default: false,
},
errorClass: {
type: String,
required: false,
@ -39,6 +45,10 @@ const props = defineProps({
{{
props.isValid
? $t("inp_input_valid")
: props.isNoMatch
? $t("inp_input_no_match")
: props.isValueTaken
? $t("inp_input_error_taken")
: !props.isValue
? $t("inp_input_error_required")
: $t("inp_input_error")

View file

@ -13,6 +13,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 ErrorDropdown from "@components/Forms/Error/Dropdown.vue";
import { useUUID } from "@utils/global.js";
/**
@ -431,18 +432,14 @@ const emits = defineEmits(["inp"]);
:value="inp.value"
:type="'text'"
/>
<div
class="select-dropdown-btn"
<ErrorDropdown
v-if="!inp.isMatching"
:aria-hidden="!inp.isMatching ? 'true' : 'false'"
role="alert"
>
<p class="combobox-no-match">
{{ $t("inp_combobox_no_match") }}
</p>
</div>
:isNoMatch="true"
:isValid="false"
/>
</div>
<div
v-if="inp.isMatching"
data-select-dropdown-items
:id="`${inp.id}-list`"
:aria-hidden="select.isOpen ? 'false' : 'true'"

View file

@ -0,0 +1,463 @@
<script setup>
import {
ref,
reactive,
watch,
onMounted,
defineEmits,
defineProps,
computed,
onBeforeMount,
} 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 { useUUID } from "@utils/global.js";
import Icons from "@components/Widget/Icons.vue";
import ErrorDropdown from "@components/Forms/Error/Dropdown.vue";
/**
@name Forms/Field/List.vue
@description This component is used display list of values in a dropdown, remove or add an item in an easy way.
We can also add popover to display more information.
@example
{
id: 'test-input',
value: 'yes no maybe',
name: 'test-list',
label: 'Test list',
inpType: "list",
popovers : [
{
text: "This is a popover text",
iconName: "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 {string} [separator=" "] - Separator to split the value, by default it is a space
@param {string} [maxBtnChars=""] - Max char to display in the dropdown button handler.
@param {array} [popovers] - List of popovers to display more information
@param {string} [inpType="list"] - 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 {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 {boolean} [onlyDown=false] - If the dropdown should stay down
@param {boolean} [overflowAttrEl=""] - Attribute the element has to check for overflow
@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: "",
},
columns: {
type: [Object, Boolean],
required: false,
default: false,
},
value: {
type: String,
required: true,
},
separator: {
type: String,
required: false,
default: " ",
},
maxBtnChars: {
type: [String, Number],
required: false,
default: "",
},
inpType: {
type: String,
required: false,
default: "select",
},
disabled: {
type: Boolean,
required: false,
},
required: {
type: Boolean,
required: false,
},
label: {
type: String,
required: true,
},
name: {
type: String,
required: true,
},
popovers: {
type: Array,
required: false,
default: [],
},
hideLabel: {
type: Boolean,
required: false,
},
onlyDown: {
type: Boolean,
required: false,
default: false,
},
containerClass: {
type: String,
required: false,
default: "",
},
overflowAttrEl: {
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,
},
});
const inp = reactive({
isOpen: false,
id: "",
value: props.value,
values: computed(() => {
return inp.value.split(props.separator);
}),
isValid: computed(() => {
// Case enter value start or en by separator
if (inp.value.startsWith(props.separator)) return false;
if (inp.value.endsWith(props.separator)) return false;
if (!inp.value && props.required) return false;
if (!props.pattern) return true;
// Check if value is valid related to pattern
return inp.value.match(new RegExp(props.pattern)) ? true : false;
}),
enterValue: "",
// Check if enter value is already a value
isEnterMatching: computed(() => {
if (!inp.enterValue) return false;
if (!props.value.split(props.separator)) return false;
return props.value
.split(props.separator)
.some((str) => str.toLowerCase() === inp.enterValue.toLowerCase());
}),
// Check that the current inp.value with the current enter value is valid related to pattern
isEnterValid: computed(() => {
// Case enter value start or en by separator
if (inp.enterValue.startsWith(props.separator)) return false;
if (inp.enterValue.endsWith(props.separator)) return false;
if (!inp.enterValue) return true;
if (!props.required) return true;
if (!props.pattern) return true;
const newValue = inp.enterValue
? `${inp.value} ${inp.enterValue}`
: inp.value;
return newValue.match(new RegExp(props.pattern)) ? true : false;
}),
});
const inputEl = ref();
const selectWidth = ref("");
const selectDropdown = ref();
// EVENTS
function openSelect() {
inp.isOpen = true;
// Reset input value
setTimeout(() => {
// Get field container rect
const fieldContainer = inputEl.value.closest("[data-field-container]");
const parent = props.overflowAttrEl
? fieldContainer.closest(`[${props.overflowAttrEl}]`)
: fieldContainer.parentElement;
// Update position only if parent has overflow
const isOverflow = parent.scrollHeight > parent.clientHeight ? true : false;
if (!isOverflow) return;
// Get all rect
const selectBtnRect = inputEl.value.getBoundingClientRect();
const fieldContainerRect = fieldContainer.getBoundingClientRect();
const selectDropRect = selectDropdown.value.getBoundingClientRect();
const parentRect = parent.getBoundingClientRect();
const canBeDown = props.onlyDown
? true
: fieldContainerRect.bottom + selectDropRect.height < parentRect.bottom
? true
: false;
if (!canBeDown) {
selectDropdown.value.style.top = `-${
selectDropRect.height + selectBtnRect.height - 16
}px`;
}
if (canBeDown) {
selectDropdown.value.style.top = `${selectBtnRect.height}px`;
}
}, 10);
}
function closeSelect() {
inp.isOpen = false;
}
// Close select when clicked outside logic
function closeOutside(e) {
try {
if (e.target !== inputEl.value && e.target !== inputEl.value) {
inp.isOpen = false;
}
} catch (err) {
inp.isOpen = false;
}
}
function closeScroll(e) {
if (!e.target) return;
// Case not a DOM element (like the document itself)
if (e.target.nodeType !== 1) return (inp.isOpen = false);
// Case DOM, check if it is the select dropdown
if (
e.target.hasAttribute("data-select-dropdown") ||
e.target.hasAttribute("data-select-dropdown-items")
)
return;
inp.isOpen = false;
}
// Check after a key is pressed if the current active element is the select button
// If not close the select
function closeTab(e) {
if (e.key !== "Tab" && e.key !== "Shift-Tab") return;
setTimeout(() => {
const activeEl = document.activeElement;
if (activeEl.closest("[data-select-dropdown]") !== selectDropdown.value)
return (inp.isOpen = false);
}, 10);
}
// Case the entry is focus and value is valid, add it to the list
function addEntry(e) {
// check if keyboard event
if (e.key && e.key !== "Enter") return;
if (!inp.isEnterValid || inp.isEnterMatching) return;
if (
document.activeElement !== inputEl.value &&
!e.target.hasAttribute("data-add-entry")
)
return;
inp.value = `${inp.enterValue}${props.separator}${inp.value}`;
console.log(inp.value);
inp.enterValue = "";
}
// Case the entry is focus and value is valid, add it to the list
function deleteValue(value) {
inp.value = inp.value
.split(props.separator)
.filter((val) => val !== value)
.join(props.separator);
}
// Close select dropdown when clicked outside element
watch(inp, () => {
if (inp.isOpen) {
window.addEventListener("click", closeOutside);
window.addEventListener("scroll", closeScroll, true);
window.addEventListener("keydown", closeTab);
window.addEventListener("keydown", addEntry);
} else {
window.removeEventListener("click", closeOutside);
window.removeEventListener("scroll", closeScroll, true);
window.removeEventListener("keydown", closeTab);
window.removeEventListener("keydown", addEntry);
}
});
onBeforeMount(() => {
inp.id = useUUID(props.id);
});
onMounted(() => {
selectWidth.value = `${inputEl.value.clientWidth}px`;
window.addEventListener("resize", () => {
try {
selectWidth.value = `${inputEl.value.clientWidth}px`;
} catch (err) {}
});
});
const emits = defineEmits(["inp"]);
</script>
<template>
<Container
data-field-container
:class="[inp.isOpen ? 'z-[100]' : '']"
:containerClass="`${props.containerClass}`"
:columns="props.columns"
>
<Header
:popovers="props.popovers"
:required="props.required"
:name="props.name"
:id="inp.id"
:label="props.label"
:hideLabel="props.hideLabel"
:headerClass="props.headerClass"
/>
<!--custom-->
<div class="relative">
<div class="input-regular-container">
<input
data-toggle-dropdown
:aria-controls="`${inp.id}-custom`"
:aria-expanded="inp.isOpen ? 'true' : 'false'"
:aria-description="$t('inp_list_input_desc')"
:tabindex="props.tabId"
ref="inputEl"
@input="
(e) => {
inp.enterValue = e.target.value;
$emit('inp', inp.value);
}
"
:id="inp.id"
:class="[
'input-regular',
inp.isValid && !inp.isEnterMatching && inp.isEnterValid
? 'valid'
: 'invalid',
props.inpClass,
]"
@focusin="openSelect()"
:required="props.required || false"
:readonly="props.readonly || false"
:disabled="props.disabled || false"
:placeholder="
props.placeholder
? $t(
props.placeholder,
$t('dashboard_placeholder', props.placeholder)
)
: ''
"
:name="props.name"
:value="inp.enterValue"
type="text"
/>
<button
data-add-entry
@click.prevent="(e) => addEntry(e)"
:disabled="
inp.isValid &&
!inp.isEnterMatching &&
inp.isEnterValid &&
inp.enterValue
? false
: true
"
:data-is="'input'"
class="input-list-add"
>
<Icons :iconName="'plus'" />
</button>
<svg
role="img"
aria-hidden="true"
:class="[inp.isOpen ? '-rotate-180' : '']"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="input-list-svg"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 4.5h14.25M3 9h9.75M3 13.5h5.25m5.25-.75L17.25 9m0 0L21 12.75M17.25 9v12"
/>
</svg>
</div>
<!-- dropdown-->
<div
data-select-dropdown
:aria-hidden="inp.isOpen ? 'false' : 'true'"
:aria-expanded="inp.isOpen ? 'true' : 'false'"
ref="selectDropdown"
role="listbox"
:style="{ width: selectWidth }"
:id="`${inp.id}-custom`"
:class="[inp.isOpen ? 'open' : 'close']"
class="select-dropdown-container"
:aria-description="$t('inp_select_dropdown_desc')"
>
<ErrorDropdown
v-if="!inp.isMatching || !inp.isEnterValid || !inp.isValid"
:isValid="inp.isValid && !inp.isEnterMatching && inp.isEnterValid"
:isValue="!!inp.value"
:isValueTaken="inp.isEnterMatching"
/>
<button
@click="deleteValue(value)"
v-if="inp.isValid && !inp.isEnterMatching && inp.isEnterValid"
role="option"
:tabindex="inp.isOpen ? props.tabId : '-1'"
v-for="(value, id) in inp.values"
:class="[
id === 0 ? 'first' : '',
id === inp.values.length - 1 ? 'last' : '',
'select-dropdown-btn',
]"
data-select-item
:data-setting-id="inp.id"
:data-setting-value="value"
:aria-controls="`${inp.id}-text`"
>
{{ value }}
</button>
</div>
<ErrorField
v-if="!inp.isOpen"
:errorClass="'input'"
:isValid="inp.isValid && !inp.isEnterMatching && inp.isEnterValid"
:isValue="!!inp.value"
:isValueTaken="inp.isEnterMatching"
/>
<!-- end dropdown-->
</div>
<!-- end custom-->
</Container>
</template>

View file

@ -102,13 +102,14 @@
"dashboard_no_match_filter" : "No match found with filter",
"dashboard_something_wrong": "Something is wrong",
"inp_input_valid": "input valid",
"inp_input_error_no_match": "No match found",
"inp_input_error_required": "input is required",
"inp_input_error": "input is invalid",
"inp_input_error_taken": "value already taken",
"inp_popover_multisite": "This setting is multisite.",
"inp_popover_global": "This setting is global.",
"inp_popover_method_disabled": "This setting method (scheduler, autoconf...) unable value change.",
"inp_combobox": "Combobox input for relate select radio group.",
"inp_combobox_no_match": "No match found",
"inp_select_dropdown_button_desc": "Toggle hide/show radio group (dropdown) to change value.",
"inp_select_dropdown_desc": "Radio group (dropdown) to change value.",
"inp_input_password_desc": "Toggle hide/show password.",
@ -126,7 +127,9 @@
"inp_editor_desc": "Editor input behaving like a code editor.",
"inp_input_clipboard_copied": "copied to clipboard",
"inp_input_clipboard_desc": "Copy to clipboard on click.",
"inp_templates_desc": "Choose a template. This will override de",
"inp_templates_desc": "Choose a template. Switching will reset none save settings update.",
"inp_list_enter_match": "This value already match existing list item",
"inp_list_invalid_entry" : "This value is invalid for list",
"icons_cross_desc": "Cross icon representing a close, delete, error or cancel state.",
"icons_check_desc": "Check icon representing a success, valid or active state.",
"icons_core_desc": "Core icon representing a core setting or plugin.",

View file

@ -1,11 +1,24 @@
<script setup>
import { reactive, onBeforeMount, triggerRef } from "vue";
import Icons from "@components/Widget/Icons.vue";
import InputList from "@components/Forms/Field/List.vue";
import GridLayout from "@components/Widget/GridLayout.vue";
import DashboardLayout from "@components/Dashboard/Layout.vue";
const icon = {
iconName: "wire",
const list = {
id: "test-input",
value: "yes no maybe",
name: "test-list",
label: "Test list",
inpType: "list",
onlyDown: true,
popovers: [
{
text: "This is a popover text",
iconName: "info",
},
],
columns: { pc: 12, tablet: 12, mobile: 12 },
};
</script>
@ -13,9 +26,7 @@ const icon = {
<DashboardLayout>
<GridLayout :columns="{ pc: 12, tablet: 12, mobile: 12 }">
<!-- widget grid -->
<div data-is="menu">
<Icons v-bind="icon" />
</div>
<InputList v-bind="list" />
</GridLayout>
</DashboardLayout>
</template>