Fixed #2831 - Improve CascadeSelect implementation for Accessibility

pull/2835/head
mertsincan 2022-08-07 23:49:21 +01:00
parent ffea8cd86d
commit f66fc40c3c
4 changed files with 801 additions and 331 deletions

View File

@ -23,6 +23,12 @@ const CascadeSelectProps = [
default: "null", default: "null",
description: "Property name or getter function to use as the value of an option, defaults to the option itself when not defined." description: "Property name or getter function to use as the value of an option, defaults to the option itself when not defined."
}, },
{
name: "optionDisabled",
type: "boolean",
default: "null",
description: "Property name or getter function to use as the disabled flag of an option, defaults to false when not defined."
},
{ {
name: "optionGroupLabel", name: "optionGroupLabel",
type: "string | function", type: "string | function",
@ -54,10 +60,40 @@ const CascadeSelectProps = [
description: "A property to uniquely identify an option." description: "A property to uniquely identify an option."
}, },
{ {
name: "tabindex", name: "inputStyle",
type: "number", type: "object",
default: "null", default: "null",
description: "Index of the element in tabbing order." description: "Inline style of the input field."
},
{
name: "inputClass",
type: "string",
default: "null",
description: "Style class of the input field."
},
{
name: "inputProps",
type: "object",
default: "null",
description: "Uses to pass all properties of the HTMLInputElement/HTMLSpanElement to the focusable input element inside the component."
},
{
name: "panelStyle",
type: "object",
default: "null",
description: "Inline style of the overlay panel."
},
{
name: "panelClass",
type: "string",
default: "null",
description: "Style class of the overlay panel."
},
{
name: "panelProps",
type: "object",
default: "null",
description: "Uses to pass all properties of the HTMLDivElement to the overlay panel inside the component."
}, },
{ {
name: "appendTo", name: "appendTo",
@ -78,22 +114,58 @@ const CascadeSelectProps = [
description: "Icon to display in loading state." description: "Icon to display in loading state."
}, },
{ {
name: "inputId", name: "autoOptionFocus",
type: "boolean",
default: "true",
description: "Whether to focus on the first visible or selected element when the overlay panel is shown."
},
{
name: "searchLocale",
type: "string",
default: "undefined",
description: "Locale to use in searching. The default locale is the host environment's current locale."
},
{
name: "searchMessage",
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: "emptySearchMessage",
type: "string",
default: "No results found",
description: "Text to display when filtering does not return any results. Defaults to value from PrimeVue locale configuration."
},
{
name: "tabindex",
type: "number",
default: "0",
description: "Index of the element in tabbing order."
},
{
name: "aria-label",
type: "string",
default: "null",
description: "Defines a string value that labels an interactive element."
},
{
name: "aria-labelledby",
type: "string", type: "string",
default: "null", default: "null",
description: "Identifier of the underlying input element." description: "Identifier of the underlying input element."
},
{
name: "inputClass",
type: "any",
default: "null",
description: "Style class of the input field."
},
{
name: "inputStyle",
type: "any",
default: "null",
description: "Inline style of the input field."
} }
]; ];
@ -114,6 +186,39 @@ const CascadeSelectEvents = [
} }
] ]
}, },
{
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: "click",
description: "Callback to invoke on click.",
arguments: [
{
name: "event",
type: "object",
description: "Browser event"
}
]
},
{ {
name: "group-change", name: "group-change",
description: "Callback to invoke when a group changes.", description: "Callback to invoke when a group changes.",

View File

@ -1,11 +1,13 @@
import { VNode } from 'vue'; import { VNode } from 'vue';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers'; import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
type CascadeSelectOptionLabelType = string | ((data: any) => string) | undefined; type CascadeSelectOptionLabelType = string | ((data: any) => string) | undefined;
type CascadeSelectOptionValueType = string | ((data: any) => any) | undefined; type CascadeSelectOptionValueType = string | ((data: any) => any) | undefined;
type CascadeSelectOptionChildrenType = string[] | string | ((data: any) => any[]) | undefined; type CascadeSelectOptionDisabledType = string | ((data: any) => boolean) | undefined;
type CascadeSelectOptionChildrenType = string[] | string | ((data: any) => any[]) | undefined;
type CascadeSelectAppendToType = 'body' | 'self' | string | undefined | HTMLElement; type CascadeSelectAppendToType = 'body' | 'self' | string | undefined | HTMLElement;
@ -23,7 +25,7 @@ export interface CascadeSelectChangeEvent {
/** /**
* @extends CascadeSelectChangeEvent * @extends CascadeSelectChangeEvent
*/ */
export interface CascadeSelectChangeGroupEvent extends CascadeSelectChangeEvent { } export interface CascadeSelectGroupChangeEvent extends CascadeSelectChangeEvent { }
export interface CascadeSelectProps { export interface CascadeSelectProps {
/** /**
@ -44,6 +46,11 @@ export interface CascadeSelectProps {
* @see CascadeSelectOptionValueType * @see CascadeSelectOptionValueType
*/ */
optionValue?: CascadeSelectOptionValueType; optionValue?: CascadeSelectOptionValueType;
/**
* Property name or getter function to use as the disabled flag of an option, defaults to false when not defined.
* @see CascadeSelectOptionDisabledType
*/
optionDisabled?: CascadeSelectOptionDisabledType;
/** /**
* Property name or getter function to use as the label of an option group. * Property name or getter function to use as the label of an option group.
* @see CascadeSelectOptionLabelType * @see CascadeSelectOptionLabelType
@ -67,9 +74,33 @@ export interface CascadeSelectProps {
*/ */
dataKey?: string | undefined; dataKey?: string | undefined;
/** /**
* Index of the element in tabbing order. * Identifier of the underlying input element.
*/ */
tabindex?: string | undefined; inputId?: string | undefined;
/**
* Inline style of the input field.
*/
inputStyle?: any;
/**
* Style class of the input field.
*/
inputClass?: any;
/**
* Uses to pass all properties of the HTMLInputElement to the focusable input element inside the component.
*/
inputProps?: HTMLInputElement | undefined;
/**
* Inline style of the overlay panel.
*/
panelStyle?: any;
/**
* Style class of the overlay panel.
*/
panelClass?: any;
/**
* Uses to pass all properties of the HTMLDivElement to the overlay panel inside the component.
*/
panelProps?: HTMLDivElement | undefined;
/** /**
* A valid query selector or an HTMLElement to specify where the overlay gets attached. Special keywords are 'body' for document body and 'self' for the element itself. * A valid query selector or an HTMLElement to specify where the overlay gets attached. Special keywords are 'body' for document body and 'self' for the element itself.
* @see CascadeSelectAppendToType * @see CascadeSelectAppendToType
@ -86,30 +117,43 @@ export interface CascadeSelectProps {
*/ */
loadingIcon?: string | undefined; loadingIcon?: string | undefined;
/** /**
* Identifier of the underlying input element. * Whether to focus on the first visible or selected element when the overlay panel is shown.
* Default value is true.
*/ */
inputId?: string | undefined; autoOptionFocus?: boolean | undefined;
/** /**
* Style class of the input field. * Locale to use in searching. The default locale is the host environment's current locale.
*/ */
inputClass?: any | undefined; searchLocale?: string | undefined;
/** /**
* Inline style of the input field. * 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'.
*/ */
inputStyle?: any | undefined; searchMessage?: 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'.
*/ */
inputProps?: object | undefined; selectionMessage?: string | undefined;
/** /**
* Style class of the overlay panel. * 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'.
*/ */
panelClass?: any; emptySelectionMessage?: string | undefined;
/** /**
* * Text to display when filtering does not return any results. Defaults to value from PrimeVue locale configuration.
* Default value is 'No results found'.
*/ */
panelProps?: object | undefined; emptySearchMessage?: string | undefined;
/**
* Text to be displayed when there are no options available. Defaults to value from PrimeVue locale configuration.
* Default value is 'No available options'.
*/
emptyMessage?: string | undefined;
/**
* Index of the element in tabbing order.
*/
tabindex?: number | string | undefined;
/** /**
* Establishes relationships between the component and label(s) where its value should be one or more element IDs. * Establishes relationships between the component and label(s) where its value should be one or more element IDs.
*/ */
@ -163,10 +207,25 @@ export declare type CascadeSelectEmits = {
*/ */
'change': (event: CascadeSelectChangeEvent) => void; 'change': (event: CascadeSelectChangeEvent) => void;
/** /**
* Callback to invoke when a group changes. * Callback to invoke when the component receives focus.
* @param { CascadeSelectChangeGroupEvent } event - Custom change event. * @param {Event} event - Browser event.
*/ */
'change-group': (event: CascadeSelectChangeGroupEvent) => void; '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 click.
* @param { Event } event - Browser event.
*/
'click': (event: Event) => void;
/**
* Callback to invoke when a group changes.
* @param { CascadeSelectGroupChangeEvent } event - Custom change event.
*/
'group-change': (event: CascadeSelectGroupChangeEvent) => void;
/** /**
* Callback to invoke before the overlay is shown. * Callback to invoke before the overlay is shown.
*/ */

View File

@ -1,27 +1,34 @@
<template> <template>
<div ref="container" :class="containerClass" @click="onClick($event)"> <div ref="container" :class="containerClass" @click="onContainerClick($event)">
<div class="p-hidden-accessible"> <div class="p-hidden-accessible">
<input ref="focusInput" role="combobox" type="text" :id="inputId" :class="inputClass" :style="inputStyle" readonly :disabled="disabled" :tabindex="tabindex" :aria-labelledby="ariaLabelledby" :aria-label="ariaLabel" <input ref="focusInput" :id="inputId" type="text" :style="inputStyle" :class="inputClass" readonly :disabled="disabled" :placeholder="placeholder" :tabindex="!disabled ? tabindex : -1"
aria-haspopup="tree" :aria-expanded="overlayVisible" :aria-controls="listId" @focus="onFocus" @blur="onBlur" @keydown="onKeyDown" v-bind="inputProps" /> role="combobox" :aria-label="ariaLabel" :aria-labelledby="ariaLabelledby" aria-haspopup="tree" :aria-expanded="overlayVisible" :aria-controls="id + '_tree'" :aria-activedescendant="focused ? focusedOptionId : undefined"
@focus="onFocus" @blur="onBlur" @keydown="onKeyDown" v-bind="inputProps" />
</div> </div>
<span :class="labelClass"> <span :class="labelClass">
<slot name="value" :value="modelValue" :placeholder="placeholder"> <slot name="value" :value="modelValue" :placeholder="placeholder">
{{label}} {{label}}
</slot> </slot>
</span> </span>
<div class="p-cascadeselect-trigger" role="button" aria-haspopup="tree" :aria-expanded="overlayVisible"> <div class="p-cascadeselect-trigger" role="button" tabindex="-1" aria-hidden="true">
<slot name="indicator"> <slot name="indicator">
<span :class="dropdownIconClass"></span> <span :class="dropdownIconClass"></span>
</slot> </slot>
</div> </div>
<span role="status" aria-live="polite" class="p-hidden-accessible">
{{searchResultMessageText}}
</span>
<Portal :appendTo="appendTo"> <Portal :appendTo="appendTo">
<transition name="p-connected-overlay" @enter="onOverlayEnter" @leave="onOverlayLeave" @after-leave="onOverlayAfterLeave"> <transition name="p-connected-overlay" @enter="onOverlayEnter" @after-enter="onOverlayAfterEnter" @leave="onOverlayLeave" @after-leave="onOverlayAfterLeave">
<div :ref="overlayRef" :class="panelStyleClass" v-if="overlayVisible" @click="onOverlayClick" v-bind="panelProps"> <div v-if="overlayVisible" :ref="overlayRef" :style="panelStyle" :class="panelStyleClass" @click="onOverlayClick" @keydown="onOverlayKeyDown" v-bind="panelProps">
<div class="p-cascadeselect-items-wrapper"> <div class="p-cascadeselect-items-wrapper">
<CascadeSelectSub :id="listId" :options="options" :selectionPath="selectionPath" <CascadeSelectSub :id="id + '_tree'" role="tree" aria-orientation="horizontal" :selectId="id" :focusedOptionId="focused ? focusedOptionId : undefined"
:optionLabel="optionLabel" :optionValue="optionValue" :level="0" :templates="$slots" :options="processedOptions" :activeOptionPath="activeOptionPath" :level="0" :templates="$slots" :optionLabel="optionLabel" :optionValue="optionValue" :optionDisabled="optionDisabled"
:optionGroupLabel="optionGroupLabel" :optionGroupChildren="optionGroupChildren" :optionGroupLabel="optionGroupLabel" :optionGroupChildren="optionGroupChildren" @option-change="onOptionChange" />
@option-select="onOptionSelect" @optiongroup-select="onOptionGroupSelect" :dirty="dirty" :root="true" />
<span role="status" aria-live="polite" class="p-hidden-accessible">
{{selectedMessageText}}
</span>
</div> </div>
</div> </div>
</transition> </transition>
@ -37,26 +44,25 @@ import Portal from 'primevue/portal';
export default { export default {
name: 'CascadeSelect', name: 'CascadeSelect',
emits: ['update:modelValue','change','group-change', 'before-show','before-hide','hide','show','focus','blur'], emits: ['update:modelValue', 'change', 'focus', 'blur', 'click', 'group-change', 'before-show', 'before-hide', 'hide', 'show'],
data() {
return {
selectionPath: null,
focused: false,
overlayVisible: false,
dirty: false
};
},
props: { props: {
modelValue: null, modelValue: null,
options: Array, options: Array,
optionLabel: String, optionLabel: null,
optionValue: String, optionValue: null,
optionGroupLabel: String, optionDisabled: null,
optionGroupChildren: Array, optionGroupLabel: null,
optionGroupChildren: null,
placeholder: String, placeholder: String,
disabled: Boolean, disabled: Boolean,
dataKey: null, dataKey: null,
tabindex: String, inputId: null,
inputStyle: null,
inputClass: null,
inputProps: null,
panelStyle: null,
panelClass: null,
panelProps: null,
appendTo: { appendTo: {
type: String, type: String,
default: 'body' default: 'body'
@ -69,12 +75,38 @@ export default {
type: String, type: String,
default: 'pi pi-spinner pi-spin' default: 'pi pi-spinner pi-spin'
}, },
inputId: null, autoOptionFocus: {
inputClass: null, type: Boolean,
inputStyle: null, default: true
inputProps: null, },
panelClass: null, searchLocale: {
panelProps: null, type: String,
default: undefined
},
searchMessage: {
type: String,
default: null
},
selectionMessage: {
type: String,
default: null
},
emptySelectionMessage: {
type: String,
default: null
},
emptySearchMessage: {
type: String,
default: null
},
emptyMessage: {
type: String,
default: null
},
tabindex: {
type: Number,
default: 0
},
'aria-labelledby': { 'aria-labelledby': {
type: String, type: String,
default: null default: null
@ -88,6 +120,28 @@ export default {
scrollHandler: null, scrollHandler: null,
resizeListener: null, resizeListener: null,
overlay: null, overlay: null,
searchTimeout: null,
searchValue: null,
selectOnFocus: false,
focusOnHover: false,
data() {
return {
id: UniqueComponentId(),
focused: false,
focusedOptionInfo: { index: -1, level: 0, parentKey: '' },
activeOptionPath: [],
overlayVisible: false,
dirty: false
}
},
watch: {
options() {
this.autoUpdateModel();
}
},
mounted() {
this.id = this.$attrs.id || this.id;
},
beforeUnmount() { beforeUnmount() {
this.unbindOutsideClickListener(); this.unbindOutsideClickListener();
this.unbindResizeListener(); this.unbindResizeListener();
@ -102,74 +156,58 @@ export default {
this.overlay = null; this.overlay = null;
} }
}, },
mounted() {
this.updateSelectionPath();
},
watch: {
modelValue() {
this.updateSelectionPath();
}
},
methods: { methods: {
onOptionSelect(event) {
this.$emit('update:modelValue', event.value);
this.$emit('change', event);
this.hide();
this.$refs.focusInput.focus();
},
onOptionGroupSelect(event) {
this.dirty = true;
this.$emit('group-change', event);
},
getOptionLabel(option) { getOptionLabel(option) {
return this.optionLabel ? ObjectUtils.resolveFieldData(option, this.optionLabel) : option; return this.optionLabel ? ObjectUtils.resolveFieldData(option, this.optionLabel) : option;
}, },
getOptionValue(option) { getOptionValue(option) {
return this.optionValue ? ObjectUtils.resolveFieldData(option, this.optionValue) : option; return this.optionValue ? ObjectUtils.resolveFieldData(option, this.optionValue) : option;
}, },
isOptionDisabled(option) {
return this.optionDisabled ? ObjectUtils.resolveFieldData(option, this.optionDisabled) : false;
},
getOptionGroupLabel(optionGroup) {
return this.optionGroupLabel ? ObjectUtils.resolveFieldData(optionGroup, this.optionGroupLabel) : null;
},
getOptionGroupChildren(optionGroup, level) { getOptionGroupChildren(optionGroup, level) {
return ObjectUtils.resolveFieldData(optionGroup, this.optionGroupChildren[level]); return ObjectUtils.resolveFieldData(optionGroup, this.optionGroupChildren[level]);
}, },
isOptionGroup(option, level) { isOptionGroup(option, level) {
return Object.prototype.hasOwnProperty.call(option, this.optionGroupChildren[level]); return Object.prototype.hasOwnProperty.call(option, this.optionGroupChildren[level]);
}, },
updateSelectionPath() { getProccessedOptionLabel(processedOption) {
let path; const grouped = this.isProccessedOptionGroup(processedOption);
if (this.modelValue != null && this.options) { return grouped ? this.getOptionGroupLabel(processedOption.option, processedOption.level) : this.getOptionLabel(processedOption.option);
for (let option of this.options) {
path = this.findModelOptionInGroup(option, 0);
if (path) {
break;
}
}
}
this.selectionPath = path;
}, },
findModelOptionInGroup(option, level) { isProccessedOptionGroup(processedOption) {
if (this.isOptionGroup(option, level)) { return ObjectUtils.isNotEmpty(processedOption.children);
let selectedOption;
for (let childOption of this.getOptionGroupChildren(option, level)) {
selectedOption = this.findModelOptionInGroup(childOption, level + 1);
if (selectedOption) {
selectedOption.unshift(option);
return selectedOption;
}
}
}
else if ((ObjectUtils.equals(this.modelValue, this.getOptionValue(option), this.dataKey))) {
return [option];
}
return null;
}, },
show() { show(isFocus) {
this.$emit('before-show'); this.$emit('before-show');
this.overlayVisible = true; this.overlayVisible = true;
this.activeOptionPath = this.findOptionPathByValue(this.modelValue);
if (this.hasSelectedOption && ObjectUtils.isNotEmpty(this.activeOptionPath)) {
const processedOption = this.activeOptionPath[this.activeOptionPath.length - 1];
this.focusedOptionInfo = { index: (this.autoOptionFocus ? processedOption.index : -1), level: processedOption.level, parentKey: processedOption.parentKey };
}
else {
this.focusedOptionInfo = { index: (this.autoOptionFocus ? this.findFirstFocusedOptionIndex() : -1), level: 0, parentKey: '' };
}
isFocus && this.$refs.focusInput.focus();
}, },
hide() { hide(isFocus) {
this.$emit('before-hide'); const _hide = () => {
this.overlayVisible = false; this.$emit('before-hide');
this.overlayVisible = false;
this.activeOptionPath = [];
this.focusedOptionInfo = { index: -1, level: 0, parentKey: '' };
isFocus && this.$refs.focusInput.focus();
}
setTimeout(() => { _hide() }, 0); // For ScreenReaders
}, },
onFocus(event) { onFocus(event) {
this.focused = true; this.focused = true;
@ -177,34 +215,259 @@ export default {
}, },
onBlur(event) { onBlur(event) {
this.focused = false; this.focused = false;
this.activeOptionPath = [];
this.focusedOptionInfo = { index: -1, level: 0, parentKey: '' };
this.searchValue = '';
this.$emit('blur', event); this.$emit('blur', event);
}, },
onClick(event) { onKeyDown(event) {
if (this.disabled || this.loading) {
event.preventDefault();
return;
}
switch (event.code) {
case 'ArrowDown':
this.onArrowDownKey(event);
break;
case 'ArrowUp':
this.onArrowUpKey(event);
break;
case 'ArrowLeft':
this.onArrowLeftKey(event);
break;
case 'ArrowRight':
this.onArrowRightKey(event);
break;
case 'Home':
this.onHomeKey(event);
break;
case 'End':
this.onEndKey(event);
break;
case 'Space':
this.onSpaceKey(event);
break;
case 'Enter':
this.onEnterKey(event);
break;
case 'Escape':
this.onEscapeKey(event);
break;
case 'Tab':
this.onTabKey(event);
break;
case 'PageDown':
case 'PageUp':
case 'Backspace':
case 'ShiftLeft':
case 'ShiftRight':
//NOOP
break;
default:
if (ObjectUtils.isPrintableCharacter(event.key)) {
!this.overlayVisible && this.show();
this.searchOptions(event, event.key);
}
break;
}
},
onOptionChange(event) {
const { originalEvent, processedOption, isFocus } = event;
const { index, level, parentKey, children } = processedOption;
const grouped = ObjectUtils.isNotEmpty(children);
const activeOptionPath = this.activeOptionPath.filter(p => p.parentKey !== parentKey);
activeOptionPath.push(processedOption);
this.focusedOptionInfo = { index, level, parentKey };
this.activeOptionPath = activeOptionPath;
grouped ? this.onOptionGroupSelect(originalEvent, processedOption) : this.onOptionSelect(originalEvent, processedOption);
isFocus && this.$refs.focusInput.focus();
},
onOptionSelect(event, processedOption) {
const value = this.getOptionValue(processedOption.option);
this.activeOptionPath.forEach(p => p.selected = true);
this.updateModel(event, value);
this.hide(true);
},
onOptionGroupSelect(event, processedOption) {
this.dirty = true;
this.$emit('group-change', { originalEvent: event, value: processedOption.option });
},
onContainerClick(event) {
if (this.disabled || this.loading) { if (this.disabled || this.loading) {
return; return;
} }
if (!this.overlay || !this.overlay.contains(event.target)) { if (!this.overlay || !this.overlay.contains(event.target)) {
if (this.overlayVisible) this.overlayVisible ? this.hide() : this.show();
this.hide();
else
this.show();
this.$refs.focusInput.focus(); this.$refs.focusInput.focus();
} }
this.$emit('click', event);
},
onOverlayClick(event) {
OverlayEventBus.emit('overlay-click', {
originalEvent: event,
target: this.$el
});
},
onOverlayKeyDown(event) {
switch (event.code) {
case 'Escape':
this.onEscapeKey(event);
break;
default:
break;
}
},
onArrowDownKey(event) {
const optionIndex = this.focusedOptionInfo.index !== -1 ? this.findNextOptionIndex(this.focusedOptionInfo.index) : this.findFirstFocusedOptionIndex();
this.changeFocusedOptionIndex(event, optionIndex);
!this.overlayVisible && this.show();
event.preventDefault();
},
onArrowUpKey(event) {
if (event.altKey) {
if (this.focusedOptionInfo.index !== -1) {
const processedOption = this.visibleOptions[this.focusedOptionInfo.index];
const grouped = this.isProccessedOptionGroup(processedOption);
!grouped && this.onOptionChange({ originalEvent: event, processedOption });
}
this.overlayVisible && this.hide();
event.preventDefault();
}
else {
const optionIndex = this.focusedOptionInfo.index !== -1 ? this.findPrevOptionIndex(this.focusedOptionInfo.index) : this.findLastFocusedOptionIndex();
this.changeFocusedOptionIndex(event, optionIndex);
!this.overlayVisible && this.show();
event.preventDefault();
}
},
onArrowLeftKey(event) {
if (this.overlayVisible) {
const processedOption = this.visibleOptions[this.focusedOptionInfo.index];
const parentOption = this.activeOptionPath.find(p => p.key === processedOption.parentKey);
const matched = this.focusedOptionInfo.parentKey === '' || (parentOption && parentOption.key === this.focusedOptionInfo.parentKey);
const root = ObjectUtils.isEmpty(processedOption.parent);
if (matched) {
this.activeOptionPath = this.activeOptionPath.filter(p => p.parentKey !== this.focusedOptionInfo.parentKey);
}
if (!root) {
this.focusedOptionInfo = { index: -1, parentKey: parentOption ? parentOption.parentKey : '' };
this.searchValue = '';
this.onArrowDownKey(event);
}
event.preventDefault();
}
},
onArrowRightKey(event) {
if (this.overlayVisible) {
const processedOption = this.visibleOptions[this.focusedOptionInfo.index];
const grouped = this.isProccessedOptionGroup(processedOption);
if (grouped) {
const matched = this.activeOptionPath.some(p => processedOption.key === p.key);
if (matched) {
this.focusedOptionInfo = { index: -1, parentKey: processedOption.key };
this.searchValue = '';
this.onArrowDownKey(event);
}
else {
this.onOptionChange({ originalEvent: event, processedOption });
}
}
event.preventDefault();
}
},
onHomeKey(event) {
this.changeFocusedOptionIndex(event, this.findFirstOptionIndex());
!this.overlayVisible && this.show();
event.preventDefault();
},
onEndKey(event) {
this.changeFocusedOptionIndex(event, this.findLastOptionIndex());
!this.overlayVisible && this.show();
event.preventDefault();
},
onEnterKey(event) {
if (!this.overlayVisible) {
this.onArrowDownKey(event);
}
else {
if (this.focusedOptionInfo.index !== -1) {
const processedOption = this.visibleOptions[this.focusedOptionInfo.index];
const grouped = this.isProccessedOptionGroup(processedOption);
this.onOptionChange({ originalEvent: event, processedOption });
!grouped && this.hide();
}
}
event.preventDefault();
},
onSpaceKey(event) {
this.onEnterKey(event);
},
onEscapeKey(event) {
this.overlayVisible && this.hide(true);
event.preventDefault();
},
onTabKey(event) {
if (this.focusedOptionInfo.index !== -1) {
const processedOption = this.visibleOptions[this.focusedOptionInfo.index];
const grouped = this.isProccessedOptionGroup(processedOption);
!grouped && this.onOptionChange({ originalEvent: event, processedOption });
}
this.overlayVisible && this.hide();
}, },
onOverlayEnter(el) { onOverlayEnter(el) {
ZIndexUtils.set('overlay', el, this.$primevue.config.zIndex.overlay); ZIndexUtils.set('overlay', el, this.$primevue.config.zIndex.overlay);
this.alignOverlay(); this.alignOverlay();
this.scrollInView();
},
onOverlayAfterEnter() {
this.bindOutsideClickListener(); this.bindOutsideClickListener();
this.bindScrollListener(); this.bindScrollListener();
this.bindResizeListener(); this.bindResizeListener();
this.$emit('show'); this.$emit('show');
}, },
onOverlayLeave() { onOverlayLeave() {
this.unbindOutsideClickListener(); this.unbindOutsideClickListener();
this.unbindScrollListener(); this.unbindScrollListener();
this.unbindResizeListener(); this.unbindResizeListener();
this.$emit('hide'); this.$emit('hide');
this.overlay = null; this.overlay = null;
this.dirty = false; this.dirty = false;
@ -269,88 +532,168 @@ export default {
this.resizeListener = null; this.resizeListener = null;
} }
}, },
isOptionMatched(processedOption) {
return this.isValidOption(processedOption) && this.getProccessedOptionLabel(processedOption).toLocaleLowerCase(this.searchLocale).startsWith(this.searchValue.toLocaleLowerCase(this.searchLocale));
},
isValidOption(processedOption) {
return !!processedOption && !this.isOptionDisabled(processedOption.option);
},
isValidSelectedOption(processedOption) {
return this.isValidOption(processedOption) && this.isSelected(processedOption);
},
isSelected(processedOption) {
return this.activeOptionPath.some(p => p.key === processedOption.key);
},
findFirstOptionIndex() {
return this.visibleOptions.findIndex(processedOption => this.isValidOption(processedOption));
},
findLastOptionIndex() {
return this.visibleOptions.findLastIndex(processedOption => this.isValidOption(processedOption));
},
findNextOptionIndex(index) {
const matchedOptionIndex = index < (this.visibleOptions.length - 1) ? this.visibleOptions.slice(index + 1).findIndex(processedOption => this.isValidOption(processedOption)) : -1;
return matchedOptionIndex > -1 ? matchedOptionIndex + index + 1 : index;
},
findPrevOptionIndex(index) {
const matchedOptionIndex = index > 0 ? this.visibleOptions.slice(0, index).findLastIndex(processedOption => this.isValidOption(processedOption)) : -1;
return matchedOptionIndex > -1 ? matchedOptionIndex : index;
},
findSelectedOptionIndex() {
return this.visibleOptions.findIndex(processedOption => this.isValidSelectedOption(processedOption));
},
findFirstFocusedOptionIndex() {
const selectedIndex = this.findSelectedOptionIndex();
return selectedIndex < 0 ? this.findFirstOptionIndex() : selectedIndex;
},
findLastFocusedOptionIndex() {
const selectedIndex = this.findSelectedOptionIndex();
return selectedIndex < 0 ? this.findLastOptionIndex() : selectedIndex;
},
findOptionPathByValue(value, processedOptions, level = 0) {
processedOptions = processedOptions || (level === 0 && this.processedOptions);
if (!processedOptions) return null;
if (ObjectUtils.isEmpty(value)) return [];
for (let i = 0; i < processedOptions.length; i++) {
const processedOption = processedOptions[i];
if (ObjectUtils.equals(value, this.getOptionValue(processedOption.option), this.equalityKey)) {
return [processedOption];
}
const matchedOptions = this.findOptionPathByValue(value, processedOption.children, level + 1);
if (matchedOptions) {
matchedOptions.unshift(processedOption);
return matchedOptions;
}
}
},
searchOptions(event, char) {
this.searchValue = (this.searchValue || '') + char;
let optionIndex = -1;
let matched = false;
if (this.focusedOptionInfo.index !== -1) {
optionIndex = this.visibleOptions.slice(this.focusedOptionInfo.index).findIndex(processedOption => this.isOptionMatched(processedOption));
optionIndex = optionIndex === -1 ? this.visibleOptions.slice(0, this.focusedOptionInfo.index).findIndex(processedOption => this.isOptionMatched(processedOption)) : optionIndex + this.focusedOptionInfo.index;
}
else {
optionIndex = this.visibleOptions.findIndex(processedOption => this.isOptionMatched(processedOption));
}
if (optionIndex !== -1) {
matched = true;
}
if (optionIndex === -1 && this.focusedOptionInfo.index === -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);
return matched;
},
changeFocusedOptionIndex(event, index) {
if (this.focusedOptionInfo.index !== index) {
this.focusedOptionInfo.index = index;
this.scrollInView();
if (this.selectOnFocus) {
this.updateModel(event, this.getOptionValue(this.visibleOptions[index]));
}
}
},
scrollInView(index = -1) {
const id = index !== -1 ? `${this.id}_${index}` : this.focusedOptionId;
const element = DomHandler.findSingle(this.list, `li[id="${id}"]`);
if (element) {
element.scrollIntoView && element.scrollIntoView({ block: 'nearest', inline: 'start' });
}
},
autoUpdateModel() {
if (this.selectOnFocus && this.autoOptionFocus && !this.hasSelectedOption) {
this.focusedOptionInfo.index = this.findFirstFocusedOptionIndex();
const value = this.getOptionValue(this.visibleOptions[this.focusedOptionInfo.index]);
this.updateModel(null, value);
}
},
updateModel(event, value) {
this.$emit('update:modelValue', value);
this.$emit('change', { originalEvent: event, value });
},
createProcessedOptions(options, level = 0, parent = {}, parentKey = '') {
const processedOptions = [];
options && options.forEach((option, index) => {
const key = (parentKey !== '' ? parentKey + '_' : '') + index;
const newOption = {
option,
index,
level,
key,
parent,
parentKey
}
newOption['children'] = this.createProcessedOptions(this.getOptionGroupChildren(option, level), level + 1, newOption, key);
processedOptions.push(newOption);
});
return processedOptions;
},
overlayRef(el) { overlayRef(el) {
this.overlay = el; this.overlay = el;
},
onKeyDown(event) {
if (this.disabled || this.loading) {
event.preventDefault();
return;
}
switch(event.code) {
case 'Down':
case 'ArrowDown':
if (this.overlayVisible) {
if (DomHandler.findSingle(this.overlay, '.p-highlight')) {
DomHandler.findSingle(this.overlay, '.p-highlight').focus();
}
else DomHandler.findSingle(this.overlay, '.p-cascadeselect-item').children[0].focus();
}
else {
this.show();
}
event.preventDefault();
break;
case 'Space':
case 'Enter':
if (this.overlayVisible) {
this.hide();
}
else {
this.show();
}
event.preventDefault();
break;
case 'Escape':
case 'Tab':
if (this.overlayVisible) {
this.hide();
event.preventDefault();
}
break;
default:
break;
}
},
onOverlayClick(event) {
OverlayEventBus.emit('overlay-click', {
originalEvent: event,
target: this.$el
});
} }
}, },
computed: { computed: {
containerClass() { containerClass() {
return [ return ['p-cascadeselect p-component p-inputwrapper', {
'p-cascadeselect p-component p-inputwrapper', 'p-disabled': this.disabled,
{ 'p-focus': this.focused,
'p-disabled': this.disabled, 'p-inputwrapper-filled': this.modelValue,
'p-focus': this.focused, 'p-inputwrapper-focus': this.focused || this.overlayVisible,
'p-inputwrapper-filled': this.modelValue, 'p-overlay-open': this.overlayVisible
'p-inputwrapper-focus': this.focused || this.overlayVisible }];
}
];
}, },
labelClass() { labelClass() {
return [ return ['p-cascadeselect-label', {
'p-cascadeselect-label', 'p-placeholder': this.label === this.placeholder,
{ 'p-cascadeselect-label-empty': !this.$slots['value'] && (this.label === 'p-emptylabel' || this.label.length === 0)
'p-placeholder': this.label === this.placeholder, }];
'p-cascadeselect-label-empty': !this.$slots['value'] && (this.label === 'p-emptylabel' || this.label.length === 0)
}
];
},
label() {
if (this.selectionPath)
return this.getOptionLabel(this.selectionPath[this.selectionPath.length - 1]);
else
return this.placeholder||'p-emptylabel';
}, },
panelStyleClass() { panelStyleClass() {
return ['p-cascadeselect-panel p-component', this.panelClass, { return ['p-cascadeselect-panel p-component', this.panelClass, {
@ -361,8 +704,54 @@ export default {
dropdownIconClass() { dropdownIconClass() {
return ['p-cascadeselect-trigger-icon', this.loading ? this.loadingIcon : 'pi pi-chevron-down']; return ['p-cascadeselect-trigger-icon', this.loading ? this.loadingIcon : 'pi pi-chevron-down'];
}, },
listId() { hasSelectedOption() {
return this.overlayVisible ? UniqueComponentId() + '_list' : null; return ObjectUtils.isNotEmpty(this.modelValue);
},
label() {
const label = this.placeholder || 'p-emptylabel';
if (this.hasSelectedOption) {
const activeOptionPath = this.findOptionPathByValue(this.modelValue);
const processedOption = activeOptionPath.length ? activeOptionPath[activeOptionPath.length - 1] : null;
return processedOption ? this.getOptionLabel(processedOption.option) : label;
}
return label;
},
processedOptions() {
return this.createProcessedOptions(this.options || []);
},
visibleOptions() {
const processedOption = this.activeOptionPath.find(p => p.key === this.focusedOptionInfo.parentKey);
return processedOption ? processedOption.children : this.processedOptions;
},
equalityKey() {
return this.optionValue ? null : this.dataKey;
},
searchResultMessageText() {
return ObjectUtils.isNotEmpty(this.visibleOptions) ? this.searchMessageText.replaceAll('{0}', this.visibleOptions.length) : this.emptySearchMessageText;
},
searchMessageText() {
return this.searchMessage || this.$primevue.config.locale.searchMessage;
},
emptySearchMessageText() {
return this.emptySearchMessage || this.$primevue.config.locale.emptySearchMessage;
},
emptyMessageText() {
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 this.hasSelectedOption ? this.selectionMessageText.replaceAll('{0}', '1') : this.emptySelectionMessageText;
},
focusedOptionId() {
return this.focusedOptionInfo.index !== -1 ? `${this.id}${ObjectUtils.isNotEmpty(this.focusedOptionInfo.parentKey) ? '_' + this.focusedOptionInfo.parentKey : ''}_${this.focusedOptionInfo.index}` : null;
} }
}, },
components: { components: {

View File

@ -1,169 +1,86 @@
<template> <template>
<ul class="p-cascadeselect-panel p-cascadeselect-items" aria-orientation="horizontal" :role="root === true ? 'tree' : 'group'"> <ul class="p-cascadeselect-panel p-cascadeselect-items">
<template v-for="(option,index) of options" :key="getOptionLabelToRender(option)"> <template v-for="(processedOption,index) of options" :key="getOptionLabelToRender(processedOption)">
<li :class="getItemClass(option)" role="treeitem" :aria-label="getOptionLabelToRender(option)" :aria-selected="isOptionActive(option)" :aria-expanded="isOptionActive(option)" <li :id="getOptionId(processedOption)" :class="['p-cascadeselect-item', {'p-cascadeselect-item-group': isOptionGroup(processedOption), 'p-cascadeselect-item-active p-highlight': isOptionActive(processedOption), 'p-focus': isOptionFocused(processedOption), 'p-disabled': isOptionDisabled(processedOption)}]"
:aria-setsize="options.length" :aria-posinset="index + 1" :aria-level="level + 1"> role="treeitem" :aria-label="getOptionLabelToRender(processedOption)" :aria-selected="isOptionGroup(processedOption) ? undefined : isOptionSelected(processedOption)" :aria-expanded="isOptionGroup(processedOption) ? isOptionActive(processedOption) : undefined"
<div class="p-cascadeselect-item-content" @click="onOptionClick($event, option)" tabindex="0" @keydown="onKeyDown($event, option, index)" v-ripple> :aria-setsize="processedOption.length" :aria-posinset="index + 1" :aria-level="level + 1">
<component :is="templates['option']" :option="option" v-if="templates['option']"/> <div class="p-cascadeselect-item-content" @click="onOptionClick($event, processedOption)" v-ripple>
<template v-else> <component v-if="templates['option']" :is="templates['option']" :option="processedOption.option" />
<span class="p-cascadeselect-item-text">{{getOptionLabelToRender(option)}}</span> <span v-else class="p-cascadeselect-item-text">{{getOptionLabelToRender(processedOption)}}</span>
</template> <span v-if="isOptionGroup(processedOption)" class="p-cascadeselect-group-icon pi pi-angle-right" aria-hidden="true"></span>
<span class="p-cascadeselect-group-icon pi pi-angle-right" v-if="isOptionGroup(option)"></span>
</div> </div>
<CascadeSelectSub v-if="isOptionGroup(option) && isOptionActive(option)" class="p-cascadeselect-sublist" :selectionPath="selectionPath" :options="getOptionGroupChildren(option)" <CascadeSelectSub v-if="isOptionGroup(processedOption) && isOptionActive(processedOption)" role="group" class="p-cascadeselect-sublist" :selectId="selectId" :focusedOptionId="focusedOptionId"
:optionLabel="optionLabel" :optionValue="optionValue" :level="level + 1" @option-select="onOptionSelect" @optiongroup-select="onOptionGroupSelect" :options="getOptionGroupChildren(processedOption)" :activeOptionPath="activeOptionPath" :level="level + 1" :templates="templates" :optionLabel="optionLabel" :optionValue="optionValue" :optionDisabled="optionDisabled"
:optionGroupLabel="optionGroupLabel" :optionGroupChildren="optionGroupChildren" :parentActive="isOptionActive(option)" :dirty="dirty" :templates="templates" :aria-level="level + 2"/> :optionGroupLabel="optionGroupLabel" :optionGroupChildren="optionGroupChildren" @option-change="onOptionChange" />
</li> </li>
</template> </template>
</ul> </ul>
</template> </template>
<script> <script>
import {ObjectUtils} from 'primevue/utils'; import {ObjectUtils,DomHandler} from 'primevue/utils';
import {DomHandler} from 'primevue/utils';
import Ripple from 'primevue/ripple'; import Ripple from 'primevue/ripple';
export default { export default {
name: 'CascadeSelectSub', name: 'CascadeSelectSub',
emits: ['option-select','optiongroup-select'], emits: ['option-change'],
props: { props: {
selectionPath: Array, selectId: String,
level: Number, focusedOptionId: String,
options: Array, options: Array,
optionLabel: String, optionLabel: String,
optionValue: String, optionValue: String,
optionDisabled: null,
optionGroupLabel: String, optionGroupLabel: String,
optionGroupChildren: Array, optionGroupChildren: Array,
parentActive: Boolean, activeOptionPath: Array,
dirty: Boolean, level: Number,
templates: null, templates: null
root: Boolean
},
data() {
return {
activeOption: null
}
}, },
mounted() { mounted() {
if (this.selectionPath && this.options && !this.dirty) { if (ObjectUtils.isNotEmpty(this.parentKey)) {
for (let option of this.options) {
if (this.selectionPath.includes(option)) {
this.activeOption = option;
break;
}
}
}
if (!this.root) {
this.position(); this.position();
} }
}, },
watch: {
parentActive(newValue) {
if (!newValue) {
this.activeOption = null;
}
}
},
methods: { methods: {
onOptionClick(event, option) { getOptionId(processedOption) {
if (this.isOptionGroup(option)) { return `${this.selectId}_${processedOption.key}`;
this.activeOption = (this.activeOption === option) ? null : option;
this.$emit('optiongroup-select', {
originalEvent: event,
value: option
});
}
else {
this.$emit('option-select', {
originalEvent: event,
value: this.getOptionValue(option)
});
}
}, },
onOptionSelect(event) { getOptionLabel(processedOption) {
this.$emit('option-select', event); return this.optionLabel ? ObjectUtils.resolveFieldData(processedOption.option, this.optionLabel) : processedOption.option;
}, },
onOptionGroupSelect(event) { getOptionValue(processedOption) {
this.$emit('optiongroup-select', event); return this.optionValue ? ObjectUtils.resolveFieldData(processedOption.option, this.optionValue) : processedOption.option;
}, },
getOptionLabel(option) { isOptionDisabled(processedOption) {
return this.optionLabel ? ObjectUtils.resolveFieldData(option, this.optionLabel) : option; return this.optionDisabled ? ObjectUtils.resolveFieldData(processedOption.option, this.optionDisabled) : false;
}, },
getOptionValue(option) { getOptionGroupLabel(processedOption) {
return this.optionValue ? ObjectUtils.resolveFieldData(option, this.optionValue) : option; return this.optionGroupLabel ? ObjectUtils.resolveFieldData(processedOption.option, this.optionGroupLabel) : null;
}, },
getOptionGroupLabel(optionGroup) { getOptionGroupChildren(processedOption) {
return this.optionGroupLabel ? ObjectUtils.resolveFieldData(optionGroup, this.optionGroupLabel) : null; return processedOption.children;
}, },
getOptionGroupChildren(optionGroup) { isOptionGroup(processedOption) {
return ObjectUtils.resolveFieldData(optionGroup, this.optionGroupChildren[this.level]); return ObjectUtils.isNotEmpty(processedOption.children);
}, },
isOptionGroup(option) { isOptionSelected(processedOption) {
return Object.prototype.hasOwnProperty.call(option, this.optionGroupChildren[this.level]); return !this.isOptionGroup(processedOption) && this.isOptionActive(processedOption);
}, },
getOptionLabelToRender(option) { isOptionActive(processedOption) {
return this.isOptionGroup(option) ? this.getOptionGroupLabel(option) : this.getOptionLabel(option); return this.activeOptionPath.some(path => path.key === processedOption.key);
}, },
getItemClass(option) { isOptionFocused(processedOption) {
return [ return this.focusedOptionId === this.getOptionId(processedOption);
'p-cascadeselect-item', {
'p-cascadeselect-item-group': this.isOptionGroup(option),
'p-cascadeselect-item-active p-highlight': this.isOptionActive(option)
}
]
}, },
isOptionActive(option) { getOptionLabelToRender(processedOption) {
return this.activeOption === option; return this.isOptionGroup(processedOption) ? this.getOptionGroupLabel(processedOption) : this.getOptionLabel(processedOption);
}, },
onKeyDown(event, option, index) { onOptionClick(event, processedOption) {
switch (event.code) { this.$emit('option-change', { originalEvent: event, processedOption, isFocus: true });
case 'Down': },
case 'ArrowDown': onOptionChange(event) {
var nextItem = this.$el.children[index + 1]; this.$emit('option-change', event);
if (nextItem) {
nextItem.children[0].focus();
}
break;
case 'Up':
case 'ArrowUp':
var prevItem = this.$el.children[index - 1];
if (prevItem) {
prevItem.children[0].focus();
}
break;
case 'Right':
case 'ArrowRight':
if (this.isOptionGroup(option)) {
if (this.isOptionActive(option)) {
event.currentTarget.nextElementSibling.children[0].children[0].focus();
}
else {
this.activeOption = option;
}
}
break;
case 'Left':
case 'ArrowLeft':
this.activeOption = null;
var parentList = event.currentTarget.parentElement.parentElement.previousElementSibling;
if (parentList) {
parentList.focus();
}
break;
case 'Enter':
case 'Space':
this.onOptionClick(event, option);
break;
}
event.preventDefault();
}, },
position() { position() {
const parentItem = this.$el.parentElement; const parentItem = this.$el.parentElement;