mirror of
https://github.com/CorentinTh/it-tools.git
synced 2025-04-25 01:06:15 -04:00
feat(ui): added c-select in the ui lib (#550)
* feat(ui): added c-select in the ui lib * refactor(ui): switched n-select to c-select
This commit is contained in:
parent
6498c9b0fa
commit
dfa1ba8554
29 changed files with 666 additions and 199 deletions
7
src/ui/c-label/c-label.types.ts
Normal file
7
src/ui/c-label/c-label.types.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
export interface CLabelProps {
|
||||
label?: string
|
||||
labelFor?: string
|
||||
labelPosition?: 'top' | 'left'
|
||||
labelWidth?: string
|
||||
labelAlign?: 'left' | 'right' | 'center'
|
||||
}
|
32
src/ui/c-label/c-label.vue
Normal file
32
src/ui/c-label/c-label.vue
Normal file
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts" setup>
|
||||
import { toRefs } from 'vue';
|
||||
import type { CLabelProps } from './c-label.types';
|
||||
|
||||
const props = withDefaults(defineProps<CLabelProps>(), { label: undefined, labelAlign: 'left', labelFor: undefined, labelPosition: 'top', labelWidth: 'auto' });
|
||||
const { label, labelAlign, labelFor, labelPosition, labelWidth } = toRefs(props);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="{
|
||||
'flex-col': labelPosition === 'top',
|
||||
'flex-row': labelPosition === 'left',
|
||||
}"
|
||||
flex
|
||||
items-baseline
|
||||
>
|
||||
<label
|
||||
v-if="label" :for="labelFor" :style="{ flex: `0 0 ${labelWidth}` }"
|
||||
mb-5px
|
||||
pr-12px
|
||||
:class="{
|
||||
'text-left': labelAlign === 'left',
|
||||
'text-center': labelAlign === 'center',
|
||||
'text-right': labelAlign === 'right',
|
||||
}"
|
||||
>
|
||||
{{ label }}
|
||||
</label>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
36
src/ui/c-select/c-select.demo.vue
Normal file
36
src/ui/c-select/c-select.demo.vue
Normal file
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts" setup>
|
||||
const optionsA = [
|
||||
{ label: 'Option A', value: 'a' },
|
||||
{ label: 'Option B', value: 'b' },
|
||||
{ label: 'Option C', value: 'c' },
|
||||
];
|
||||
|
||||
const optionsBig = Array.from({ length: 1000 }, (_, i) => ({ label: `Option ${i}`, value: i }));
|
||||
|
||||
const sizes = ['small', 'medium', 'large'] as const;
|
||||
const value = ref('');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h2>Sizes</h2>
|
||||
<c-select v-for="size in sizes" :key="size" v-model:value="value" :options="optionsA" :size="size" mb-2 />
|
||||
|
||||
<h2>Searchable</h2>
|
||||
<c-select v-for="size in sizes" :key="size" v-model:value="value" :options="optionsA" :size="size" searchable mb-2 />
|
||||
|
||||
<h2>Big list</h2>
|
||||
<c-select v-model:value="value" :options="optionsBig" searchable />
|
||||
|
||||
<h2>Empty</h2>
|
||||
<c-select :options="[]" />
|
||||
|
||||
<h2>String array as options</h2>
|
||||
<c-select v-model:value="value" :options="['a', 'Option B', 'Option C']" />
|
||||
|
||||
<h2>Labels</h2>
|
||||
<c-select label="Label" mb-2 />
|
||||
<c-select label="Label" label-position="left" mb-2 />
|
||||
<c-select label="Label" label-position="left" label-align="left" mb-2 label-width="200px" />
|
||||
<c-select label="Label" label-position="left" label-align="center" mb-2 label-width="200px" />
|
||||
<c-select label="Label" label-position="left" label-align="right" mb-2 label-width="200px" />
|
||||
</template>
|
60
src/ui/c-select/c-select.theme.ts
Normal file
60
src/ui/c-select/c-select.theme.ts
Normal file
|
@ -0,0 +1,60 @@
|
|||
import { defineThemes } from '../theme/theme.models';
|
||||
import { appThemes } from '../theme/themes';
|
||||
|
||||
const sizes = {
|
||||
small: {
|
||||
height: '28px',
|
||||
fontSize: '12px',
|
||||
},
|
||||
medium: {
|
||||
height: '34px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
large: {
|
||||
height: '40px',
|
||||
fontSize: '16px',
|
||||
},
|
||||
};
|
||||
|
||||
export const { useTheme } = defineThemes({
|
||||
dark: {
|
||||
sizes,
|
||||
|
||||
backgroundColor: '#333333',
|
||||
borderColor: '#333333',
|
||||
dropdownShadow: 'rgba(0, 0, 0, 0.2) 0px 8px 24px',
|
||||
|
||||
option: {
|
||||
hover: {
|
||||
backgroundColor: '#444444',
|
||||
},
|
||||
active: {
|
||||
textColor: appThemes.dark.primary.color,
|
||||
},
|
||||
},
|
||||
|
||||
focus: {
|
||||
backgroundColor: '#1ea54c1a',
|
||||
},
|
||||
},
|
||||
light: {
|
||||
sizes,
|
||||
|
||||
backgroundColor: '#ffffff',
|
||||
borderColor: '#e0e0e69e',
|
||||
dropdownShadow: 'rgba(149, 157, 165, 0.2) 0px 8px 24px',
|
||||
|
||||
option: {
|
||||
hover: {
|
||||
backgroundColor: '#eee',
|
||||
},
|
||||
active: {
|
||||
textColor: appThemes.light.primary.color,
|
||||
},
|
||||
},
|
||||
|
||||
focus: {
|
||||
backgroundColor: '#ffffff',
|
||||
},
|
||||
},
|
||||
});
|
4
src/ui/c-select/c-select.types.ts
Normal file
4
src/ui/c-select/c-select.types.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export interface CSelectOption<Value = unknown> {
|
||||
label: string
|
||||
value: Value
|
||||
}
|
262
src/ui/c-select/c-select.vue
Normal file
262
src/ui/c-select/c-select.vue
Normal file
|
@ -0,0 +1,262 @@
|
|||
<script setup lang="ts" generic="T extends unknown">
|
||||
import { useAppTheme } from '../theme/themes';
|
||||
import type { CLabelProps } from '../c-label/c-label.types';
|
||||
import type { CSelectOption } from './c-select.types';
|
||||
import { useTheme } from './c-select.theme';
|
||||
import { clamp } from '@/modules/shared/number.models';
|
||||
import { useFuzzySearch } from '@/composable/fuzzySearch';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options?: CSelectOption<T>[] | string[]
|
||||
value?: T
|
||||
placeholder?: string
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
searchable?: boolean
|
||||
} & CLabelProps >(),
|
||||
{
|
||||
options: () => [],
|
||||
value: undefined,
|
||||
placeholder: undefined,
|
||||
size: 'medium',
|
||||
searchable: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emits = defineEmits(['update:value']);
|
||||
|
||||
const { options: rawOptions, placeholder, size: sizeName, searchable } = toRefs(props);
|
||||
|
||||
const options = computed(() => {
|
||||
return rawOptions.value.map((option: string | CSelectOption<T>) => {
|
||||
if (typeof option === 'string') {
|
||||
return { label: option, value: option };
|
||||
}
|
||||
|
||||
return option;
|
||||
});
|
||||
});
|
||||
|
||||
const keys = useMagicKeys();
|
||||
const value = useVModel(props, 'value', emits);
|
||||
const theme = useTheme();
|
||||
const appTheme = useAppTheme();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const selectedOption = shallowRef<CSelectOption<T> | undefined>(options.value.find((option: CSelectOption<T>) => option.value === value.value));
|
||||
const focusIndex = ref(0);
|
||||
const elementRef = ref(null);
|
||||
|
||||
const size = computed(() => theme.value.sizes[sizeName.value as 'small' | 'medium' | 'large']);
|
||||
|
||||
const searchQuery = ref('');
|
||||
const searchInputRef = ref();
|
||||
|
||||
whenever(() => !isOpen.value, () => {
|
||||
focusIndex.value = 0;
|
||||
searchQuery.value = '';
|
||||
});
|
||||
|
||||
whenever(() => isOpen.value, () => {
|
||||
nextTick(() => searchInputRef.value?.focus());
|
||||
});
|
||||
|
||||
onClickOutside(elementRef, close);
|
||||
whenever(keys.escape, close);
|
||||
|
||||
watch(
|
||||
value,
|
||||
(newValue) => {
|
||||
const option = options.value.find((option: CSelectOption<T>) => option.value === newValue);
|
||||
if (option) {
|
||||
selectedOption.value = option;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const { searchResult: filteredOptions } = useFuzzySearch<CSelectOption<T>>({
|
||||
search: searchQuery,
|
||||
data: options.value,
|
||||
options: {
|
||||
keys: ['label'],
|
||||
shouldSort: false,
|
||||
threshold: 0.3,
|
||||
filterEmpty: false,
|
||||
},
|
||||
});
|
||||
|
||||
function close() {
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function toggleOpen() {
|
||||
isOpen.value = !isOpen.value;
|
||||
}
|
||||
|
||||
function selectOption({ option }: { option: CSelectOption<T> }) {
|
||||
selectedOption.value = option;
|
||||
// @ts-expect-error vue template generic is a bit flacky thanks to withDefaults
|
||||
value.value = option.value;
|
||||
isOpen.value = false;
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const { key } = event;
|
||||
const isEnter = ['Enter'].includes(key);
|
||||
const isArrowUpOrDown = ['ArrowUp', 'ArrowDown'].includes(key);
|
||||
const isArrowDown = key === 'ArrowDown';
|
||||
|
||||
if (isEnter) {
|
||||
const valueCanBeSelected = isOpen.value && focusIndex.value !== -1;
|
||||
|
||||
if (valueCanBeSelected) {
|
||||
selectOption({ option: filteredOptions.value[focusIndex.value] });
|
||||
}
|
||||
else {
|
||||
toggleOpen();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (isArrowUpOrDown) {
|
||||
const increment = isArrowDown ? 1 : -1;
|
||||
focusIndex.value = clamp({
|
||||
value: focusIndex.value + increment,
|
||||
min: 0,
|
||||
max: options.value.length - 1,
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function onSearchInput() {
|
||||
focusIndex.value = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<c-label v-bind="props">
|
||||
<div ref="elementRef" relative class="c-select" w-full>
|
||||
<div
|
||||
flex flex-nowrap cursor-pointer items-center
|
||||
:class="{ 'is-open': isOpen, 'important:border-primary': isOpen }"
|
||||
class="c-select-input"
|
||||
tabindex="0"
|
||||
hover:important:border-primary
|
||||
@click="toggleOpen"
|
||||
@keydown="handleKeydown"
|
||||
>
|
||||
<div flex-1 truncate>
|
||||
<input v-if="searchable && isOpen" ref="searchInputRef" v-model="searchQuery" type="text" placeholder="Search..." class="search-input" w-full lh-normal color-current @input="onSearchInput">
|
||||
<span v-else-if="selectedOption" lh-normal>
|
||||
{{ selectedOption.label }}
|
||||
</span>
|
||||
<span v-else class="placeholder" lh-normal>
|
||||
{{ placeholder ?? 'Select an option' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<icon-mdi-chevron-down class="chevron" />
|
||||
</div>
|
||||
|
||||
<transition name="dropdown">
|
||||
<div v-show="isOpen" class="c-select-dropdown" absolute z-10 mt-1 max-h-312px w-full overflow-y-auto pretty-scrollbar>
|
||||
<template v-if="!filteredOptions.length">
|
||||
<slot name="empty">
|
||||
<div px-4 py-1 opacity-70>
|
||||
No results found
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="option.label"
|
||||
cursor-pointer
|
||||
px-4
|
||||
py-1
|
||||
:class="{ active: selectedOption?.label === option.label, hover: focusIndex === index }"
|
||||
class="c-select-dropdown-option"
|
||||
@click="selectOption({ option })"
|
||||
>
|
||||
{{ option.label }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</c-label>
|
||||
</template>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.c-select {
|
||||
.search-input{
|
||||
all: unset;
|
||||
|
||||
&::placeholder {
|
||||
color: v-bind('appTheme.text.mutedColor');
|
||||
}
|
||||
}
|
||||
|
||||
.c-select-input {
|
||||
background-color: v-bind('theme.backgroundColor');
|
||||
border: 1px solid v-bind('theme.borderColor');
|
||||
border-radius: 4px;
|
||||
padding: 0 12px;
|
||||
font-family: inherit;
|
||||
font-size: v-bind('size.fontSize');
|
||||
height: v-bind('size.height');
|
||||
transition: border-color 0.2s ease-in-out;
|
||||
|
||||
.placeholder, .chevron {
|
||||
color: v-bind('appTheme.text.mutedColor');
|
||||
}
|
||||
}
|
||||
|
||||
.c-select-dropdown {
|
||||
background-color: v-bind('theme.backgroundColor');
|
||||
border-radius: 4px;
|
||||
// box-shadow: rgba(149, 157, 165, 0.2) 0px 8px 24px;
|
||||
box-shadow: v-bind('theme.dropdownShadow');
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: 1;
|
||||
padding: 6px;
|
||||
|
||||
.c-select-dropdown-option{
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
background-color: transparent;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
|
||||
&.active {
|
||||
color: v-bind('theme.option.active.textColor');
|
||||
}
|
||||
|
||||
&:hover, &.hover {
|
||||
background-color: v-bind('theme.option.hover.backgroundColor');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-enter-active,
|
||||
.dropdown-leave-active {
|
||||
transition: opacity 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-enter-from,
|
||||
.dropdown-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.dropdown-enter-to,
|
||||
.dropdown-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
13
src/ui/demo/demo-home.page.vue
Normal file
13
src/ui/demo/demo-home.page.vue
Normal file
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts" setup>
|
||||
import { demoRoutes } from './demo.routes';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div grid grid-cols-5 gap-2>
|
||||
<c-card v-for="{ name } of demoRoutes" :key="name" :title="String(name)">
|
||||
<c-button :to="{ name }">
|
||||
{{ name }}
|
||||
</c-button>
|
||||
</c-card>
|
||||
</div>
|
||||
</template>
|
|
@ -1,4 +1,5 @@
|
|||
import type { RouteRecordRaw } from 'vue-router';
|
||||
import DemoHome from './demo-home.page.vue';
|
||||
|
||||
const demoPages = import.meta.glob('../*/*.demo.vue');
|
||||
|
||||
|
@ -17,7 +18,14 @@ export const routes = [
|
|||
{
|
||||
path: '/c-lib',
|
||||
name: 'c-lib',
|
||||
children: demoRoutes,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'c-lib-index',
|
||||
component: DemoHome,
|
||||
},
|
||||
...demoRoutes,
|
||||
],
|
||||
component: () => import('./demo-wrapper.vue'),
|
||||
},
|
||||
];
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue