Fixed #2819 - Improve Listbox implementation for Accessibility

pull/2835/head
mertsincan 2022-07-31 03:41:05 +01:00
parent a96a133a98
commit a67befc047
3 changed files with 634 additions and 152 deletions

View File

@ -101,6 +101,42 @@ const ListboxProps = [
default: "null", default: "null",
description: "Fields used when filtering the options, defaults to optionLabel." description: "Fields used when filtering the options, defaults to optionLabel."
}, },
{
name: "filterInputProps",
type: "object",
default: "null",
description: "Uses to pass all properties of the HTMLInputElement to the filter input inside the component."
},
{
name: "virtualScrollerOptions",
type: "object",
default: "null",
description: "Whether to use the virtualScroller feature. The properties of VirtualScroller component can be used like an object in it."
},
{
name: "autoOptionFocus",
type: "boolean",
default: "true",
description: "Whether to focus on the first visible or selected element."
},
{
name: "filterMessage",
type: "string",
default: "{0} results are available",
description: "Text to be displayed in hidden accessible field when filtering returns any results. Defaults to value from PrimeVue locale configuration."
},
{
name: "selectionMessage",
type: "string",
default: "{0} items selected",
description: "Text to be displayed in hidden accessible field when options are selected. Defaults to value from PrimeVue locale configuration."
},
{
name: "emptySelectionMessage",
type: "string",
default: "No selected item",
description: "Text to be displayed in hidden accessible field when any option is not selected. Defaults to value from PrimeVue locale configuration."
},
{ {
name: "emptyFilterMessage", name: "emptyFilterMessage",
type: "string", type: "string",
@ -112,6 +148,24 @@ const ListboxProps = [
type: "string", type: "string",
default: "No results found", default: "No results found",
description: "Text to display when there are no options available. Defaults to value from PrimeVue locale configuration." description: "Text to display when there are no options available. Defaults to value from PrimeVue locale configuration."
},
{
name: "tabindex",
type: "number",
default: "0",
description: "Index of the element in tabbing order."
},
{
name: "ariaLabel",
type: "string",
default: "null",
description: "Defines a string value that labels an interactive element."
}
{
name: "ariaLabelledby",
type: "string",
default: "null",
description: "Identifier of the underlying input element."
} }
]; ];
@ -132,6 +186,28 @@ const ListboxEvents = [
} }
] ]
}, },
{
name: "focus",
description: "Callback to invoke when component receives focus.",
arguments: [
{
name: "event",
type: "object",
description: "Browser event"
}
]
},
{
name: "blur",
description: "Callback to invoke when component loses focus.",
arguments: [
{
name: "event",
type: "object",
description: "Browser event"
}
]
},
{ {
name: "filter", name: "filter",
description: "Callback to invoke on filter input.", description: "Callback to invoke on filter input.",
@ -147,12 +223,6 @@ const ListboxEvents = [
description: "Filter value" description: "Filter value"
} }
] ]
},
{
name: "virtualScrollerOptions",
type: "object",
default: "null",
description: "Whether to use the virtualScroller feature. The properties of VirtualScroller component can be used like an object in it."
} }
]; ];

View File

@ -3,13 +3,13 @@ import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
import { VirtualScrollerProps, VirtualScrollerItemOptions } from '../virtualscroller'; import { VirtualScrollerProps, VirtualScrollerItemOptions } from '../virtualscroller';
type ListboxOptionLabelType = string | ((data: any) => string) | undefined; type ListboxOptionLabelType = string | ((data: any) => string) | undefined;
type ListboxOptionValueType = string | ((data: any) => any) | undefined; type ListboxOptionValueType = string | ((data: any) => any) | undefined;
type ListboxOptionDisabledType = string | ((data: any) => boolean) | undefined; type ListboxOptionDisabledType = string | ((data: any) => boolean) | undefined;
type ListboxOptionChildrenType = string | ((data: any) => any[]) | undefined; type ListboxOptionChildrenType = string | ((data: any) => any[]) | undefined;
type ListboxFilterMatchModeType = 'contains' | 'startsWith' | 'endsWith' | undefined; type ListboxFilterMatchModeType = 'contains' | 'startsWith' | 'endsWith' | undefined;
@ -108,6 +108,35 @@ export interface ListboxProps {
* Fields used when filtering the options, defaults to optionLabel. * Fields used when filtering the options, defaults to optionLabel.
*/ */
filterFields?: string[] | undefined; filterFields?: string[] | undefined;
/**
* Uses to pass all properties of the HTMLInputElement to the filter input inside the component.
*/
filterInputProps?: HTMLInputElement | undefined;
/**
* Whether to use the virtualScroller feature. The properties of VirtualScroller component can be used like an object in it.
* @see VirtualScroller.VirtualScrollerProps
*/
virtualScrollerOptions?: VirtualScrollerProps;
/**
* Whether to focus on the first visible or selected element.
* Default value is true.
*/
autoOptionFocus?: boolean | undefined;
/**
* Text to be displayed in hidden accessible field when filtering returns any results. Defaults to value from PrimeVue locale configuration.
* Default value is '{0} results are available'.
*/
filterMessage?: string | undefined;
/**
* Text to be displayed in hidden accessible field when options are selected. Defaults to value from PrimeVue locale configuration.
* Default value is '{0} items selected'.
*/
selectionMessage?: string | undefined;
/**
* Text to be displayed in hidden accessible field when any option is not selected. Defaults to value from PrimeVue locale configuration.
* Default value is 'No selected item'.
*/
emptySelectionMessage?: string | undefined;
/** /**
* Text to display when filtering does not return any results. Defaults to value from PrimeVue locale configuration. * Text to display when filtering does not return any results. Defaults to value from PrimeVue locale configuration.
* Default value is 'No results found'. * Default value is 'No results found'.
@ -119,10 +148,17 @@ export interface ListboxProps {
*/ */
emptyMessage?: string | undefined; emptyMessage?: string | undefined;
/** /**
* Whether to use the virtualScroller feature. The properties of VirtualScroller component can be used like an object in it. * Index of the element in tabbing order.
* @see VirtualScroller.VirtualScrollerProps
*/ */
virtualScrollerOptions?: VirtualScrollerProps; tabindex?: number | string | undefined;
/**
* Defines a string value that labels an interactive element.
*/
ariaLabel?: string | undefined;
/**
* Identifier of the underlying input element.
*/
ariaLabelledby?: string | undefined;
} }
export interface ListboxSlots { export interface ListboxSlots {
@ -238,6 +274,16 @@ export declare type ListboxEmits = {
* @param {ListboxChangeEvent} event - Custom change event. * @param {ListboxChangeEvent} event - Custom change event.
*/ */
'change': (event: ListboxChangeEvent) => void; 'change': (event: ListboxChangeEvent) => void;
/**
* Callback to invoke when the component receives focus.
* @param {Event} event - Browser event.
*/
'focus': (event: Event) => void;
/**
* Callback to invoke when the component loses focus.
* @param {Event} event - Browser event.
*/
'blur': (event: Event) => void;
/** /**
* Callback to invoke on filter input. * Callback to invoke on filter input.
* @param {ListboxFilterEvent} event - Custom filter event. * @param {ListboxFilterEvent} event - Custom filter event.

View File

@ -1,60 +1,68 @@
<template> <template>
<div class="p-listbox p-component"> <div :id="id" :class="containerClass" @focusout="onFocusout">
<a ref="firstHiddenFocusableElement" role="presentation" aria-hidden="true" class="p-hidden-accessible p-hidden-focusable" :tabindex="!disabled ? tabindex : -1" @focus="onFirstHiddenFocus"></a>
<slot name="header" :value="modelValue" :options="visibleOptions"></slot> <slot name="header" :value="modelValue" :options="visibleOptions"></slot>
<div class="p-listbox-header" v-if="filter"> <div v-if="filter" class="p-listbox-header">
<div class="p-listbox-filter-container"> <div class="p-listbox-filter-container">
<input type="text" class="p-listbox-filter p-inputtext p-component" v-model="filterValue" :placeholder="filterPlaceholder" @input="onFilterChange"> <input ref="filterInput" type="text" class="p-listbox-filter p-inputtext p-component" v-model="filterValue" :placeholder="filterPlaceholder"
role="searchbox" autocomplete="off" :aria-owns="id + '_list'" :aria-activedescendant="focusedOptionId" :tabindex="!disabled && !focused ? tabindex : -1"
@input="onFilterChange" @blur="onFilterBlur" @keydown="onFilterKeyDown" v-bind="filterInputProps">
<span class="p-listbox-filter-icon pi pi-search"></span> <span class="p-listbox-filter-icon pi pi-search"></span>
</div> </div>
<span role="status" aria-live="polite" class="p-hidden-accessible">
{{filterResultMessageText}}
</span>
</div> </div>
<div class="p-listbox-list-wrapper" :style="listStyle"> <div ref="listWrapper" class="p-listbox-list-wrapper" :style="listStyle">
<VirtualScroller :ref="virtualScrollerRef" v-bind="virtualScrollerOptions" :style="listStyle" :items="visibleOptions" :disabled="virtualScrollerDisabled"> <VirtualScroller :ref="virtualScrollerRef" v-bind="virtualScrollerOptions" :style="listStyle" :items="visibleOptions" :tabindex="-1" :disabled="virtualScrollerDisabled">
<template v-slot:content="{ styleClass, contentRef, items, getItemOptions, contentStyle }"> <template v-slot:content="{ styleClass, contentRef, items, getItemOptions, contentStyle, itemSize }">
<ul :ref="contentRef" :class="['p-listbox-list', styleClass]" :style="contentStyle" role="listbox" aria-multiselectable="multiple"> <ul :ref="(el) => listRef(el, contentRef)" :id="id + '_list'" :class="['p-listbox-list', styleClass]" :style="contentStyle" :tabindex="-1" role="listbox"
<template v-if="!optionGroupLabel"> :aria-multiselectable="multiple" :aria-label="ariaLabel" :aria-labelledby="ariaLabelledby" :aria-activedescendant="focused ? focusedOptionId : undefined" :aria-disabled="disabled"
<li v-for="(option, i) of items" :tabindex="isOptionDisabled(option) ? null : '0'" :class="['p-listbox-item', {'p-highlight': isSelected(option), 'p-disabled': isOptionDisabled(option)}]" v-ripple @focus="onListFocus" @blur="onListBlur" @keydown="onListKeyDown">
:key="getOptionRenderKey(option)" @click="onOptionSelect($event, option)" @touchend="onOptionTouchEnd()" @keydown="onOptionKeyDown($event, option)" role="option" :aria-label="getOptionLabel(option)" :aria-selected="isSelected(option)" > <template v-for="(option, i) of items" :key="getOptionRenderKey(option, getOptionIndex(i, getItemOptions))">
<slot name="option" :option="option" :index="getOptionIndex(i, getItemOptions)">{{getOptionLabel(option)}} </slot> <li v-if="isOptionGroup(option)" :id="id + '_' + getOptionIndex(i, getItemOptions)" :style="{height: itemSize ? itemSize + 'px' : undefined}" class="p-listbox-item-group" role="option">
<slot name="optiongroup" :option="option.optionGroup" :index="getOptionIndex(i, getItemOptions)">{{getOptionGroupLabel(option.optionGroup)}}</slot>
</li>
<li v-else v-ripple :id="id + '_' + getOptionIndex(i, getItemOptions)" :style="{height: itemSize ? itemSize + 'px' : undefined}"
:class="['p-listbox-item', {'p-highlight': isSelected(option), 'p-focus': focusedOptionIndex === getOptionIndex(i, getItemOptions), 'p-disabled': isOptionDisabled(option)}]"
role="option" :aria-label="getOptionLabel(option)" :aria-selected="isSelected(option)" :aria-disabled="isOptionDisabled(option)" :aria-setsize="ariaSetSize" :aria-posinset="getAriaPosInset(getOptionIndex(i, getItemOptions))"
@click="onOptionSelect($event, option, getOptionIndex(i, getItemOptions))" @mousemove="onOptionMouseMove($event, getOptionIndex(i, getItemOptions))" @touchend="onOptionTouchEnd()">
<slot name="option" :option="option" :index="getOptionIndex(i, getItemOptions)">{{getOptionLabel(option)}}</slot>
</li> </li>
</template> </template>
<template v-else> <li v-if="filterValue && (!items || (items && items.length === 0))" class="p-listbox-empty-message" role="option">
<template v-for="(optionGroup, i) of items" :key="getOptionGroupRenderKey(optionGroup)">
<li class="p-listbox-item-group">
<slot name="optiongroup" :option="optionGroup" :index="getOptionIndex(i, getItemOptions)">{{getOptionGroupLabel(optionGroup)}}</slot>
</li>
<li v-for="(option, i) of getOptionGroupChildren(optionGroup)" :tabindex="isOptionDisabled(option) ? null : '0'" :class="['p-listbox-item', {'p-highlight': isSelected(option), 'p-disabled': isOptionDisabled(option)}]" v-ripple
:key="getOptionRenderKey(option)" @click="onOptionSelect($event, option)" @touchend="onOptionTouchEnd()" @keydown="onOptionKeyDown($event, option)" role="option" :aria-label="getOptionLabel(option)" :aria-selected="isSelected(option)" >
<slot name="option" :option="option" :index="getOptionIndex(i, getItemOptions)">{{getOptionLabel(option)}}</slot>
</li>
</template>
</template>
<li v-if="filterValue && (!items || (items && items.length === 0))" class="p-listbox-empty-message">
<slot name="emptyfilter">{{emptyFilterMessageText}}</slot> <slot name="emptyfilter">{{emptyFilterMessageText}}</slot>
</li> </li>
<li v-else-if="(!options || (options && options.length === 0))" class="p-listbox-empty-message"> <li v-else-if="(!options || (options && options.length === 0))" class="p-listbox-empty-message" role="option">
<slot name="empty">{{emptyMessageText}}</slot> <slot name="empty">{{emptyMessageText}}</slot>
</li> </li>
</ul> </ul>
<span v-if="(!options || (options && options.length === 0))" role="status" aria-live="polite" class="p-hidden-accessible">
{{emptyMessageText}}
</span>
<span role="status" aria-live="polite" class="p-hidden-accessible">
{{selectedMessageText}}
</span>
</template> </template>
<template v-slot:loader="{ options }" v-if="$slots.loader"> <template v-if="$slots.loader" v-slot:loader="{ options }">
<slot name="loader" :options="options"></slot> <slot name="loader" :options="options"></slot>
</template> </template>
</VirtualScroller> </VirtualScroller>
</div> </div>
<slot name="footer" :value="modelValue" :options="visibleOptions"></slot> <slot name="footer" :value="modelValue" :options="visibleOptions"></slot>
<a ref="lastHiddenFocusableElement" role="presentation" aria-hidden="true" class="p-hidden-accessible p-hidden-focusable" :tabindex="!disabled ? tabindex : -1" @focus="onLastHiddenFocus"></a>
</div> </div>
</template> </template>
<script> <script>
import {ObjectUtils} from 'primevue/utils'; import {DomHandler,ObjectUtils,UniqueComponentId} from 'primevue/utils';
import {DomHandler} from 'primevue/utils';
import {FilterService} from 'primevue/api'; import {FilterService} from 'primevue/api';
import Ripple from 'primevue/ripple'; import Ripple from 'primevue/ripple';
import VirtualScroller from 'primevue/virtualscroller'; import VirtualScroller from 'primevue/virtualscroller';
export default { export default {
name: 'Listbox', name: 'Listbox',
emits: ['update:modelValue', 'change', 'filter'], emits: ['update:modelValue', 'change', 'focus', 'blur', 'filter'],
props: { props: {
modelValue: null, modelValue: null,
options: Array, options: Array,
@ -79,6 +87,27 @@ export default {
type: Array, type: Array,
default: null default: null
}, },
filterInputProps: null,
virtualScrollerOptions: {
type: Object,
default: null
},
autoOptionFocus: {
type: Boolean,
default: true
},
filterMessage: {
type: String,
default: null
},
selectionMessage: {
type: String,
default: null
},
emptySelectionMessage: {
type: String,
default: null
},
emptyFilterMessage: { emptyFilterMessage: {
type: String, type: String,
default: null default: null
@ -87,17 +116,44 @@ export default {
type: String, type: String,
default: null default: null
}, },
virtualScrollerOptions: { tabindex: {
type: Object, type: Number,
default: 0
},
ariaLabel: {
type: String,
default: null
},
ariaLabelledby: {
type: String,
default: null default: null
} }
}, },
optionTouched: false, list: null,
virtualScroller: null, virtualScroller: null,
optionTouched: false,
startRangeIndex: -1,
searchTimeout: null,
searchValue: '',
selectOnFocus: false,
focusOnHover: false,
data() { data() {
return { return {
filterValue: null id: UniqueComponentId(),
}; filterValue: null,
focused: false,
focusedOptionIndex: -1
}
},
watch: {
options() {
this.autoUpdateModel();
}
},
mounted() {
this.id = this.$attrs.id || this.id;
this.autoUpdateModel();
}, },
methods: { methods: {
getOptionIndex(index, fn) { getOptionIndex(index, fn) {
@ -109,32 +165,128 @@ export default {
getOptionValue(option) { getOptionValue(option) {
return this.optionValue ? ObjectUtils.resolveFieldData(option, this.optionValue) : option; return this.optionValue ? ObjectUtils.resolveFieldData(option, this.optionValue) : option;
}, },
getOptionRenderKey(option) { getOptionRenderKey(option, index) {
return this.dataKey ? ObjectUtils.resolveFieldData(option, this.dataKey) : this.getOptionLabel(option); return (this.dataKey ? ObjectUtils.resolveFieldData(option, this.dataKey) : this.getOptionLabel(option)) + '_' + index;
}, },
isOptionDisabled(option) { isOptionDisabled(option) {
return this.optionDisabled ? ObjectUtils.resolveFieldData(option, this.optionDisabled) : false; return this.optionDisabled ? ObjectUtils.resolveFieldData(option, this.optionDisabled) : false;
}, },
getOptionGroupRenderKey(optionGroup) {
return ObjectUtils.resolveFieldData(optionGroup, this.optionGroupLabel);
},
getOptionGroupLabel(optionGroup) { getOptionGroupLabel(optionGroup) {
return ObjectUtils.resolveFieldData(optionGroup, this.optionGroupLabel); return ObjectUtils.resolveFieldData(optionGroup, this.optionGroupLabel);
}, },
getOptionGroupChildren(optionGroup) { getOptionGroupChildren(optionGroup) {
return ObjectUtils.resolveFieldData(optionGroup, this.optionGroupChildren); return ObjectUtils.resolveFieldData(optionGroup, this.optionGroupChildren);
}, },
onOptionSelect(event, option) { getAriaPosInset(index) {
return (this.optionGroupLabel ? index - this.visibleOptions.slice(0, index).filter(option => this.isOptionGroup(option)).length : index) + 1;
},
onFirstHiddenFocus() {
this.list.focus();
const firstFocusableEl = DomHandler.getFirstFocusableElement(this.$el, ':not(.p-hidden-focusable)');
this.$refs.lastHiddenFocusableElement.tabIndex = ObjectUtils.isEmpty(firstFocusableEl) ? -1 : undefined;
this.$refs.firstHiddenFocusableElement.tabIndex = -1;
},
onLastHiddenFocus(event) {
const relatedTarget = event.relatedTarget;
if (relatedTarget === this.list) {
const firstFocusableEl = DomHandler.getFirstFocusableElement(this.$el, ':not(.p-hidden-focusable)');
firstFocusableEl && firstFocusableEl.focus();
this.$refs.firstHiddenFocusableElement.tabIndex = undefined;
}
else {
this.$refs.firstHiddenFocusableElement.focus();
}
this.$refs.lastHiddenFocusableElement.tabIndex = -1;
},
onFocusout(event) {
if (!this.$el.contains(event.relatedTarget)) {
this.$refs.lastHiddenFocusableElement.tabIndex = this.$refs.firstHiddenFocusableElement.tabIndex = undefined;
}
},
onListFocus(event) {
this.focused = true;
this.focusedOptionIndex = this.autoOptionFocus ? this.findFirstFocusedOptionIndex() : -1;
this.$emit('focus', event);
},
onListBlur(event) {
this.focused = false;
this.focusedOptionIndex = this.startRangeIndex = -1;
this.searchValue = '';
this.$emit('blur', event);
},
onListKeyDown(event) {
switch (event.code) {
case 'ArrowDown':
this.onArrowDownKey(event);
break;
case 'ArrowUp':
this.onArrowUpKey(event);
break;
case 'Home':
this.onHomeKey(event);
break;
case 'End':
this.onEndKey(event);
break;
case 'PageDown':
this.onPageDownKey(event);
break;
case 'PageUp':
this.onPageUpKey(event);
break;
case 'Enter':
case 'Space':
this.onSpaceKey(event);
break;
case 'Tab':
//NOOP
break;
case 'ShiftLeft':
case 'ShiftRight':
this.onShiftKey(event);
break;
default:
if (event.code === 'KeyA' && this.multiple && (event.metaKey || event.ctrlKey)) {
const value = this.visibleOptions.filter(option => this.isValidOption(option)).map(option => this.getOptionValue(option));
this.updateModel(event, value);
event.preventDefault();
break;
}
if (ObjectUtils.isPrintableCharacter(event.key)) {
this.searchOptions(event, event.key);
event.preventDefault();
}
break;
}
},
onOptionSelect(event, option, index = -1) {
if (this.disabled || this.isOptionDisabled(option)) { if (this.disabled || this.isOptionDisabled(option)) {
return; return;
} }
if(this.multiple) this.multiple ? this.onOptionSelectMultiple(event, option) : this.onOptionSelectSingle(event, option);
this.onOptionSelectMultiple(event, option);
else
this.onOptionSelectSingle(event, option);
this.optionTouched = false; this.optionTouched = false;
index !== -1 && (this.focusedOptionIndex = index);
},
onOptionMouseMove(event, index) {
if (this.focusOnHover) {
this.changeFocusedOptionIndex(event, index);
}
}, },
onOptionTouchEnd() { onOptionTouchEnd() {
if (this.disabled) { if (this.disabled) {
@ -174,7 +326,6 @@ export default {
}, },
onOptionSelectMultiple(event, option) { onOptionSelectMultiple(event, option) {
let selected = this.isSelected(option); let selected = this.isSelected(option);
let valueChanged = false;
let value = null; let value = null;
let metaSelection = this.optionTouched ? false : this.metaKeySelection; let metaSelection = this.optionTouched ? false : this.metaKeySelection;
@ -182,133 +333,327 @@ export default {
let metaKey = (event.metaKey || event.ctrlKey); let metaKey = (event.metaKey || event.ctrlKey);
if (selected) { if (selected) {
if(metaKey) value = metaKey ? this.removeOption(option) : [this.getOptionValue(option)];
value = this.removeOption(option);
else
value = [this.getOptionValue(option)];
valueChanged = true;
} }
else { else {
value = (metaKey) ? this.modelValue || [] : []; value = (metaKey) ? this.modelValue || [] : [];
value = [...value, this.getOptionValue(option)]; value = [...value, this.getOptionValue(option)];
valueChanged = true;
} }
} }
else { else {
if (selected) value = selected ? this.removeOption(option) : [...this.modelValue || [], this.getOptionValue(option)];
value = this.removeOption(option);
else
value = [...this.modelValue || [], this.getOptionValue(option)];
valueChanged = true;
} }
if(valueChanged) { this.updateModel(event, value);
},
onOptionSelectRange(event, start = -1, end = -1) {
start === -1 && (start = this.findNearestSelectedOptionIndex(end, true));
end === -1 && (end = this.findNearestSelectedOptionIndex(start));
if (start !== -1 && end !== -1) {
const rangeStart = Math.min(start, end);
const rangeEnd = Math.max(start, end);
const value = this.visibleOptions.slice(rangeStart, rangeEnd + 1).filter(option => this.isValidOption(option)).map(option => this.getOptionValue(option));
this.updateModel(event, value); this.updateModel(event, value);
} }
}, },
isSelected(option) { onFilterChange(event) {
let selected = false; this.$emit('filter', {originalEvent: event, value: event.target.value});
let optionValue = this.getOptionValue(option); this.focusedOptionIndex = this.startRangeIndex = -1;
},
onFilterBlur() {
this.focusedOptionIndex = this.startRangeIndex = -1;
},
onFilterKeyDown(event) {
switch (event.code) {
case 'ArrowDown':
this.onArrowDownKey(event);
break;
if (this.multiple) { case 'ArrowUp':
if (this.modelValue) { this.onArrowUpKey(event);
for (let val of this.modelValue) { break;
if (ObjectUtils.equals(val, optionValue, this.equalityKey)) {
selected = true; case 'ArrowLeft':
break; case 'ArrowRight':
} this.onArrowLeftKey(event, true);
} break;
}
case 'Home':
this.onHomeKey(event, true);
break;
case 'End':
this.onEndKey(event, true);
break;
case 'Enter':
this.onEnterKey(event);
break;
case 'ShiftLeft':
case 'ShiftRight':
this.onShiftKey(event);
break;
default:
break;
}
},
onArrowDownKey(event) {
const optionIndex = this.focusedOptionIndex !== -1 ? this.findNextOptionIndex(this.focusedOptionIndex) : this.findFirstFocusedOptionIndex();
if (this.multiple && event.shiftKey) {
this.onOptionSelectRange(event, this.startRangeIndex, optionIndex);
}
this.changeFocusedOptionIndex(event, optionIndex);
event.preventDefault();
},
onArrowUpKey(event) {
const optionIndex = this.focusedOptionIndex !== -1 ? this.findPrevOptionIndex(this.focusedOptionIndex) : this.findLastFocusedOptionIndex();
if (this.multiple && event.shiftKey) {
this.onOptionSelectRange(event, optionIndex, this.startRangeIndex);
}
this.changeFocusedOptionIndex(event, optionIndex);
event.preventDefault();
},
onArrowLeftKey(event, pressedInInputText = false) {
pressedInInputText && (this.focusedOptionIndex = -1);
},
onHomeKey(event, pressedInInputText = false) {
if (pressedInInputText) {
event.currentTarget.setSelectionRange(0, 0);
this.focusedOptionIndex = -1;
} }
else { else {
selected = ObjectUtils.equals(this.modelValue, optionValue, this.equalityKey); let metaKey = event.metaKey || event.ctrlKey;
let optionIndex = this.findFirstOptionIndex();
if (this.multiple && event.shiftKey && metaKey) {
this.onOptionSelectRange(event, optionIndex, this.startRangeIndex);
}
this.changeFocusedOptionIndex(event, optionIndex);
} }
return selected; event.preventDefault();
},
onEndKey(event, pressedInInputText = false) {
if (pressedInInputText) {
const target = event.currentTarget;
const len = target.value.length;
target.setSelectionRange(len, len);
this.focusedOptionIndex = -1;
}
else {
let metaKey = event.metaKey || event.ctrlKey;
let optionIndex = this.findLastOptionIndex();
if (this.multiple && event.shiftKey && metaKey) {
this.onOptionSelectRange(event, this.startRangeIndex, optionIndex);
}
this.changeFocusedOptionIndex(event, optionIndex);
}
event.preventDefault();
},
onPageUpKey(event) {
this.scrollInView(0);
event.preventDefault();
},
onPageDownKey(event) {
this.scrollInView(this.visibleOptions.length - 1);
event.preventDefault();
},
onEnterKey(event) {
if (this.focusedOptionIndex !== -1) {
if (this.multiple && event.shiftKey)
this.onOptionSelectRange(event, this.focusedOptionIndex);
else
this.onOptionSelect(event, this.visibleOptions[this.focusedOptionIndex]);
}
event.preventDefault();
},
onSpaceKey(event) {
this.onEnterKey(event);
},
onShiftKey() {
this.startRangeIndex = this.focusedOptionIndex;
},
isOptionGroup(option) {
return this.optionGroupLabel && option.optionGroup && option.group;
},
isOptionMatched(option) {
return this.isValidOption(option) && this.getOptionLabel(option).toLocaleLowerCase(this.filterLocale).startsWith(this.searchValue.toLocaleLowerCase(this.filterLocale));
},
isValidOption(option) {
return option && !(this.isOptionDisabled(option) || option.optionGroup);
},
isValidSelectedOption(option) {
return this.isValidOption(option) && this.isSelected(option);
},
isSelected(option) {
const optionValue = this.getOptionValue(option);
if (this.multiple)
return (this.modelValue || []).some(value => ObjectUtils.equals(value, optionValue, this.equalityKey));
else
return ObjectUtils.equals(this.modelValue, optionValue, this.equalityKey);
},
findFirstOptionIndex() {
return this.visibleOptions.findIndex(option => this.isValidOption(option));
},
findLastOptionIndex() {
return this.visibleOptions.findLastIndex(option => this.isValidOption(option));
},
findNextOptionIndex(index) {
const matchedOptionIndex = index < (this.visibleOptions.length - 1) ? this.visibleOptions.slice(index + 1).findIndex(option => this.isValidOption(option)) : -1;
return matchedOptionIndex > -1 ? matchedOptionIndex + index + 1 : index;
},
findPrevOptionIndex(index) {
const matchedOptionIndex = index > 0 ? this.visibleOptions.slice(0, index).findLastIndex(option => this.isValidOption(option)) : -1;
return matchedOptionIndex > -1 ? matchedOptionIndex : index;
},
findFirstSelectedOptionIndex() {
return this.hasSelectedOption ? this.visibleOptions.findIndex(option => this.isValidSelectedOption(option)) : -1;
},
findLastSelectedOptionIndex() {
return this.hasSelectedOption ? this.visibleOptions.findLastIndex(option => this.isValidSelectedOption(option)) : -1;
},
findNextSelectedOptionIndex(index) {
const matchedOptionIndex = this.hasSelectedOption && index < (this.visibleOptions.length - 1) ? this.visibleOptions.slice(index + 1).findIndex(option => this.isValidSelectedOption(option)) : -1;
return matchedOptionIndex > -1 ? matchedOptionIndex + index + 1 : -1;
},
findPrevSelectedOptionIndex(index) {
const matchedOptionIndex = this.hasSelectedOption && index > 0 ? this.visibleOptions.slice(0, index).findLastIndex(option => this.isValidSelectedOption(option)) : -1;
return matchedOptionIndex > -1 ? matchedOptionIndex : -1;
},
findNearestSelectedOptionIndex(index, firstCheckUp = false) {
let matchedOptionIndex = -1;
if (this.hasSelectedOption) {
if (firstCheckUp) {
matchedOptionIndex = this.findPrevSelectedOptionIndex(index);
matchedOptionIndex = matchedOptionIndex === -1 ? this.findNextSelectedOptionIndex(index) : matchedOptionIndex;
}
else {
matchedOptionIndex = this.findNextSelectedOptionIndex(index);
matchedOptionIndex = matchedOptionIndex === -1 ? this.findPrevSelectedOptionIndex(index) : matchedOptionIndex;
}
}
return matchedOptionIndex > -1 ? matchedOptionIndex : index;
},
findFirstFocusedOptionIndex() {
const selectedIndex = this.findFirstSelectedOptionIndex();
return selectedIndex < 0 ? this.findFirstOptionIndex() : selectedIndex;
},
findLastFocusedOptionIndex() {
const selectedIndex = this.findLastSelectedOptionIndex();
return selectedIndex < 0 ? this.findLastOptionIndex() : selectedIndex;
},
searchOptions(event, char) {
this.searchValue = (this.searchValue || '') + char;
let optionIndex = -1;
if (this.focusedOptionIndex !== -1) {
optionIndex = this.visibleOptions.slice(this.focusedOptionIndex).findIndex(option => this.isOptionMatched(option));
optionIndex = optionIndex === -1 ? this.visibleOptions.slice(0, this.focusedOptionIndex).findIndex(option => this.isOptionMatched(option)) : optionIndex + this.focusedOptionIndex;
}
else {
optionIndex = this.visibleOptions.findIndex(option => this.isOptionMatched(option));
}
if (optionIndex === -1 && this.focusedOptionIndex === -1) {
optionIndex = this.findFirstFocusedOptionIndex();
}
if (optionIndex !== -1) {
this.changeFocusedOptionIndex(event, optionIndex);
}
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
this.searchTimeout = setTimeout(() => {
this.searchValue = '';
this.searchTimeout = null;
}, 500);
}, },
removeOption(option) { removeOption(option) {
return this.modelValue.filter(val => !ObjectUtils.equals(val, this.getOptionValue(option), this.equalityKey)); return this.modelValue.filter(val => !ObjectUtils.equals(val, this.getOptionValue(option), this.equalityKey));
}, },
updateModel(event, value) { changeFocusedOptionIndex(event, index) {
this.$emit('update:modelValue', value); if (this.focusedOptionIndex !== index) {
this.$emit('change', {originalEvent: event, value: value}); this.focusedOptionIndex = index;
}, this.scrollInView();
onOptionKeyDown(event, option) {
let item = event.currentTarget;
switch (event.which) { if (this.selectOnFocus && !this.multiple) {
//down this.updateModel(event, this.getOptionValue(this.visibleOptions[index]));
case 40: }
var nextItem = this.findNextItem(item);
if(nextItem) {
nextItem.focus();
}
event.preventDefault();
break;
//up
case 38:
var prevItem = this.findPrevItem(item);
if(prevItem) {
prevItem.focus();
}
event.preventDefault();
break;
//enter
case 13:
this.onOptionSelect(event, option);
event.preventDefault();
break;
} }
}, },
findNextItem(item) { scrollInView(index = -1) {
let nextItem = item.nextElementSibling; const id = index !== -1 ? `${this.id}_${index}` : this.focusedOptionId;
const element = DomHandler.findSingle(this.list, `li[id="${id}"]`);
if (nextItem) if (element) {
return DomHandler.hasClass(nextItem, 'p-disabled') || DomHandler.hasClass(nextItem, 'p-listbox-item-group') ? this.findNextItem(nextItem) : nextItem; element.scrollIntoView && element.scrollIntoView({ block: 'nearest', inline: 'nearest' });
else }
return null; else if (!this.virtualScrollerDisabled) {
this.virtualScroller && this.virtualScroller.scrollToIndex(index !== -1 ? index : this.focusedOptionIndex);
}
}, },
findPrevItem(item) { autoUpdateModel() {
let prevItem = item.previousElementSibling; if (this.selectOnFocus && this.autoOptionFocus && !this.hasSelectedOption) {
this.focusedOptionIndex = this.findFirstFocusedOptionIndex();
if (prevItem) const value = this.getOptionValue(this.visibleOptions[this.focusedOptionIndex]);
return DomHandler.hasClass(prevItem, 'p-disabled') || DomHandler.hasClass(prevItem, 'p-listbox-item-group') ? this.findPrevItem(prevItem) : prevItem; this.updateModel(null, this.multiple ? [value] : value);
else }
return null;
}, },
onFilterChange(event) { updateModel(event, value) {
this.$emit('filter', {originalEvent: event, value: event.target.value}); this.$emit('update:modelValue', value);
this.$emit('change', { originalEvent: event, value });
},
listRef(el, contentRef) {
this.list = el;
contentRef && contentRef(el); // For VirtualScroller
}, },
virtualScrollerRef(el) { virtualScrollerRef(el) {
this.virtualScroller = el; this.virtualScroller = el;
} }
}, },
computed: { computed: {
containerClass() {
return ['p-listbox p-component', {
'p-focus': this.focused,
'p-disabled': this.disabled
}];
},
visibleOptions() { visibleOptions() {
if (this.filterValue) { let options = this.options || [];
if (this.optionGroupLabel) {
let filteredGroups = []; if (this.optionGroupLabel) {
for (let optgroup of this.options) { options = options.reduce((result, option, index) => {
let filteredSubOptions = FilterService.filter(this.getOptionGroupChildren(optgroup), this.searchFields, this.filterValue, this.filterMatchMode, this.filterLocale); result.push({ optionGroup: option, group: true, groupIndex: index });
if (filteredSubOptions && filteredSubOptions.length) {
filteredGroups.push({...optgroup, ...{items: filteredSubOptions}}); let optionGroupChildren = this.getOptionGroupChildren(option);
} optionGroupChildren && optionGroupChildren.forEach(o => result.push(o));
}
return filteredGroups return result;
} }, []);
else {
return FilterService.filter(this.options, this.searchFields, this.filterValue, 'contains', this.filterLocale);
}
}
else {
return this.options;
} }
return this.filterValue ? FilterService.filter(options, this.searchFields, this.filterValue, this.filterMatchMode, this.filterLocale) : options;
},
hasSelectedOption() {
return ObjectUtils.isNotEmpty(this.modelValue);
}, },
equalityKey() { equalityKey() {
return this.optionValue ? null : this.dataKey; return this.optionValue ? null : this.dataKey;
@ -316,12 +661,33 @@ export default {
searchFields() { searchFields() {
return this.filterFields || [this.optionLabel]; return this.filterFields || [this.optionLabel];
}, },
filterResultMessageText() {
return ObjectUtils.isNotEmpty(this.visibleOptions) ? this.filterMessageText.replaceAll('{0}', this.visibleOptions.length) : this.emptyFilterMessageText;
},
filterMessageText() {
return this.filterMessage || this.$primevue.config.locale.searchMessage;
},
emptyFilterMessageText() { emptyFilterMessageText() {
return this.emptyFilterMessage || this.$primevue.config.locale.emptyFilterMessage; return this.emptyFilterMessage || this.$primevue.config.locale.emptySearchMessage || this.$primevue.config.locale.emptyFilterMessage;
}, },
emptyMessageText() { emptyMessageText() {
return this.emptyMessage || this.$primevue.config.locale.emptyMessage; return this.emptyMessage || this.$primevue.config.locale.emptyMessage;
}, },
selectionMessageText() {
return this.selectionMessage || this.$primevue.config.locale.selectionMessage;
},
emptySelectionMessageText() {
return this.emptySelectionMessage || this.$primevue.config.locale.emptySelectionMessage;
},
selectedMessageText() {
return ObjectUtils.isNotEmpty(this.modelValue) ? this.selectionMessageText.replaceAll('{0}', this.multiple ? this.modelValue.length : '1') : this.emptySelectionMessageText;
},
focusedOptionId() {
return this.focusedOptionIndex !== -1 ? `${this.id}_${this.focusedOptionIndex}` : null;
},
ariaSetSize() {
return this.visibleOptions.filter(option => !this.isOptionGroup(option)).length;
},
virtualScrollerDisabled() { virtualScrollerDisabled() {
return !this.virtualScrollerOptions; return !this.virtualScrollerOptions;
} }