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:
Corentin THOMASSET 2023-08-07 17:30:00 +02:00 committed by GitHub
parent 6498c9b0fa
commit dfa1ba8554
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 666 additions and 199 deletions

View file

@ -11,12 +11,19 @@ function useFuzzySearch<Data>({
}: {
search: MaybeRef<string>
data: Data[]
options?: Fuse.IFuseOptions<Data>
options?: Fuse.IFuseOptions<Data> & { filterEmpty?: boolean }
}) {
const fuse = new Fuse(data, options);
const filterEmpty = options.filterEmpty ?? true;
const searchResult = computed(() => {
return fuse.search(get(search)).map(({ item }) => item);
const searchResult = computed<Data[]>(() => {
const query = get(search);
if (!filterEmpty && query === '') {
return data;
}
return fuse.search(query).map(({ item }) => item);
});
return { searchResult };

View file

@ -103,6 +103,10 @@ const tools = computed<ToolCategory[]>(() => [
Home
</n-tooltip>
<c-button to="/c-lib" circle variant="text" aria-label="UI Lib">
<icon-mdi:brush-variant text-20px />
</c-button>
<command-palette />
<div>

View file

@ -0,0 +1,5 @@
function clamp({ value, min = 0, max = 100 }: { value: number; min?: number; max?: number }) {
return Math.min(Math.max(value, min), max);
}
export { clamp };

View file

@ -84,12 +84,12 @@ const { copy: copyPassphrase } = useCopy({ source: passphrase, text: 'Passphrase
<div>
<n-grid cols="3" x-gap="12">
<n-gi span="1">
<n-form-item label="Language:">
<n-select
v-model:value="language"
:options="Object.keys(languages).map((label) => ({ label, value: label }))"
/>
</n-form-item>
<c-select
v-model:value="language"
searchable
label="Language:"
:options="Object.keys(languages)"
/>
</n-gi>
<n-gi span="2">
<n-form-item

View file

@ -122,23 +122,24 @@ function downloadMedia({ type, value, createdAt }: Media) {
</c-card>
<c-card v-else>
<div flex gap-2>
<div flex-1>
<div>Video</div>
<n-select
v-model:value="currentCamera"
:options="cameras.map(({ deviceId, label }) => ({ value: deviceId, label }))"
placeholder="Select camera"
/>
</div>
<div v-if="currentMicrophone && microphones.length > 0" flex-1>
<div>Audio</div>
<n-select
v-model:value="currentMicrophone"
:options="microphones.map(({ deviceId, label }) => ({ value: deviceId, label }))"
placeholder="Select microphone"
/>
</div>
<div flex flex-col gap-2>
<c-select
v-model:value="currentCamera"
label-position="left"
label-width="60px"
label="Video:"
:options="cameras.map(({ deviceId, label }) => ({ value: deviceId, label }))"
placeholder="Select camera"
/>
<c-select
v-if="currentMicrophone && microphones.length > 0"
v-model:value="currentMicrophone"
label="Audio:"
label-position="left"
label-width="60px"
:options="microphones.map(({ deviceId, label }) => ({ value: deviceId, label }))"
placeholder="Select microphone"
/>
</div>
<div v-if="!isMediaStreamAvailable" mt-3 flex justify-center>

View file

@ -142,7 +142,7 @@ function formatDateUsingFormatter(formatter: (date: Date) => string, date?: Date
<template>
<div>
<n-input-group>
<div flex gap-2>
<c-input-text
v-model:value="inputDate"
autofocus
@ -153,13 +153,13 @@ function formatDateUsingFormatter(formatter: (date: Date) => string, date?: Date
@update:value="onDateInputChanged"
/>
<n-select
<c-select
v-model:value="formatIndex"
style="flex: 0 0 170px"
:options="formats.map(({ name }, i) => ({ label: name, value: i }))"
data-test-id="date-time-converter-format-select"
/>
</n-input-group>
</div>
<n-divider />

View file

@ -29,12 +29,11 @@ const decryptOutput = computed(() =>
<div flex flex-1 flex-col gap-2>
<c-input-text v-model:value="cypherSecret" label="Your secret key:" clearable raw-text />
<n-form-item label="Encryption algorithm:" :show-feedback="false">
<n-select
v-model:value="cypherAlgo"
:options="Object.keys(algos).map((label) => ({ label, value: label }))"
/>
</n-form-item>
<c-select
v-model:value="cypherAlgo"
label="Encryption algorithm:"
:options="Object.keys(algos).map((label) => ({ label, value: label }))"
/>
</div>
</div>
<c-input-text
@ -57,12 +56,11 @@ const decryptOutput = computed(() =>
<div flex flex-1 flex-col gap-2>
<c-input-text v-model:value="decryptSecret" label="Your secret key:" clearable raw-text />
<n-form-item label="Encryption algorithm:" :show-feedback="false">
<n-select
v-model:value="decryptAlgo"
:options="Object.keys(algos).map((label) => ({ label, value: label }))"
/>
</n-form-item>
<c-select
v-model:value="decryptAlgo"
label="Encryption algorithm:"
:options="Object.keys(algos).map((label) => ({ label, value: label }))"
/>
</div>
</div>
<c-input-text

View file

@ -39,13 +39,15 @@ const endAt = computed(() =>
</n-form-item>
</div>
<n-form-item label="Amount of unit consumed by time span" :show-feedback="false">
<p>Amount of unit consumed by time span</p>
<div flex flex-col items-baseline gap-y-2 md:flex-row>
<n-input-number v-model:value="unitPerTimeSpan" :min="1" />
<span mx-3>in</span>
<n-input-group>
<n-input-number v-model:value="timeSpan" :min="1" />
<n-select
<div flex items-baseline gap-2>
<span ml-2>in</span>
<n-input-number v-model:value="timeSpan" min-w-130px :min="1" />
<c-select
v-model:value="timeSpanUnitMultiplier"
min-w-130px
:options="[
{ label: 'milliseconds', value: 1 },
{ label: 'seconds', value: 1000 },
@ -54,8 +56,8 @@ const endAt = computed(() =>
{ label: 'days', value: 1000 * 60 * 60 * 24 },
]"
/>
</n-input-group>
</n-form-item>
</div>
</div>
<n-divider />
<c-card mb-2>

View file

@ -41,29 +41,29 @@ const hashText = (algo: AlgoNames, value: string) => formatWithEncoding(algos[al
<n-divider />
<n-form-item label="Digest encoding">
<n-select
v-model:value="encoding"
:options="[
{
label: 'Binary (base 2)',
value: 'Bin',
},
{
label: 'Hexadecimal (base 16)',
value: 'Hex',
},
{
label: 'Base64 (base 64)',
value: 'Base64',
},
{
label: 'Base64url (base 64 with url safe chars)',
value: 'Base64url',
},
]"
/>
</n-form-item>
<c-select
v-model:value="encoding"
mb-4
label="Digest encoding"
:options="[
{
label: 'Binary (base 2)',
value: 'Bin',
},
{
label: 'Hexadecimal (base 16)',
value: 'Hex',
},
{
label: 'Base64 (base 64)',
value: 'Base64',
},
{
label: 'Base64url (base 64 with url safe chars)',
value: 'Base64url',
},
]"
/>
<div v-for="algo in algoNames" :key="algo" style="margin: 5px 0">
<n-input-group>

View file

@ -51,37 +51,35 @@ const { copy } = useCopy({ source: hmac });
<c-input-text v-model:value="secret" raw-text placeholder="Enter the secret key..." label="Secret key" clearable />
<div flex gap-2>
<n-form-item label="Hashing function" flex-1>
<n-select
v-model:value="hashFunction"
placeholder="Select an hashing function..."
:options="Object.keys(algos).map((label) => ({ label, value: label }))"
/>
</n-form-item>
<n-form-item label="Output encoding" flex-1>
<n-select
v-model:value="encoding"
placeholder="Select the result encoding..."
:options="[
{
label: 'Binary (base 2)',
value: 'Bin',
},
{
label: 'Hexadecimal (base 16)',
value: 'Hex',
},
{
label: 'Base64 (base 64)',
value: 'Base64',
},
{
label: 'Base64-url (base 64 with url safe chars)',
value: 'Base64url',
},
]"
/>
</n-form-item>
<c-select
v-model:value="hashFunction" label="Hashing function"
flex-1
placeholder="Select an hashing function..."
:options="Object.keys(algos).map((label) => ({ label, value: label }))"
/>
<c-select
v-model:value="encoding" label="Output encoding"
flex-1
placeholder="Select the result encoding..."
:options="[
{
label: 'Binary (base 2)',
value: 'Bin',
},
{
label: 'Hexadecimal (base 16)',
value: 'Hex',
},
{
label: 'Base64 (base 64)',
value: 'Base64',
},
{
label: 'Base64-url (base 64 with url safe chars)',
value: 'Base64url',
},
]"
/>
</div>
<input-copyable v-model:value="hmac" type="textarea" placeholder="The result of the HMAC..." label="HMAC of your text" />
<div flex justify-center>

View file

@ -61,17 +61,19 @@ function transformer(value: string) {
</n-form-item>
</div>
<div flex-1>
<n-form-item label="Sort list" label-placement="left" label-width="120" :show-feedback="false" mb-2>
<n-select
v-model:value="conversionConfig.sortList"
:options="sortOrderOptions"
clearable
w-full
:disabled="conversionConfig.reverseList"
data-test-id="sortList"
placeholder="Sort alphabetically"
/>
</n-form-item>
<c-select
v-model:value="conversionConfig.sortList"
label="Sort list"
label-position="left"
label-width="120px"
label-align="right"
mb-2
:options="sortOrderOptions"
w-full
:disabled="conversionConfig.reverseList"
data-test-id="sortList"
placeholder="Sort alphabetically"
/>
<c-input-text
v-model:value="conversionConfig.separator"

View file

@ -53,12 +53,15 @@ const metaTags = computed(() => {
<template>
<div>
<div v-for="{ name, elements } of sections" :key="name" style="margin-bottom: 15px">
<n-form-item :label="name" :show-feedback="false" />
<div mb-5px>
{{ name }}
</div>
<n-input-group v-for="{ key, type, label, placeholder, ...element } of elements" :key="key">
<n-input-group-label style="flex: 0 0 110px">
{{ label }}
</n-input-group-label>
<c-input-text v-if="type === 'input'" v-model:value="metadata[key]" :placeholder="placeholder" clearable />
<n-dynamic-input
v-else-if="type === 'input-multiple'"
@ -69,9 +72,10 @@ const metaTags = computed(() => {
:show-sort-button="true"
/>
<n-select
<c-select
v-else-if="type === 'select'"
v-model:value="metadata[key]"
w-full
:placeholder="placeholder"
:options="(element as OGSchemaTypeElementSelect).options"
/>

View file

@ -26,15 +26,13 @@ const mimeTypeFound = computed(() => (selectedExtension.value ? extensionToMimeT
<div style="opacity: 0.8">
Know which file extensions are associated to a mime-type
</div>
<n-form-item>
<n-select
v-model:value="selectedMimeType"
filterable
:options="mimeToExtensionsOptions"
size="large"
placeholder="Select your mimetype here... (ex: application/pdf)"
/>
</n-form-item>
<c-select
v-model:value="selectedMimeType"
searchable
my-4
:options="mimeToExtensionsOptions"
placeholder="Select your mimetype here... (ex: application/pdf)"
/>
<div v-if="extensionsFound.length > 0">
Extensions of files with the <n-tag round :bordered="false">
@ -62,15 +60,13 @@ const mimeTypeFound = computed(() => (selectedExtension.value ? extensionToMimeT
<div style="opacity: 0.8">
Know which mime type is associated to a file extension
</div>
<n-form-item>
<n-select
v-model:value="selectedExtension"
filterable
:options="extensionToMimeTypeOptions"
size="large"
placeholder="Select your mimetype here... (ex: application/pdf)"
/>
</n-form-item>
<c-select
v-model:value="selectedExtension"
searchable
my-4
:options="extensionToMimeTypeOptions"
placeholder="Select your mimetype here... (ex: application/pdf)"
/>
<div v-if="selectedExtension">
Mime type associated to the extension <n-tag round :bordered="false">

View file

@ -85,9 +85,7 @@ const countriesOptions = getCountries().map(code => ({
<template>
<div>
<n-form-item label="Default country code:">
<n-select v-model:value="defaultCountryCode" :options="countriesOptions" filterable />
</n-form-item>
<c-select v-model:value="defaultCountryCode" label="Default country code:" :options="countriesOptions" searchable mb-5 />
<c-input-text
v-model:value="rawPhone"

View file

@ -35,6 +35,7 @@ const { download } = useDownloadFileFromBase64({ source: qrcode, filename: 'qr-c
label="Text:"
multiline
rows="1"
autosize
placeholder="Your link or text..."
mb-6
/>
@ -45,12 +46,14 @@ const { download } = useDownloadFileFromBase64({ source: qrcode, filename: 'qr-c
<n-form-item label="Background color:">
<n-color-picker v-model:value="background" :modes="['hex']" />
</n-form-item>
<n-form-item label="Error resistance:">
<n-select
v-model:value="errorCorrectionLevel"
:options="errorCorrectionLevels.map((value) => ({ label: value, value }))"
/>
</n-form-item>
<c-select
v-model:value="errorCorrectionLevel"
label="Error resistance:"
label-position="left"
label-width="130px"
label-align="right"
:options="errorCorrectionLevels.map((value) => ({ label: value, value }))"
/>
</n-form>
</n-gi>
<n-gi>

View file

@ -19,47 +19,45 @@ const prettySQL = computed(() => formatSQL(rawSQL.value, config));
<template>
<div style="flex: 0 0 100%">
<div mx-auto style="max-width: 600px" flex gap-2 :class="{ 'flex-col': styleStore.isSmallScreen }">
<n-form-item label="Dialect" label-width="500" flex-1>
<n-select
v-model:value="config.language"
:options="[
{ label: 'GCP BigQuery', value: 'bigquery' },
{ label: 'IBM DB2', value: 'db2' },
{ label: 'Apache Hive', value: 'hive' },
{ label: 'MariaDB', value: 'mariadb' },
{ label: 'MySQL', value: 'mysql' },
{ label: 'Couchbase N1QL', value: 'n1ql' },
{ label: 'Oracle PL/SQL', value: 'plsql' },
{ label: 'PostgreSQL', value: 'postgresql' },
{ label: 'Amazon Redshift', value: 'redshift' },
{ label: 'Spark', value: 'spark' },
{ label: 'Standard SQL', value: 'sql' },
{ label: 'sqlite', value: 'sqlite' },
{ label: 'SQL Server Transact-SQL', value: 'tsql' },
]"
/>
</n-form-item>
<n-form-item label="Keyword case" flex-1>
<n-select
v-model:value="config.keywordCase"
:options="[
{ label: 'UPPERCASE', value: 'upper' },
{ label: 'lowercase', value: 'lower' },
{ label: 'Preserve', value: 'preserve' },
]"
/>
</n-form-item>
<n-form-item label="Indent style" flex-1>
<n-select
v-model:value="config.indentStyle"
:options="[
{ label: 'Standard', value: 'standard' },
{ label: 'Tabular left', value: 'tabularLeft' },
{ label: 'Tabular right', value: 'tabularRight' },
]"
/>
</n-form-item>
<div style="max-width: 600px" :class="{ 'flex-col': styleStore.isSmallScreen }" mx-auto mb-5 flex gap-2>
<c-select
v-model:value="config.language"
flex-1
label="Dialect"
:options="[
{ label: 'GCP BigQuery', value: 'bigquery' },
{ label: 'IBM DB2', value: 'db2' },
{ label: 'Apache Hive', value: 'hive' },
{ label: 'MariaDB', value: 'mariadb' },
{ label: 'MySQL', value: 'mysql' },
{ label: 'Couchbase N1QL', value: 'n1ql' },
{ label: 'Oracle PL/SQL', value: 'plsql' },
{ label: 'PostgreSQL', value: 'postgresql' },
{ label: 'Amazon Redshift', value: 'redshift' },
{ label: 'Spark', value: 'spark' },
{ label: 'Standard SQL', value: 'sql' },
{ label: 'sqlite', value: 'sqlite' },
{ label: 'SQL Server Transact-SQL', value: 'tsql' },
]"
/>
<c-select
v-model:value="config.keywordCase" label="Keyword case"
flex-1
:options="[
{ label: 'UPPERCASE', value: 'upper' },
{ label: 'lowercase', value: 'lower' },
{ label: 'Preserve', value: 'preserve' },
]"
/>
<c-select
v-model:value="config.indentStyle" label="Indent style"
flex-1
:options="[
{ label: 'Standard', value: 'standard' },
{ label: 'Tabular left', value: 'tabularLeft' },
{ label: 'Tabular right', value: 'tabularRight' },
]"
/>
</div>
</div>

View file

@ -0,0 +1,7 @@
export interface CLabelProps {
label?: string
labelFor?: string
labelPosition?: 'top' | 'left'
labelWidth?: string
labelAlign?: 'left' | 'right' | 'center'
}

View 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>

View 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>

View 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',
},
},
});

View file

@ -0,0 +1,4 @@
export interface CSelectOption<Value = unknown> {
label: string
value: Value
}

View 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>

View 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>

View file

@ -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'),
},
];