diff options
Diffstat (limited to 'src/ui')
-rw-r--r-- | src/ui/c-label/c-label.types.ts | 7 | ||||
-rw-r--r-- | src/ui/c-label/c-label.vue | 32 | ||||
-rw-r--r-- | src/ui/c-select/c-select.demo.vue | 36 | ||||
-rw-r--r-- | src/ui/c-select/c-select.theme.ts | 60 | ||||
-rw-r--r-- | src/ui/c-select/c-select.types.ts | 4 | ||||
-rw-r--r-- | src/ui/c-select/c-select.vue | 262 | ||||
-rw-r--r-- | src/ui/demo/demo-home.page.vue | 13 | ||||
-rw-r--r-- | src/ui/demo/demo.routes.ts | 10 |
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'), }, ]; |