mirror of
https://github.com/speatzle/nfsense.git
synced 2025-05-11 19:08:20 +00:00
Reworked Multiselect into multiple Components
- Also finished feature implementation
This commit is contained in:
parent
eea54c306d
commit
4acfe7c104
6 changed files with 305 additions and 148 deletions
208
client/src/components/inputs/DropdownInput.vue
Normal file
208
client/src/components/inputs/DropdownInput.vue
Normal file
|
@ -0,0 +1,208 @@
|
||||||
|
<!-- Base component that implements selecting single and multiple values from a list in a type-unsafe manner -->
|
||||||
|
<script lang="ts">
|
||||||
|
export type Index = string | number | symbol;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { equals, isNullish } from '../../util';
|
||||||
|
// --- Prop setup ---
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
// Two-Way Bindings (v-model)
|
||||||
|
modelValue?: any,
|
||||||
|
search?: string,
|
||||||
|
|
||||||
|
// One-Way Bindings
|
||||||
|
multiple?: boolean,
|
||||||
|
options?: Record<Index, {
|
||||||
|
[key: Index]: any, // Allow additional properties for customization
|
||||||
|
display?: string,
|
||||||
|
}>,
|
||||||
|
}>(), {
|
||||||
|
modelValue: null,
|
||||||
|
search: "",
|
||||||
|
multiple: false,
|
||||||
|
options: () => ({}),
|
||||||
|
});
|
||||||
|
let { multiple, options } = $(props);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: any): void,
|
||||||
|
(e: 'update:search', value: string): void,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Hook up two-way bindings
|
||||||
|
let modelValue = $ref(multiple ? props.modelValue ?? [] : props.modelValue);
|
||||||
|
watch(() => props.modelValue, (val: any) => { if (!equals(val, modelValue)) modelValue = val; }, { deep: true });
|
||||||
|
watch($$(modelValue), (val: any) => { if(!equals(val, props.modelValue)) emit('update:modelValue', modelValue); }, { deep: true });
|
||||||
|
let search = $ref(props.search);
|
||||||
|
watch(() => props.search, (val: string) => { if (!equals(val, search)) search = val; }, { deep: true });
|
||||||
|
watch($$(search), (val) => { if(!equals(val, props.search)) emit('update:search', search); }, { deep: true });
|
||||||
|
|
||||||
|
// --- Everything Else ---
|
||||||
|
let expanded = $ref(false);
|
||||||
|
let navigated = $ref(0);
|
||||||
|
let inputDiv: HTMLElement | null = $ref(null);
|
||||||
|
let input: HTMLElement | null = $ref(null);
|
||||||
|
let valueButton: HTMLElement | null = $ref(null);
|
||||||
|
|
||||||
|
const selCount = $computed(() => modelValue?.length || 0);
|
||||||
|
|
||||||
|
watch($$(multiple), () => modelValue = multiple ? [] : null );
|
||||||
|
|
||||||
|
function expand() {
|
||||||
|
expanded = true;
|
||||||
|
navigated = 0;
|
||||||
|
focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function focus() {
|
||||||
|
if (multiple || isNullish(modelValue)) input?.focus();
|
||||||
|
else valueButton?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
let skipFocusIn = false;
|
||||||
|
function focusIn() {
|
||||||
|
if (skipFocusIn) return skipFocusIn = false;
|
||||||
|
expand();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(key: any) {
|
||||||
|
if (multiple) {
|
||||||
|
const mv = modelValue as Index[];
|
||||||
|
if (mv?.includes(key)) mv?.splice(mv?.indexOf(key), 1);
|
||||||
|
else mv?.push(key);
|
||||||
|
focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputDiv?.focus();
|
||||||
|
navigated = 0;
|
||||||
|
if (modelValue === null || modelValue !== key) {
|
||||||
|
modelValue = key;
|
||||||
|
expanded = false;
|
||||||
|
}
|
||||||
|
else modelValue = null;
|
||||||
|
skipFocusIn = true;
|
||||||
|
setTimeout(focus, 0); // nextTick causes double fire on keydown.Enter, so defer to next event loop instance
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
switch (e.key) {
|
||||||
|
case "Backspace":
|
||||||
|
case "Delete":
|
||||||
|
if (!modelValue || search !== "" || !multiple) break;
|
||||||
|
if (navigated === 0) modelValue.pop();
|
||||||
|
else if (navigated > 0) navigated = 0;
|
||||||
|
else {
|
||||||
|
modelValue.splice(navigated, 1);
|
||||||
|
navigated = 0;
|
||||||
|
}
|
||||||
|
modelValue = modelValue;
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
navigated--;
|
||||||
|
if (-navigated > selCount) navigated = 0;
|
||||||
|
e.preventDefault(); // Prevent cursor from moving to the front/back
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
navigated++;
|
||||||
|
if (navigated > Object.entries(options).length) navigated = 0;
|
||||||
|
e.preventDefault(); // Prevent cursor from moving to the front/back
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
if (!expanded) expand();
|
||||||
|
else if (navigated > 0) {
|
||||||
|
toggle(Object.entries(options)[navigated-1][0]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
if (navigated !== 0) navigated = 0;
|
||||||
|
else expanded = false;
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div :class="{'multiselect': 1, 'cl-secondary': 1, expanded}" ref="inputDiv"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
@focusin="$event => { if (!inputDiv?.contains($event.relatedTarget as HTMLElement)) focusIn(); }"
|
||||||
|
@focusout="$event => expanded = inputDiv?.contains($event.relatedTarget as HTMLElement) ?? false">
|
||||||
|
<div class="head">
|
||||||
|
<div class="selection" v-if="multiple" tabindex="-1">
|
||||||
|
<div v-for="(key, index) of modelValue as Index[]" :key="key" v-text="options[key].display" :class="{navigated: selCount + navigated === index}"
|
||||||
|
@click="() => toggle(key)"/>
|
||||||
|
</div>
|
||||||
|
<div class="searchbar">
|
||||||
|
<div class="expand button" :tabindex="expanded ? undefined : -1">
|
||||||
|
<i-material-symbols-expand-circle-down-outline width="1em" height="1em"/>
|
||||||
|
</div>
|
||||||
|
<input v-if="multiple || modelValue === null" placeholder="Search..." v-model="search" ref="input"/>
|
||||||
|
<button v-else v-text="options[modelValue]?.display" ref="valueButton"
|
||||||
|
@click="() => toggle(modelValue)"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Transition name="fade-fast">
|
||||||
|
<div tabindex="-1" class="dropdown" v-if="expanded">
|
||||||
|
<div v-for="([key, option], index) in Object.entries(options)" :key="key" :class="{selected: modelValue?.includes(key), navigated: navigated === index + 1}"
|
||||||
|
@click="() => toggle(key)">
|
||||||
|
<template v-if="multiple">
|
||||||
|
<i-material-symbols-check-box-outline v-if="modelValue?.includes(key)" width="1em" height="1em"/>
|
||||||
|
<i-material-symbols-check-box-outline-blank v-else width="1em" height="1em"/>
|
||||||
|
</template>
|
||||||
|
<div v-text="option.display"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
* { border: none; }
|
||||||
|
.selection, .searchbar, .dropdown {
|
||||||
|
border: 1px solid var(--cl-fg);
|
||||||
|
}
|
||||||
|
.selection { border-bottom: none; }
|
||||||
|
.dropdown {
|
||||||
|
border-top: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection, .dropdown {
|
||||||
|
flex-grow: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background-color: var(--cl-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection > div {
|
||||||
|
background-color: var(--cl-bg-el);
|
||||||
|
}
|
||||||
|
|
||||||
|
:is(.selection, .dropdown) > div {
|
||||||
|
flex-flow: row nowrap;
|
||||||
|
padding: 0.25rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
:is(.selection, .dropdown) > div > div {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
:is(.selection, .dropdown) > div.selected {
|
||||||
|
background-color: var(--cl-bg-sl);
|
||||||
|
}
|
||||||
|
:is(.selection, .dropdown) > div:hover,
|
||||||
|
:is(.selection, .dropdown) > div.navigated {
|
||||||
|
background-color: var(--cl-bg-hl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchbar { flex-flow: row nowrap; background: var(--cl-bg-hl) }
|
||||||
|
.expand { padding: 0px; }
|
||||||
|
.searchbar > :not(:first-child) { flex-grow: 1; justify-content: flex-start; }
|
||||||
|
input { padding: 0.25rem; outline: none; }
|
||||||
|
.expand > svg { transition: all 0.2s ease-out; }
|
||||||
|
.expanded .expand > svg { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
div:empty { display: none; }
|
||||||
|
button { padding: 0.25rem; }
|
||||||
|
</style>
|
38
client/src/components/inputs/MultiSelect.vue
Normal file
38
client/src/components/inputs/MultiSelect.vue
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<!-- Wrapper component that sets "multiple" on DropdownInput to true and declares its type to be an array of any -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Index } from "./DropdownInput.vue";
|
||||||
|
import { equals } from "../../util";
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
// Two-Way Bindings (v-model)
|
||||||
|
modelValue?: Index[],
|
||||||
|
search?: string,
|
||||||
|
|
||||||
|
// One-Way Bindings
|
||||||
|
options?: Record<Index, {
|
||||||
|
[key: Index]: any, // Allow additional properties for customization
|
||||||
|
display?: string,
|
||||||
|
}>,
|
||||||
|
}>(), {
|
||||||
|
modelValue: () => [],
|
||||||
|
search: "",
|
||||||
|
options: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: any): void,
|
||||||
|
(e: 'update:search', value: string): void,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Hook up two-way bindings
|
||||||
|
let modelValue = $ref([]);
|
||||||
|
watch(() => props.modelValue, (val: any) => { if (!equals(val, modelValue)) modelValue = val; }, { deep: true });
|
||||||
|
watch($$(modelValue), (val: any) => { if(!equals(val, props.modelValue)) emit('update:modelValue', modelValue); }, { deep: true });
|
||||||
|
let search = $ref(props.search);
|
||||||
|
watch(() => props.search, (val: string) => { if (!equals(val, search)) search = val; }, { deep: true });
|
||||||
|
watch($$(search), (val) => { if(!equals(val, props.search)) emit('update:search', search); }, { deep: true });
|
||||||
|
|
||||||
|
let { options } = $(props);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<DropdownInput :multiple="true" :options="options" v-model="modelValue"/>
|
||||||
|
</template>
|
|
@ -1,148 +0,0 @@
|
||||||
<script setup lang="ts">
|
|
||||||
const props = defineModel<{
|
|
||||||
options?: Record<any, any>,
|
|
||||||
selection?: any[],
|
|
||||||
search?: string,
|
|
||||||
}>();
|
|
||||||
let {options, selection, search} = $(props);
|
|
||||||
|
|
||||||
options ??= {
|
|
||||||
bt1: "Boring Test Data 1",
|
|
||||||
bt2: "Boring Test Data 2",
|
|
||||||
bt3: "Boring Test Data 3",
|
|
||||||
bt4: "Boring Test Data 4",
|
|
||||||
bt5: "Boring Test Data 5",
|
|
||||||
bt6: "Boring Test Data 6",
|
|
||||||
};
|
|
||||||
selection ??= ["bt1"];
|
|
||||||
search ??= "";
|
|
||||||
|
|
||||||
let open = $ref(false);
|
|
||||||
let navigated = $ref(0);
|
|
||||||
let input: HTMLElement | null = $ref(null);
|
|
||||||
|
|
||||||
const selected = $computed(() => selection?.length || 0);
|
|
||||||
|
|
||||||
function toggle(key: any) {
|
|
||||||
if (selection?.includes(key)) selection?.splice(selection?.indexOf(key), 1);
|
|
||||||
else selection?.push(key);
|
|
||||||
input?.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInputKeydown(e: KeyboardEvent) {
|
|
||||||
switch (e.key) {
|
|
||||||
case "Backspace":
|
|
||||||
case "Delete":
|
|
||||||
if (!selection || search !== "") break;
|
|
||||||
if (navigated === 0) selection?.pop();
|
|
||||||
else if (navigated > 0) navigated = 0;
|
|
||||||
else {
|
|
||||||
selection?.splice(navigated, 1);
|
|
||||||
navigated = 0;
|
|
||||||
}
|
|
||||||
selection = selection;
|
|
||||||
break;
|
|
||||||
case "ArrowUp":
|
|
||||||
navigated--;
|
|
||||||
if (-navigated > selected) navigated = 0;
|
|
||||||
break;
|
|
||||||
case "ArrowDown":
|
|
||||||
navigated++;
|
|
||||||
if (navigated > Object.entries(options || {}).length) navigated = 0;
|
|
||||||
break;
|
|
||||||
case "Enter":
|
|
||||||
if (!open) open = true;
|
|
||||||
else if (navigated > 0)
|
|
||||||
toggle(Object.entries(options || {})[navigated-1][0]);
|
|
||||||
break;
|
|
||||||
case "Escape":
|
|
||||||
if (navigated !== 0) navigated = 0;
|
|
||||||
else open = false;
|
|
||||||
break;
|
|
||||||
default: break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<div :class="{'multiselect': 1, 'cl-secondary': 1, open}"
|
|
||||||
@focusout="$event => open = ($event.currentTarget as HTMLElement).contains(($event.relatedTarget as HTMLElement))">
|
|
||||||
<div class="head">
|
|
||||||
<div class="selection">
|
|
||||||
<div v-for="(key, index) of selection" :key="key" v-text="(options || {})[key]" @click="() => toggle(key)" :class="{navigated: selected + navigated === index}"/>
|
|
||||||
</div>
|
|
||||||
<div class="searchbar" @focusin="() => open = true">
|
|
||||||
<button class="expand" @click="() => ($refs.input as any).focus()">
|
|
||||||
<i-material-symbols-expand-circle-down-outline/>
|
|
||||||
</button>
|
|
||||||
<input placeholder="Search..." v-model="search" ref="input" @keydown="onInputKeydown"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Transition name="fade-fast">
|
|
||||||
<div class="dropdown" v-if="open" tabindex="-1" @focusin="() => open = true">
|
|
||||||
<div v-for="([key, option], index) in Object.entries(options || {})" :key="key" @click="() => toggle(key)" :class="{selected: selection?.includes(key), navigated: navigated === index + 1}">
|
|
||||||
<i-material-symbols-check-box-outline v-if="selection?.includes(key)"/>
|
|
||||||
<i-material-symbols-check-box-outline-blank v-else/>
|
|
||||||
<div v-text="option"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<style scoped>
|
|
||||||
* { border: none; }
|
|
||||||
.selection, .searchbar, .dropdown {
|
|
||||||
border: 1px solid var(--cl-fg);
|
|
||||||
}
|
|
||||||
.selection { border-bottom: none; }
|
|
||||||
.dropdown { border-top: none; }
|
|
||||||
|
|
||||||
.selection, .dropdown {
|
|
||||||
flex-grow: 1;
|
|
||||||
padding: 0.5rem;
|
|
||||||
background-color: var(--cl-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection {
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selection > div {
|
|
||||||
background-color: var(--cl-bg-el);
|
|
||||||
}
|
|
||||||
|
|
||||||
:is(.selection, .dropdown) > div {
|
|
||||||
flex-flow: row nowrap;
|
|
||||||
gap: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.25rem;
|
|
||||||
}
|
|
||||||
:is(.selection, .dropdown) > div > div {
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
:is(.selection, .dropdown) > div.selected {
|
|
||||||
background-color: var(--cl-bg-sl);
|
|
||||||
}
|
|
||||||
:is(.selection, .dropdown) > div:hover,
|
|
||||||
:is(.selection, .dropdown) > div.navigated {
|
|
||||||
background-color: var(--cl-bg-hl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchbar {
|
|
||||||
flex-flow: row nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.searchbar > input {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expand > svg { transition: all 0.2s ease-out; }
|
|
||||||
.open .expand > svg { transform: rotate(180deg); }
|
|
||||||
|
|
||||||
div:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
38
client/src/components/inputs/SingleSelect.vue
Normal file
38
client/src/components/inputs/SingleSelect.vue
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
<!-- Wrapper component that sets "multiple" on DropdownInput to false and declares its type to be an Index -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Index } from "./DropdownInput.vue";
|
||||||
|
import { equals } from "../../util";
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
// Two-Way Bindings (v-model)
|
||||||
|
modelValue?: Index | null,
|
||||||
|
search?: string,
|
||||||
|
|
||||||
|
// One-Way Bindings
|
||||||
|
options?: Record<Index, {
|
||||||
|
[key: Index]: any, // Allow additional properties for customization
|
||||||
|
display?: string,
|
||||||
|
}>,
|
||||||
|
}>(), {
|
||||||
|
modelValue: null,
|
||||||
|
search: "",
|
||||||
|
options: () => ({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: any): void,
|
||||||
|
(e: 'update:search', value: string): void,
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Hook up two-way bindings
|
||||||
|
let modelValue: Index[] | null = $ref(null);
|
||||||
|
watch(() => props.modelValue, (val: any) => { if (!equals(val, modelValue)) modelValue = val; }, { deep: true });
|
||||||
|
watch($$(modelValue), (val: any) => { if(!equals(val, props.modelValue)) emit('update:modelValue', modelValue); }, { deep: true });
|
||||||
|
let search = $ref(props.search);
|
||||||
|
watch(() => props.search, (val: string) => { if (!equals(val, search)) search = val; }, { deep: true });
|
||||||
|
watch($$(search), (val) => { if(!equals(val, props.search)) emit('update:search', search); }, { deep: true });
|
||||||
|
|
||||||
|
let { options } = $(props);
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<DropdownInput :multiple="false" :options="options" v-model="modelValue"/>
|
||||||
|
</template>
|
|
@ -20,6 +20,11 @@ button, .button {
|
||||||
min-height: 1.5rem;
|
min-height: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
height: 1.5rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
|
|
16
client/src/util.ts
Normal file
16
client/src/util.ts
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
// Performs a type-agnostic deep comparison of two values by process of elimination. This mainly covers the use cases of Primitives, Objects and Arrays, but not certain builtins like Dates.
|
||||||
|
export function equals(a: any, b: any): boolean {
|
||||||
|
if (typeof a !== typeof b) return false; // Different types can't be equal, except number and bigint but who cares. Null and undefined are considered different.
|
||||||
|
if (typeof a !== 'object') return a === b; // A simple comparison suffices for non-objects
|
||||||
|
if (isNullish(a) !== isNullish(b)) return false; // A common use case is checking for something to exist vs. not, so it's covered explicitly here
|
||||||
|
if (isNullish(a) && isNullish(b)) return true;
|
||||||
|
if (a instanceof Array && !(b instanceof Array)) return false; // Could be generalized with Object.getPrototypeOf, but I think it's preferable to have "pure" and regular objects match (Look up Object.create(null))
|
||||||
|
if (a?.length !== b?.length) return false; // Another not technically necessary but common point of comparison.
|
||||||
|
for (const key of new Set(Object.keys(a).concat(Object.keys(b)))) // All there's left is to deep-compare what are clearly objects. The keys of both need to be merged to prevent an extra key on one side being ignored.
|
||||||
|
if (!equals(a[key], b[key])) return false;
|
||||||
|
return true; // Only once all points of inequality are rules out can we say for certain that the two values are equal.
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNullish(value: any) {
|
||||||
|
return !!(value === null || value === undefined);
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue