aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/c-label/c-label.types.ts7
-rw-r--r--src/ui/c-label/c-label.vue32
-rw-r--r--src/ui/c-select/c-select.demo.vue36
-rw-r--r--src/ui/c-select/c-select.theme.ts60
-rw-r--r--src/ui/c-select/c-select.types.ts4
-rw-r--r--src/ui/c-select/c-select.vue262
-rw-r--r--src/ui/demo/demo-home.page.vue13
-rw-r--r--src/ui/demo/demo.routes.ts10
8 files changed, 423 insertions, 1 deletions
diff --git a/src/ui/c-label/c-label.types.ts b/src/ui/c-label/c-label.types.ts
new file mode 100644
index 0000000..f82ba0f
--- /dev/null
+++ b/src/ui/c-label/c-label.types.ts
@@ -0,0 +1,7 @@
+export interface CLabelProps {
+ label?: string
+ labelFor?: string
+ labelPosition?: 'top' | 'left'
+ labelWidth?: string
+ labelAlign?: 'left' | 'right' | 'center'
+}
diff --git a/src/ui/c-label/c-label.vue b/src/ui/c-label/c-label.vue
new file mode 100644
index 0000000..3e0f64d
--- /dev/null
+++ b/src/ui/c-label/c-label.vue
@@ -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>
diff --git a/src/ui/c-select/c-select.demo.vue b/src/ui/c-select/c-select.demo.vue
new file mode 100644
index 0000000..ae553bb
--- /dev/null
+++ b/src/ui/c-select/c-select.demo.vue
@@ -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>
diff --git a/src/ui/c-select/c-select.theme.ts b/src/ui/c-select/c-select.theme.ts
new file mode 100644
index 0000000..d799671
--- /dev/null
+++ b/src/ui/c-select/c-select.theme.ts
@@ -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',
+ },
+ },
+});
diff --git a/src/ui/c-select/c-select.types.ts b/src/ui/c-select/c-select.types.ts
new file mode 100644
index 0000000..6736b84
--- /dev/null
+++ b/src/ui/c-select/c-select.types.ts
@@ -0,0 +1,4 @@
+export interface CSelectOption<Value = unknown> {
+ label: string
+ value: Value
+}
diff --git a/src/ui/c-select/c-select.vue b/src/ui/c-select/c-select.vue
new file mode 100644
index 0000000..fb34038
--- /dev/null
+++ b/src/ui/c-select/c-select.vue
@@ -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>
diff --git a/src/ui/demo/demo-home.page.vue b/src/ui/demo/demo-home.page.vue
new file mode 100644
index 0000000..b7c04e9
--- /dev/null
+++ b/src/ui/demo/demo-home.page.vue
@@ -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>
diff --git a/src/ui/demo/demo.routes.ts b/src/ui/demo/demo.routes.ts
index 9ae1e77..ff514fc 100644
--- a/src/ui/demo/demo.routes.ts
+++ b/src/ui/demo/demo.routes.ts
@@ -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'),
},
];