diff --git a/src/components/multi-select/CMultiSelect.vue b/src/components/multi-select/CMultiSelect.vue new file mode 100644 index 0000000..95b3278 --- /dev/null +++ b/src/components/multi-select/CMultiSelect.vue @@ -0,0 +1,587 @@ + + + + + diff --git a/src/components/multi-select/types.ts b/src/components/multi-select/types.ts new file mode 100644 index 0000000..330b658 --- /dev/null +++ b/src/components/multi-select/types.ts @@ -0,0 +1,4 @@ +import type { CSelectValue } from '../select/types' + +export type CMultiSelectValue = CSelectValue +export type CMultiSelectModelValue = CMultiSelectValue[] diff --git a/src/components/select/CSelect.vue b/src/components/select/CSelect.vue index b00e5b8..f8c51d1 100644 --- a/src/components/select/CSelect.vue +++ b/src/components/select/CSelect.vue @@ -98,6 +98,34 @@ function objectKey(value: object) { return key } +function valuesMatch(left: CSelectValue | null, right: CSelectValue | null) { + if (Object.is(left, right)) return true + if ( + props.optionValue || + !props.optionKey || + typeof left !== 'object' || + left === null || + typeof right !== 'object' || + right === null + ) { + return false + } + + const leftKey = + typeof props.optionKey === 'function' + ? props.optionKey(left) + : readPath(left, props.optionKey) + const rightKey = + typeof props.optionKey === 'function' + ? props.optionKey(right) + : readPath(right, props.optionKey) + + return ( + (typeof leftKey === 'string' || typeof leftKey === 'number') && + Object.is(leftKey, rightKey) + ) +} + const visibleOptions = computed(() => props.options.flatMap((source, index) => { if (props.optionLabel || props.optionValue) { @@ -136,7 +164,7 @@ const visibleOptions = computed(() => ) const selectedOption = computed(() => - visibleOptions.value.find((option) => Object.is(option.value, selection.value)), + visibleOptions.value.find((option) => valuesMatch(option.value, selection.value)), ) const selectedLabel = computed(() => selectedOption.value?.label ?? '') const filterText = computed(() => (isFiltering.value ? query.value.trim() : '')) @@ -341,11 +369,11 @@ function clearSelection() { class="option" :class="{ 'is-highlighted': option.key === highlightedKey, - 'is-selected': Object.is(option.value, selection), + 'is-selected': valuesMatch(option.value, selection), 'is-disabled': option.disabled, }" role="option" - :aria-selected="Object.is(option.value, selection) ? 'true' : 'false'" + :aria-selected="valuesMatch(option.value, selection) ? 'true' : 'false'" :aria-disabled="option.disabled ? 'true' : undefined" @mousemove="!option.disabled && (highlightedKey = option.key)" @mousedown.prevent diff --git a/src/index.ts b/src/index.ts index 01119a1..f08a45d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,7 @@ export { default as CInputAddon } from './components/input-addon/CInputAddon.vue export { default as CInputGroup } from './components/input-group/CInputGroup.vue' export { default as CInput } from './components/input/CInput.vue' export { default as CMenu } from './components/menu/CMenu.vue' +export { default as CMultiSelect } from './components/multi-select/CMultiSelect.vue' export { default as CNumberInput } from './components/number-input/CNumberInput.vue' export { default as CPassword } from './components/password/CPassword.vue' export { default as CRadio } from './components/radio/CRadio.vue' @@ -27,6 +28,10 @@ export type { export type { CButtonSize, CButtonType, CButtonVariant } from './components/button/types' export type { CCheckboxModelValue, CCheckboxValue } from './components/checkbox/types' export type { CFormControlSize } from './components/form/types' +export type { + CMultiSelectModelValue, + CMultiSelectValue, +} from './components/multi-select/types' export type { CRadioValue } from './components/radio/types' export type { CSelectKeyAccessor, diff --git a/src/layouts/useLayoutNavigation.ts b/src/layouts/useLayoutNavigation.ts index e52dc3d..fee8ca4 100644 --- a/src/layouts/useLayoutNavigation.ts +++ b/src/layouts/useLayoutNavigation.ts @@ -106,6 +106,13 @@ export function useTopNavigation() { active: route.name === 'select', command: () => void router.push({ name: 'select' }), }, + { + id: 'multi-select', + label: 'Multi Select', + icon: '☷', + active: route.name === 'multi-select', + command: () => void router.push({ name: 'multi-select' }), + }, { type: 'separator' }, { id: 'icon', @@ -216,6 +223,13 @@ export function useComponentNavigation() { active: route.name === 'select', command: () => void router.push({ name: 'select' }), }, + { + id: 'multi-select', + label: 'Multi Select', + icon: '☷', + active: route.name === 'multi-select', + command: () => void router.push({ name: 'multi-select' }), + }, { type: 'separator' }, { id: 'icon', diff --git a/src/pages/components/MultiSelect.vue b/src/pages/components/MultiSelect.vue new file mode 100644 index 0000000..75fb53a --- /dev/null +++ b/src/pages/components/MultiSelect.vue @@ -0,0 +1,195 @@ + + + + + diff --git a/src/pages/components/Select.vue b/src/pages/components/Select.vue index 17f7180..76d5a2f 100644 --- a/src/pages/components/Select.vue +++ b/src/pages/components/Select.vue @@ -217,7 +217,7 @@ const filterableUsage = `
options
Array of { label, value } records or raw objects.
option-label
Property path or function used to label raw object options.
option-value
Property path or function selecting the bound value. Omit it to bind the complete object.
-
option-key
Property path or function providing a stable string or number key for raw objects.
+
option-key
Property path or function providing stable rendering and object-selection identity.
clearable
Shows a compact × button that clears the selected model to null.
filterable
Replaces the native select with a searchable, existing-options-only combobox.
placeholder
Disabled initial option displayed while the model is null.
diff --git a/src/router/index.ts b/src/router/index.ts index 759cc2c..3424a03 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -86,6 +86,11 @@ const router = createRouter({ name: 'select', component: () => import('@/pages/components/Select.vue'), }, + { + path: 'multi-select', + name: 'multi-select', + component: () => import('@/pages/components/MultiSelect.vue'), + }, { path: 'icon', name: 'icon',