Components update v.3.21.0

pull/3420/head
Bahadır Sofuoğlu 2022-12-08 14:04:25 +03:00
parent 18497d55b1
commit defd6ff6e2
242 changed files with 28022 additions and 17523 deletions

View File

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import Accordion from '../accordion/Accordion.vue'; import Accordion from '@/components/accordion/Accordion.vue';
import AccordionTab from '../accordiontab/AccordionTab.vue'; import AccordionTab from '@/components/accordiontab/AccordionTab.vue';
describe('Accordion.vue', () => { describe('Accordion.vue', () => {
let wrapper; let wrapper;

View File

@ -40,8 +40,8 @@
</template> </template>
<script> <script>
import { UniqueComponentId, DomHandler } from 'primevue/utils';
import Ripple from 'primevue/ripple'; import Ripple from 'primevue/ripple';
import { DomHandler, UniqueComponentId } from 'primevue/utils';
export default { export default {
name: 'Accordion', name: 'Accordion',
@ -61,11 +61,11 @@ export default {
}, },
expandIcon: { expandIcon: {
type: String, type: String,
default: 'pi-chevron-right' default: 'pi pi-chevron-right'
}, },
collapseIcon: { collapseIcon: {
type: String, type: String,
default: 'pi-chevron-down' default: 'pi pi-chevron-down'
}, },
tabindex: { tabindex: {
type: Number, type: Number,
@ -234,7 +234,7 @@ export default {
]; ];
}, },
getTabHeaderIconClass(i) { getTabHeaderIconClass(i) {
return ['p-accordion-toggle-icon pi', this.isTabActive(i) ? this.collapseIcon : this.expandIcon]; return ['p-accordion-toggle-icon', this.isTabActive(i) ? this.collapseIcon : this.expandIcon];
}, },
getTabContentClass(tab) { getTabContentClass(tab) {
return ['p-toggleable-content', this.getTabProp(tab, 'contentClass')]; return ['p-toggleable-content', this.getTabProp(tab, 'contentClass')];

View File

@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import AccordionTab from '../accordiontab/AccordionTab.vue'; import AccordionTab from '@/components/accordiontab/AccordionTab.vue';
describe('AccordionTab.vue', () => { describe('AccordionTab.vue', () => {
it('should exists', () => { it('should exists', () => {

View File

@ -76,16 +76,19 @@ export interface PrimeIconsOptions {
readonly ARROW_DOWN_RIGHT: string; readonly ARROW_DOWN_RIGHT: string;
readonly ARROW_LEFT: string; readonly ARROW_LEFT: string;
readonly ARROW_RIGHT: string; readonly ARROW_RIGHT: string;
readonly ARROW_RIGHT_ARROW_LEFT: string;
readonly ARROW_UP: string; readonly ARROW_UP: string;
readonly ARROW_UP_LEFT: string; readonly ARROW_UP_LEFT: string;
readonly ARROW_UP_RIGHT: string; readonly ARROW_UP_RIGHT: string;
readonly ARROW_H: string; readonly ARROW_H: string;
readonly ARROW_V: string; readonly ARROW_V: string;
readonly ARROW_A: string;
readonly AT: string; readonly AT: string;
readonly BACKWARD: string; readonly BACKWARD: string;
readonly BAN: string; readonly BAN: string;
readonly BARS: string; readonly BARS: string;
readonly BELL: string; readonly BELL: string;
readonly BITCOIN: string;
readonly BOLT: string; readonly BOLT: string;
readonly BOOK: string; readonly BOOK: string;
readonly BOOKMARK: string; readonly BOOKMARK: string;
@ -97,12 +100,14 @@ export interface PrimeIconsOptions {
readonly CALENDAR_MINUS: string; readonly CALENDAR_MINUS: string;
readonly CALENDAR_PLUS: string; readonly CALENDAR_PLUS: string;
readonly CALENDAR_TIMES: string; readonly CALENDAR_TIMES: string;
readonly CALCULATOR: string;
readonly CAMERA: string; readonly CAMERA: string;
readonly CAR: string; readonly CAR: string;
readonly CARET_DOWN: string; readonly CARET_DOWN: string;
readonly CARET_LEFT: string; readonly CARET_LEFT: string;
readonly CARET_RIGHT: string; readonly CARET_RIGHT: string;
readonly CARET_UP: string; readonly CARET_UP: string;
readonly CART_PLUS: string;
readonly CHART_BAR: string; readonly CHART_BAR: string;
readonly CHART_LINE: string; readonly CHART_LINE: string;
readonly CHART_PIE: string; readonly CHART_PIE: string;
@ -132,6 +137,7 @@ export interface PrimeIconsOptions {
readonly COPY: string; readonly COPY: string;
readonly CREDIT_CARD: string; readonly CREDIT_CARD: string;
readonly DATABASE: string; readonly DATABASE: string;
readonly DELETELEFT: string;
readonly DESKTOP: string; readonly DESKTOP: string;
readonly DIRECTIONS: string; readonly DIRECTIONS: string;
readonly DIRECTIONS_ALT: string; readonly DIRECTIONS_ALT: string;
@ -142,6 +148,7 @@ export interface PrimeIconsOptions {
readonly ELLIPSIS_H: string; readonly ELLIPSIS_H: string;
readonly ELLIPSIS_V: string; readonly ELLIPSIS_V: string;
readonly ENVELOPE: string; readonly ENVELOPE: string;
readonly ERASER: string;
readonly EURO: string; readonly EURO: string;
readonly EXCLAMATION_CIRCLE: string; readonly EXCLAMATION_CIRCLE: string;
readonly EXCLAMATION_TRIANGLE: string; readonly EXCLAMATION_TRIANGLE: string;
@ -152,8 +159,12 @@ export interface PrimeIconsOptions {
readonly FAST_BACKWARD: string; readonly FAST_BACKWARD: string;
readonly FAST_FORWARD: string; readonly FAST_FORWARD: string;
readonly FILE: string; readonly FILE: string;
readonly FILE_EDIT: string;
readonly FILE_EXCEL: string; readonly FILE_EXCEL: string;
readonly FILE_EXPORT: string;
readonly FILE_IMPORT: string;
readonly FILE_PDF: string; readonly FILE_PDF: string;
readonly FILE_WORD: string;
readonly FILTER: string; readonly FILTER: string;
readonly FILTER_FILL: string; readonly FILTER_FILL: string;
readonly FILTER_SLASH: string; readonly FILTER_SLASH: string;
@ -162,6 +173,7 @@ export interface PrimeIconsOptions {
readonly FOLDER: string; readonly FOLDER: string;
readonly FOLDER_OPEN: string; readonly FOLDER_OPEN: string;
readonly FORWARD: string; readonly FORWARD: string;
readonly GIFT: string;
readonly GITHUB: string; readonly GITHUB: string;
readonly GLOBE: string; readonly GLOBE: string;
readonly GOOGLE: string; readonly GOOGLE: string;
@ -169,6 +181,7 @@ export interface PrimeIconsOptions {
readonly HEART: string; readonly HEART: string;
readonly HEART_FILL: string; readonly HEART_FILL: string;
readonly HISTORY: string; readonly HISTORY: string;
readonly HOURGLASS: string;
readonly HOME: string; readonly HOME: string;
readonly ID_CARD: string; readonly ID_CARD: string;
readonly IMAGE: string; readonly IMAGE: string;
@ -178,6 +191,7 @@ export interface PrimeIconsOptions {
readonly INFO_CIRCLE: string; readonly INFO_CIRCLE: string;
readonly INSTAGRAM: string; readonly INSTAGRAM: string;
readonly KEY: string; readonly KEY: string;
readonly LANGUAGE: string;
readonly LINK: string; readonly LINK: string;
readonly LINKEDIN: string; readonly LINKEDIN: string;
readonly LIST: string; readonly LIST: string;
@ -185,6 +199,8 @@ export interface PrimeIconsOptions {
readonly LOCK_OPEN: string; readonly LOCK_OPEN: string;
readonly MAP: string; readonly MAP: string;
readonly MAP_MARKER: string; readonly MAP_MARKER: string;
readonly MEGAPHONE: string;
readonly MICREPHONE: string;
readonly MICROSOFT: string; readonly MICROSOFT: string;
readonly MINUS: string; readonly MINUS: string;
readonly MINUS_CIRCLE: string; readonly MINUS_CIRCLE: string;
@ -253,6 +269,7 @@ export interface PrimeIconsOptions {
readonly STEP_FORWARD: string; readonly STEP_FORWARD: string;
readonly STEP_FORWARD_ALT: string; readonly STEP_FORWARD_ALT: string;
readonly STOP: string; readonly STOP: string;
readonly STOPWATCH: string;
readonly STOP_CIRCLE: string; readonly STOP_CIRCLE: string;
readonly SUN: string; readonly SUN: string;
readonly SYNC: string; readonly SYNC: string;
@ -263,11 +280,14 @@ export interface PrimeIconsOptions {
readonly TELEGRAM: string; readonly TELEGRAM: string;
readonly TH_LARGE: string; readonly TH_LARGE: string;
readonly THUMBS_DOWN: string; readonly THUMBS_DOWN: string;
readonly THUMBS_DOWN_FILL: string;
readonly THUMBS_UP: string; readonly THUMBS_UP: string;
readonly THUMBS_UP_FILL: string;
readonly TICKET: string; readonly TICKET: string;
readonly TIMES: string; readonly TIMES: string;
readonly TIMES_CIRCLE: string; readonly TIMES_CIRCLE: string;
readonly TRASH: string; readonly TRASH: string;
readonly TRUCK: string;
readonly TWITTER: string; readonly TWITTER: string;
readonly UNDO: string; readonly UNDO: string;
readonly UNLOCK: string; readonly UNLOCK: string;
@ -277,6 +297,7 @@ export interface PrimeIconsOptions {
readonly USER_MINUS: string; readonly USER_MINUS: string;
readonly USER_PLUS: string; readonly USER_PLUS: string;
readonly USERS: string; readonly USERS: string;
readonly VERIFIED: string;
readonly VIDEO: string; readonly VIDEO: string;
readonly VIMEO: string; readonly VIMEO: string;
readonly VOLUME_DOWN: string; readonly VOLUME_DOWN: string;
@ -287,6 +308,7 @@ export interface PrimeIconsOptions {
readonly WIFI: string; readonly WIFI: string;
readonly WINDOW_MAXIMIZE: string; readonly WINDOW_MAXIMIZE: string;
readonly WINDOW_MINIMIZE: string; readonly WINDOW_MINIMIZE: string;
readonly WRENCH: string;
readonly YOUTUBE: string; readonly YOUTUBE: string;
} }

View File

@ -23,16 +23,19 @@ const PrimeIcons = {
ARROW_DOWN_RIGHT: 'pi pi-arrow-down-right', ARROW_DOWN_RIGHT: 'pi pi-arrow-down-right',
ARROW_LEFT: 'pi pi-arrow-left', ARROW_LEFT: 'pi pi-arrow-left',
ARROW_RIGHT: 'pi pi-arrow-right', ARROW_RIGHT: 'pi pi-arrow-right',
ARROW_RIGHT_ARROW_LEFT: 'pi pi-arrow-right-arrow-left',
ARROW_UP: 'pi pi-arrow-up', ARROW_UP: 'pi pi-arrow-up',
ARROW_UP_LEFT: 'pi pi-arrow-up-left', ARROW_UP_LEFT: 'pi pi-arrow-up-left',
ARROW_UP_RIGHT: 'pi pi-arrow-up-right', ARROW_UP_RIGHT: 'pi pi-arrow-up-right',
ARROW_H: 'pi pi-arrow-h', ARROW_H: 'pi pi-arrows-h',
ARROW_V: 'pi pi-arrow-v', ARROW_V: 'pi pi-arrows-v',
ARROW_A: 'pi pi-arrows-alt',
AT: 'pi pi-at', AT: 'pi pi-at',
BACKWARD: 'pi pi-backward', BACKWARD: 'pi pi-backward',
BAN: 'pi pi-ban', BAN: 'pi pi-ban',
BARS: 'pi pi-bars', BARS: 'pi pi-bars',
BELL: 'pi pi-bell', BELL: 'pi pi-bell',
BITCOIN: 'pi pi-bitcoin',
BOLT: 'pi pi-bolt', BOLT: 'pi pi-bolt',
BOOK: 'pi pi-book', BOOK: 'pi pi-book',
BOOKMARK: 'pi pi-bookmark', BOOKMARK: 'pi pi-bookmark',
@ -44,12 +47,14 @@ const PrimeIcons = {
CALENDAR_MINUS: 'pi pi-calendar-minus', CALENDAR_MINUS: 'pi pi-calendar-minus',
CALENDAR_PLUS: 'pi pi-calendar-plus', CALENDAR_PLUS: 'pi pi-calendar-plus',
CALENDAR_TIMES: 'pi pi-calendar-times', CALENDAR_TIMES: 'pi pi-calendar-times',
CALCULATOR: 'pi pi-calculator',
CAMERA: 'pi pi-camera', CAMERA: 'pi pi-camera',
CAR: 'pi pi-car', CAR: 'pi pi-car',
CARET_DOWN: 'pi pi-caret-down', CARET_DOWN: 'pi pi-caret-down',
CARET_LEFT: 'pi pi-caret-left', CARET_LEFT: 'pi pi-caret-left',
CARET_RIGHT: 'pi pi-caret-right', CARET_RIGHT: 'pi pi-caret-right',
CARET_UP: 'pi pi-caret-up', CARET_UP: 'pi pi-caret-up',
CART_PLUS: 'pi pi-cart-plus',
CHART_BAR: 'pi pi-chart-bar', CHART_BAR: 'pi pi-chart-bar',
CHART_LINE: 'pi pi-chart-line', CHART_LINE: 'pi pi-chart-line',
CHART_PIE: 'pi pi-chart-pie', CHART_PIE: 'pi pi-chart-pie',
@ -79,6 +84,7 @@ const PrimeIcons = {
COPY: 'pi pi-copy', COPY: 'pi pi-copy',
CREDIT_CARD: 'pi pi-credit-card', CREDIT_CARD: 'pi pi-credit-card',
DATABASE: 'pi pi-database', DATABASE: 'pi pi-database',
DELETELEFT: 'pi pi-delete-left',
DESKTOP: 'pi pi-desktop', DESKTOP: 'pi pi-desktop',
DIRECTIONS: 'pi pi-directions', DIRECTIONS: 'pi pi-directions',
DIRECTIONS_ALT: 'pi pi-directions-alt', DIRECTIONS_ALT: 'pi pi-directions-alt',
@ -89,6 +95,7 @@ const PrimeIcons = {
ELLIPSIS_H: 'pi pi-ellipsis-h', ELLIPSIS_H: 'pi pi-ellipsis-h',
ELLIPSIS_V: 'pi pi-ellipsis-v', ELLIPSIS_V: 'pi pi-ellipsis-v',
ENVELOPE: 'pi pi-envelope', ENVELOPE: 'pi pi-envelope',
ERASER: 'pi pi-eraser',
EURO: 'pi pi-euro', EURO: 'pi pi-euro',
EXCLAMATION_CIRCLE: 'pi pi-exclamation-circle', EXCLAMATION_CIRCLE: 'pi pi-exclamation-circle',
EXCLAMATION_TRIANGLE: 'pi pi-exclamation-triangle', EXCLAMATION_TRIANGLE: 'pi pi-exclamation-triangle',
@ -99,8 +106,12 @@ const PrimeIcons = {
FAST_BACKWARD: 'pi pi-fast-backward', FAST_BACKWARD: 'pi pi-fast-backward',
FAST_FORWARD: 'pi pi-fast-forward', FAST_FORWARD: 'pi pi-fast-forward',
FILE: 'pi pi-file', FILE: 'pi pi-file',
FILE_EDIT: 'pi pi-file-edit',
FILE_EXCEL: 'pi pi-file-excel', FILE_EXCEL: 'pi pi-file-excel',
FILE_EXPORT: 'pi pi-file-export',
FILE_IMPORT: 'pi pi-file-import',
FILE_PDF: 'pi pi-file-pdf', FILE_PDF: 'pi pi-file-pdf',
FILE_WORD: 'pi pi-file-word',
FILTER: 'pi pi-filter', FILTER: 'pi pi-filter',
FILTER_FILL: 'pi pi-filter-fill', FILTER_FILL: 'pi pi-filter-fill',
FILTER_SLASH: 'pi pi-filter-slash', FILTER_SLASH: 'pi pi-filter-slash',
@ -109,6 +120,7 @@ const PrimeIcons = {
FOLDER: 'pi pi-folder', FOLDER: 'pi pi-folder',
FOLDER_OPEN: 'pi pi-folder-open', FOLDER_OPEN: 'pi pi-folder-open',
FORWARD: 'pi pi-forward', FORWARD: 'pi pi-forward',
GIFT: 'pi pi-gift',
GITHUB: 'pi pi-github', GITHUB: 'pi pi-github',
GLOBE: 'pi pi-globe', GLOBE: 'pi pi-globe',
GOOGLE: 'pi pi-google', GOOGLE: 'pi pi-google',
@ -116,6 +128,7 @@ const PrimeIcons = {
HEART: 'pi pi-heart', HEART: 'pi pi-heart',
HEART_FILL: 'pi pi-heart-fill', HEART_FILL: 'pi pi-heart-fill',
HISTORY: 'pi pi-history', HISTORY: 'pi pi-history',
HOURGLASS: 'pi pi-hourglass',
HOME: 'pi pi-home', HOME: 'pi pi-home',
ID_CARD: 'pi pi-id-card', ID_CARD: 'pi pi-id-card',
IMAGE: 'pi pi-image', IMAGE: 'pi pi-image',
@ -125,6 +138,7 @@ const PrimeIcons = {
INFO_CIRCLE: 'pi pi-info-circle', INFO_CIRCLE: 'pi pi-info-circle',
INSTAGRAM: 'pi pi-instagram', INSTAGRAM: 'pi pi-instagram',
KEY: 'pi pi-key', KEY: 'pi pi-key',
LANGUAGE: 'pi pi-language',
LINK: 'pi pi-link', LINK: 'pi pi-link',
LINKEDIN: 'pi pi-linkedin', LINKEDIN: 'pi pi-linkedin',
LIST: 'pi pi-list', LIST: 'pi pi-list',
@ -132,6 +146,8 @@ const PrimeIcons = {
LOCK_OPEN: 'pi pi-lock-open', LOCK_OPEN: 'pi pi-lock-open',
MAP: 'pi pi-map', MAP: 'pi pi-map',
MAP_MARKER: 'pi pi-map-marker', MAP_MARKER: 'pi pi-map-marker',
MEGAPHONE: 'pi pi-megaphone',
MICREPHONE: 'pi pi-microphone',
MICROSOFT: 'pi pi-microsoft', MICROSOFT: 'pi pi-microsoft',
MINUS: 'pi pi-minus', MINUS: 'pi pi-minus',
MINUS_CIRCLE: 'pi pi-minus-circle', MINUS_CIRCLE: 'pi pi-minus-circle',
@ -200,6 +216,7 @@ const PrimeIcons = {
STEP_FORWARD: 'pi pi-step-forward', STEP_FORWARD: 'pi pi-step-forward',
STEP_FORWARD_ALT: 'pi pi-step-forward-alt', STEP_FORWARD_ALT: 'pi pi-step-forward-alt',
STOP: 'pi pi-stop', STOP: 'pi pi-stop',
STOPWATCH: 'pi pi-stop-watch',
STOP_CIRCLE: 'pi pi-stop-circle', STOP_CIRCLE: 'pi pi-stop-circle',
SUN: 'pi pi-sun', SUN: 'pi pi-sun',
SYNC: 'pi pi-sync', SYNC: 'pi pi-sync',
@ -210,11 +227,14 @@ const PrimeIcons = {
TELEGRAM: 'pi pi-telegram', TELEGRAM: 'pi pi-telegram',
TH_LARGE: 'pi pi-th-large', TH_LARGE: 'pi pi-th-large',
THUMBS_DOWN: 'pi pi-thumbs-down', THUMBS_DOWN: 'pi pi-thumbs-down',
THUMBS_DOWN_FILL: 'pi pi-thumbs-down-fill',
THUMBS_UP: 'pi pi-thumbs-up', THUMBS_UP: 'pi pi-thumbs-up',
THUMBS_UP_FILL: 'pi pi-thumbs-up-fill',
TICKET: 'pi pi-ticket', TICKET: 'pi pi-ticket',
TIMES: 'pi pi-times', TIMES: 'pi pi-times',
TIMES_CIRCLE: 'pi pi-times-circle', TIMES_CIRCLE: 'pi pi-times-circle',
TRASH: 'pi pi-trash', TRASH: 'pi pi-trash',
TRUCK: 'pi pi-truck',
TWITTER: 'pi pi-twitter', TWITTER: 'pi pi-twitter',
UNDO: 'pi pi-undo', UNDO: 'pi pi-undo',
UNLOCK: 'pi pi-unlock', UNLOCK: 'pi pi-unlock',
@ -224,6 +244,7 @@ const PrimeIcons = {
USER_MINUS: 'pi pi-user-minus', USER_MINUS: 'pi pi-user-minus',
USER_PLUS: 'pi pi-user-plus', USER_PLUS: 'pi pi-user-plus',
USERS: 'pi pi-users', USERS: 'pi pi-users',
VERIFIED: 'pi pi-verified',
VIDEO: 'pi pi-video', VIDEO: 'pi pi-video',
VIMEO: 'pi pi-vimeo', VIMEO: 'pi pi-vimeo',
VOLUME_DOWN: 'pi pi-volume-down', VOLUME_DOWN: 'pi pi-volume-down',
@ -234,6 +255,7 @@ const PrimeIcons = {
WIFI: 'pi pi-wifi', WIFI: 'pi pi-wifi',
WINDOW_MAXIMIZE: 'pi pi-window-maximize', WINDOW_MAXIMIZE: 'pi pi-window-maximize',
WINDOW_MINIMIZE: 'pi pi-window-minimize', WINDOW_MINIMIZE: 'pi pi-window-minimize',
WRENCH: 'pi pi-wrench',
YOUTUBE: 'pi pi-youtube' YOUTUBE: 'pi pi-youtube'
}; };

View File

@ -1,6 +1,6 @@
import { HTMLAttributes, InputHTMLAttributes, VNode } from 'vue'; import { HTMLAttributes, InputHTMLAttributes, VNode } from 'vue';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers'; import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
import { VirtualScrollerProps, VirtualScrollerItemOptions } from '../virtualscroller'; import { VirtualScrollerItemOptions, VirtualScrollerProps } from '../virtualscroller';
type AutoCompleteFieldType = string | ((data: any) => string) | undefined; type AutoCompleteFieldType = string | ((data: any) => string) | undefined;
@ -187,11 +187,25 @@ export interface AutoCompleteProps {
* Uses to pass all properties of the HTMLDivElement to the overlay panel inside the component. * Uses to pass all properties of the HTMLDivElement to the overlay panel inside the component.
*/ */
panelProps?: HTMLAttributes | undefined; panelProps?: HTMLAttributes | undefined;
/**
* Icon to display in the dropdown.
* Default value is 'pi pi-chevron-down'.
*/
dropdownIcon?: string | undefined;
/**
* Style class of the dropdown button.
*/
dropdownClass?: string | undefined;
/** /**
* Icon to display in loading state. * Icon to display in loading state.
* Default value is 'pi pi-spinner pi-spin'. * Default value is 'pi pi-spinner pi-spin'.
*/ */
loadingIcon?: string | undefined; loadingIcon?: string | undefined;
/**
* Icon to display in chip remove action.
* Default value is 'pi pi-times-circle'.
*/
removeTokenIcon?: string | undefined;
/** /**
* Whether to use the virtualScroller feature. The properties of VirtualScroller component can be used like an object in it. * Whether to use the virtualScroller feature. The properties of VirtualScroller component can be used like an object in it.
* @see VirtualScroller.VirtualScrollerProps * @see VirtualScroller.VirtualScrollerProps

View File

@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import PrimeVue from '../config/PrimeVue'; import PrimeVue from 'primevue/config';
import { nextTick } from 'vue';
import AutoComplete from './AutoComplete.vue'; import AutoComplete from './AutoComplete.vue';
describe('AutoComplete.vue', () => { describe('AutoComplete.vue', () => {
@ -37,7 +38,7 @@ describe('AutoComplete.vue', () => {
expect(wrapper.find('.p-autocomplete-input').exists()).toBe(true); expect(wrapper.find('.p-autocomplete-input').exists()).toBe(true);
}); });
it('search copmlete', async () => { it('search complete', async () => {
const event = { target: { value: 'b' } }; const event = { target: { value: 'b' } };
wrapper.vm.onInput(event); wrapper.vm.onInput(event);
@ -53,4 +54,35 @@ describe('AutoComplete.vue', () => {
expect(wrapper.find('.p-autocomplete-items').exists()).toBe(true); expect(wrapper.find('.p-autocomplete-items').exists()).toBe(true);
expect(wrapper.findAll('.p-autocomplete-item').length).toBe(1); expect(wrapper.findAll('.p-autocomplete-item').length).toBe(1);
}); });
describe('dropdown', () => {
it('should have correct custom icon', async () => {
wrapper.setProps({
dropdown: true,
dropdownIcon: 'pi pi-discord'
});
await nextTick();
const token = wrapper.find('.p-button-icon');
expect(token.classes()).toContain('pi-discord');
});
});
describe('multiple', () => {
it('should have correct custom icon', async () => {
wrapper.setProps({
multiple: true,
removeTokenIcon: 'pi pi-discord',
modelValue: ['foo', 'bar']
});
await nextTick();
wrapper.findAll('.p-autocomplete-token-icon').forEach((tokenIcon) => {
expect(tokenIcon.classes()).toContain('pi-discord');
});
});
});
}); });

View File

@ -53,7 +53,7 @@
<slot name="chip" :value="option"> <slot name="chip" :value="option">
<span class="p-autocomplete-token-label">{{ getOptionLabel(option) }}</span> <span class="p-autocomplete-token-label">{{ getOptionLabel(option) }}</span>
</slot> </slot>
<span class="p-autocomplete-token-icon pi pi-times-circle" @click="removeOption($event, i)" aria-hidden="true"></span> <span :class="['p-autocomplete-token-icon', removeTokenIcon]" @click="removeOption($event, i)" aria-hidden="true"></span>
</li> </li>
<li class="p-autocomplete-input-token" role="option"> <li class="p-autocomplete-input-token" role="option">
<input <input
@ -84,7 +84,7 @@
</li> </li>
</ul> </ul>
<i v-if="searching" :class="loadingIconClass" aria-hidden="true"></i> <i v-if="searching" :class="loadingIconClass" aria-hidden="true"></i>
<Button v-if="dropdown" ref="dropdownButton" type="button" icon="pi pi-chevron-down" class="p-autocomplete-dropdown" tabindex="-1" :disabled="disabled" aria-hidden="true" @click="onDropdownClick" /> <Button v-if="dropdown" ref="dropdownButton" type="button" :icon="dropdownIcon" :class="['p-autocomplete-dropdown', dropdownClass]" tabindex="-1" :disabled="disabled" aria-hidden="true" @click="onDropdownClick" />
<span role="status" aria-live="polite" class="p-hidden-accessible"> <span role="status" aria-live="polite" class="p-hidden-accessible">
{{ searchResultMessageText }} {{ searchResultMessageText }}
</span> </span>
@ -120,15 +120,15 @@
</li> </li>
</template> </template>
</ul> </ul>
<span role="status" aria-live="polite" class="p-hidden-accessible">
{{ selectedMessageText }}
</span>
</template> </template>
<template v-if="$slots.loader" v-slot:loader="{ options }"> <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>
<slot name="footer" :value="modelValue" :suggestions="visibleOptions"></slot> <slot name="footer" :value="modelValue" :suggestions="visibleOptions"></slot>
<span role="status" aria-live="polite" class="p-hidden-accessible">
{{ selectedMessageText }}
</span>
</div> </div>
</transition> </transition>
</Portal> </Portal>
@ -136,12 +136,12 @@
</template> </template>
<script> <script>
import { ConnectedOverlayScrollHandler, UniqueComponentId, ObjectUtils, DomHandler, ZIndexUtils } from 'primevue/utils';
import OverlayEventBus from 'primevue/overlayeventbus';
import Button from 'primevue/button'; import Button from 'primevue/button';
import Ripple from 'primevue/ripple'; import OverlayEventBus from 'primevue/overlayeventbus';
import VirtualScroller from 'primevue/virtualscroller';
import Portal from 'primevue/portal'; import Portal from 'primevue/portal';
import Ripple from 'primevue/ripple';
import { ConnectedOverlayScrollHandler, DomHandler, ObjectUtils, UniqueComponentId, ZIndexUtils } from 'primevue/utils';
import VirtualScroller from 'primevue/virtualscroller';
export default { export default {
name: 'AutoComplete', name: 'AutoComplete',
@ -242,10 +242,22 @@ export default {
type: null, type: null,
default: null default: null
}, },
dropdownIcon: {
type: String,
default: 'pi pi-chevron-down'
},
dropdownClass: {
type: String,
default: null
},
loadingIcon: { loadingIcon: {
type: String, type: String,
default: 'pi pi-spinner' default: 'pi pi-spinner'
}, },
removeTokenIcon: {
type: String,
default: 'pi pi-times-circle'
},
virtualScrollerOptions: { virtualScrollerOptions: {
type: Object, type: Object,
default: null default: null
@ -398,7 +410,7 @@ export default {
this.dirty = true; this.dirty = true;
this.focused = true; this.focused = true;
this.focusedOptionIndex = this.overlayVisible && this.autoOptionFocus ? this.findFirstFocusedOptionIndex() : -1; this.focusedOptionIndex = this.focusedOptionIndex !== -1 ? this.focusedOptionIndex : this.overlayVisible && this.autoOptionFocus ? this.findFirstFocusedOptionIndex() : -1;
this.overlayVisible && this.scrollInView(this.focusedOptionIndex); this.overlayVisible && this.scrollInView(this.focusedOptionIndex);
this.$emit('focus', event); this.$emit('focus', event);
}, },
@ -498,7 +510,7 @@ export default {
let valid = false; let valid = false;
if (this.visibleOptions) { if (this.visibleOptions) {
const matchedValue = this.visibleOptions.find((option) => this.isOptionMatched(option, event.target.value)); const matchedValue = this.visibleOptions.find((option) => this.isOptionMatched(option, this.$refs.focusInput.value || ''));
if (matchedValue !== undefined) { if (matchedValue !== undefined) {
valid = true; valid = true;
@ -651,7 +663,15 @@ export default {
this.multiple && event.stopPropagation(); // To prevent onArrowRightKeyOnMultiple method this.multiple && event.stopPropagation(); // To prevent onArrowRightKeyOnMultiple method
}, },
onHomeKey(event) { onHomeKey(event) {
event.currentTarget.setSelectionRange(0, 0); const target = event.currentTarget;
const len = target.value.length;
if (event.shiftKey) {
event.currentTarget.setSelectionRange(0, len);
} else {
event.currentTarget.setSelectionRange(0, 0);
}
this.focusedOptionIndex = -1; this.focusedOptionIndex = -1;
event.preventDefault(); event.preventDefault();
@ -660,7 +680,12 @@ export default {
const target = event.currentTarget; const target = event.currentTarget;
const len = target.value.length; const len = target.value.length;
target.setSelectionRange(len, len); if (event.shiftKey) {
event.currentTarget.setSelectionRange(0, len);
} else {
target.setSelectionRange(len, len);
}
this.focusedOptionIndex = -1; this.focusedOptionIndex = -1;
event.preventDefault(); event.preventDefault();

View File

@ -30,6 +30,14 @@ export interface AvatarProps {
* Default value is 'square'. * Default value is 'square'.
*/ */
shape?: AvatarShapeType; shape?: AvatarShapeType;
/**
* Establishes a string value that labels the component.
*/
'aria-label'?: string | undefined;
/**
* Establishes relationships between the component and label(s) where its value should be one or more element IDs.
*/
'aria-labelledby'?: string | undefined;
} }
export interface AvatarSlots { export interface AvatarSlots {

View File

@ -1,5 +1,5 @@
<template> <template>
<div :class="containerClass"> <div :class="containerClass" :aria-labelledby="ariaLabelledby" :aria-label="ariaLabel">
<slot> <slot>
<span v-if="label" class="p-avatar-text">{{ label }}</span> <span v-if="label" class="p-avatar-text">{{ label }}</span>
<span v-else-if="icon" :class="iconClass"></span> <span v-else-if="icon" :class="iconClass"></span>
@ -32,6 +32,14 @@ export default {
shape: { shape: {
type: String, type: String,
default: 'square' default: 'square'
},
'aria-labelledby': {
type: String,
default: null
},
'aria-label': {
type: String,
default: null
} }
}, },
methods: { methods: {

View File

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import AvatarGroup from './AvatarGroup.vue'; import AvatarGroup from './AvatarGroup.vue';
import Avatar from '../avatar/Avatar.vue'; import Avatar from '@/components/avatar/Avatar.vue';
describe('AvatarGroup.vue', () => { describe('AvatarGroup.vue', () => {
it('should exist', () => { it('should exist', () => {

View File

@ -1,6 +1,6 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import Badge from './Badge.vue'; import Badge from './Badge.vue';
import Button from '../button/Button.vue'; import Button from '@/components/button/Button.vue';
describe('Badge.vue', () => { describe('Badge.vue', () => {
it('should exist', () => { it('should exist', () => {

View File

@ -1,8 +1,7 @@
import { config, mount } from '@vue/test-utils'; import { config, mount } from '@vue/test-utils';
import BlockUI from './BlockUI.vue'; import BlockUI from './BlockUI.vue';
import Panel from '../panel/Panel.vue'; import Panel from '@/components/panel/Panel.vue';
import Button from '../button/Button.vue'; import Button from '@/components/button/Button.vue';
config.global.mocks = { config.global.mocks = {
$primevue: { $primevue: {
@ -10,17 +9,10 @@ config.global.mocks = {
zIndex: { zIndex: {
modal: 1100 modal: 1100
} }
},
DomHandler: {
addClass: vi.fn(),
removeClass: vi.fn()
},
ZIndexUtils: {
set: vi.fn(),
clear: vi.fn()
} }
} }
}; };
describe('BlockUI.vue', () => { describe('BlockUI.vue', () => {
it('should blocked and unblocked the panel', async () => { it('should blocked and unblocked the panel', async () => {
const wrapper = mount({ const wrapper = mount({

View File

@ -1,5 +1,5 @@
<template> <template>
<div ref="container" class="p-blockui-container" v-bind="$attrs"> <div ref="container" class="p-blockui-container" :aria-busy="isBlocked">
<slot></slot> <slot></slot>
</div> </div>
</template> </template>
@ -29,6 +29,11 @@ export default {
} }
}, },
mask: null, mask: null,
data() {
return {
isBlocked: false
};
},
watch: { watch: {
blocked(newValue) { blocked(newValue) {
if (newValue === true) this.block(); if (newValue === true) this.block();
@ -61,6 +66,7 @@ export default {
ZIndexUtils.set('modal', this.mask, this.baseZIndex + this.$primevue.config.zIndex.modal); ZIndexUtils.set('modal', this.mask, this.baseZIndex + this.$primevue.config.zIndex.modal);
} }
this.isBlocked = true;
this.$emit('block'); this.$emit('block');
}, },
unblock() { unblock() {
@ -79,6 +85,7 @@ export default {
this.$refs.container.removeChild(this.mask); this.$refs.container.removeChild(this.mask);
} }
this.isBlocked = false;
this.$emit('unblock'); this.$emit('unblock');
} }
} }

View File

@ -1,6 +1,6 @@
import { VNode } from 'vue'; import { VNode } from 'vue';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
import { MenuItem } from '../menuitem'; import { MenuItem } from '../menuitem';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
export interface BreadcrumbProps { export interface BreadcrumbProps {
/** /**
@ -16,6 +16,14 @@ export interface BreadcrumbProps {
* Default value is true. * Default value is true.
*/ */
exact?: boolean; exact?: boolean;
/**
* Defines a string value that labels an interactive element.
*/
'aria-label'?: string | undefined;
/**
* Identifier of the underlying menu element.
*/
'aria-labelledby'?: string | undefined;
} }
export interface BreadcrumbSlots { export interface BreadcrumbSlots {

View File

@ -5,7 +5,16 @@ describe('Breadcrumb', () => {
it('should exist', () => { it('should exist', () => {
const wrapper = mount(Breadcrumb, { const wrapper = mount(Breadcrumb, {
global: { global: {
stubs: ['router-link'] stubs: {
'router-link': true
},
mocks: {
$router: {
currentRoute: {
path: jest.fn()
}
}
}
}, },
props: { props: {
home: { icon: 'pi pi-home', to: '/' }, home: { icon: 'pi pi-home', to: '/' },
@ -14,7 +23,7 @@ describe('Breadcrumb', () => {
}); });
expect(wrapper.find('.p-breadcrumb.p-component').exists()).toBe(true); expect(wrapper.find('.p-breadcrumb.p-component').exists()).toBe(true);
expect(wrapper.findAll('.p-breadcrumb-chevron').length).toBe(5); expect(wrapper.findAll('.p-menuitem-separator').length).toBe(5);
expect(wrapper.findAll('.p-menuitem-text').length).toBe(5); expect(wrapper.findAll('.p-menuitem-text').length).toBe(5);
}); });
}); });

View File

@ -1,12 +1,14 @@
<template> <template>
<nav class="p-breadcrumb p-component" aria-label="Breadcrumb"> <nav class="p-breadcrumb p-component">
<ul> <ol class="p-breadcrumb-list">
<BreadcrumbItem v-if="home" :item="home" class="p-breadcrumb-home" :template="$slots.item" :exact="exact" /> <BreadcrumbItem v-if="home" :item="home" class="p-breadcrumb-home" :template="$slots.item" :exact="exact" />
<template v-for="item of model" :key="item.label"> <template v-for="item of model" :key="item.label">
<li class="p-breadcrumb-chevron pi pi-chevron-right"></li> <li class="p-menuitem-separator">
<span class="pi pi-chevron-right" aria-hidden="true"></span>
</li>
<BreadcrumbItem :item="item" :template="$slots.item" :exact="exact" /> <BreadcrumbItem :item="item" :template="$slots.item" :exact="exact" />
</template> </template>
</ul> </ol>
</nav> </nav>
</template> </template>
@ -40,7 +42,7 @@ export default {
overflow-x: auto; overflow-x: auto;
} }
.p-breadcrumb ul { .p-breadcrumb .p-breadcrumb-list {
margin: 0; margin: 0;
padding: 0; padding: 0;
list-style-type: none; list-style-type: none;
@ -55,6 +57,13 @@ export default {
.p-breadcrumb .p-menuitem-link { .p-breadcrumb .p-menuitem-link {
text-decoration: none; text-decoration: none;
display: flex;
align-items: center;
}
.p-breadcrumb .p-menuitem-separator {
display: flex;
align-items: center;
} }
.p-breadcrumb::-webkit-scrollbar { .p-breadcrumb::-webkit-scrollbar {

View File

@ -1,13 +1,13 @@
<template> <template>
<li v-if="visible()" :class="containerClass(item)"> <li v-if="visible()" :class="containerClass()">
<template v-if="!template"> <template v-if="!template">
<router-link v-if="item.to" v-slot="{ navigate, href, isActive, isExactActive }" :to="item.to" custom> <router-link v-if="item.to" v-slot="{ navigate, href, isActive, isExactActive }" :to="item.to" custom>
<a :href="href" :class="linkClass({ isActive, isExactActive })" @click="onClick($event, navigate)"> <a :href="href" :class="linkClass({ isActive, isExactActive })" :aria-current="isCurrentUrl()" @click="onClick($event, navigate)">
<span v-if="item.icon" :class="iconClass"></span> <span v-if="item.icon" :class="iconClass"></span>
<span v-if="item.label" class="p-menuitem-text">{{ label() }}</span> <span v-if="item.label" class="p-menuitem-text">{{ label() }}</span>
</a> </a>
</router-link> </router-link>
<a v-else :href="item.url || '#'" :class="linkClass()" @click="onClick" :target="item.target"> <a v-else :href="item.url || '#'" :class="linkClass()" :target="item.target" :aria-current="isCurrentUrl()" @click="onClick">
<span v-if="item.icon" :class="iconClass"></span> <span v-if="item.icon" :class="iconClass"></span>
<span v-if="item.label" class="p-menuitem-text">{{ label() }}</span> <span v-if="item.label" class="p-menuitem-text">{{ label() }}</span>
</a> </a>
@ -37,8 +37,8 @@ export default {
navigate(event); navigate(event);
} }
}, },
containerClass(item) { containerClass() {
return [{ 'p-disabled': this.disabled(item) }, this.item.class]; return ['p-menuitem', { 'p-disabled': this.disabled() }, this.item.class];
}, },
linkClass(routerProps) { linkClass(routerProps) {
return [ return [
@ -52,11 +52,17 @@ export default {
visible() { visible() {
return typeof this.item.visible === 'function' ? this.item.visible() : this.item.visible !== false; return typeof this.item.visible === 'function' ? this.item.visible() : this.item.visible !== false;
}, },
disabled(item) { disabled() {
return typeof item.disabled === 'function' ? item.disabled() : item.disabled; return typeof this.item.disabled === 'function' ? this.item.disabled() : this.item.disabled;
}, },
label() { label() {
return typeof this.item.label === 'function' ? this.item.label() : this.item.label; return typeof this.item.label === 'function' ? this.item.label() : this.item.label;
},
isCurrentUrl() {
const { to, url } = this.item;
let lastPath = this.$router ? this.$router.currentRoute.path : '';
return to === lastPath || url === lastPath ? 'page' : undefined;
} }
}, },
computed: { computed: {

View File

@ -1,6 +1,6 @@
import { h } from 'vue'; import Button from '@/components/button/Button.vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import Button from '../button/Button.vue'; import { h } from 'vue';
describe('Button.vue', () => { describe('Button.vue', () => {
it('is Button element exist', () => { it('is Button element exist', () => {

View File

@ -16,10 +16,12 @@ export default {
name: 'Button', name: 'Button',
props: { props: {
label: { label: {
type: String type: String,
default: null
}, },
icon: { icon: {
type: String type: String,
default: null
}, },
iconPos: { iconPos: {
type: String, type: String,
@ -30,7 +32,8 @@ export default {
default: null default: null
}, },
badge: { badge: {
type: String type: String,
default: null
}, },
badgeClass: { badgeClass: {
type: String, type: String,

View File

@ -94,6 +94,26 @@ export interface CalendarProps {
* Default value is 'pi pi-calendar'. * Default value is 'pi pi-calendar'.
*/ */
icon?: string | undefined; icon?: string | undefined;
/**
* Icon to show in the previous button.
* Default value is 'pi pi-chevron-left'.
*/
previousIcon?: string | undefined;
/**
* Icon to show in the next button.
* Default value is 'pi pi-chevron-right'.
*/
nextIcon?: string | undefined;
/**
* Icon to show in each of the increment buttons.
* Default value is 'pi pi-chevron-up'.
*/
incrementIcon?: string | undefined;
/**
* Icon to show in each of the decrement buttons.
* Default value is 'pi pi-chevron-down'.
*/
decrementIcon?: string | undefined;
/** /**
* Number of months to display. * Number of months to display.
* Default value is 1. * Default value is 1.

View File

@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import PrimeVue from '../config/PrimeVue'; import PrimeVue from 'primevue/config';
import Calendar from './Calendar.vue'; import Calendar from './Calendar.vue';
describe('Calendar.vue', () => { describe('Calendar.vue', () => {
@ -18,6 +18,7 @@ describe('Calendar.vue', () => {
} }
}); });
}); });
it('should exist', async () => { it('should exist', async () => {
expect(wrapper.find('.p-calendar.p-component').exists()).toBe(true); expect(wrapper.find('.p-calendar.p-component').exists()).toBe(true);
expect(wrapper.find('.p-inputtext').exists()).toBe(true); expect(wrapper.find('.p-inputtext').exists()).toBe(true);
@ -37,11 +38,12 @@ describe('Calendar.vue', () => {
const event = { day: 8, month: 2, year: 2022, today: false, selectable: true }; const event = { day: 8, month: 2, year: 2022, today: false, selectable: true };
const onDateSelect = vi.spyOn(wrapper.vm, 'onDateSelect'); const onDateSelect = jest.spyOn(wrapper.vm, 'onDateSelect');
await wrapper.vm.onDateSelect({ currentTarget: { focus: () => {} } }, event); await wrapper.vm.onDateSelect({ currentTarget: { focus: () => {} } }, event);
expect(onDateSelect).toHaveBeenCalled(); expect(onDateSelect).toHaveBeenCalled();
}); });
it('should calculate the correct view date when in range mode', async () => { it('should calculate the correct view date when in range mode', async () => {
const dateOne = new Date(); const dateOne = new Date();
const dateTwo = new Date(); const dateTwo = new Date();
@ -51,4 +53,28 @@ describe('Calendar.vue', () => {
expect(wrapper.vm.viewDate).toEqual(dateTwo); expect(wrapper.vm.viewDate).toEqual(dateTwo);
}); });
it('should respect custom icon settings', async () => {
await wrapper.setProps({
previousIcon: 'pi pi-discord',
nextIcon: 'pi pi-facebook',
incrementIcon: 'pi pi-linkedin',
decrementIcon: 'pi pi-microsoft',
inline: true
});
const previousIcon = wrapper.find('.p-datepicker-prev-icon');
const nextIcon = wrapper.find('.p-datepicker-next-icon');
expect(previousIcon.classes()).toContain('pi-discord');
expect(nextIcon.classes()).toContain('pi-facebook');
await wrapper.setProps({
timeOnly: true,
hourFormat: '12'
});
expect(wrapper.findAll('.pi-linkedin')).toHaveLength(3);
expect(wrapper.findAll('.pi-microsoft')).toHaveLength(3);
});
}); });

View File

@ -9,6 +9,7 @@
:class="['p-inputtext p-component', inputClass]" :class="['p-inputtext p-component', inputClass]"
:style="inputStyle" :style="inputStyle"
:placeholder="placeholder" :placeholder="placeholder"
autocomplete="off"
aria-autocomplete="none" aria-autocomplete="none"
aria-haspopup="dialog" aria-haspopup="dialog"
:aria-expanded="overlayVisible" :aria-expanded="overlayVisible"
@ -17,9 +18,10 @@
:aria-label="ariaLabel" :aria-label="ariaLabel"
inputmode="none" inputmode="none"
:disabled="disabled" :disabled="disabled"
:readonly="!manualInput" :readonly="!manualInput || readonly"
:tabindex="0" :tabindex="0"
@input="onInput" @input="onInput"
@click="onInputClick"
@focus="onFocus" @focus="onFocus"
@blur="onBlur" @blur="onBlur"
@keydown="onKeyDown" @keydown="onKeyDown"
@ -68,7 +70,7 @@
:disabled="disabled" :disabled="disabled"
:aria-label="currentView === 'year' ? $primevue.config.locale.prevDecade : currentView === 'month' ? $primevue.config.locale.prevYear : $primevue.config.locale.prevMonth" :aria-label="currentView === 'year' ? $primevue.config.locale.prevDecade : currentView === 'month' ? $primevue.config.locale.prevYear : $primevue.config.locale.prevMonth"
> >
<span class="p-datepicker-prev-icon pi pi-chevron-left"></span> <span :class="['p-datepicker-prev-icon', previousIcon]" />
</button> </button>
<div class="p-datepicker-title"> <div class="p-datepicker-title">
<button <button
@ -107,7 +109,7 @@
:disabled="disabled" :disabled="disabled"
:aria-label="currentView === 'year' ? $primevue.config.locale.nextDecade : currentView === 'month' ? $primevue.config.locale.nextYear : $primevue.config.locale.nextMonth" :aria-label="currentView === 'year' ? $primevue.config.locale.nextDecade : currentView === 'month' ? $primevue.config.locale.nextYear : $primevue.config.locale.nextMonth"
> >
<span class="p-datepicker-next-icon pi pi-chevron-right"></span> <span :class="['p-datepicker-next-icon', nextIcon]" />
</button> </button>
</div> </div>
<div v-if="currentView === 'date'" class="p-datepicker-calendar-container"> <div v-if="currentView === 'date'" class="p-datepicker-calendar-container">
@ -184,7 +186,7 @@
@keyup.space="onTimePickerElementMouseUp($event)" @keyup.space="onTimePickerElementMouseUp($event)"
type="button" type="button"
> >
<span class="pi pi-chevron-up"></span> <span :class="incrementIcon" />
</button> </button>
<span>{{ formattedCurrentHour }}</span> <span>{{ formattedCurrentHour }}</span>
<button <button
@ -201,7 +203,7 @@
@keyup.space="onTimePickerElementMouseUp($event)" @keyup.space="onTimePickerElementMouseUp($event)"
type="button" type="button"
> >
<span class="pi pi-chevron-down"></span> <span :class="decrementIcon" />
</button> </button>
</div> </div>
<div class="p-separator"> <div class="p-separator">
@ -223,7 +225,7 @@
@keyup.space="onTimePickerElementMouseUp($event)" @keyup.space="onTimePickerElementMouseUp($event)"
type="button" type="button"
> >
<span class="pi pi-chevron-up"></span> <span :class="incrementIcon" />
</button> </button>
<span>{{ formattedCurrentMinute }}</span> <span>{{ formattedCurrentMinute }}</span>
<button <button
@ -241,7 +243,7 @@
@keyup.space="onTimePickerElementMouseUp($event)" @keyup.space="onTimePickerElementMouseUp($event)"
type="button" type="button"
> >
<span class="pi pi-chevron-down"></span> <span :class="decrementIcon" />
</button> </button>
</div> </div>
<div v-if="showSeconds" class="p-separator"> <div v-if="showSeconds" class="p-separator">
@ -263,7 +265,7 @@
@keyup.space="onTimePickerElementMouseUp($event)" @keyup.space="onTimePickerElementMouseUp($event)"
type="button" type="button"
> >
<span class="pi pi-chevron-up"></span> <span :class="incrementIcon" />
</button> </button>
<span>{{ formattedCurrentSecond }}</span> <span>{{ formattedCurrentSecond }}</span>
<button <button
@ -281,7 +283,7 @@
@keyup.space="onTimePickerElementMouseUp($event)" @keyup.space="onTimePickerElementMouseUp($event)"
type="button" type="button"
> >
<span class="pi pi-chevron-down"></span> <span :class="decrementIcon" />
</button> </button>
</div> </div>
<div v-if="hourFormat == '12'" class="p-separator"> <div v-if="hourFormat == '12'" class="p-separator">
@ -289,11 +291,11 @@
</div> </div>
<div v-if="hourFormat == '12'" class="p-ampm-picker"> <div v-if="hourFormat == '12'" class="p-ampm-picker">
<button v-ripple class="p-link" :aria-label="$primevue.config.locale.am" @click="toggleAMPM($event)" type="button" :disabled="disabled"> <button v-ripple class="p-link" :aria-label="$primevue.config.locale.am" @click="toggleAMPM($event)" type="button" :disabled="disabled">
<span class="pi pi-chevron-up"></span> <span :class="incrementIcon" />
</button> </button>
<span>{{ pm ? 'PM' : 'AM' }}</span> <span>{{ pm ? $primevue.config.locale.pm : $primevue.config.locale.am }}</span>
<button v-ripple class="p-link" :aria-label="$primevue.config.locale.pm" @click="toggleAMPM($event)" type="button" :disabled="disabled"> <button v-ripple class="p-link" :aria-label="$primevue.config.locale.pm" @click="toggleAMPM($event)" type="button" :disabled="disabled">
<span class="pi pi-chevron-down"></span> <span :class="decrementIcon" />
</button> </button>
</div> </div>
</div> </div>
@ -309,11 +311,11 @@
</template> </template>
<script> <script>
import { ConnectedOverlayScrollHandler, DomHandler, ZIndexUtils, UniqueComponentId } from 'primevue/utils';
import OverlayEventBus from 'primevue/overlayeventbus';
import Button from 'primevue/button'; import Button from 'primevue/button';
import Ripple from 'primevue/ripple'; import OverlayEventBus from 'primevue/overlayeventbus';
import Portal from 'primevue/portal'; import Portal from 'primevue/portal';
import Ripple from 'primevue/ripple';
import { ConnectedOverlayScrollHandler, DomHandler, UniqueComponentId, ZIndexUtils } from 'primevue/utils';
export default { export default {
name: 'Calendar', name: 'Calendar',
@ -348,6 +350,22 @@ export default {
type: String, type: String,
default: 'pi pi-calendar' default: 'pi pi-calendar'
}, },
previousIcon: {
type: String,
default: 'pi pi-chevron-left'
},
nextIcon: {
type: String,
default: 'pi pi-chevron-right'
},
incrementIcon: {
type: String,
default: 'pi pi-chevron-up'
},
decrementIcon: {
type: String,
default: 'pi pi-chevron-down'
},
numberOfMonths: { numberOfMonths: {
type: Number, type: Number,
default: 1 default: 1
@ -1094,7 +1112,10 @@ export default {
if (this.isSingleSelection() && (!this.showTime || this.hideOnDateTimeSelect)) { if (this.isSingleSelection() && (!this.showTime || this.hideOnDateTimeSelect)) {
setTimeout(() => { setTimeout(() => {
this.input.focus(); if (this.input) {
this.input.focus();
}
this.overlayVisible = false; this.overlayVisible = false;
}, 150); }, 150);
} }
@ -1347,7 +1368,7 @@ export default {
} }
if (this.hourFormat === '12') { if (this.hourFormat === '12') {
output += date.getHours() > 11 ? ' PM' : ' AM'; output += date.getHours() > 11 ? ` ${this.$primevue.config.locale.pm}` : ` ${this.$primevue.config.locale.am}`;
} }
return output; return output;
@ -1598,6 +1619,10 @@ export default {
setTimeout(() => (this.timePickerChange = false), 0); setTimeout(() => (this.timePickerChange = false), 0);
}, },
toggleAMPM(event) { toggleAMPM(event) {
const validHour = this.validateTime(this.currentHour, this.currentMinute, this.currentSecond, !this.pm);
if (!validHour && (this.maxDate || this.minDate)) return;
this.pm = !this.pm; this.pm = !this.pm;
this.updateModelTime(); this.updateModelTime();
event.preventDefault(); event.preventDefault();
@ -1758,7 +1783,7 @@ export default {
throw 'Invalid Time'; throw 'Invalid Time';
} }
this.pm = ampm === 'PM' || ampm === 'pm'; this.pm = ampm === this.$primevue.config.locale.am || ampm === this.$primevue.config.locale.am.toLowerCase();
let time = this.parseTime(timeString); let time = this.parseTime(timeString);
value.setHours(time.hour); value.setHours(time.hour);
@ -1982,21 +2007,33 @@ export default {
const cellContent = event.currentTarget; const cellContent = event.currentTarget;
const cell = cellContent.parentElement; const cell = cellContent.parentElement;
const cellIndex = DomHandler.index(cell);
switch (event.code) { switch (event.code) {
case 'ArrowDown': { case 'ArrowDown': {
cellContent.tabIndex = '-1'; cellContent.tabIndex = '-1';
let cellIndex = DomHandler.index(cell);
let nextRow = cell.parentElement.nextElementSibling; let nextRow = cell.parentElement.nextElementSibling;
if (nextRow) { if (nextRow) {
let focusCell = nextRow.children[cellIndex].children[0]; let tableRowIndex = DomHandler.index(cell.parentElement);
const tableRows = Array.from(cell.parentElement.parentElement.children);
const nextTableRows = tableRows.slice(tableRowIndex + 1);
if (DomHandler.hasClass(focusCell, 'p-disabled')) { let hasNextFocusableDate = nextTableRows.find((el) => {
let focusCell = el.children[cellIndex].children[0];
return !DomHandler.hasClass(focusCell, 'p-disabled');
});
if (hasNextFocusableDate) {
let focusCell = hasNextFocusableDate.children[cellIndex].children[0];
focusCell.tabIndex = '0';
focusCell.focus();
} else {
this.navigationState = { backward: false }; this.navigationState = { backward: false };
this.navForward(event); this.navForward(event);
} else {
nextRow.children[cellIndex].children[0].tabIndex = '0';
nextRow.children[cellIndex].children[0].focus();
} }
} else { } else {
this.navigationState = { backward: false }; this.navigationState = { backward: false };
@ -2009,18 +2046,27 @@ export default {
case 'ArrowUp': { case 'ArrowUp': {
cellContent.tabIndex = '-1'; cellContent.tabIndex = '-1';
let cellIndex = DomHandler.index(cell);
let prevRow = cell.parentElement.previousElementSibling; let prevRow = cell.parentElement.previousElementSibling;
if (prevRow) { if (prevRow) {
let focusCell = prevRow.children[cellIndex].children[0]; let tableRowIndex = DomHandler.index(cell.parentElement);
const tableRows = Array.from(cell.parentElement.parentElement.children);
const prevTableRows = tableRows.slice(0, tableRowIndex).reverse();
let hasNextFocusableDate = prevTableRows.find((el) => {
let focusCell = el.children[cellIndex].children[0];
return !DomHandler.hasClass(focusCell, 'p-disabled');
});
if (hasNextFocusableDate) {
let focusCell = hasNextFocusableDate.children[cellIndex].children[0];
if (DomHandler.hasClass(focusCell, 'p-disabled')) {
this.navigationState = { backward: true };
this.navBackward(event);
} else {
focusCell.tabIndex = '0'; focusCell.tabIndex = '0';
focusCell.focus(); focusCell.focus();
} else {
this.navigationState = { backward: true };
this.navBackward(event);
} }
} else { } else {
this.navigationState = { backward: true }; this.navigationState = { backward: true };
@ -2036,13 +2082,22 @@ export default {
let prevCell = cell.previousElementSibling; let prevCell = cell.previousElementSibling;
if (prevCell) { if (prevCell) {
let focusCell = prevCell.children[0]; const cells = Array.from(cell.parentElement.children);
const prevCells = cells.slice(0, cellIndex).reverse();
let hasNextFocusableDate = prevCells.find((el) => {
let focusCell = el.children[0];
return !DomHandler.hasClass(focusCell, 'p-disabled');
});
if (hasNextFocusableDate) {
let focusCell = hasNextFocusableDate.children[0];
if (DomHandler.hasClass(focusCell, 'p-disabled')) {
this.navigateToMonth(event, true, groupIndex);
} else {
focusCell.tabIndex = '0'; focusCell.tabIndex = '0';
focusCell.focus(); focusCell.focus();
} else {
this.navigateToMonth(event, true, groupIndex);
} }
} else { } else {
this.navigateToMonth(event, true, groupIndex); this.navigateToMonth(event, true, groupIndex);
@ -2057,13 +2112,21 @@ export default {
let nextCell = cell.nextElementSibling; let nextCell = cell.nextElementSibling;
if (nextCell) { if (nextCell) {
let focusCell = nextCell.children[0]; const cells = Array.from(cell.parentElement.children);
const nextCells = cells.slice(cellIndex + 1);
let hasNextFocusableDate = nextCells.find((el) => {
let focusCell = el.children[0];
return !DomHandler.hasClass(focusCell, 'p-disabled');
});
if (hasNextFocusableDate) {
let focusCell = hasNextFocusableDate.children[0];
if (DomHandler.hasClass(focusCell, 'p-disabled')) {
this.navigateToMonth(event, false, groupIndex);
} else {
focusCell.tabIndex = '0'; focusCell.tabIndex = '0';
focusCell.focus(); focusCell.focus();
} else {
this.navigateToMonth(event, false, groupIndex);
} }
} else { } else {
this.navigateToMonth(event, false, groupIndex); this.navigateToMonth(event, false, groupIndex);
@ -2514,6 +2577,11 @@ export default {
this.$emit('input', event); this.$emit('input', event);
}, },
onInputClick() {
if (this.showOnFocus && this.isEnabled() && !this.overlayVisible) {
this.overlayVisible = true;
}
},
onFocus(event) { onFocus(event) {
if (this.showOnFocus && this.isEnabled()) { if (this.showOnFocus && this.isEnabled()) {
this.overlayVisible = true; this.overlayVisible = true;
@ -2635,7 +2703,7 @@ export default {
if (propValue && Array.isArray(propValue)) { if (propValue && Array.isArray(propValue)) {
if (this.isRangeSelection()) { if (this.isRangeSelection()) {
propValue = propValue[1] || propValue[0]; propValue = this.inline ? propValue[0] : propValue[1] || propValue[0];
} else if (this.isMultipleSelection()) { } else if (this.isMultipleSelection()) {
propValue = propValue[propValue.length - 1]; propValue = propValue[propValue.length - 1];
} }

View File

@ -1,4 +1,4 @@
import { VNode } from 'vue'; import { ButtonHTMLAttributes, VNode } from 'vue';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers'; import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
type CarouselOrientationType = 'horizontal' | 'vertical' | undefined; type CarouselOrientationType = 'horizontal' | 'vertical' | undefined;
@ -85,6 +85,14 @@ export interface CarouselProps {
* Default value is true. * Default value is true.
*/ */
showIndicators?: boolean | undefined; showIndicators?: boolean | undefined;
/**
* Uses to pass all properties of the HTMLButtonElement to the previous navigation button.
*/
prevButtonProps?: ButtonHTMLAttributes | undefined;
/**
* Uses to pass all properties of the HTMLButtonElement to the next navigation button.
*/
nextButtonProps?: ButtonHTMLAttributes | undefined;
} }
export interface CarouselSlots { export interface CarouselSlots {

View File

@ -1,9 +1,13 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import PrimeVue from 'primevue/config';
import Carousel from './Carousel.vue'; import Carousel from './Carousel.vue';
describe('Carousel.vue', () => { describe('Carousel.vue', () => {
it('should exist', async () => { it('should exist', async () => {
const wrapper = mount(Carousel, { const wrapper = mount(Carousel, {
global: {
plugins: [PrimeVue]
},
props: { props: {
value: [ value: [
{ {

View File

@ -1,11 +1,20 @@
<template> <template>
<div :id="id" :class="['p-carousel p-component', { 'p-carousel-vertical': isVertical(), 'p-carousel-horizontal': !isVertical() }]"> <div :id="id" :class="['p-carousel p-component', { 'p-carousel-vertical': isVertical(), 'p-carousel-horizontal': !isVertical() }]" role="region">
<div v-if="$slots.header" class="p-carousel-header"> <div v-if="$slots.header" class="p-carousel-header">
<slot name="header"></slot> <slot name="header"></slot>
</div> </div>
<div :class="contentClasses"> <div :class="contentClasses">
<div :class="containerClasses"> <div :class="containerClasses" :aria-live="allowAutoplay ? 'polite' : 'off'">
<button v-if="showNavigators" v-ripple :class="['p-carousel-prev p-link', { 'p-disabled': backwardIsDisabled }]" :disabled="backwardIsDisabled" @click="navBackward" type="button"> <button
v-if="showNavigators"
v-ripple
type="button"
:class="['p-carousel-prev p-link', { 'p-disabled': backwardIsDisabled }]"
:disabled="backwardIsDisabled"
:aria-label="ariaPrevButtonLabel"
@click="navBackward"
v-bind="prevButtonProps"
>
<span :class="['p-carousel-prev-icon pi', { 'pi-chevron-left': !isVertical(), 'pi-chevron-up': isVertical() }]"></span> <span :class="['p-carousel-prev-icon pi', { 'pi-chevron-left': !isVertical(), 'pi-chevron-up': isVertical() }]"></span>
</button> </button>
@ -27,6 +36,10 @@
v-for="(item, index) of value" v-for="(item, index) of value"
:key="index" :key="index"
:class="['p-carousel-item', { 'p-carousel-item-active': firstIndex() <= index && lastIndex() >= index, 'p-carousel-item-start': firstIndex() === index, 'p-carousel-item-end': lastIndex() === index }]" :class="['p-carousel-item', { 'p-carousel-item-active': firstIndex() <= index && lastIndex() >= index, 'p-carousel-item-start': firstIndex() === index, 'p-carousel-item-end': lastIndex() === index }]"
role="group"
:aria-hidden="firstIndex() > index || lastIndex() < index ? true : undefined"
:aria-label="ariaSlideNumber(index)"
:aria-roledescription="ariaSlideLabel"
> >
<slot name="item" :data="item" :index="index"></slot> <slot name="item" :data="item" :index="index"></slot>
</div> </div>
@ -42,13 +55,22 @@
</div> </div>
</div> </div>
<button v-if="showNavigators" v-ripple :class="['p-carousel-next p-link', { 'p-disabled': forwardIsDisabled }]" :disabled="forwardIsDisabled" @click="navForward" type="button"> <button
v-if="showNavigators"
v-ripple
type="button"
:class="['p-carousel-next p-link', { 'p-disabled': forwardIsDisabled }]"
:disabled="forwardIsDisabled"
:aria-label="ariaNextButtonLabel"
@click="navForward"
v-bind="nextButtonProps"
>
<span :class="['p-carousel-prev-icon pi', { 'pi-chevron-right': !isVertical(), 'pi-chevron-down': isVertical() }]"></span> <span :class="['p-carousel-prev-icon pi', { 'pi-chevron-right': !isVertical(), 'pi-chevron-down': isVertical() }]"></span>
</button> </button>
</div> </div>
<ul v-if="totalIndicators >= 0 && showIndicators" :class="indicatorsContentClasses"> <ul v-if="totalIndicators >= 0 && showIndicators" ref="indicatorContent" :class="indicatorsContentClasses" @keydown="onIndicatorKeydown">
<li v-for="(indicator, i) of totalIndicators" :key="'p-carousel-indicator-' + i.toString()" :class="['p-carousel-indicator', { 'p-highlight': d_page === i }]"> <li v-for="(indicator, i) of totalIndicators" :key="'p-carousel-indicator-' + i.toString()" :class="['p-carousel-indicator', { 'p-highlight': d_page === i }]">
<button class="p-link" @click="onIndicatorClick($event, i)" type="button" /> <button class="p-link" type="button" :tabindex="d_page === i ? '0' : '-1'" :aria-label="ariaPageLabel(i + 1)" :aria-current="d_page === i ? 'page' : undefined" @click="onIndicatorClick($event, i)" />
</li> </li>
</ul> </ul>
</div> </div>
@ -59,9 +81,8 @@
</template> </template>
<script> <script>
import { UniqueComponentId } from 'primevue/utils';
import { DomHandler } from 'primevue/utils';
import Ripple from 'primevue/ripple'; import Ripple from 'primevue/ripple';
import { DomHandler, UniqueComponentId } from 'primevue/utils';
export default { export default {
name: 'Carousel', name: 'Carousel',
@ -107,8 +128,17 @@ export default {
showIndicators: { showIndicators: {
type: Boolean, type: Boolean,
default: true default: true
},
prevButtonProps: {
type: null,
default: null
},
nextButtonProps: {
type: null,
default: null
} }
}, },
isRemainingItemsAdded: false,
data() { data() {
return { return {
id: UniqueComponentId(), id: UniqueComponentId(),
@ -125,7 +155,6 @@ export default {
swipeThreshold: 20 swipeThreshold: 20
}; };
}, },
isRemainingItemsAdded: false,
watch: { watch: {
page(newValue) { page(newValue) {
this.d_page = newValue; this.d_page = newValue;
@ -419,6 +448,84 @@ export default {
} }
} }
}, },
onIndicatorKeydown(event) {
switch (event.code) {
case 'ArrowRight':
this.onRightKey();
break;
case 'ArrowLeft':
this.onLeftKey();
break;
case 'Home':
this.onHomeKey();
event.preventDefault();
break;
case 'End':
this.onEndKey();
event.preventDefault();
break;
case 'ArrowUp':
case 'ArrowDown':
event.preventDefault();
break;
case 'Tab':
this.onTabKey();
break;
default:
break;
}
},
onRightKey() {
const indicators = [...DomHandler.find(this.$refs.indicatorContent, '.p-carousel-indicator')];
const activeIndex = this.findFocusedIndicatorIndex();
this.changedFocusedIndicator(activeIndex, activeIndex + 1 === indicators.length ? indicators.length - 1 : activeIndex + 1);
},
onLeftKey() {
const activeIndex = this.findFocusedIndicatorIndex();
this.changedFocusedIndicator(activeIndex, activeIndex - 1 <= 0 ? 0 : activeIndex - 1);
},
onHomeKey() {
const activeIndex = this.findFocusedIndicatorIndex();
this.changedFocusedIndicator(activeIndex, 0);
},
onEndKey() {
const indicators = [...DomHandler.find(this.$refs.indicatorContent, '.p-carousel-indicator')];
const activeIndex = this.findFocusedIndicatorIndex();
this.changedFocusedIndicator(activeIndex, indicators.length - 1);
},
onTabKey() {
const indicators = [...DomHandler.find(this.$refs.indicatorContent, '.p-carousel-indicator')];
const highlightedIndex = indicators.findIndex((ind) => DomHandler.hasClass(ind, 'p-highlight'));
const activeIndicator = DomHandler.findSingle(this.$refs.indicatorContent, '.p-carousel-indicator > button[tabindex="0"]');
const activeIndex = indicators.findIndex((ind) => ind === activeIndicator.parentElement);
indicators[activeIndex].children[0].tabIndex = '-1';
indicators[highlightedIndex].children[0].tabIndex = '0';
},
findFocusedIndicatorIndex() {
const indicators = [...DomHandler.find(this.$refs.indicatorContent, '.p-carousel-indicator')];
const activeIndicator = DomHandler.findSingle(this.$refs.indicatorContent, '.p-carousel-indicator > button[tabindex="0"]');
return indicators.findIndex((ind) => ind === activeIndicator.parentElement);
},
changedFocusedIndicator(prevInd, nextInd) {
const indicators = [...DomHandler.find(this.$refs.indicatorContent, '.p-carousel-indicator')];
indicators[prevInd].children[0].tabIndex = '-1';
indicators[nextInd].children[0].tabIndex = '0';
indicators[nextInd].children[0].focus();
},
bindDocumentListeners() { bindDocumentListeners() {
if (!this.documentResizeListener) { if (!this.documentResizeListener) {
this.documentResizeListener = (e) => { this.documentResizeListener = (e) => {
@ -507,6 +614,12 @@ export default {
}, },
lastIndex() { lastIndex() {
return this.firstIndex() + this.d_numVisible - 1; return this.firstIndex() + this.d_numVisible - 1;
},
ariaSlideNumber(value) {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.slideNumber.replace(/{slideNumber}/g, value) : undefined;
},
ariaPageLabel(value) {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.pageLabel.replace(/{page}/g, value) : undefined;
} }
}, },
computed: { computed: {
@ -527,6 +640,15 @@ export default {
}, },
indicatorsContentClasses() { indicatorsContentClasses() {
return ['p-carousel-indicators p-reset', this.indicatorsContentClass]; return ['p-carousel-indicators p-reset', this.indicatorsContentClass];
},
ariaSlideLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.slide : undefined;
},
ariaPrevButtonLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.prevPageLabel : undefined;
},
ariaNextButtonLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.nextPageLabel : undefined;
} }
}, },
directives: { directives: {

View File

@ -111,11 +111,21 @@ export interface CascadeSelectProps {
* Whether the dropdown is in loading state. * Whether the dropdown is in loading state.
*/ */
loading?: boolean | undefined; loading?: boolean | undefined;
/**
* Icon to display in the dropdown.
* Default value is 'pi pi-chevron-down'.
*/
dropdownIcon?: string | undefined;
/** /**
* Icon to display in loading state. * Icon to display in loading state.
* Default value is 'pi pi-spinner pi-spin'. * Default value is 'pi pi-spinner pi-spin'.
*/ */
loadingIcon?: string | undefined; loadingIcon?: string | undefined;
/**
* Icon to display in the option group.
* Default value is 'pi pi-angle-right'.
*/
optionGroupIcon?: string | undefined;
/** /**
* Whether to focus on the first visible or selected element when the overlay panel is shown. * Whether to focus on the first visible or selected element when the overlay panel is shown.
* Default value is true. * Default value is true.

View File

@ -1,5 +1,6 @@
import { nextTick } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import PrimeVue from '../config/PrimeVue'; import PrimeVue from 'primevue/config';
import CascadeSelect from './CascadeSelect.vue'; import CascadeSelect from './CascadeSelect.vue';
describe('CascadeSelect.vue', () => { describe('CascadeSelect.vue', () => {
@ -96,7 +97,7 @@ describe('CascadeSelect.vue', () => {
}); });
}); });
it('should exist', async () => { it('should exist', () => {
expect(wrapper.find('.p-cascadeselect.p-component').exists()).toBe(true); expect(wrapper.find('.p-cascadeselect.p-component').exists()).toBe(true);
}); });
@ -120,4 +121,19 @@ describe('CascadeSelect.vue', () => {
expect(sublist.findAll('.p-cascadeselect-item.p-cascadeselect-item-group').length).toBe(2); expect(sublist.findAll('.p-cascadeselect-item.p-cascadeselect-item-group').length).toBe(2);
expect(sublist.findAll('.p-cascadeselect-item-text')[0].text()).toBe('New South Wales'); expect(sublist.findAll('.p-cascadeselect-item-text')[0].text()).toBe('New South Wales');
}); });
it('should accept custom icons', async () => {
wrapper.setProps({
dropdownIcon: 'pi pi-discord',
optionGroupIcon: 'pi pi-bell'
});
await nextTick();
expect(wrapper.find('.p-cascadeselect-trigger-icon').classes()).toContain('pi-discord');
await wrapper.trigger('click');
expect(wrapper.find('.p-cascadeselect-group-icon').classes()).toContain('pi-bell');
});
}); });

View File

@ -54,15 +54,16 @@
:optionLabel="optionLabel" :optionLabel="optionLabel"
:optionValue="optionValue" :optionValue="optionValue"
:optionDisabled="optionDisabled" :optionDisabled="optionDisabled"
:optionGroupIcon="optionGroupIcon"
:optionGroupLabel="optionGroupLabel" :optionGroupLabel="optionGroupLabel"
:optionGroupChildren="optionGroupChildren" :optionGroupChildren="optionGroupChildren"
@option-change="onOptionChange" @option-change="onOptionChange"
/> />
<span role="status" aria-live="polite" class="p-hidden-accessible">
{{ selectedMessageText }}
</span>
</div> </div>
<span role="status" aria-live="polite" class="p-hidden-accessible">
{{ selectedMessageText }}
</span>
</div> </div>
</transition> </transition>
</Portal> </Portal>
@ -70,10 +71,10 @@
</template> </template>
<script> <script>
import { ConnectedOverlayScrollHandler, ObjectUtils, DomHandler, ZIndexUtils, UniqueComponentId } from 'primevue/utils';
import OverlayEventBus from 'primevue/overlayeventbus'; import OverlayEventBus from 'primevue/overlayeventbus';
import CascadeSelectSub from './CascadeSelectSub.vue';
import Portal from 'primevue/portal'; import Portal from 'primevue/portal';
import { ConnectedOverlayScrollHandler, DomHandler, ObjectUtils, UniqueComponentId, ZIndexUtils } from 'primevue/utils';
import CascadeSelectSub from './CascadeSelectSub.vue';
export default { export default {
name: 'CascadeSelect', name: 'CascadeSelect',
@ -125,10 +126,18 @@ export default {
type: Boolean, type: Boolean,
default: false default: false
}, },
dropdownIcon: {
type: String,
default: 'pi pi-chevron-down'
},
loadingIcon: { loadingIcon: {
type: String, type: String,
default: 'pi pi-spinner pi-spin' default: 'pi pi-spinner pi-spin'
}, },
optionGroupIcon: {
type: String,
default: 'pi pi-angle-right'
},
autoOptionFocus: { autoOptionFocus: {
type: Boolean, type: Boolean,
default: true default: true
@ -765,7 +774,7 @@ export default {
}, },
labelClass() { labelClass() {
return [ return [
'p-cascadeselect-label', 'p-cascadeselect-label p-inputtext',
{ {
'p-placeholder': this.label === this.placeholder, 'p-placeholder': this.label === this.placeholder,
'p-cascadeselect-label-empty': !this.$slots['value'] && (this.label === 'p-emptylabel' || this.label.length === 0) 'p-cascadeselect-label-empty': !this.$slots['value'] && (this.label === 'p-emptylabel' || this.label.length === 0)
@ -783,7 +792,7 @@ 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 : this.dropdownIcon];
}, },
hasSelectedOption() { hasSelectedOption() {
return ObjectUtils.isNotEmpty(this.modelValue); return ObjectUtils.isNotEmpty(this.modelValue);

View File

@ -3,27 +3,19 @@
<template v-for="(processedOption, index) of options" :key="getOptionLabelToRender(processedOption)"> <template v-for="(processedOption, index) of options" :key="getOptionLabelToRender(processedOption)">
<li <li
:id="getOptionId(processedOption)" :id="getOptionId(processedOption)"
:class="[ :class="getOptionClass(processedOption)"
'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)
}
]"
role="treeitem" role="treeitem"
:aria-label="getOptionLabelToRender(processedOption)" :aria-label="getOptionLabelToRender(processedOption)"
:aria-selected="isOptionGroup(processedOption) ? undefined : isOptionSelected(processedOption)" :aria-selected="isOptionGroup(processedOption) ? undefined : isOptionSelected(processedOption)"
:aria-expanded="isOptionGroup(processedOption) ? isOptionActive(processedOption) : undefined" :aria-expanded="isOptionGroup(processedOption) ? isOptionActive(processedOption) : undefined"
:aria-setsize="processedOption.length"
:aria-posinset="index + 1"
:aria-level="level + 1" :aria-level="level + 1"
:aria-setsize="options.length"
:aria-posinset="index + 1"
> >
<div v-ripple class="p-cascadeselect-item-content" @click="onOptionClick($event, processedOption)"> <div v-ripple class="p-cascadeselect-item-content" @click="onOptionClick($event, processedOption)">
<component v-if="templates['option']" :is="templates['option']" :option="processedOption.option" /> <component v-if="templates['option']" :is="templates['option']" :option="processedOption.option" />
<span v-else class="p-cascadeselect-item-text">{{ getOptionLabelToRender(processedOption) }}</span> <span v-else class="p-cascadeselect-item-text">{{ getOptionLabelToRender(processedOption) }}</span>
<span v-if="isOptionGroup(processedOption)" class="p-cascadeselect-group-icon pi pi-angle-right" aria-hidden="true"></span> <span v-if="isOptionGroup(processedOption)" :class="['p-cascadeselect-group-icon', optionGroupIcon]" aria-hidden="true"></span>
</div> </div>
<CascadeSelectSub <CascadeSelectSub
v-if="isOptionGroup(processedOption) && isOptionActive(processedOption)" v-if="isOptionGroup(processedOption) && isOptionActive(processedOption)"
@ -38,6 +30,7 @@
:optionLabel="optionLabel" :optionLabel="optionLabel"
:optionValue="optionValue" :optionValue="optionValue"
:optionDisabled="optionDisabled" :optionDisabled="optionDisabled"
:optionGroupIcon="optionGroupIcon"
:optionGroupLabel="optionGroupLabel" :optionGroupLabel="optionGroupLabel"
:optionGroupChildren="optionGroupChildren" :optionGroupChildren="optionGroupChildren"
@option-change="onOptionChange" @option-change="onOptionChange"
@ -48,8 +41,8 @@
</template> </template>
<script> <script>
import { ObjectUtils, DomHandler } from 'primevue/utils';
import Ripple from 'primevue/ripple'; import Ripple from 'primevue/ripple';
import { DomHandler, ObjectUtils } from 'primevue/utils';
export default { export default {
name: 'CascadeSelectSub', name: 'CascadeSelectSub',
@ -61,6 +54,7 @@ export default {
optionLabel: String, optionLabel: String,
optionValue: String, optionValue: String,
optionDisabled: null, optionDisabled: null,
optionGroupIcon: String,
optionGroupLabel: String, optionGroupLabel: String,
optionGroupChildren: Array, optionGroupChildren: Array,
activeOptionPath: Array, activeOptionPath: Array,
@ -122,6 +116,17 @@ export default {
if (parseInt(containerOffset.left, 10) + itemOuterWidth + sublistWidth > viewport.width - DomHandler.calculateScrollbarWidth()) { if (parseInt(containerOffset.left, 10) + itemOuterWidth + sublistWidth > viewport.width - DomHandler.calculateScrollbarWidth()) {
this.$el.style.left = '-100%'; this.$el.style.left = '-100%';
} }
},
getOptionClass(processedOption) {
return [
'p-cascadeselect-item',
{
'p-cascadeselect-item-group': this.isOptionGroup(processedOption),
'p-cascadeselect-item-active p-highlight': this.isOptionActive(processedOption),
'p-focus': this.isOptionFocused(processedOption),
'p-disabled': this.isOptionDisabled(processedOption)
}
];
} }
}, },
directives: { directives: {

View File

@ -1,3 +1,4 @@
import { CanvasHTMLAttributes } from 'vue';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers'; import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
export interface ChartSelectEvent { export interface ChartSelectEvent {
@ -42,6 +43,10 @@ export interface ChartProps {
* Default value is 150. * Default value is 150.
*/ */
height?: number | undefined; height?: number | undefined;
/**
* Uses to pass all properties of the CanvasHTMLAttributes to canvas element inside the component.
*/
canvasProps?: CanvasHTMLAttributes | undefined;
} }
export interface ChartSlots {} export interface ChartSlots {}

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="p-chart"> <div class="p-chart">
<canvas ref="canvas" :width="width" :height="height" @click="onCanvasClick($event)"></canvas> <canvas ref="canvas" :width="width" :height="height" @click="onCanvasClick($event)" v-bind="canvasProps"></canvas>
</div> </div>
</template> </template>
@ -20,6 +20,10 @@ export default {
height: { height: {
type: Number, type: Number,
default: 150 default: 150
},
canvasProps: {
type: null,
default: null
} }
}, },
chart: null, chart: null,

View File

@ -1,11 +1,11 @@
<template> <template>
<div v-if="visible" :class="containerClass"> <div v-if="visible" :class="containerClass" :aria-label="label">
<slot> <slot>
<img v-if="image" :src="image" /> <img v-if="image" :src="image" />
<span v-else-if="icon" :class="iconClass"></span> <span v-else-if="icon" :class="iconClass"></span>
<div v-if="label" class="p-chip-text">{{ label }}</div> <div v-if="label" class="p-chip-text">{{ label }}</div>
</slot> </slot>
<span v-if="removable" tabindex="0" :class="removeIconClass" @click="close" @keydown.enter="close"></span> <span v-if="removable" tabindex="0" :class="removeIconClass" @click="close" @keydown="onKeydown"></span>
</div> </div>
</template> </template>
@ -41,6 +41,11 @@ export default {
}; };
}, },
methods: { methods: {
onKeydown(event) {
if (event.key === 'Enter' || event.key === 'Backspace') {
this.close(event);
}
},
close(event) { close(event) {
this.visible = false; this.visible = false;
this.$emit('remove', event); this.$emit('remove', event);

View File

@ -36,9 +36,9 @@ export interface ChipsProps {
*/ */
allowDuplicate?: boolean | undefined; allowDuplicate?: boolean | undefined;
/** /**
* Separator char to add an item when pressed in addition to the enter key. Currently only possible value is ',' * Separator char to add an item when pressed in addition to the enter key.
*/ */
separator?: string | undefined; separator?: string | any;
/** /**
* Identifier of the focus input to match a label defined for the chips. * Identifier of the focus input to match a label defined for the chips.
*/ */
@ -55,6 +55,11 @@ export interface ChipsProps {
* Uses to pass all properties of the HTMLInputElement to the focusable input element inside the component. * Uses to pass all properties of the HTMLInputElement to the focusable input element inside the component.
*/ */
inputProps?: InputHTMLAttributes | undefined; inputProps?: InputHTMLAttributes | undefined;
/**
* Icon to display in chip remove action.
* Default value is 'pi pi-times-circle'.
*/
removeTokenIcon?: string | undefined;
/** /**
* When present, it specifies that the element should be disabled. * When present, it specifies that the element should be disabled.
*/ */

View File

@ -19,7 +19,7 @@ describe('Chips.vue', () => {
}); });
it('should add item', async () => { it('should add item', async () => {
const addItem = vi.spyOn(wrapper.vm, 'addItem'); const addItem = jest.spyOn(wrapper.vm, 'addItem');
await wrapper.vm.addItem({}, 'PrimeVue', false); await wrapper.vm.addItem({}, 'PrimeVue', false);
@ -30,4 +30,15 @@ describe('Chips.vue', () => {
expect(wrapper.find('.p-chips-token-label').exists()).toBe(true); expect(wrapper.find('.p-chips-token-label').exists()).toBe(true);
expect(wrapper.find('.p-chips-token-label').text()).toBe('PrimeVue'); expect(wrapper.find('.p-chips-token-label').text()).toBe('PrimeVue');
}); });
it('should have correct custom chip removal icon', async () => {
await wrapper.setProps({
modelValue: ['foo', 'bar'],
removeTokenIcon: 'pi pi-discord'
});
const icon = wrapper.find('.p-chips-token-icon');
expect(icon.classes()).toContain('pi-discord');
});
}); });

View File

@ -28,7 +28,7 @@
<slot name="chip" :value="val"> <slot name="chip" :value="val">
<span class="p-chips-token-label">{{ val }}</span> <span class="p-chips-token-label">{{ val }}</span>
</slot> </slot>
<span class="p-chips-token-icon pi pi-times-circle" @click="removeItem($event, i)" aria-hidden="true"></span> <span :class="['p-chips-token-icon', removeTokenIcon]" @click="removeItem($event, i)" aria-hidden="true"></span>
</li> </li>
<li class="p-chips-input-token" role="option"> <li class="p-chips-input-token" role="option">
<input <input
@ -67,7 +67,7 @@ export default {
default: null default: null
}, },
separator: { separator: {
type: String, type: [String, Object],
default: null default: null
}, },
addOnBlur: { addOnBlur: {
@ -102,6 +102,10 @@ export default {
type: null, type: null,
default: null default: null
}, },
removeTokenIcon: {
type: String,
default: 'pi pi-times-circle'
},
'aria-labelledby': { 'aria-labelledby': {
type: String, type: String,
default: null default: null
@ -175,7 +179,7 @@ export default {
default: default:
if (this.separator) { if (this.separator) {
if (this.separator === ',' && event.key === ',') { if (this.separator === event.key || event.key.match(this.separator)) {
this.addItem(event, inputValue, true); this.addItem(event, inputValue, true);
} }
} }
@ -252,6 +256,10 @@ export default {
this.$refs.input.value = ''; this.$refs.input.value = '';
this.inputValue = ''; this.inputValue = '';
setTimeout(() => {
this.maxedOut && (this.focused = false);
}, 0);
if (preventDefault) { if (preventDefault) {
event.preventDefault(); event.preventDefault();
} }

View File

@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import PrimeVue from '../config/PrimeVue'; import PrimeVue from 'primevue/config';
import ColorPicker from './ColorPicker.vue'; import ColorPicker from './ColorPicker.vue';
describe('ColorPicker.vue', () => { describe('ColorPicker.vue', () => {
@ -26,7 +26,7 @@ describe('ColorPicker.vue', () => {
it('should input click triggered', async () => { it('should input click triggered', async () => {
const input = wrapper.find('.p-colorpicker-preview.p-inputtext'); const input = wrapper.find('.p-colorpicker-preview.p-inputtext');
const onInputClick = vi.spyOn(wrapper.vm, 'onInputClick'); const onInputClick = jest.spyOn(wrapper.vm, 'onInputClick');
await input.trigger('click'); await input.trigger('click');
@ -41,8 +41,8 @@ describe('ColorPicker.vue', () => {
await input.trigger('click'); await input.trigger('click');
const onColorMousedown = vi.spyOn(wrapper.vm, 'onColorMousedown'); const onColorMousedown = jest.spyOn(wrapper.vm, 'onColorMousedown');
const onHueMousedown = vi.spyOn(wrapper.vm, 'onHueMousedown'); const onHueMousedown = jest.spyOn(wrapper.vm, 'onHueMousedown');
const event = { pageX: 100, pageY: 120, preventDefault: () => {} }; const event = { pageX: 100, pageY: 120, preventDefault: () => {} };
const event2 = { pageX: 70, pageY: 20, preventDefault: () => {} }; const event2 = { pageX: 70, pageY: 20, preventDefault: () => {} };

View File

@ -21,9 +21,9 @@
</template> </template>
<script> <script>
import { ConnectedOverlayScrollHandler, DomHandler, ZIndexUtils } from 'primevue/utils';
import OverlayEventBus from 'primevue/overlayeventbus'; import OverlayEventBus from 'primevue/overlayeventbus';
import Portal from 'primevue/portal'; import Portal from 'primevue/portal';
import { ConnectedOverlayScrollHandler, DomHandler, ZIndexUtils } from 'primevue/utils';
export default { export default {
name: 'ColorPicker', name: 'ColorPicker',
@ -411,16 +411,14 @@ export default {
this.overlayVisible = !this.overlayVisible; this.overlayVisible = !this.overlayVisible;
}, },
onInputKeydown(event) { onInputKeydown(event) {
switch (event.which) { switch (event.code) {
//space case 'Space':
case 32:
this.overlayVisible = !this.overlayVisible; this.overlayVisible = !this.overlayVisible;
event.preventDefault(); event.preventDefault();
break; break;
//escape and tab case 'Escape':
case 27: case 'Tab':
case 9:
this.overlayVisible = false; this.overlayVisible = false;
break; break;

View File

@ -1,4 +1,4 @@
import Vue, { Plugin } from 'vue'; import { Plugin } from 'vue';
interface PrimeVueConfiguration { interface PrimeVueConfiguration {
ripple?: boolean; ripple?: boolean;
@ -26,6 +26,45 @@ interface PrimeVueLocaleAriaOptions {
close?: string; close?: string;
previous?: string; previous?: string;
next?: string; next?: string;
navigation?: string;
scrollTop?: string;
moveUp?: string;
moveTop?: string;
moveDown?: string;
moveBottom?: string;
moveToTarget?: string;
moveToSource?: string;
moveAllToTarget?: string;
moveAllToSource?: string;
pageLabel?: string;
firstPageLabel?: string;
lastPageLabel?: string;
nextPageLabel?: string;
prevPageLabel?: string;
rowsPerPageLabel?: string;
previousPageLabel?: string;
jumpToPageDropdownLabel?: string;
jumpToPageInputLabel?: string;
selectRow?: string;
unselectRow?: string;
expandRow?: string;
collapseRow?: string;
showFilterMenu?: string;
hideFilterMenu?: string;
filterOperator?: string;
filterConstraint?: string;
editRow?: string;
saveEdit?: string;
cancelEdit?: string;
listView?: string;
gridView?: string;
slide?: string;
slideNumber?: string;
zoomImage?: string;
zoomIn?: string;
zoomOut?: string;
rotateRight?: string;
rotateLeft?: string;
} }
interface PrimeVueLocaleOptions { interface PrimeVueLocaleOptions {
@ -55,6 +94,8 @@ interface PrimeVueLocaleOptions {
choose?: string; choose?: string;
upload?: string; upload?: string;
cancel?: string; cancel?: string;
completed?: string;
pending?: string;
dayNames: string[]; dayNames: string[];
dayNamesShort: string[]; dayNamesShort: string[];
dayNamesMin: string[]; dayNamesMin: string[];

View File

@ -1,5 +1,5 @@
import { reactive, inject } from 'vue';
import { FilterMatchMode } from 'primevue/api'; import { FilterMatchMode } from 'primevue/api';
import { inject, reactive } from 'vue';
const defaultOptions = { const defaultOptions = {
ripple: false, ripple: false,
@ -31,6 +31,8 @@ const defaultOptions = {
choose: 'Choose', choose: 'Choose',
upload: 'Upload', upload: 'Upload',
cancel: 'Cancel', cancel: 'Cancel',
completed: 'Completed',
pending: 'Pending',
dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'], dayNames: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
dayNamesShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], dayNamesShort: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
dayNamesMin: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'], dayNamesMin: ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'],
@ -77,7 +79,46 @@ const defaultOptions = {
unselectAll: 'All items unselected', unselectAll: 'All items unselected',
close: 'Close', close: 'Close',
previous: 'Previous', previous: 'Previous',
next: 'Next' next: 'Next',
navigation: 'Navigation',
scrollTop: 'Scroll Top',
moveTop: 'Move Top',
moveUp: 'Move Up',
moveDown: 'Move Down',
moveBottom: 'Move Bottom',
moveToTarget: 'Move to Target',
moveToSource: 'Move to Source',
moveAllToTarget: 'Move All to Target',
moveAllToSource: 'Move All to Source',
pageLabel: '{page}',
firstPageLabel: 'First Page',
lastPageLabel: 'Last Page',
nextPageLabel: 'Next Page',
prevPageLabel: 'Previous Page',
rowsPerPageLabel: 'Rows per page',
previousPageLabel: 'Previous Page',
jumpToPageDropdownLabel: 'Jump to Page Dropdown',
jumpToPageInputLabel: 'Jump to Page Input',
selectRow: 'Row Selected',
unselectRow: 'Row Unselected',
expandRow: 'Row Expanded',
collapseRow: 'Row Collapsed',
showFilterMenu: 'Show Filter Menu',
hideFilterMenu: 'Hide Filter Menu',
filterOperator: 'Filter Operator',
filterConstraint: 'Filter Constraint',
editRow: 'Row Edit',
saveEdit: 'Save Edit',
cancelEdit: 'Cancel Edit',
listView: 'List View',
gridView: 'Grid View',
slide: 'Slide',
slideNumber: '{slideNumber}',
zoomImage: 'Zoom Image',
zoomIn: 'Zoom In',
zoomOut: 'Zoom Out',
rotateRight: 'Rotate Right',
rotateLeft: 'Rotate Left'
} }
}, },
filterMatchModeOptions: { filterMatchModeOptions: {

View File

@ -1,4 +1,4 @@
import PrimeVue from '../config/PrimeVue'; import PrimeVue from 'primevue/config';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import ConfirmDialog from './ConfirmDialog.vue'; import ConfirmDialog from './ConfirmDialog.vue';
@ -18,12 +18,13 @@ describe('ConfirmDialog', () => {
message: 'Are you sure you want to proceed?', message: 'Are you sure you want to proceed?',
header: 'Confirmation', header: 'Confirmation',
icon: 'pi pi-exclamation-triangle' icon: 'pi pi-exclamation-triangle'
} },
visible: true
}; };
} }
}); });
await wrapper.setData({ visible: true }); await wrapper.vm.$nextTick();
expect(wrapper.find('.p-dialog-mask .p-dialog.p-component').exists()).toBe(true); expect(wrapper.find('.p-dialog-mask .p-dialog.p-component').exists()).toBe(true);
expect(wrapper.find('.p-dialog-title').text()).toBe('Confirmation'); expect(wrapper.find('.p-dialog-title').text()).toBe('Confirmation');
@ -57,15 +58,15 @@ describe('ConfirmDialog', () => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('reject'); console.log('reject');
} }
} },
visible: true
}; };
} }
}); });
const acceptTriggered = vi.spyOn(wrapper.componentVM.confirmation, 'accept'); await wrapper.vm.$nextTick();
await wrapper.setData({ visible: true });
const acceptTriggered = jest.spyOn(wrapper.componentVM.confirmation, 'accept');
const CDAcceptBtn = wrapper.find('.p-confirm-dialog-accept'); const CDAcceptBtn = wrapper.find('.p-confirm-dialog-accept');
await CDAcceptBtn.trigger('click'); await CDAcceptBtn.trigger('click');
@ -96,15 +97,15 @@ describe('ConfirmDialog', () => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('reject'); console.log('reject');
} }
} },
visible: true
}; };
} }
}); });
const rejectTriggered = vi.spyOn(wrapper.componentVM.confirmation, 'reject'); await wrapper.vm.$nextTick();
await wrapper.setData({ visible: true });
const rejectTriggered = jest.spyOn(wrapper.componentVM.confirmation, 'reject');
const CDRejectBtn = wrapper.find('.p-confirm-dialog-reject'); const CDRejectBtn = wrapper.find('.p-confirm-dialog-reject');
await CDRejectBtn.trigger('click'); await CDRejectBtn.trigger('click');
@ -127,12 +128,13 @@ describe('ConfirmDialog', () => {
message: 'Are you sure you want to proceed?', message: 'Are you sure you want to proceed?',
header: 'Confirmation', header: 'Confirmation',
icon: 'pi pi-exclamation-triangle' icon: 'pi pi-exclamation-triangle'
} },
visible: true
}; };
} }
}); });
await wrapper.setData({ visible: true }); await wrapper.vm.$nextTick();
const dialogCloseBtn = wrapper.find('.p-dialog-header-close'); const dialogCloseBtn = wrapper.find('.p-dialog-header-close');
@ -158,12 +160,13 @@ describe('ConfirmDialog', () => {
header: 'Delete Confirmation', header: 'Delete Confirmation',
icon: 'pi pi-info-circle', icon: 'pi pi-info-circle',
position: 'bottom' position: 'bottom'
} },
visible: true
}; };
} }
}); });
await wrapper.setData({ visible: true }); await wrapper.vm.$nextTick();
expect(wrapper.find('.p-dialog-mask.p-dialog-bottom').exists()).toBe(true); expect(wrapper.find('.p-dialog-mask.p-dialog-bottom').exists()).toBe(true);
}); });

View File

@ -1,5 +1,5 @@
<template> <template>
<CDialog v-model:visible="visible" :modal="true" :header="header" :blockScroll="blockScroll" :position="position" class="p-confirm-dialog" :breakpoints="breakpoints" :closeOnEscape="closeOnEscape" @update:visible="onHide"> <CDialog v-model:visible="visible" role="alertdialog" class="p-confirm-dialog" :modal="true" :header="header" :blockScroll="blockScroll" :position="position" :breakpoints="breakpoints" :closeOnEscape="closeOnEscape" @update:visible="onHide">
<template v-if="!$slots.message"> <template v-if="!$slots.message">
<i :class="iconClass" /> <i :class="iconClass" />
<span class="p-confirm-dialog-message">{{ message }}</span> <span class="p-confirm-dialog-message">{{ message }}</span>
@ -42,6 +42,11 @@ export default {
if (options.group === this.group) { if (options.group === this.group) {
this.confirmation = options; this.confirmation = options;
if (this.confirmation.onShow) {
this.confirmation.onShow();
}
this.visible = true; this.visible = true;
} }
}; };

View File

@ -1,7 +1,7 @@
<template> <template>
<Portal> <Portal>
<transition name="p-confirm-popup" @enter="onEnter" @leave="onLeave" @after-leave="onAfterLeave"> <transition name="p-confirm-popup" @enter="onEnter" @leave="onLeave" @after-leave="onAfterLeave">
<div v-if="visible" :ref="containerRef" :class="containerClass" v-bind="$attrs" @click="onOverlayClick"> <div v-if="visible" :ref="containerRef" v-focustrap role="alertdialog" :class="containerClass" :aria-modal="visible" @click="onOverlayClick" @keydown="onOverlayKeydown" v-bind="$attrs">
<template v-if="!$slots.message"> <template v-if="!$slots.message">
<div class="p-confirm-popup-content"> <div class="p-confirm-popup-content">
<i :class="iconClass" /> <i :class="iconClass" />
@ -10,8 +10,8 @@
</template> </template>
<component v-else :is="$slots.message" :message="confirmation"></component> <component v-else :is="$slots.message" :message="confirmation"></component>
<div class="p-confirm-popup-footer"> <div class="p-confirm-popup-footer">
<CPButton :label="rejectLabel" :icon="rejectIcon" :class="rejectClass" @click="reject()" /> <CPButton :label="rejectLabel" :icon="rejectIcon" :class="rejectClass" @click="reject()" @keydown="onRejectKeydown" :autofocus="autoFocusReject" />
<CPButton :label="acceptLabel" :icon="acceptIcon" :class="acceptClass" @click="accept()" autofocus /> <CPButton :label="acceptLabel" :icon="acceptIcon" :class="acceptClass" @click="accept()" @keydown="onAcceptKeydown" :autofocus="autoFocusAccept" />
</div> </div>
</div> </div>
</transition> </transition>
@ -19,11 +19,12 @@
</template> </template>
<script> <script>
import ConfirmationEventBus from 'primevue/confirmationeventbus';
import { ConnectedOverlayScrollHandler, DomHandler, ZIndexUtils } from 'primevue/utils';
import OverlayEventBus from 'primevue/overlayeventbus';
import Button from 'primevue/button'; import Button from 'primevue/button';
import ConfirmationEventBus from 'primevue/confirmationeventbus';
import FocusTrap from 'primevue/focustrap';
import OverlayEventBus from 'primevue/overlayeventbus';
import Portal from 'primevue/portal'; import Portal from 'primevue/portal';
import { ConnectedOverlayScrollHandler, DomHandler, ZIndexUtils } from 'primevue/utils';
export default { export default {
name: 'ConfirmPopup', name: 'ConfirmPopup',
@ -53,6 +54,11 @@ export default {
if (options.group === this.group) { if (options.group === this.group) {
this.confirmation = options; this.confirmation = options;
this.target = options.target; this.target = options.target;
if (this.confirmation.onShow) {
this.confirmation.onShow();
}
this.visible = true; this.visible = true;
} }
}; };
@ -101,7 +107,29 @@ export default {
this.visible = false; this.visible = false;
}, },
onHide() {
if (this.confirmation.onHide) {
this.confirmation.onHide();
}
this.visible = false;
},
onAcceptKeydown(event) {
if (event.code === 'Space' || event.code === 'Enter') {
this.accept();
DomHandler.focus(this.target);
event.preventDefault();
}
},
onRejectKeydown(event) {
if (event.code === 'Space' || event.code === 'Enter') {
this.reject();
DomHandler.focus(this.target);
event.preventDefault();
}
},
onEnter(el) { onEnter(el) {
this.focus();
this.bindOutsideClickListener(); this.bindOutsideClickListener();
this.bindScrollListener(); this.bindScrollListener();
this.bindResizeListener(); this.bindResizeListener();
@ -137,6 +165,10 @@ export default {
if (!this.outsideClickListener) { if (!this.outsideClickListener) {
this.outsideClickListener = (event) => { this.outsideClickListener = (event) => {
if (this.visible && this.container && !this.container.contains(event.target) && !this.isTargetClicked(event)) { if (this.visible && this.container && !this.container.contains(event.target) && !this.isTargetClicked(event)) {
if (this.confirmation.onHide) {
this.confirmation.onHide();
}
this.visible = false; this.visible = false;
} else { } else {
this.alignOverlay(); this.alignOverlay();
@ -185,6 +217,13 @@ export default {
this.resizeListener = null; this.resizeListener = null;
} }
}, },
focus() {
let focusTarget = this.container.querySelector('[autofocus]');
if (focusTarget) {
focusTarget.focus();
}
},
isTargetClicked(event) { isTargetClicked(event) {
return this.target && (this.target === event.target || this.target.contains(event.target)); return this.target && (this.target === event.target || this.target.contains(event.target));
}, },
@ -196,6 +235,12 @@ export default {
originalEvent: event, originalEvent: event,
target: this.target target: this.target
}); });
},
onOverlayKeydown(event) {
if (event.code === 'Escape') {
ConfirmationEventBus.emit('close', this.closeListener);
DomHandler.focus(this.target);
}
} }
}, },
computed: { computed: {
@ -231,11 +276,20 @@ export default {
}, },
rejectClass() { rejectClass() {
return ['p-confirm-popup-reject p-button-sm', this.confirmation ? this.confirmation.rejectClass || 'p-button-text' : null]; return ['p-confirm-popup-reject p-button-sm', this.confirmation ? this.confirmation.rejectClass || 'p-button-text' : null];
},
autoFocusAccept() {
return this.confirmation.defaultFocus === undefined || this.confirmation.defaultFocus === 'accept' ? true : false;
},
autoFocusReject() {
return this.confirmation.defaultFocus === 'reject' ? true : false;
} }
}, },
components: { components: {
CPButton: Button, CPButton: Button,
Portal: Portal Portal: Portal
},
directives: {
focustrap: FocusTrap
} }
}; };
</script> </script>

View File

@ -1,6 +1,6 @@
import { VNode } from 'vue'; import { VNode } from 'vue';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
import { MenuItem } from '../menuitem'; import { MenuItem } from '../menuitem';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
type ContextMenuAppendTo = 'body' | 'self' | string | undefined | HTMLElement; type ContextMenuAppendTo = 'body' | 'self' | string | undefined | HTMLElement;
@ -34,6 +34,18 @@ export interface ContextMenuProps {
* Default value is true. * Default value is true.
*/ */
exact?: boolean | undefined; exact?: boolean | undefined;
/**
* Index of the element in tabbing order.
*/
tabindex?: number | string | undefined;
/**
* Defines a string value that labels an interactive element.
*/
'aria-label'?: string | undefined;
/**
* Identifier of the underlying menu element.
*/
'aria-labelledby'?: string | undefined;
} }
export interface ContextMenuSlots { export interface ContextMenuSlots {
@ -49,7 +61,34 @@ export interface ContextMenuSlots {
}) => VNode[]; }) => VNode[];
} }
export declare type ContextMenuEmits = {}; export declare type ContextMenuEmits = {
/**
* 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 before the popup is shown.
*/
'before-show': () => void;
/**
* Callback to invoke before the popup is hidden.
*/
'before-hide': () => void;
/**
* Callback to invoke when the popup is shown.
*/
show: () => void;
/**
* Callback to invoke when the popup is hidden.
*/
hide: () => void;
};
declare class ContextMenu extends ClassComponent<ContextMenuProps, ContextMenuSlots, ContextMenuEmits> { declare class ContextMenu extends ClassComponent<ContextMenuProps, ContextMenuSlots, ContextMenuEmits> {
/** /**

View File

@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import PrimeVue from '../config/PrimeVue'; import PrimeVue from 'primevue/config';
import ContextMenu from './ContextMenu.vue'; import ContextMenu from './ContextMenu.vue';
describe('ContextMenu.vue', () => { describe('ContextMenu.vue', () => {
@ -147,7 +147,7 @@ describe('ContextMenu.vue', () => {
it('should exist', async () => { it('should exist', async () => {
const event = { pageX: 100, pageY: 120, preventDefault: () => {}, stopPropagation: () => {} }; const event = { pageX: 100, pageY: 120, preventDefault: () => {}, stopPropagation: () => {} };
const show = vi.spyOn(wrapper.vm, 'show'); const show = jest.spyOn(wrapper.vm, 'show');
wrapper.vm.show(event); wrapper.vm.show(event);
await wrapper.setData({ visible: true }); await wrapper.setData({ visible: true });
@ -159,7 +159,7 @@ describe('ContextMenu.vue', () => {
}); });
it('should hide menu', async () => { it('should hide menu', async () => {
const hide = vi.spyOn(wrapper.vm, 'hide'); const hide = jest.spyOn(wrapper.vm, 'hide');
await wrapper.setData({ visible: true }); await wrapper.setData({ visible: true });

View File

@ -1,21 +1,46 @@
<template> <template>
<Portal :appendTo="appendTo"> <Portal :appendTo="appendTo">
<transition name="p-contextmenu" @enter="onEnter" @leave="onLeave" @after-leave="onAfterLeave"> <transition name="p-contextmenu" @enter="onEnter" @after-enter="onAfterEnter" @leave="onLeave" @after-leave="onAfterLeave">
<div v-if="visible" :ref="containerRef" :class="containerClass" v-bind="$attrs"> <div v-if="visible" :ref="containerRef" :class="containerClass" v-bind="$attrs">
<ContextMenuSub :model="model" :root="true" @leaf-click="onLeafClick" :template="$slots.item" :exact="exact" /> <ContextMenuSub
:ref="listRef"
:id="id + '_list'"
class="p-contextmenu-root-list"
role="menubar"
:root="true"
:tabindex="tabindex"
aria-orientation="vertical"
:aria-activedescendant="focused ? focusedItemId : undefined"
:menuId="id"
:focusedItemId="focused ? focusedItemId : undefined"
:items="processedItems"
:template="$slots.item"
:activeItemPath="activeItemPath"
:exact="exact"
:aria-labelledby="ariaLabelledby"
:aria-label="ariaLabel"
:level="0"
:visible="submenuVisible"
@focus="onFocus"
@blur="onBlur"
@keydown="onKeyDown"
@item-click="onItemClick"
@item-mouseenter="onItemMouseEnter"
/>
</div> </div>
</transition> </transition>
</Portal> </Portal>
</template> </template>
<script> <script>
import { DomHandler, ZIndexUtils } from 'primevue/utils';
import ContextMenuSub from './ContextMenuSub.vue';
import Portal from 'primevue/portal'; import Portal from 'primevue/portal';
import { DomHandler, ObjectUtils, UniqueComponentId, ZIndexUtils } from 'primevue/utils';
import ContextMenuSub from './ContextMenuSub.vue';
export default { export default {
name: 'ContextMenu', name: 'ContextMenu',
inheritAttrs: false, inheritAttrs: false,
emits: ['focus', 'blur', 'show', 'hide'],
props: { props: {
model: { model: {
type: Array, type: Array,
@ -40,6 +65,18 @@ export default {
exact: { exact: {
type: Boolean, type: Boolean,
default: true default: true
},
tabindex: {
type: Number,
default: 0
},
'aria-labelledby': {
type: String,
default: null
},
'aria-label': {
type: String,
default: null
} }
}, },
target: null, target: null,
@ -49,11 +86,29 @@ export default {
pageX: null, pageX: null,
pageY: null, pageY: null,
container: null, container: null,
list: null,
data() { data() {
return { return {
visible: false focused: false,
focusedItemInfo: { index: -1, level: 0, parentKey: '' },
activeItemPath: [],
visible: false,
submenuVisible: false
}; };
}, },
watch: {
activeItemPath(newPath) {
if (ObjectUtils.isNotEmpty(newPath)) {
this.bindOutsideClickListener();
this.bindResizeListener();
this.bindDocumentContextMenuListener();
} else if (!this.visible) {
this.unbindOutsideClickListener();
this.unbindResizeListener();
this.unbindDocumentContextMenuListener();
}
}
},
beforeUnmount() { beforeUnmount() {
this.unbindResizeListener(); this.unbindResizeListener();
this.unbindOutsideClickListener(); this.unbindOutsideClickListener();
@ -63,6 +118,7 @@ export default {
ZIndexUtils.clear(this.container); ZIndexUtils.clear(this.container);
} }
this.target = null;
this.container = null; this.container = null;
}, },
mounted() { mounted() {
@ -71,53 +127,276 @@ export default {
} }
}, },
methods: { methods: {
itemClick(event) { getItemProp(item, name) {
const item = event.item; return item ? ObjectUtils.getItemValue(item[name]) : undefined;
},
if (item.command) { getItemLabel(item) {
item.command(event); return this.getItemProp(item, 'label');
event.originalEvent.preventDefault(); },
} isItemDisabled(item) {
return this.getItemProp(item, 'disabled');
this.hide(); },
isItemGroup(item) {
return ObjectUtils.isNotEmpty(this.getItemProp(item, 'items'));
},
isItemSeparator(item) {
return this.getItemProp(item, 'separator');
},
getProccessedItemLabel(processedItem) {
return processedItem ? this.getItemLabel(processedItem.item) : undefined;
},
isProccessedItemGroup(processedItem) {
return processedItem && ObjectUtils.isNotEmpty(processedItem.items);
}, },
toggle(event) { toggle(event) {
if (this.visible) this.hide(); this.visible ? this.hide() : this.show(event);
else this.show(event);
},
onLeafClick() {
this.hide();
}, },
show(event) { show(event) {
this.activeItemPath = [];
this.focusedItemInfo = { index: -1, level: 0, parentKey: '' };
DomHandler.focus(this.list);
this.pageX = event.pageX; this.pageX = event.pageX;
this.pageY = event.pageY; this.pageY = event.pageY;
this.visible ? this.position() : (this.visible = true);
if (this.visible) this.position();
else this.visible = true;
event.stopPropagation(); event.stopPropagation();
event.preventDefault(); event.preventDefault();
}, },
hide() { hide() {
this.visible = false; this.visible = false;
this.activeItemPath = [];
this.focusedItemInfo = { index: -1, level: 0, parentKey: '' };
},
onFocus(event) {
this.focused = true;
this.focusedItemInfo = this.focusedItemInfo.index !== -1 ? this.focusedItemInfo : { index: -1, level: 0, parentKey: '' };
this.$emit('focus', event);
},
onBlur(event) {
this.focused = false;
this.focusedItemInfo = { index: -1, level: 0, parentKey: '' };
this.searchValue = '';
this.$emit('blur', event);
},
onKeyDown(event) {
const metaKey = event.metaKey || event.ctrlKey;
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 (!metaKey && ObjectUtils.isPrintableCharacter(event.key)) {
this.searchItems(event, event.key);
}
break;
}
},
onItemChange(event) {
const { processedItem, isFocus } = event;
if (ObjectUtils.isEmpty(processedItem)) return;
const { index, key, level, parentKey, items } = processedItem;
const grouped = ObjectUtils.isNotEmpty(items);
const activeItemPath = this.activeItemPath.filter((p) => p.parentKey !== parentKey && p.parentKey !== key);
if (grouped) {
activeItemPath.push(processedItem);
this.submenuVisible = true;
}
this.focusedItemInfo = { index, level, parentKey };
this.activeItemPath = activeItemPath;
isFocus && DomHandler.focus(this.list);
},
onItemClick(event) {
const { processedItem } = event;
const grouped = this.isProccessedItemGroup(processedItem);
const selected = this.isSelected(processedItem);
if (selected) {
const { index, key, level, parentKey } = processedItem;
this.activeItemPath = this.activeItemPath.filter((p) => key !== p.key && key.startsWith(p.key));
this.focusedItemInfo = { index, level, parentKey };
DomHandler.focus(this.list);
} else {
grouped ? this.onItemChange(event) : this.hide();
}
},
onItemMouseEnter(event) {
this.onItemChange(event);
},
onArrowDownKey(event) {
const itemIndex = this.focusedItemInfo.index !== -1 ? this.findNextItemIndex(this.focusedItemInfo.index) : this.findFirstFocusedItemIndex();
this.changeFocusedItemIndex(event, itemIndex);
event.preventDefault();
},
onArrowUpKey(event) {
if (event.altKey) {
if (this.focusedItemInfo.index !== -1) {
const processedItem = this.visibleItems[this.focusedItemInfo.index];
const grouped = this.isProccessedItemGroup(processedItem);
!grouped && this.onItemChange({ originalEvent: event, processedItem });
}
this.popup && this.hide();
event.preventDefault();
} else {
const itemIndex = this.focusedItemInfo.index !== -1 ? this.findPrevItemIndex(this.focusedItemInfo.index) : this.findLastFocusedItemIndex();
this.changeFocusedItemIndex(event, itemIndex);
event.preventDefault();
}
},
onArrowLeftKey(event) {
const processedItem = this.visibleItems[this.focusedItemInfo.index];
const parentItem = this.activeItemPath.find((p) => p.key === processedItem.parentKey);
const root = ObjectUtils.isEmpty(processedItem.parent);
if (!root) {
this.focusedItemInfo = { index: -1, parentKey: parentItem ? parentItem.parentKey : '' };
this.searchValue = '';
this.onArrowDownKey(event);
}
this.activeItemPath = this.activeItemPath.filter((p) => p.parentKey !== this.focusedItemInfo.parentKey);
event.preventDefault();
},
onArrowRightKey(event) {
const processedItem = this.visibleItems[this.focusedItemInfo.index];
const grouped = this.isProccessedItemGroup(processedItem);
if (grouped) {
this.onItemChange({ originalEvent: event, processedItem });
this.focusedItemInfo = { index: -1, parentKey: processedItem.key };
this.searchValue = '';
this.onArrowDownKey(event);
}
event.preventDefault();
},
onHomeKey(event) {
this.changeFocusedItemIndex(event, this.findFirstItemIndex());
event.preventDefault();
},
onEndKey(event) {
this.changeFocusedItemIndex(event, this.findLastItemIndex());
event.preventDefault();
},
onEnterKey(event) {
if (this.focusedItemInfo.index !== -1) {
const element = DomHandler.findSingle(this.list, `li[id="${`${this.focusedItemId}`}"]`);
const anchorElement = element && DomHandler.findSingle(element, '.p-menuitem-link');
anchorElement ? anchorElement.click() : element && element.click();
const processedItem = this.visibleItems[this.focusedItemInfo.index];
const grouped = this.isProccessedItemGroup(processedItem);
!grouped && (this.focusedItemInfo.index = this.findFirstFocusedItemIndex());
}
event.preventDefault();
},
onSpaceKey(event) {
this.onEnterKey(event);
},
onEscapeKey(event) {
this.hide();
!this.popup && (this.focusedItemInfo.index = this.findFirstFocusedItemIndex());
event.preventDefault();
},
onTabKey(event) {
if (this.focusedItemInfo.index !== -1) {
const processedItem = this.visibleItems[this.focusedItemInfo.index];
const grouped = this.isProccessedItemGroup(processedItem);
!grouped && this.onItemChange({ originalEvent: event, processedItem });
}
this.hide();
}, },
onEnter(el) { onEnter(el) {
this.position(); this.position();
this.bindOutsideClickListener();
this.bindResizeListener();
if (this.autoZIndex) { if (this.autoZIndex) {
ZIndexUtils.set('menu', el, this.baseZIndex + this.$primevue.config.zIndex.menu); ZIndexUtils.set('menu', el, this.baseZIndex + this.$primevue.config.zIndex.menu);
} }
}, },
onAfterEnter() {
this.bindOutsideClickListener();
this.bindResizeListener();
this.bindDocumentContextMenuListener();
this.$emit('show');
DomHandler.focus(this.list);
},
onLeave() { onLeave() {
this.unbindOutsideClickListener(); this.$emit('hide');
this.unbindResizeListener(); this.container = null;
}, },
onAfterLeave(el) { onAfterLeave(el) {
if (this.autoZIndex) { if (this.autoZIndex) {
ZIndexUtils.clear(el); ZIndexUtils.clear(el);
} }
this.unbindOutsideClickListener();
this.unbindResizeListener();
this.unbindDocumentContextMenuListener();
}, },
position() { position() {
let left = this.pageX + 1; let left = this.pageX + 1;
@ -152,7 +431,10 @@ export default {
bindOutsideClickListener() { bindOutsideClickListener() {
if (!this.outsideClickListener) { if (!this.outsideClickListener) {
this.outsideClickListener = (event) => { this.outsideClickListener = (event) => {
if (this.visible && this.container && !this.container.contains(event.target) && !event.ctrlKey) { const isOutsideContainer = this.container && !this.container.contains(event.target);
const isOutsideTarget = this.visible ? !(this.target && (this.target === event.target || this.target.contains(event.target))) : true;
if (isOutsideContainer && isOutsideTarget) {
this.hide(); this.hide();
} }
}; };
@ -186,7 +468,7 @@ export default {
bindDocumentContextMenuListener() { bindDocumentContextMenuListener() {
if (!this.documentContextMenuListener) { if (!this.documentContextMenuListener) {
this.documentContextMenuListener = (event) => { this.documentContextMenuListener = (event) => {
this.show(event); event.button !== 2 ? this.show(event) : this.hide();
}; };
document.addEventListener('contextmenu', this.documentContextMenuListener); document.addEventListener('contextmenu', this.documentContextMenuListener);
@ -198,19 +480,142 @@ export default {
this.documentContextMenuListener = null; this.documentContextMenuListener = null;
} }
}, },
isItemMatched(processedItem) {
return this.isValidItem(processedItem) && this.getProccessedItemLabel(processedItem).toLocaleLowerCase().startsWith(this.searchValue.toLocaleLowerCase());
},
isValidItem(processedItem) {
return !!processedItem && !this.isItemDisabled(processedItem.item) && !this.isItemSeparator(processedItem.item);
},
isValidSelectedItem(processedItem) {
return this.isValidItem(processedItem) && this.isSelected(processedItem);
},
isSelected(processedItem) {
return this.activeItemPath.some((p) => p.key === processedItem.key);
},
findFirstItemIndex() {
return this.visibleItems.findIndex((processedItem) => this.isValidItem(processedItem));
},
findLastItemIndex() {
return ObjectUtils.findLastIndex(this.visibleItems, (processedItem) => this.isValidItem(processedItem));
},
findNextItemIndex(index) {
const matchedItemIndex = index < this.visibleItems.length - 1 ? this.visibleItems.slice(index + 1).findIndex((processedItem) => this.isValidItem(processedItem)) : -1;
return matchedItemIndex > -1 ? matchedItemIndex + index + 1 : index;
},
findPrevItemIndex(index) {
const matchedItemIndex = index > 0 ? ObjectUtils.findLastIndex(this.visibleItems.slice(0, index), (processedItem) => this.isValidItem(processedItem)) : -1;
return matchedItemIndex > -1 ? matchedItemIndex : index;
},
findSelectedItemIndex() {
return this.visibleItems.findIndex((processedItem) => this.isValidSelectedItem(processedItem));
},
findFirstFocusedItemIndex() {
const selectedIndex = this.findSelectedItemIndex();
return selectedIndex < 0 ? this.findFirstItemIndex() : selectedIndex;
},
findLastFocusedItemIndex() {
const selectedIndex = this.findSelectedItemIndex();
return selectedIndex < 0 ? this.findLastItemIndex() : selectedIndex;
},
searchItems(event, char) {
this.searchValue = (this.searchValue || '') + char;
let itemIndex = -1;
let matched = false;
if (this.focusedItemInfo.index !== -1) {
itemIndex = this.visibleItems.slice(this.focusedItemInfo.index).findIndex((processedItem) => this.isItemMatched(processedItem));
itemIndex = itemIndex === -1 ? this.visibleItems.slice(0, this.focusedItemInfo.index).findIndex((processedItem) => this.isItemMatched(processedItem)) : itemIndex + this.focusedItemInfo.index;
} else {
itemIndex = this.visibleItems.findIndex((processedItem) => this.isItemMatched(processedItem));
}
if (itemIndex !== -1) {
matched = true;
}
if (itemIndex === -1 && this.focusedItemInfo.index === -1) {
itemIndex = this.findFirstFocusedItemIndex();
}
if (itemIndex !== -1) {
this.changeFocusedItemIndex(event, itemIndex);
}
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
this.searchTimeout = setTimeout(() => {
this.searchValue = '';
this.searchTimeout = null;
}, 500);
return matched;
},
changeFocusedItemIndex(event, index) {
if (this.focusedItemInfo.index !== index) {
this.focusedItemInfo.index = index;
this.scrollInView();
}
},
scrollInView(index = -1) {
const id = index !== -1 ? `${this.id}_${index}` : this.focusedItemId;
const element = DomHandler.findSingle(this.list, `li[id="${id}"]`);
if (element) {
element.scrollIntoView && element.scrollIntoView({ block: 'nearest', inline: 'start' });
}
},
createProcessedItems(items, level = 0, parent = {}, parentKey = '') {
const processedItems = [];
items &&
items.forEach((item, index) => {
const key = (parentKey !== '' ? parentKey + '_' : '') + index;
const newItem = {
item,
index,
level,
key,
parent,
parentKey
};
newItem['items'] = this.createProcessedItems(item.items, level + 1, newItem, key);
processedItems.push(newItem);
});
return processedItems;
},
containerRef(el) { containerRef(el) {
this.container = el; this.container = el;
},
listRef(el) {
this.list = el ? el.$el : undefined;
} }
}, },
computed: { computed: {
containerClass() { containerClass() {
return [ return ['p-contextmenu p-component', { 'p-input-filled': this.$primevue.config.inputStyle === 'filled', 'p-ripple-disabled': this.$primevue.config.ripple === false }];
'p-contextmenu p-component', },
{ processedItems() {
'p-input-filled': this.$primevue.config.inputStyle === 'filled', return this.createProcessedItems(this.model || []);
'p-ripple-disabled': this.$primevue.config.ripple === false },
} visibleItems() {
]; const processedItem = this.activeItemPath.find((p) => p.key === this.focusedItemInfo.parentKey);
return processedItem ? processedItem.items : this.processedItems;
},
id() {
return this.$attrs.id || UniqueComponentId();
},
focusedItemId() {
return this.focusedItemInfo.index !== -1 ? `${this.id}${ObjectUtils.isNotEmpty(this.focusedItemInfo.parentKey) ? '_' + this.focusedItemInfo.parentKey : ''}_${this.focusedItemInfo.index}` : null;
} }
}, },
components: { components: {

View File

@ -1,61 +1,92 @@
<template> <template>
<transition name="p-contextmenusub" @enter="onEnter"> <transition name="p-contextmenusub" @enter="onEnter">
<ul v-if="root ? true : parentActive" ref="container" :class="containerClass" role="menu"> <ul v-if="root ? true : visible" ref="container">
<template v-for="(item, i) of model" :key="label(item) + i.toString()"> <template v-for="(processedItem, index) of items" :key="getItemKey(processedItem)">
<li v-if="visible(item) && !item.separator" role="none" :class="getItemClass(item)" :style="item.style" @mouseenter="onItemMouseEnter($event, item)"> <li
<template v-if="!template"> v-if="isItemVisible(processedItem) && !getItemProp(processedItem, 'separator')"
<router-link v-if="item.to && !disabled(item)" v-slot="{ navigate, href, isActive, isExactActive }" :to="item.to" custom> :id="getItemId(processedItem)"
<a v-ripple :href="href" @click="onItemClick($event, item, navigate)" :class="linkClass(item, { isActive, isExactActive })" role="menuitem"> :style="getItemProp(processedItem, 'style')"
<span v-if="item.icon" :class="['p-menuitem-icon', item.icon]"></span> :class="getItemClass(processedItem)"
<span class="p-menuitem-text">{{ label(item) }}</span> role="menuitem"
:aria-label="getItemLabel(processedItem)"
:aria-disabled="isItemDisabled(processedItem) || undefined"
:aria-expanded="isItemGroup(processedItem) ? isItemActive(processedItem) : undefined"
:aria-haspopup="isItemGroup(processedItem) && !getItemProp(processedItem, 'to') ? 'menu' : undefined"
:aria-level="level + 1"
:aria-setsize="getAriaSetSize()"
:aria-posinset="getAriaPosInset(index)"
>
<div class="p-menuitem-content" @click="onItemClick($event, processedItem)" @mouseenter="onItemMouseEnter($event, processedItem)">
<template v-if="!template">
<router-link v-if="getItemProp(processedItem, 'to') && !isItemDisabled(processedItem)" v-slot="{ navigate, href, isActive, isExactActive }" :to="getItemProp(processedItem, 'to')" custom>
<a v-ripple :href="href" :class="getItemActionClass(processedItem, { isActive, isExactActive })" tabindex="-1" aria-hidden="true" @click="onItemActionClick($event, navigate)">
<span v-if="getItemProp(processedItem, 'icon')" :class="getItemIconClass(processedItem)"></span>
<span class="p-menuitem-text">{{ getItemLabel(processedItem) }}</span>
</a>
</router-link>
<a v-else v-ripple :href="getItemProp(processedItem, 'url')" :class="getItemActionClass(processedItem)" :target="getItemProp(processedItem, 'target')" tabindex="-1" aria-hidden="true">
<span v-if="getItemProp(processedItem, 'icon')" :class="getItemIconClass(processedItem)"></span>
<span class="p-menuitem-text">{{ getItemLabel(processedItem) }}</span>
<span v-if="getItemProp(processedItem, 'items')" class="p-submenu-icon pi pi-angle-right"></span>
</a> </a>
</router-link> </template>
<a <component v-else :is="template" :item="processedItem.item"></component>
v-else </div>
v-ripple <ContextMenuSub
:href="item.url" v-if="isItemVisible(processedItem) && isItemGroup(processedItem)"
:class="linkClass(item)" :id="getItemId(processedItem) + '_list'"
:target="item.target" role="menu"
@click="onItemClick($event, item)" class="p-submenu-list"
:aria-haspopup="item.items != null" :menuId="menuId"
:aria-expanded="item === activeItem" :focusedItemId="focusedItemId"
role="menuitem" :items="processedItem.items"
:tabindex="disabled(item) ? null : '0'" :template="template"
> :activeItemPath="activeItemPath"
<span v-if="item.icon" :class="['p-menuitem-icon', item.icon]"></span> :exact="exact"
<span class="p-menuitem-text">{{ label(item) }}</span> :level="level + 1"
<span v-if="item.items" class="p-submenu-icon pi pi-angle-right"></span> :visible="isItemActive(processedItem) && isItemGroup(processedItem)"
</a> @item-click="$emit('item-click', $event)"
</template> @item-mouseenter="$emit('item-mouseenter', $event)"
<component v-else :is="template" :item="item"></component> />
<ContextMenuSub v-if="visible(item) && item.items" :key="label(item) + '_sub_'" :model="item.items" :template="template" @leaf-click="onLeafClick" :parentActive="item === activeItem" :exact="exact" />
</li> </li>
<li v-if="visible(item) && item.separator" :key="'separator' + i.toString()" :class="['p-menu-separator', item.class]" :style="item.style" role="separator"></li> <li v-if="isItemVisible(processedItem) && getItemProp(processedItem, 'separator')" :id="getItemId(processedItem)" :style="getItemProp(processedItem, 'style')" :class="getSeparatorItemClass(processedItem)" role="separator"></li>
</template> </template>
</ul> </ul>
</transition> </transition>
</template> </template>
<script> <script>
import { DomHandler } from 'primevue/utils';
import Ripple from 'primevue/ripple'; import Ripple from 'primevue/ripple';
import { DomHandler, ObjectUtils } from 'primevue/utils';
export default { export default {
name: 'ContextMenuSub', name: 'ContextMenuSub',
emits: ['leaf-click'], emits: ['item-click', 'item-mouseenter'],
props: { props: {
model: { items: {
type: Array, type: Array,
default: null default: null
}, },
menuId: {
type: String,
default: null
},
focusedItemId: {
type: String,
default: null
},
root: { root: {
type: Boolean, type: Boolean,
default: false default: false
}, },
parentActive: { visible: {
type: Boolean, type: Boolean,
default: false default: false
}, },
level: {
type: Number,
default: 0
},
template: { template: {
type: Function, type: Function,
default: null default: null
@ -63,60 +94,57 @@ export default {
exact: { exact: {
type: Boolean, type: Boolean,
default: true default: true
} },
}, activeItemPath: {
data() { type: Object,
return { default: null
activeItem: null
};
},
watch: {
parentActive(newValue) {
if (!newValue) {
this.activeItem = null;
}
} }
}, },
methods: { methods: {
onItemMouseEnter(event, item) { getItemId(processedItem) {
if (this.disabled(item)) { return `${this.menuId}_${processedItem.key}`;
event.preventDefault();
return;
}
this.activeItem = item;
}, },
onItemClick(event, item, navigate) { getItemKey(processedItem) {
if (this.disabled(item)) { return this.getItemId(processedItem);
event.preventDefault();
return;
}
if (item.command) {
item.command({
originalEvent: event,
item: item
});
}
if (item.items) {
if (this.activeItem && item === this.activeItem) this.activeItem = null;
else this.activeItem = item;
}
if (!item.items) {
this.onLeafClick();
}
if (item.to && navigate) {
navigate(event);
}
}, },
onLeafClick() { getItemProp(processedItem, name) {
this.activeItem = null; return processedItem && processedItem.item ? ObjectUtils.getItemValue(processedItem.item[name]) : undefined;
this.$emit('leaf-click'); },
getItemLabel(processedItem) {
return this.getItemProp(processedItem, 'label');
},
isItemActive(processedItem) {
return this.activeItemPath.some((path) => path.key === processedItem.key);
},
isItemVisible(processedItem) {
return this.getItemProp(processedItem, 'visible') !== false;
},
isItemDisabled(processedItem) {
return this.getItemProp(processedItem, 'disabled');
},
isItemFocused(processedItem) {
return this.focusedItemId === this.getItemId(processedItem);
},
isItemGroup(processedItem) {
return ObjectUtils.isNotEmpty(processedItem.items);
},
onItemClick(event, processedItem) {
const command = this.getItemProp(processedItem, 'command');
command && command({ originalEvent: event, item: processedItem.item });
this.$emit('item-click', { originalEvent: event, processedItem, isFocus: true });
},
onItemMouseEnter(event, processedItem) {
this.$emit('item-mouseenter', { originalEvent: event, processedItem });
},
onItemActionClick(event, navigate) {
navigate && navigate(event);
},
getAriaSetSize() {
return this.items.filter((processedItem) => this.isItemVisible(processedItem) && !this.getItemProp(processedItem, 'separator')).length;
},
getAriaPosInset(index) {
return index - this.items.slice(0, index).filter((processedItem) => this.isItemVisible(processedItem) && this.getItemProp(processedItem, 'separator')).length + 1;
}, },
onEnter() { onEnter() {
this.position(); this.position();
@ -136,38 +164,31 @@ export default {
this.$refs.container.style.left = itemOuterWidth + 'px'; this.$refs.container.style.left = itemOuterWidth + 'px';
} }
}, },
getItemClass(item) { getItemClass(processedItem) {
return [ return [
'p-menuitem', 'p-menuitem',
item.class, this.getItemProp(processedItem, 'class'),
{ {
'p-menuitem-active': this.activeItem === item 'p-menuitem-active p-highlight': this.isItemActive(processedItem),
'p-focus': this.isItemFocused(processedItem),
'p-disabled': this.isItemDisabled(processedItem)
} }
]; ];
}, },
linkClass(item, routerProps) { getItemActionClass(processedItem, routerProps) {
return [ return [
'p-menuitem-link', 'p-menuitem-link',
{ {
'p-disabled': this.disabled(item),
'router-link-active': routerProps && routerProps.isActive, 'router-link-active': routerProps && routerProps.isActive,
'router-link-active-exact': this.exact && routerProps && routerProps.isExactActive 'router-link-active-exact': this.exact && routerProps && routerProps.isExactActive
} }
]; ];
}, },
visible(item) { getItemIconClass(processedItem) {
return typeof item.visible === 'function' ? item.visible() : item.visible !== false; return ['p-menuitem-icon', this.getItemProp(processedItem, 'icon')];
}, },
disabled(item) { getSeparatorItemClass(processedItem) {
return typeof item.disabled === 'function' ? item.disabled() : item.disabled; return ['p-menuitem-separator', this.getItemProp(processedItem, 'class')];
},
label(item) {
return typeof item.label === 'function' ? item.label() : item.label;
}
},
computed: {
containerClass() {
return { 'p-submenu-list': !this.root };
} }
}, },
directives: { directives: {

View File

@ -18,25 +18,25 @@
/> />
<component v-else-if="column.children && column.children.body && !column.children.editor && d_editing" :is="column.children.body" :data="editingRowData" :column="column" :field="field" :index="rowIndex" :frozenRow="frozenRow" /> <component v-else-if="column.children && column.children.body && !column.children.editor && d_editing" :is="column.children.body" :data="editingRowData" :column="column" :field="field" :index="rowIndex" :frozenRow="frozenRow" />
<template v-else-if="columnProp('selectionMode')"> <template v-else-if="columnProp('selectionMode')">
<DTRadioButton v-if="columnProp('selectionMode') === 'single'" :value="rowData" :checked="selected" @change="toggleRowWithRadio($event, rowIndex)" /> <DTRadioButton v-if="columnProp('selectionMode') === 'single'" :value="rowData" :name="name" :checked="selected" @change="toggleRowWithRadio($event, rowIndex)" />
<DTCheckbox v-else-if="columnProp('selectionMode') === 'multiple'" :value="rowData" :checked="selected" @change="toggleRowWithCheckbox($event, rowIndex)" /> <DTCheckbox v-else-if="columnProp('selectionMode') === 'multiple'" :value="rowData" :checked="selected" :aria-selected="selected ? true : undefined" @change="toggleRowWithCheckbox($event, rowIndex)" />
</template> </template>
<template v-else-if="columnProp('rowReorder')"> <template v-else-if="columnProp('rowReorder')">
<i :class="['p-datatable-reorderablerow-handle', columnProp('rowReorderIcon') || 'pi pi-bars']"></i> <i :class="['p-datatable-reorderablerow-handle', columnProp('rowReorderIcon') || 'pi pi-bars']"></i>
</template> </template>
<template v-else-if="columnProp('expander')"> <template v-else-if="columnProp('expander')">
<button v-ripple class="p-row-toggler p-link" @click="toggleRow" type="button"> <button v-ripple class="p-row-toggler p-link" type="button" :aria-expanded="isRowExpanded" :aria-controls="ariaControls" :aria-label="expandButtonAriaLabel" @click="toggleRow">
<span :class="rowTogglerIcon"></span> <span :class="rowTogglerIcon"></span>
</button> </button>
</template> </template>
<template v-else-if="editMode === 'row' && columnProp('rowEditor')"> <template v-else-if="editMode === 'row' && columnProp('rowEditor')">
<button v-if="!d_editing" v-ripple class="p-row-editor-init p-link" @click="onRowEditInit" type="button"> <button v-if="!d_editing" v-ripple class="p-row-editor-init p-link" type="button" :aria-label="initButtonAriaLabel" @click="onRowEditInit">
<span class="p-row-editor-init-icon pi pi-fw pi-pencil"></span> <span class="p-row-editor-init-icon pi pi-fw pi-pencil"></span>
</button> </button>
<button v-if="d_editing" v-ripple class="p-row-editor-save p-link" @click="onRowEditSave" type="button"> <button v-if="d_editing" v-ripple class="p-row-editor-save p-link" type="button" :aria-label="saveButtonAriaLabel" @click="onRowEditSave">
<span class="p-row-editor-save-icon pi pi-fw pi-check"></span> <span class="p-row-editor-save-icon pi pi-fw pi-check"></span>
</button> </button>
<button v-if="d_editing" v-ripple class="p-row-editor-cancel p-link" @click="onRowEditCancel" type="button"> <button v-if="d_editing" v-ripple class="p-row-editor-cancel p-link" type="button" :aria-label="cancelButtonAriaLabel" @click="onRowEditCancel">
<span class="p-row-editor-cancel-icon pi pi-fw pi-times"></span> <span class="p-row-editor-cancel-icon pi pi-fw pi-times"></span>
</button> </button>
</template> </template>
@ -45,11 +45,11 @@
</template> </template>
<script> <script>
import { DomHandler, ObjectUtils } from 'primevue/utils';
import OverlayEventBus from 'primevue/overlayeventbus'; import OverlayEventBus from 'primevue/overlayeventbus';
import RowRadioButton from './RowRadioButton.vue';
import RowCheckbox from './RowCheckbox.vue';
import Ripple from 'primevue/ripple'; import Ripple from 'primevue/ripple';
import { DomHandler, ObjectUtils } from 'primevue/utils';
import RowCheckbox from './RowCheckbox.vue';
import RowRadioButton from './RowRadioButton.vue';
export default { export default {
name: 'BodyCell', name: 'BodyCell',
@ -102,6 +102,14 @@ export default {
virtualScrollerContentProps: { virtualScrollerContentProps: {
type: Object, type: Object,
default: null default: null
},
ariaControls: {
type: String,
default: null
},
name: {
type: String,
default: null
} }
}, },
documentEditListener: null, documentEditListener: null,
@ -110,7 +118,8 @@ export default {
data() { data() {
return { return {
d_editing: this.editing, d_editing: this.editing,
styleObject: {} styleObject: {},
isRowExpanded: false
}; };
}, },
watch: { watch: {
@ -151,6 +160,7 @@ export default {
return ObjectUtils.resolveFieldData(this.rowData, this.field); return ObjectUtils.resolveFieldData(this.rowData, this.field);
}, },
toggleRow(event) { toggleRow(event) {
this.isRowExpanded = !this.isRowExpanded;
this.$emit('row-toggle', { this.$emit('row-toggle', {
originalEvent: event, originalEvent: event,
data: this.rowData data: this.rowData
@ -234,22 +244,25 @@ export default {
}, },
onKeyDown(event) { onKeyDown(event) {
if (this.editMode === 'cell') { if (this.editMode === 'cell') {
switch (event.which) { switch (event.code) {
case 13: case 'Enter':
this.completeEdit(event, 'enter'); this.completeEdit(event, 'enter');
break; break;
case 27: case 'Escape':
this.switchCellToViewMode(); this.switchCellToViewMode();
this.$emit('cell-edit-cancel', { originalEvent: event, data: this.rowData, field: this.field, index: this.rowIndex }); this.$emit('cell-edit-cancel', { originalEvent: event, data: this.rowData, field: this.field, index: this.rowIndex });
break; break;
case 9: case 'Tab':
this.completeEdit(event, 'tab'); this.completeEdit(event, 'tab');
if (event.shiftKey) this.moveToPreviousCell(event); if (event.shiftKey) this.moveToPreviousCell(event);
else this.moveToNextCell(event); else this.moveToNextCell(event);
break; break;
default:
break;
} }
} }
}, },
@ -422,6 +435,18 @@ export default {
field: this.field field: this.field
}) })
); );
},
expandButtonAriaLabel() {
return this.$primevue.config.locale.aria ? (this.isRowExpanded ? this.$primevue.config.locale.aria.expandRow : this.$primevue.config.locale.aria.collapseRow) : undefined;
},
initButtonAriaLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.editRow : undefined;
},
saveButtonAriaLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.saveEdit : undefined;
},
cancelButtonAriaLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.cancelEdit : undefined;
} }
}, },
components: { components: {

View File

@ -1,6 +1,6 @@
<template> <template>
<div :class="containerClass"> <div :class="containerClass">
<div v-if="display === 'row'" class="p-fluid p-column-filter-element"> <div v-if="display === 'row'" class="p-fluid p-column-filter-element" v-bind="filterInputProps">
<component :is="filterElement" :field="field" :filterModel="filters[field]" :filterCallback="filterCallback" /> <component :is="filterElement" :field="field" :filterModel="filters[field]" :filterCallback="filterCallback" />
</div> </div>
<button <button
@ -8,8 +8,10 @@
ref="icon" ref="icon"
type="button" type="button"
class="p-column-filter-menu-button p-link" class="p-column-filter-menu-button p-link"
:aria-label="filterMenuButtonAriaLabel"
aria-haspopup="true" aria-haspopup="true"
:aria-expanded="overlayVisible" :aria-expanded="overlayVisible"
:aria-controls="overlayId"
:class="{ 'p-column-filter-menu-button-open': overlayVisible, 'p-column-filter-menu-button-active': hasFilter() }" :class="{ 'p-column-filter-menu-button-open': overlayVisible, 'p-column-filter-menu-button-active': hasFilter() }"
@click="toggleMenu()" @click="toggleMenu()"
@keydown="onToggleButtonKeyDown($event)" @keydown="onToggleButtonKeyDown($event)"
@ -19,7 +21,18 @@
<button v-if="showClearButton && display === 'row'" :class="{ 'p-hidden-space': !hasRowFilter() }" type="button" class="p-column-filter-clear-button p-link" @click="clearFilter()"><span class="pi pi-filter-slash"></span></button> <button v-if="showClearButton && display === 'row'" :class="{ 'p-hidden-space': !hasRowFilter() }" type="button" class="p-column-filter-clear-button p-link" @click="clearFilter()"><span class="pi pi-filter-slash"></span></button>
<Portal> <Portal>
<transition name="p-connected-overlay" @enter="onOverlayEnter" @leave="onOverlayLeave" @after-leave="onOverlayAfterLeave"> <transition name="p-connected-overlay" @enter="onOverlayEnter" @leave="onOverlayLeave" @after-leave="onOverlayAfterLeave">
<div v-if="overlayVisible" :ref="overlayRef" :class="overlayClass" @keydown.escape="onEscape" @click="onContentClick" @mousedown="onContentMouseDown"> <div
v-if="overlayVisible"
:ref="overlayRef"
:id="overlayId"
v-focustrap="{ autoFocus: true }"
:aria-modal="overlayVisible"
role="dialog"
:class="overlayClass"
@keydown.escape="hide"
@click="onContentClick"
@mousedown="onContentMouseDown"
>
<component :is="filterHeaderTemplate" :field="field" :filterModel="filters[field]" :filterCallback="filterCallback" /> <component :is="filterHeaderTemplate" :field="field" :filterModel="filters[field]" :filterCallback="filterCallback" />
<template v-if="display === 'row'"> <template v-if="display === 'row'">
<ul class="p-column-filter-row-items"> <ul class="p-column-filter-row-items">
@ -41,7 +54,15 @@
</template> </template>
<template v-else> <template v-else>
<div v-if="isShowOperator" class="p-column-filter-operator"> <div v-if="isShowOperator" class="p-column-filter-operator">
<CFDropdown :options="operatorOptions" :modelValue="operator" @update:modelValue="onOperatorChange($event)" class="p-column-filter-operator-dropdown" optionLabel="label" optionValue="value"></CFDropdown> <CFDropdown
:options="operatorOptions"
:modelValue="operator"
:aria-label="filterOperatorAriaLabel"
class="p-column-filter-operator-dropdown"
optionLabel="label"
optionValue="value"
@update:modelValue="onOperatorChange($event)"
></CFDropdown>
</div> </div>
<div class="p-column-filter-constraints"> <div class="p-column-filter-constraints">
<div v-for="(fieldConstraint, i) of fieldConstraints" :key="i" class="p-column-filter-constraint"> <div v-for="(fieldConstraint, i) of fieldConstraints" :key="i" class="p-column-filter-constraint">
@ -49,10 +70,11 @@
v-if="isShowMatchModes" v-if="isShowMatchModes"
:options="matchModes" :options="matchModes"
:modelValue="fieldConstraint.matchMode" :modelValue="fieldConstraint.matchMode"
class="p-column-filter-matchmode-dropdown"
optionLabel="label" optionLabel="label"
optionValue="value" optionValue="value"
:aria-label="filterConstraintAriaLabel"
@update:modelValue="onMenuMatchModeChange($event, i)" @update:modelValue="onMenuMatchModeChange($event, i)"
class="p-column-filter-matchmode-dropdown"
></CFDropdown> ></CFDropdown>
<component v-if="display === 'menu'" :is="filterElement" :field="field" :filterModel="fieldConstraint" :filterCallback="filterCallback" /> <component v-if="display === 'menu'" :is="filterElement" :field="field" :filterModel="fieldConstraint" :filterCallback="filterCallback" />
<div> <div>
@ -71,10 +93,10 @@
<CFButton type="button" :label="addRuleButtonLabel" icon="pi pi-plus" class="p-column-filter-add-button p-button-text p-button-sm" @click="addConstraint()"></CFButton> <CFButton type="button" :label="addRuleButtonLabel" icon="pi pi-plus" class="p-column-filter-add-button p-button-text p-button-sm" @click="addConstraint()"></CFButton>
</div> </div>
<div class="p-column-filter-buttonbar"> <div class="p-column-filter-buttonbar">
<CFButton v-if="!filterClearTemplate && showClearButton" type="button" class="p-button-outlined p-button-sm" @click="clearFilter()" :label="clearButtonLabel"></CFButton> <CFButton v-if="!filterClearTemplate && showClearButton" type="button" class="p-button-outlined p-button-sm" :label="clearButtonLabel" @click="clearFilter"></CFButton>
<component v-else :is="filterClearTemplate" :field="field" :filterModel="filters[field]" :filterCallback="clearFilter" /> <component v-else :is="filterClearTemplate" :field="field" :filterModel="filters[field]" :filterCallback="clearFilter" />
<template v-if="showApplyButton"> <template v-if="showApplyButton">
<CFButton v-if="!filterApplyTemplate" type="button" class="p-button-sm" @click="applyFilter()" :label="applyButtonLabel"></CFButton> <CFButton v-if="!filterApplyTemplate" type="button" class="p-button-sm" :label="applyButtonLabel" @click="applyFilter()"></CFButton>
<component v-else :is="filterApplyTemplate" :field="field" :filterModel="filters[field]" :filterCallback="applyFilter" /> <component v-else :is="filterApplyTemplate" :field="field" :filterModel="filters[field]" :filterCallback="applyFilter" />
</template> </template>
</div> </div>
@ -87,12 +109,13 @@
</template> </template>
<script> <script>
import { DomHandler, ConnectedOverlayScrollHandler, ZIndexUtils } from 'primevue/utils';
import OverlayEventBus from 'primevue/overlayeventbus';
import { FilterOperator } from 'primevue/api'; import { FilterOperator } from 'primevue/api';
import Dropdown from 'primevue/dropdown';
import Button from 'primevue/button'; import Button from 'primevue/button';
import Dropdown from 'primevue/dropdown';
import FocusTrap from 'primevue/focustrap';
import OverlayEventBus from 'primevue/overlayeventbus';
import Portal from 'primevue/portal'; import Portal from 'primevue/portal';
import { ConnectedOverlayScrollHandler, DomHandler, UniqueComponentId, ZIndexUtils } from 'primevue/utils';
export default { export default {
name: 'ColumnFilter', name: 'ColumnFilter',
@ -166,6 +189,10 @@ export default {
filterMenuStyle: { filterMenuStyle: {
type: null, type: null,
default: null default: null
},
filterInputProps: {
type: null,
default: null
} }
}, },
data() { data() {
@ -251,34 +278,16 @@ export default {
this.overlayVisible = !this.overlayVisible; this.overlayVisible = !this.overlayVisible;
}, },
onToggleButtonKeyDown(event) { onToggleButtonKeyDown(event) {
switch (event.key) { switch (event.code) {
case 'Enter':
case 'Space':
this.toggleMenu();
event.preventDefault();
break;
case 'Escape': case 'Escape':
case 'Tab':
this.overlayVisible = false; this.overlayVisible = false;
break; break;
case 'ArrowDown':
if (this.overlayVisible) {
let focusable = DomHandler.getFocusableElements(this.overlay);
if (focusable) {
focusable[0].focus();
}
event.preventDefault();
} else if (event.altKey) {
this.overlayVisible = true;
event.preventDefault();
}
break;
}
},
onEscape() {
this.overlayVisible = false;
if (this.$refs.icon) {
this.$refs.icon.focus();
} }
}, },
onRowMatchModeChange(matchMode) { onRowMatchModeChange(matchMode) {
@ -293,7 +302,7 @@ export default {
onRowMatchModeKeyDown(event) { onRowMatchModeKeyDown(event) {
let item = event.target; let item = event.target;
switch (event.key) { switch (event.code) {
case 'ArrowDown': case 'ArrowDown':
var nextItem = this.findNextItem(item); var nextItem = this.findNextItem(item);
@ -379,11 +388,13 @@ export default {
findPrevItem(item) { findPrevItem(item) {
let prevItem = item.previousElementSibling; let prevItem = item.previousElementSibling;
if (prevItem) DomHandler.hasClass(prevItem, 'p-column-filter-separator') ? this.findPrevItem(prevItem) : prevItem; if (prevItem) return DomHandler.hasClass(prevItem, 'p-column-filter-separator') ? this.findPrevItem(prevItem) : prevItem;
else return item.parentElement.lastElementChild; else return item.parentElement.lastElementChild;
}, },
hide() { hide() {
this.overlayVisible = false; this.overlayVisible = false;
DomHandler.focus(this.$refs.icon);
}, },
onContentClick(event) { onContentClick(event) {
this.selfClick = true; this.selfClick = true;
@ -516,6 +527,9 @@ export default {
showMenuButton() { showMenuButton() {
return this.showMenu && (this.display === 'row' ? this.type !== 'boolean' : true); return this.showMenu && (this.display === 'row' ? this.type !== 'boolean' : true);
}, },
overlayId() {
return UniqueComponentId();
},
matchModes() { matchModes() {
return ( return (
this.matchModeOptions || this.matchModeOptions ||
@ -534,7 +548,7 @@ export default {
]; ];
}, },
noFilterLabel() { noFilterLabel() {
return this.$primevue.config.locale.noFilter; return this.$primevue.config.locale ? this.$primevue.config.locale.noFilter : undefined;
}, },
isShowOperator() { isShowOperator() {
return this.showOperator && this.filters[this.field].operator; return this.showOperator && this.filters[this.field].operator;
@ -549,25 +563,37 @@ export default {
return this.fieldConstraints.length > 1; return this.fieldConstraints.length > 1;
}, },
removeRuleButtonLabel() { removeRuleButtonLabel() {
return this.$primevue.config.locale.removeRule; return this.$primevue.config.locale ? this.$primevue.config.locale.removeRule : undefined;
}, },
addRuleButtonLabel() { addRuleButtonLabel() {
return this.$primevue.config.locale.addRule; return this.$primevue.config.locale ? this.$primevue.config.locale.addRule : undefined;
}, },
isShowAddConstraint() { isShowAddConstraint() {
return this.showAddButton && this.filters[this.field].operator && this.fieldConstraints && this.fieldConstraints.length < this.maxConstraints; return this.showAddButton && this.filters[this.field].operator && this.fieldConstraints && this.fieldConstraints.length < this.maxConstraints;
}, },
clearButtonLabel() { clearButtonLabel() {
return this.$primevue.config.locale.clear; return this.$primevue.config.locale ? this.$primevue.config.locale.clear : undefined;
}, },
applyButtonLabel() { applyButtonLabel() {
return this.$primevue.config.locale.apply; return this.$primevue.config.locale ? this.$primevue.config.locale.apply : undefined;
},
filterMenuButtonAriaLabel() {
return this.$primevue.config.locale ? (this.overlayVisible ? this.$primevue.config.locale.showFilterMenu : this.$primevue.config.locale.hideFilterMenu) : undefined;
},
filterOperatorAriaLabel() {
return this.$primevue.config.locale ? this.$primevue.config.locale.filterOperator : undefined;
},
filterConstraintAriaLabel() {
return this.$primevue.config.locale ? this.$primevue.config.locale.filterConstraint : undefined;
} }
}, },
components: { components: {
CFDropdown: Dropdown, CFDropdown: Dropdown,
CFButton: Button, CFButton: Button,
Portal: Portal Portal: Portal
},
directives: {
focustrap: FocusTrap
} }
}; };
</script> </script>

View File

@ -1,6 +1,5 @@
import { VNode } from 'vue'; import { InputHTMLAttributes, TableHTMLAttributes, VNode } from 'vue';
import { ClassComponent, GlobalComponentConstructor, Nullable } from '../ts-helpers'; import { ClassComponent, GlobalComponentConstructor, Nullable } from '../ts-helpers';
import Column from '../column';
import { VirtualScrollerProps } from '../virtualscroller'; import { VirtualScrollerProps } from '../virtualscroller';
type DataTablePaginatorPositionType = 'top' | 'bottom' | 'both' | undefined; type DataTablePaginatorPositionType = 'top' | 'bottom' | 'both' | undefined;
@ -511,7 +510,7 @@ export interface DataTableProps {
* - JumpToPageInput * - JumpToPageInput
* - CurrentPageReport * - CurrentPageReport
*/ */
paginatorTemplate?: string | undefined; paginatorTemplate?: any | string;
/** /**
* Number of page links to display. * Number of page links to display.
* Default value is 5. * Default value is 5.
@ -685,7 +684,7 @@ export interface DataTableProps {
/** /**
* One or more field names to use in row grouping. * One or more field names to use in row grouping.
*/ */
groupRowsBy?: string[] | string | undefined; groupRowsBy?: (field: string) => object | string[] | string | undefined;
/** /**
* Whether the row groups can be expandable. * Whether the row groups can be expandable.
*/ */
@ -776,6 +775,14 @@ export interface DataTableProps {
* Style class of the table element. * Style class of the table element.
*/ */
tableClass?: any; tableClass?: any;
/**
* Uses to pass all properties of the TableHTMLAttributes to table element inside the component.
*/
tableProps?: TableHTMLAttributes | undefined;
/**
* Uses to pass all properties of the HTMLInputElement to the focusable filter input element inside the component.
*/
filterInputProps?: InputHTMLAttributes | undefined;
} }
export interface DataTableSlots { export interface DataTableSlots {

View File

@ -1,11 +1,12 @@
import Button from '@/components/button/Button.vue';
import Column from '@/components/column/Column.vue';
import ColumnGroup from '@/components/columngroup/ColumnGroup.vue';
import InputText from '@/components/inputtext/InputText.vue';
import Row from '@/components/row/Row.vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import DataTable from './DataTable.vue';
import ColumnGroup from '../columngroup/ColumnGroup.vue';
import Row from '../row/Row.vue';
import Column from '../column/Column.vue';
import Button from '../button/Button.vue';
import InputText from '../inputtext/InputText.vue';
import { FilterMatchMode } from 'primevue/api'; import { FilterMatchMode } from 'primevue/api';
import PrimeVue from 'primevue/config';
import DataTable from './DataTable.vue';
window.URL.createObjectURL = function () {}; window.URL.createObjectURL = function () {};
@ -86,6 +87,7 @@ describe('DataTable.vue', () => {
beforeEach(() => { beforeEach(() => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column, Column,
Button Button
@ -284,6 +286,7 @@ describe('DataTable.vue', () => {
it('should single sort', async () => { it('should single sort', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -301,7 +304,7 @@ describe('DataTable.vue', () => {
const sortableTH = wrapper.findAll('.p-sortable-column')[0]; const sortableTH = wrapper.findAll('.p-sortable-column')[0];
const firstCellText = wrapper.findAll('.p-datatable-tbody > tr')[0].findAll('td')[1].text(); const firstCellText = wrapper.findAll('.p-datatable-tbody > tr')[0].findAll('td')[1].text();
const headerClick = vi.spyOn(wrapper.vm, 'onColumnHeaderClick'); const headerClick = jest.spyOn(wrapper.vm, 'onColumnHeaderClick');
await sortableTH.trigger('click'); await sortableTH.trigger('click');
@ -315,6 +318,7 @@ describe('DataTable.vue', () => {
it('should multiple sort', async () => { it('should multiple sort', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -333,7 +337,7 @@ describe('DataTable.vue', () => {
const sortableTHs = wrapper.findAll('.p-sortable-column'); const sortableTHs = wrapper.findAll('.p-sortable-column');
const firstCellText = wrapper.findAll('.p-datatable-tbody > tr')[0].findAll('td')[1].text(); const firstCellText = wrapper.findAll('.p-datatable-tbody > tr')[0].findAll('td')[1].text();
const headerClick = vi.spyOn(wrapper.vm, 'onColumnHeaderClick'); const headerClick = jest.spyOn(wrapper.vm, 'onColumnHeaderClick');
await sortableTHs[0].trigger('click'); await sortableTHs[0].trigger('click');
@ -355,6 +359,7 @@ describe('DataTable.vue', () => {
it('should have presort', async () => { it('should have presort', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -381,6 +386,7 @@ describe('DataTable.vue', () => {
it('should remove sort', async () => { it('should remove sort', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -509,6 +515,7 @@ describe('DataTable.vue', () => {
it('should select when radiobutton selection is enabled', async () => { it('should select when radiobutton selection is enabled', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -544,6 +551,7 @@ describe('DataTable.vue', () => {
it('should select when checkbox selection is enabled', async () => { it('should select when checkbox selection is enabled', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -577,6 +585,7 @@ describe('DataTable.vue', () => {
it('should select all rows', async () => { it('should select all rows', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -603,6 +612,7 @@ describe('DataTable.vue', () => {
it('should unselect all rows', async () => { it('should unselect all rows', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -663,6 +673,7 @@ describe('DataTable.vue', () => {
wrapper = null; wrapper = null;
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -719,6 +730,7 @@ describe('DataTable.vue', () => {
it('should init row editing', async () => { it('should init row editing', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column, Column,
InputText InputText
@ -759,6 +771,7 @@ describe('DataTable.vue', () => {
it('should save row editing', async () => { it('should save row editing', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column, Column,
InputText InputText
@ -795,6 +808,7 @@ describe('DataTable.vue', () => {
it('should cancel row editing', async () => { it('should cancel row editing', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column, Column,
InputText InputText
@ -832,6 +846,7 @@ describe('DataTable.vue', () => {
it('should fit mode expanding exists', () => { it('should fit mode expanding exists', () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -857,6 +872,7 @@ describe('DataTable.vue', () => {
it('should fit mode resize start', async () => { it('should fit mode resize start', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -885,6 +901,7 @@ describe('DataTable.vue', () => {
it('should fit mode resize', async () => { it('should fit mode resize', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -910,6 +927,7 @@ describe('DataTable.vue', () => {
it('should fit mode column resize end', async () => { it('should fit mode column resize end', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -939,6 +957,7 @@ describe('DataTable.vue', () => {
it('should expand mode resize start', async () => { it('should expand mode resize start', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -967,6 +986,7 @@ describe('DataTable.vue', () => {
it('should fit mode resize', async () => { it('should fit mode resize', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -992,6 +1012,7 @@ describe('DataTable.vue', () => {
it('should fit mode column resize end', async () => { it('should fit mode column resize end', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -1030,6 +1051,7 @@ describe('DataTable.vue', () => {
it('should exist', () => { it('should exist', () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -1054,6 +1076,7 @@ describe('DataTable.vue', () => {
it('should exist', () => { it('should exist', () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -1085,6 +1108,7 @@ describe('DataTable.vue', () => {
it('should have groupheader templating', () => { it('should have groupheader templating', () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -1115,6 +1139,7 @@ describe('DataTable.vue', () => {
it('should have groupfooter templating', () => { it('should have groupfooter templating', () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -1145,6 +1170,7 @@ describe('DataTable.vue', () => {
it('should have expandable row groups and expand rows', async () => { it('should have expandable row groups and expand rows', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -1270,6 +1296,7 @@ describe('DataTable.vue', () => {
it('should have rowspan grouping', async () => { it('should have rowspan grouping', async () => {
wrapper = mount(DataTable, { wrapper = mount(DataTable, {
global: { global: {
plugins: [PrimeVue],
components: { components: {
Column Column
} }
@ -1338,7 +1365,7 @@ describe('DataTable.vue', () => {
// export // export
it('should export table', async () => { it('should export table', async () => {
const exportCSV = vi.spyOn(wrapper.vm, 'exportCSV'); const exportCSV = jest.spyOn(wrapper.vm, 'exportCSV');
await wrapper.vm.exportCSV(); await wrapper.vm.exportCSV();
@ -1355,8 +1382,8 @@ describe('DataTable.vue', () => {
}); });
it('should save session storage', async () => { it('should save session storage', async () => {
vi.spyOn(window.sessionStorage.__proto__, 'setItem'); jest.spyOn(window.sessionStorage.__proto__, 'setItem');
window.sessionStorage.__proto__.setItem = vi.fn(); window.sessionStorage.__proto__.setItem = jest.fn();
await wrapper.vm.saveState(); await wrapper.vm.saveState();
@ -1365,8 +1392,8 @@ describe('DataTable.vue', () => {
}); });
it('should save local storage', async () => { it('should save local storage', async () => {
vi.spyOn(window.localStorage.__proto__, 'setItem'); jest.spyOn(window.localStorage.__proto__, 'setItem');
window.localStorage.__proto__.setItem = vi.fn(); window.localStorage.__proto__.setItem = jest.fn();
await wrapper.vm.saveState(); await wrapper.vm.saveState();

View File

@ -31,7 +31,7 @@
<div class="p-datatable-wrapper" :style="{ maxHeight: virtualScrollerDisabled ? scrollHeight : '' }"> <div class="p-datatable-wrapper" :style="{ maxHeight: virtualScrollerDisabled ? scrollHeight : '' }">
<DTVirtualScroller ref="virtualScroller" v-bind="virtualScrollerOptions" :items="processedData" :columns="columns" :style="{ height: scrollHeight }" :disabled="virtualScrollerDisabled" loaderDisabled :showSpacer="false"> <DTVirtualScroller ref="virtualScroller" v-bind="virtualScrollerOptions" :items="processedData" :columns="columns" :style="{ height: scrollHeight }" :disabled="virtualScrollerDisabled" loaderDisabled :showSpacer="false">
<template #content="slotProps"> <template #content="slotProps">
<table ref="table" role="table" :class="[tableClass, 'p-datatable-table']" :style="[tableStyle, slotProps.spacerStyle]"> <table ref="table" role="table" :class="[tableClass, 'p-datatable-table']" :style="[tableStyle, slotProps.spacerStyle]" v-bind="tableProps">
<DTTableHeader <DTTableHeader
:columnGroup="headerColumnGroup" :columnGroup="headerColumnGroup"
:columns="slotProps.columns" :columns="slotProps.columns"
@ -49,6 +49,7 @@
:filters="d_filters" :filters="d_filters"
:filtersStore="filters" :filtersStore="filters"
:filterDisplay="filterDisplay" :filterDisplay="filterDisplay"
:filterInputProps="filterInputProps"
@column-click="onColumnHeaderClick($event)" @column-click="onColumnHeaderClick($event)"
@column-mousedown="onColumnHeaderMouseDown($event)" @column-mousedown="onColumnHeaderMouseDown($event)"
@filter-change="onFilterChange" @filter-change="onFilterChange"
@ -90,6 +91,7 @@
:editingRowKeys="d_editingRowKeys" :editingRowKeys="d_editingRowKeys"
:templates="$slots" :templates="$slots"
:responsiveLayout="responsiveLayout" :responsiveLayout="responsiveLayout"
:isVirtualScrollerDisabled="true"
@rowgroup-toggle="toggleRowGroup" @rowgroup-toggle="toggleRowGroup"
@row-click="onRowClick($event)" @row-click="onRowClick($event)"
@row-dblclick="onRowDblClick($event)" @row-dblclick="onRowDblClick($event)"
@ -113,7 +115,6 @@
@row-edit-cancel="onRowEditCancel($event)" @row-edit-cancel="onRowEditCancel($event)"
:editingMeta="d_editingMeta" :editingMeta="d_editingMeta"
@editing-meta-change="onEditingMetaChange" @editing-meta-change="onEditingMetaChange"
:isVirtualScrollerDisabled="true"
/> />
<DTTableBody <DTTableBody
ref="bodyRef" ref="bodyRef"
@ -144,12 +145,14 @@
:editingRowKeys="d_editingRowKeys" :editingRowKeys="d_editingRowKeys"
:templates="$slots" :templates="$slots"
:responsiveLayout="responsiveLayout" :responsiveLayout="responsiveLayout"
:virtualScrollerContentProps="slotProps"
:isVirtualScrollerDisabled="virtualScrollerDisabled"
@rowgroup-toggle="toggleRowGroup" @rowgroup-toggle="toggleRowGroup"
@row-click="onRowClick($event)" @row-click="onRowClick($event)"
@row-dblclick="onRowDblClick($event)" @row-dblclick="onRowDblClick($event)"
@row-rightclick="onRowRightClick($event)" @row-rightclick="onRowRightClick($event)"
@row-touchend="onRowTouchEnd" @row-touchend="onRowTouchEnd"
@row-keydown="onRowKeyDown" @row-keydown="onRowKeyDown($event, slotProps)"
@row-mousedown="onRowMouseDown" @row-mousedown="onRowMouseDown"
@row-dragstart="onRowDragStart($event)" @row-dragstart="onRowDragStart($event)"
@row-dragover="onRowDragOver($event)" @row-dragover="onRowDragOver($event)"
@ -167,8 +170,6 @@
@row-edit-cancel="onRowEditCancel($event)" @row-edit-cancel="onRowEditCancel($event)"
:editingMeta="d_editingMeta" :editingMeta="d_editingMeta"
@editing-meta-change="onEditingMetaChange" @editing-meta-change="onEditingMetaChange"
:virtualScrollerContentProps="slotProps"
:isVirtualScrollerDisabled="virtualScrollerDisabled"
/> />
<DTTableFooter :columnGroup="footerColumnGroup" :columns="slotProps.columns" /> <DTTableFooter :columnGroup="footerColumnGroup" :columns="slotProps.columns" />
</table> </table>
@ -205,13 +206,13 @@
</template> </template>
<script> <script>
import { ObjectUtils, DomHandler, UniqueComponentId } from 'primevue/utils';
import { FilterMatchMode, FilterOperator, FilterService } from 'primevue/api'; import { FilterMatchMode, FilterOperator, FilterService } from 'primevue/api';
import Paginator from 'primevue/paginator'; import Paginator from 'primevue/paginator';
import { DomHandler, ObjectUtils, UniqueComponentId } from 'primevue/utils';
import VirtualScroller from 'primevue/virtualscroller'; import VirtualScroller from 'primevue/virtualscroller';
import TableHeader from './TableHeader.vue';
import TableBody from './TableBody.vue'; import TableBody from './TableBody.vue';
import TableFooter from './TableFooter.vue'; import TableFooter from './TableFooter.vue';
import TableHeader from './TableHeader.vue';
export default { export default {
name: 'DataTable', name: 'DataTable',
@ -289,7 +290,7 @@ export default {
default: true default: true
}, },
paginatorTemplate: { paginatorTemplate: {
type: String, type: [Object, String],
default: 'FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown' default: 'FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown'
}, },
pageLinkSize: { pageLinkSize: {
@ -433,7 +434,7 @@ export default {
default: null default: null
}, },
groupRowsBy: { groupRowsBy: {
type: [Array, String], type: [Array, String, Function],
default: null default: null
}, },
expandableRowGroups: { expandableRowGroups: {
@ -511,6 +512,14 @@ export default {
tableClass: { tableClass: {
type: String, type: String,
default: null default: null
},
tableProps: {
type: null,
default: null
},
filterInputProps: {
type: null,
default: null
} }
}, },
data() { data() {
@ -864,6 +873,9 @@ export default {
}, },
onRowClick(e) { onRowClick(e) {
const event = e.originalEvent; const event = e.originalEvent;
const index = e.index;
const body = this.$refs.bodyRef && this.$refs.bodyRef.$el;
const focusedItem = DomHandler.findSingle(body, 'tr.p-selectable-row[tabindex="0"]');
if (DomHandler.isClickable(event.target)) { if (DomHandler.isClickable(event.target)) {
return; return;
@ -940,6 +952,11 @@ export default {
} }
this.rowTouched = false; this.rowTouched = false;
if (focusedItem) {
focusedItem.tabIndex = '-1';
DomHandler.find(body, 'tr.p-selectable-row')[index].tabIndex = '0';
}
}, },
onRowDblClick(e) { onRowDblClick(e) {
const event = e.originalEvent; const event = e.originalEvent;
@ -960,48 +977,153 @@ export default {
onRowTouchEnd() { onRowTouchEnd() {
this.rowTouched = true; this.rowTouched = true;
}, },
onRowKeyDown(e) { onRowKeyDown(e, slotProps) {
const event = e.originalEvent; const event = e.originalEvent;
const rowData = e.data; const rowData = e.data;
const rowIndex = e.index; const rowIndex = e.index;
const metaKey = event.metaKey || event.ctrlKey;
if (this.selectionMode) { if (this.selectionMode) {
const row = event.target; const row = event.target;
switch (event.which) { switch (event.code) {
//down arrow case 'ArrowDown':
case 40: this.onArrowDownKey(event, row, rowIndex, slotProps);
var nextRow = this.findNextSelectableRow(row);
if (nextRow) {
nextRow.focus();
}
event.preventDefault();
break; break;
//up arrow case 'ArrowUp':
case 38: this.onArrowUpKey(event, row, rowIndex, slotProps);
var prevRow = this.findPrevSelectableRow(row);
if (prevRow) {
prevRow.focus();
}
event.preventDefault();
break; break;
//enter case 'Home':
case 13: this.onHomeKey(event, row, rowIndex, slotProps);
this.onRowClick({ originalEvent: event, data: rowData, index: rowIndex }); break;
case 'End':
this.onEndKey(event, row, rowIndex, slotProps);
break;
case 'Enter':
this.onEnterKey(event, rowData, rowIndex);
break;
case 'Space':
this.onSpaceKey(event, rowData, rowIndex, slotProps);
break;
case 'Tab':
this.onTabKey(event, rowIndex);
break; break;
default: default:
//no op if (event.code === 'KeyA' && metaKey) {
const data = this.dataToRender(slotProps.rows);
this.$emit('update:selection', data);
}
break; break;
} }
} }
}, },
onArrowDownKey(event, row, rowIndex, slotProps) {
const nextRow = this.findNextSelectableRow(row);
nextRow && this.focusRowChange(row, nextRow);
if (event.shiftKey) {
const data = this.dataToRender(slotProps.rows);
const nextRowIndex = rowIndex + 1 >= data.length ? data.length - 1 : rowIndex + 1;
this.onRowClick({ originalEvent: event, data: data[nextRowIndex], index: nextRowIndex });
}
event.preventDefault();
},
onArrowUpKey(event, row, rowIndex, slotProps) {
const prevRow = this.findPrevSelectableRow(row);
prevRow && this.focusRowChange(row, prevRow);
if (event.shiftKey) {
const data = this.dataToRender(slotProps.rows);
const prevRowIndex = rowIndex - 1 <= 0 ? 0 : rowIndex - 1;
this.onRowClick({ originalEvent: event, data: data[prevRowIndex], index: prevRowIndex });
}
event.preventDefault();
},
onHomeKey(event, row, rowIndex, slotProps) {
const firstRow = this.findFirstSelectableRow();
firstRow && this.focusRowChange(row, firstRow);
if (event.ctrlKey && event.shiftKey) {
const data = this.dataToRender(slotProps.rows);
this.$emit('update:selection', data.slice(0, rowIndex + 1));
}
event.preventDefault();
},
onEndKey(event, row, rowIndex, slotProps) {
const lastRow = this.findLastSelectableRow();
lastRow && this.focusRowChange(row, lastRow);
if (event.ctrlKey && event.shiftKey) {
const data = this.dataToRender(slotProps.rows);
this.$emit('update:selection', data.slice(rowIndex, data.length));
}
event.preventDefault();
},
onEnterKey(event, rowData, rowIndex) {
this.onRowClick({ originalEvent: event, data: rowData, index: rowIndex });
event.preventDefault();
},
onSpaceKey(event, rowData, rowIndex, slotProps) {
this.onEnterKey(event, rowData, rowIndex);
if (event.shiftKey && this.selection !== null) {
const data = this.dataToRender(slotProps.rows);
let index;
if (this.selection.length > 0) {
let firstSelectedRowIndex, lastSelectedRowIndex;
firstSelectedRowIndex = ObjectUtils.findIndexInList(this.selection[0], data);
lastSelectedRowIndex = ObjectUtils.findIndexInList(this.selection[this.selection.length - 1], data);
index = rowIndex <= firstSelectedRowIndex ? lastSelectedRowIndex : firstSelectedRowIndex;
} else {
index = ObjectUtils.findIndexInList(this.selection, data);
}
const _selection = index !== rowIndex ? data.slice(Math.min(index, rowIndex), Math.max(index, rowIndex) + 1) : rowData;
this.$emit('update:selection', _selection);
}
},
onTabKey(event, rowIndex) {
const body = this.$refs.bodyRef && this.$refs.bodyRef.$el;
const rows = DomHandler.find(body, 'tr.p-selectable-row');
if (event.code === 'Tab' && rows && rows.length > 0) {
const firstSelectedRow = DomHandler.findSingle(body, 'tr.p-highlight');
const focusedItem = DomHandler.findSingle(body, 'tr.p-selectable-row[tabindex="0"]');
if (firstSelectedRow) {
firstSelectedRow.tabIndex = '0';
focusedItem !== firstSelectedRow && (focusedItem.tabIndex = '-1');
} else {
rows[0].tabIndex = '0';
focusedItem !== rows[0] && (rows[rowIndex].tabIndex = '-1');
}
}
},
findNextSelectableRow(row) { findNextSelectableRow(row) {
let nextRow = row.nextElementSibling; let nextRow = row.nextElementSibling;
@ -1022,6 +1144,21 @@ export default {
return null; return null;
} }
}, },
findFirstSelectableRow() {
const firstRow = DomHandler.findSingle(this.$refs.table, '.p-selectable-row');
return firstRow;
},
findLastSelectableRow() {
const rows = DomHandler.find(this.$refs.table, '.p-selectable-row');
return rows ? rows[rows.length - 1] : null;
},
focusRowChange(firstFocusableRow, currentFocusedRow) {
firstFocusableRow.tabIndex = '-1';
currentFocusedRow.tabIndex = '0';
DomHandler.focus(currentFocusedRow);
},
toggleRowWithRadio(event) { toggleRowWithRadio(event) {
const rowData = event.data; const rowData = event.data;

View File

@ -3,7 +3,10 @@
:style="containerStyle" :style="containerStyle"
:class="containerClass" :class="containerClass"
:tabindex="columnProp('sortable') ? '0' : null" :tabindex="columnProp('sortable') ? '0' : null"
role="cell" role="columnheader"
:colspan="columnProp('colspan')"
:rowspan="columnProp('rowspan')"
:aria-sort="ariaSort"
@click="onClick" @click="onClick"
@keydown="onKeyDown" @keydown="onKeyDown"
@mousedown="onMouseDown" @mousedown="onMouseDown"
@ -11,9 +14,6 @@
@dragover="onDragOver" @dragover="onDragOver"
@dragleave="onDragLeave" @dragleave="onDragLeave"
@drop="onDrop" @drop="onDrop"
:colspan="columnProp('colspan')"
:rowspan="columnProp('rowspan')"
:aria-sort="ariaSort"
> >
<span v-if="resizableColumns && !columnProp('frozen')" class="p-column-resizer" @mousedown="onResizeStart"></span> <span v-if="resizableColumns && !columnProp('frozen')" class="p-column-resizer" @mousedown="onResizeStart"></span>
<div class="p-column-header-content"> <div class="p-column-header-content">
@ -35,6 +35,7 @@
:filterApplyTemplate="column.children && column.children.filterapply" :filterApplyTemplate="column.children && column.children.filterapply"
:filters="filters" :filters="filters"
:filtersStore="filtersStore" :filtersStore="filtersStore"
:filterInputProps="filterInputProps"
@filter-change="$emit('filter-change', $event)" @filter-change="$emit('filter-change', $event)"
@filter-apply="$emit('filter-apply')" @filter-apply="$emit('filter-apply')"
:filterMenuStyle="columnProp('filterMenuStyle')" :filterMenuStyle="columnProp('filterMenuStyle')"
@ -58,8 +59,8 @@
<script> <script>
import { DomHandler, ObjectUtils } from 'primevue/utils'; import { DomHandler, ObjectUtils } from 'primevue/utils';
import HeaderCheckbox from './HeaderCheckbox.vue';
import ColumnFilter from './ColumnFilter.vue'; import ColumnFilter from './ColumnFilter.vue';
import HeaderCheckbox from './HeaderCheckbox.vue';
export default { export default {
name: 'HeaderCell', name: 'HeaderCell',
@ -91,7 +92,7 @@ export default {
default: false default: false
}, },
groupRowsBy: { groupRowsBy: {
type: [Array, String], type: [Array, String, Function],
default: null default: null
}, },
sortMode: { sortMode: {
@ -141,6 +142,10 @@ export default {
reorderableColumns: { reorderableColumns: {
type: Boolean, type: Boolean,
default: false default: false
},
filterInputProps: {
type: null,
default: null
} }
}, },
data() { data() {
@ -166,8 +171,9 @@ export default {
this.$emit('column-click', { originalEvent: event, column: this.column }); this.$emit('column-click', { originalEvent: event, column: this.column });
}, },
onKeyDown(event) { onKeyDown(event) {
if (event.which === 13 && event.currentTarget.nodeName === 'TH' && DomHandler.hasClass(event.currentTarget, 'p-sortable-column')) { if ((event.code === 'Enter' || event.code === 'Space') && event.currentTarget.nodeName === 'TH' && DomHandler.hasClass(event.currentTarget, 'p-sortable-column')) {
this.$emit('column-click', { originalEvent: event, column: this.column }); this.$emit('column-click', { originalEvent: event, column: this.column });
event.preventDefault();
} }
}, },
onMouseDown(event) { onMouseDown(event) {

View File

@ -1,26 +1,23 @@
<template> <template>
<div :class="['p-checkbox p-component', { 'p-checkbox-focused': focused, 'p-disabled': $attrs.disabled }]" @click="onClick" @keydown.space.prevent="onClick"> <div :class="['p-checkbox p-component', { 'p-checkbox-focused': focused, 'p-disabled': disabled }]" @click="onClick" @keydown.space.prevent="onClick">
<div <div class="p-hidden-accessible">
ref="box" <input ref="input" type="checkbox" :checked="checked" :disabled="disabled" :tabindex="disabled ? null : '0'" :aria-label="headerCheckboxAriaLabel" @focus="onFocus($event)" @blur="onBlur($event)" />
:class="['p-checkbox-box p-component', { 'p-highlight': checked, 'p-disabled': $attrs.disabled, 'p-focus': focused }]" </div>
role="checkbox" <div ref="box" :class="['p-checkbox-box p-component', { 'p-highlight': checked, 'p-disabled': disabled, 'p-focus': focused }]">
:aria-checked="checked"
:tabindex="$attrs.disabled ? null : '0'"
@focus="onFocus($event)"
@blur="onBlur($event)"
>
<span :class="['p-checkbox-icon', { 'pi pi-check': checked }]"></span> <span :class="['p-checkbox-icon', { 'pi pi-check': checked }]"></span>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { DomHandler } from 'primevue/utils';
export default { export default {
name: 'HeaderCheckbox', name: 'HeaderCheckbox',
inheritAttrs: false,
emits: ['change'], emits: ['change'],
props: { props: {
checked: null checked: null,
disabled: null
}, },
data() { data() {
return { return {
@ -29,12 +26,13 @@ export default {
}, },
methods: { methods: {
onClick(event) { onClick(event) {
if (!this.$attrs.disabled) { if (!this.disabled) {
this.focused = true;
this.$emit('change', { this.$emit('change', {
originalEvent: event, originalEvent: event,
checked: !this.checked checked: !this.checked
}); });
DomHandler.focus(this.$refs.input);
} }
}, },
onFocus() { onFocus() {
@ -43,6 +41,11 @@ export default {
onBlur() { onBlur() {
this.focused = false; this.focused = false;
} }
},
computed: {
headerCheckboxAriaLabel() {
return this.$primevue.config.locale.aria ? (this.checked ? this.$primevue.config.locale.aria.selectAll : this.$primevue.config.locale.aria.unselectAll) : undefined;
}
} }
}; };
</script> </script>

View File

@ -1,24 +1,19 @@
<template> <template>
<div :class="['p-checkbox p-component', { 'p-checkbox-focused': focused }]" @click.stop.prevent="onClick"> <div :class="['p-checkbox p-component', { 'p-checkbox-focused': focused }]" @click="onClick">
<div <div class="p-hidden-accessible">
ref="box" <input ref="input" type="checkbox" :checked="checked" :disabled="$attrs.disabled" :tabindex="$attrs.disabled ? null : '0'" :aria-label="checkboxAriaLabel" @focus="onFocus($event)" @blur="onBlur($event)" @keydown="onKeydown" />
:class="['p-checkbox-box p-component', { 'p-highlight': checked, 'p-disabled': $attrs.disabled, 'p-focus': focused }]" </div>
role="checkbox" <div ref="box" :class="['p-checkbox-box p-component', { 'p-highlight': checked, 'p-disabled': $attrs.disabled, 'p-focus': focused }]">
:aria-checked="checked"
:tabindex="$attrs.disabled ? null : '0'"
@keydown.space.prevent="onClick"
@focus="onFocus($event)"
@blur="onBlur($event)"
>
<span :class="['p-checkbox-icon', { 'pi pi-check': checked }]"></span> <span :class="['p-checkbox-icon', { 'pi pi-check': checked }]"></span>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { DomHandler } from 'primevue/utils';
export default { export default {
name: 'RowCheckbox', name: 'RowCheckbox',
inheritAttrs: false,
emits: ['change'], emits: ['change'],
props: { props: {
value: null, value: null,
@ -32,18 +27,38 @@ export default {
methods: { methods: {
onClick(event) { onClick(event) {
if (!this.$attrs.disabled) { if (!this.$attrs.disabled) {
this.focused = true;
this.$emit('change', { this.$emit('change', {
originalEvent: event, originalEvent: event,
data: this.value data: this.value
}); });
DomHandler.focus(this.$refs.input);
} }
event.preventDefault();
}, },
onFocus() { onFocus() {
this.focused = true; this.focused = true;
}, },
onBlur() { onBlur() {
this.focused = false; this.focused = false;
},
onKeydown(event) {
switch (event.code) {
case 'Space': {
this.onClick(event);
break;
}
default:
break;
}
}
},
computed: {
checkboxAriaLabel() {
return this.$primevue.config.locale.aria ? (this.checked ? this.$primevue.config.locale.aria.selectRow : this.$primevue.config.locale.aria.unselectRow) : undefined;
} }
} }
}; };

View File

@ -1,19 +1,25 @@
<template> <template>
<div :class="['p-radiobutton p-component', { 'p-radiobutton-focused': focused }]" @click="onClick" tabindex="0" @focus="onFocus($event)" @blur="onBlur($event)" @keydown.space.prevent="onClick"> <div :class="['p-radiobutton p-component', { 'p-radiobutton-focused': focused }]" @click="onClick">
<div ref="box" :class="['p-radiobutton-box p-component', { 'p-highlight': checked, 'p-disabled': $attrs.disabled, 'p-focus': focused }]" role="radio" :aria-checked="checked"> <div class="p-hidden-accessible">
<input ref="input" type="radio" :checked="checked" :disabled="$attrs.disabled" :name="name" tabindex="0" @focus="onFocus($event)" @blur="onBlur($event)" @keydown.space.prevent="onClick" />
</div>
<div ref="box" :class="['p-radiobutton-box p-component', { 'p-highlight': checked, 'p-disabled': $attrs.disabled, 'p-focus': focused }]">
<div class="p-radiobutton-icon"></div> <div class="p-radiobutton-icon"></div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { DomHandler } from 'primevue/utils';
export default { export default {
name: 'RowRadioButton', name: 'RowRadioButton',
inheritAttrs: false, inheritAttrs: false,
emits: ['change'], emits: ['change'],
props: { props: {
value: null, value: null,
checked: null checked: null,
name: null
}, },
data() { data() {
return { return {
@ -28,6 +34,8 @@ export default {
originalEvent: event, originalEvent: event,
data: this.value data: this.value
}); });
DomHandler.focus(this.$refs.input);
} }
} }
}, },

View File

@ -15,19 +15,20 @@
:key="getRowKey(rowData, getRowIndex(index))" :key="getRowKey(rowData, getRowIndex(index))"
:class="getRowClass(rowData)" :class="getRowClass(rowData)"
:style="rowStyle" :style="rowStyle"
:tabindex="setRowTabindex(index)"
role="row"
:aria-selected="selectionMode ? isSelected(rowData) : null"
@click="onRowClick($event, rowData, getRowIndex(index))" @click="onRowClick($event, rowData, getRowIndex(index))"
@dblclick="onRowDblClick($event, rowData, getRowIndex(index))" @dblclick="onRowDblClick($event, rowData, getRowIndex(index))"
@contextmenu="onRowRightClick($event, rowData, getRowIndex(index))" @contextmenu="onRowRightClick($event, rowData, getRowIndex(index))"
@touchend="onRowTouchEnd($event)" @touchend="onRowTouchEnd($event)"
@keydown="onRowKeyDown($event, rowData, getRowIndex(index))" @keydown="onRowKeyDown($event, rowData, getRowIndex(index))"
:tabindex="selectionMode || contextMenu ? '0' : null"
@mousedown="onRowMouseDown($event)" @mousedown="onRowMouseDown($event)"
@dragstart="onRowDragStart($event, getRowIndex(index))" @dragstart="onRowDragStart($event, getRowIndex(index))"
@dragover="onRowDragOver($event, getRowIndex(index))" @dragover="onRowDragOver($event, getRowIndex(index))"
@dragleave="onRowDragLeave($event)" @dragleave="onRowDragLeave($event)"
@dragend="onRowDragEnd($event)" @dragend="onRowDragEnd($event)"
@drop="onRowDrop($event)" @drop="onRowDrop($event)"
role="row"
> >
<template v-for="(col, i) of columns" :key="columnProp(col, 'columnKey') || columnProp(col, 'field') || i"> <template v-for="(col, i) of columns" :key="columnProp(col, 'columnKey') || columnProp(col, 'field') || i">
<DTBodyCell <DTBodyCell
@ -42,7 +43,11 @@
:rowspan="rowGroupMode === 'rowspan' ? calculateRowGroupSize(value, col, getRowIndex(index)) : null" :rowspan="rowGroupMode === 'rowspan' ? calculateRowGroupSize(value, col, getRowIndex(index)) : null"
:editMode="editMode" :editMode="editMode"
:editing="editMode === 'row' && isRowEditing(rowData)" :editing="editMode === 'row' && isRowEditing(rowData)"
:editingMeta="editingMeta"
:responsiveLayout="responsiveLayout" :responsiveLayout="responsiveLayout"
:virtualScrollerContentProps="virtualScrollerContentProps"
:ariaControls="expandedRowId + '_' + index + '_expansion'"
:name="nameAttributeSelector"
@radio-change="onRadioChange($event)" @radio-change="onRadioChange($event)"
@checkbox-change="onCheckboxChange($event)" @checkbox-change="onCheckboxChange($event)"
@row-toggle="onRowToggle($event)" @row-toggle="onRowToggle($event)"
@ -52,13 +57,11 @@
@row-edit-init="onRowEditInit($event)" @row-edit-init="onRowEditInit($event)"
@row-edit-save="onRowEditSave($event)" @row-edit-save="onRowEditSave($event)"
@row-edit-cancel="onRowEditCancel($event)" @row-edit-cancel="onRowEditCancel($event)"
:editingMeta="editingMeta"
@editing-meta-change="onEditingMetaChange" @editing-meta-change="onEditingMetaChange"
:virtualScrollerContentProps="virtualScrollerContentProps"
/> />
</template> </template>
</tr> </tr>
<tr v-if="templates['expansion'] && expandedRows && isRowExpanded(rowData)" :key="getRowKey(rowData, getRowIndex(index)) + '_expansion'" class="p-datatable-row-expansion" role="row"> <tr v-if="templates['expansion'] && expandedRows && isRowExpanded(rowData)" :key="getRowKey(rowData, getRowIndex(index)) + '_expansion'" :id="expandedRowId + '_' + index + '_expansion'" class="p-datatable-row-expansion" role="row">
<td :colspan="columnsLength"> <td :colspan="columnsLength">
<component :is="templates['expansion']" :data="rowData" :index="getRowIndex(index)" /> <component :is="templates['expansion']" :data="rowData" :index="getRowIndex(index)" />
</td> </td>
@ -77,7 +80,7 @@
</template> </template>
<script> <script>
import { ObjectUtils, DomHandler } from 'primevue/utils'; import { DomHandler, ObjectUtils, UniqueComponentId } from 'primevue/utils';
import BodyCell from './BodyCell.vue'; import BodyCell from './BodyCell.vue';
export default { export default {
@ -128,7 +131,7 @@ export default {
default: null default: null
}, },
groupRowsBy: { groupRowsBy: {
type: [Array, String], type: [Array, String, Function],
default: null default: null
}, },
expandableRowGroups: { expandableRowGroups: {
@ -230,7 +233,9 @@ export default {
}, },
data() { data() {
return { return {
rowGroupHeaderStyleObject: {} rowGroupHeaderStyleObject: {},
tabindexArray: [],
isARowSelected: false
}; };
}, },
watch: { watch: {
@ -548,6 +553,13 @@ export default {
const contentRef = this.getVirtualScrollerProp('contentRef'); const contentRef = this.getVirtualScrollerProp('contentRef');
contentRef && contentRef(el); contentRef && contentRef(el);
},
setRowTabindex(index) {
if (this.selection === null && (this.selectionMode === 'single' || this.selectionMode === 'multiple')) {
return index === 0 ? 0 : -1;
}
return -1;
} }
}, },
computed: { computed: {
@ -569,6 +581,12 @@ export default {
}, },
bodyStyle() { bodyStyle() {
return this.getVirtualScrollerProp('contentStyle'); return this.getVirtualScrollerProp('contentStyle');
},
expandedRowId() {
return UniqueComponentId();
},
nameAttributeSelector() {
return UniqueComponentId();
} }
}, },
components: { components: {

View File

@ -16,8 +16,8 @@
</template> </template>
<script> <script>
import FooterCell from './FooterCell.vue';
import { ObjectUtils } from 'primevue/utils'; import { ObjectUtils } from 'primevue/utils';
import FooterCell from './FooterCell.vue';
export default { export default {
name: 'TableFooter', name: 'TableFooter',

View File

@ -27,6 +27,7 @@
:filters="filters" :filters="filters"
:filterDisplay="filterDisplay" :filterDisplay="filterDisplay"
:filtersStore="filtersStore" :filtersStore="filtersStore"
:filterInputProps="filterInputProps"
@filter-change="$emit('filter-change', $event)" @filter-change="$emit('filter-change', $event)"
@filter-apply="$emit('filter-apply')" @filter-apply="$emit('filter-apply')"
@operator-change="$emit('operator-change', $event)" @operator-change="$emit('operator-change', $event)"
@ -40,7 +41,7 @@
<tr v-if="filterDisplay === 'row'" role="row"> <tr v-if="filterDisplay === 'row'" role="row">
<template v-for="(col, i) of columns" :key="columnProp(col, 'columnKey') || columnProp(col, 'field') || i"> <template v-for="(col, i) of columns" :key="columnProp(col, 'columnKey') || columnProp(col, 'field') || i">
<th v-if="!columnProp(col, 'hidden') && (rowGroupMode !== 'subheader' || groupRowsBy !== columnProp(col, 'field'))" :style="getFilterColumnHeaderStyle(col)" :class="getFilterColumnHeaderClass(col)"> <th v-if="!columnProp(col, 'hidden') && (rowGroupMode !== 'subheader' || groupRowsBy !== columnProp(col, 'field'))" :style="getFilterColumnHeaderStyle(col)" :class="getFilterColumnHeaderClass(col)">
<DTHeaderCheckbox v-if="columnProp(col, 'selectionMode') === 'multiple'" :checked="allRowsSelected" @change="$emit('checkbox-change', $event)" :disabled="empty" /> <DTHeaderCheckbox v-if="columnProp(col, 'selectionMode') === 'multiple'" :checked="allRowsSelected" :disabled="empty" @change="$emit('checkbox-change', $event)" />
<DTColumnFilter <DTColumnFilter
v-if="col.children && col.children.filter" v-if="col.children && col.children.filter"
:field="columnProp(col, 'filterField') || columnProp(col, 'field')" :field="columnProp(col, 'filterField') || columnProp(col, 'field')"
@ -54,6 +55,7 @@
:filterApplyTemplate="col.children && col.children.filterapply" :filterApplyTemplate="col.children && col.children.filterapply"
:filters="filters" :filters="filters"
:filtersStore="filtersStore" :filtersStore="filtersStore"
:filterInputProps="filterInputProps"
@filter-change="$emit('filter-change', $event)" @filter-change="$emit('filter-change', $event)"
@filter-apply="$emit('filter-apply')" @filter-apply="$emit('filter-apply')"
:filterMenuStyle="columnProp(col, 'filterMenuStyle')" :filterMenuStyle="columnProp(col, 'filterMenuStyle')"
@ -110,10 +112,10 @@
</template> </template>
<script> <script>
import { ObjectUtils } from 'primevue/utils';
import ColumnFilter from './ColumnFilter.vue';
import HeaderCell from './HeaderCell.vue'; import HeaderCell from './HeaderCell.vue';
import HeaderCheckbox from './HeaderCheckbox.vue'; import HeaderCheckbox from './HeaderCheckbox.vue';
import ColumnFilter from './ColumnFilter.vue';
import { ObjectUtils } from 'primevue/utils';
export default { export default {
name: 'TableHeader', name: 'TableHeader',
@ -149,7 +151,7 @@ export default {
default: null default: null
}, },
groupRowsBy: { groupRowsBy: {
type: [Array, String], type: [Array, String, Function],
default: null default: null
}, },
resizableColumns: { resizableColumns: {
@ -199,6 +201,10 @@ export default {
reorderableColumns: { reorderableColumns: {
type: Boolean, type: Boolean,
default: false default: false
},
filterInputProps: {
type: null,
default: null
} }
}, },
methods: { methods: {

View File

@ -1,6 +1,6 @@
<template> <template>
<tbody class="p-datatable-tbody"> <tbody class="p-datatable-tbody">
<tr v-for="n in rows" :key="n"> <tr v-for="n in rows" :key="n" role="row">
<td v-for="(col, i) of columns" :key="col.props.columnKey || col.props.field || i"> <td v-for="(col, i) of columns" :key="col.props.columnKey || col.props.field || i">
<component v-if="col.children && col.children.loading" :is="col.children.loading" :column="col" :index="i" /> <component v-if="col.children && col.children.loading" :is="col.children.loading" :column="col" :index="i" />
</td> </td>

View File

@ -1,9 +1,13 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import PrimeVue from 'primevue/config';
import DataView from './DataView.vue'; import DataView from './DataView.vue';
describe('DataView.vue', () => { describe('DataView.vue', () => {
it('should exist', () => { it('should exist', () => {
const wrapper = mount(DataView, { const wrapper = mount(DataView, {
global: {
plugins: [PrimeVue]
},
props: { props: {
value: [ value: [
{ {

View File

@ -1,6 +1,19 @@
import { mount } from '@vue/test-utils'; import { config, mount } from '@vue/test-utils';
import DataViewLayoutOptions from './DataViewLayoutOptions.vue'; import DataViewLayoutOptions from './DataViewLayoutOptions.vue';
config.global.mocks = {
$primevue: {
config: {
locale: {
aria: {
listView: 'listView',
gridView: 'gridView'
}
}
}
}
};
describe('DataViewLayoutOptions.vue', () => { describe('DataViewLayoutOptions.vue', () => {
it('should exist', async () => { it('should exist', async () => {
const wrapper = mount(DataViewLayoutOptions, { const wrapper = mount(DataViewLayoutOptions, {

View File

@ -1,9 +1,9 @@
<template> <template>
<div class="p-dataview-layout-options p-selectbutton p-buttonset"> <div class="p-dataview-layout-options p-selectbutton p-buttonset" role="group">
<button :class="buttonListClass" @click="changeLayout('list')" type="button"> <button :aria-label="listViewAriaLabel" :class="buttonListClass" @click="changeLayout('list')" type="button" :aria-pressed="isListButtonPressed">
<i class="pi pi-bars"></i> <i class="pi pi-bars"></i>
</button> </button>
<button :class="buttonGridClass" @click="changeLayout('grid')" type="button"> <button :aria-label="gridViewAriaLabel" :class="buttonGridClass" @click="changeLayout('grid')" type="button" :aria-pressed="isGridButtonPressed">
<i class="pi pi-th-large"></i> <i class="pi pi-th-large"></i>
</button> </button>
</div> </div>
@ -16,9 +16,23 @@ export default {
props: { props: {
modelValue: String modelValue: String
}, },
data() {
return {
isListButtonPressed: false,
isGridButtonPressed: false
};
},
methods: { methods: {
changeLayout(layout) { changeLayout(layout) {
this.$emit('update:modelValue', layout); this.$emit('update:modelValue', layout);
if (layout === 'list') {
this.isListButtonPressed = true;
this.isGridButtonPressed = false;
} else if (layout === 'grid') {
this.isGridButtonPressed = true;
this.isListButtonPressed = false;
}
} }
}, },
computed: { computed: {
@ -27,6 +41,12 @@ export default {
}, },
buttonGridClass() { buttonGridClass() {
return ['p-button p-button-icon-only', { 'p-highlight': this.modelValue === 'grid' }]; return ['p-button p-button-icon-only', { 'p-highlight': this.modelValue === 'grid' }];
},
listViewAriaLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.listView : undefined;
},
gridViewAriaLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.gridView : undefined;
} }
} }
}; };

View File

@ -1,4 +1,4 @@
import { VNode } from 'vue'; import { HTMLAttributes, VNode } from 'vue';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers'; import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
type DialogPositionType = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'topleft' | 'topright' | 'bottomleft' | 'bottomright' | undefined; type DialogPositionType = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'topleft' | 'topright' | 'bottomleft' | 'bottomright' | undefined;
@ -49,6 +49,10 @@ export interface DialogProps {
* Style class of the content section. * Style class of the content section.
*/ */
contentClass?: any; contentClass?: any;
/**
* Uses to pass all properties of the HTMLDivElement to the overlay panel inside the component.
*/
contentProps?: HTMLAttributes | undefined;
/** /**
* When enabled dialog is displayed in RTL direction. * When enabled dialog is displayed in RTL direction.
*/ */
@ -82,11 +86,6 @@ export interface DialogProps {
* Default value is true. * Default value is true.
*/ */
autoZIndex?: boolean | undefined; autoZIndex?: boolean | undefined;
/**
* Aria label of the close icon.
* Default value is 'close'.
*/
ariaCloseLabel?: string | undefined;
/** /**
* Position of the dialog, options are 'center', 'top', 'bottom', 'left', 'right', 'topleft', 'topright', 'bottomleft' or 'bottomright'. * Position of the dialog, options are 'center', 'top', 'bottom', 'left', 'right', 'topleft', 'topright', 'bottomleft' or 'bottomright'.
* @see DialogPositionType * @see DialogPositionType
@ -132,6 +131,21 @@ export interface DialogProps {
* Style of the dynamic dialog. * Style of the dynamic dialog.
*/ */
style?: any; style?: any;
/**
* Icon to display in the dialog close button.
* Default value is 'pi pi-times'.
*/
closeIcon?: string | undefined;
/**
* Icon to display in the dialog maximize button when dialog is not maximized.
* Default value is 'pi pi-window-maximize'.
*/
maximizeIcon?: string | undefined;
/**
* Icon to display in the dialog maximize button when dialog is maximized.
* Default value is 'pi pi-window-minimize'.
*/
minimizeIcon?: string | undefined;
} }
export interface DialogSlots { export interface DialogSlots {

View File

@ -1,4 +1,4 @@
import PrimeVue from '../config/PrimeVue'; import PrimeVue from 'primevue/config';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import Dialog from './Dialog.vue'; import Dialog from './Dialog.vue';
@ -8,11 +8,17 @@ describe('Dialog.vue', () => {
global: { global: {
plugins: [PrimeVue], plugins: [PrimeVue],
stubs: { stubs: {
teleport: true teleport: true,
transition: false
} }
}, },
props: { props: {
visible: false visible: false
},
data() {
return {
containerVisible: true
};
} }
}); });
@ -31,16 +37,86 @@ describe('Dialog.vue', () => {
teleport: true teleport: true
} }
}, },
props: {
visible: true
},
slots: { slots: {
default: '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>', default: '<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit</p>',
footer: '<p>Dialog Footer</p>' footer: '<p>Dialog Footer</p>'
},
data() {
return {
containerVisible: true
};
} }
}); });
expect(wrapper.find('.p-dialog-content').exists()).toBe(false); await wrapper.setProps({ visible: true });
expect(wrapper.find('.p-dialog-footer').exists()).toBe(false);
expect(wrapper.find('.p-dialog-content').exists()).toBe(true);
expect(wrapper.find('.p-dialog-footer').exists()).toBe(true);
});
});
describe('closable', () => {
it('should have custom close icon when provided', async () => {
const wrapper = mount(Dialog, {
global: {
plugins: [PrimeVue],
stubs: {
teleport: true
}
},
props: {
closable: true,
closeIcon: 'pi pi-discord',
showHeader: true,
visible: false
},
data() {
return {
containerVisible: true
};
}
});
await wrapper.setProps({ visible: true });
const icon = wrapper.find('.p-dialog-header-close-icon');
expect(icon.classes()).toContain('pi-discord');
});
});
describe('maximizable', () => {
it('should have custom maximize and minimize icons when provided', async () => {
const wrapper = mount(Dialog, {
global: {
plugins: [PrimeVue],
stubs: {
teleport: true
}
},
props: {
maximizable: true,
maximizeIcon: 'pi pi-discord',
minimizeIcon: 'pi pi-facebook',
showHeader: true,
visible: false
},
data() {
return {
containerVisible: true
};
}
});
await wrapper.setProps({ visible: true });
await wrapper.setData({ maximized: false });
const icon = wrapper.find('.p-dialog-header-maximize-icon');
expect(icon.classes()).toContain('pi-discord');
await wrapper.setData({ maximized: true });
expect(icon.classes()).toContain('pi-facebook');
}); });
}); });

View File

@ -2,24 +2,24 @@
<Portal :appendTo="appendTo"> <Portal :appendTo="appendTo">
<div v-if="containerVisible" :ref="maskRef" :class="maskClass" @click="onMaskClick"> <div v-if="containerVisible" :ref="maskRef" :class="maskClass" @click="onMaskClick">
<transition name="p-dialog" @before-enter="onBeforeEnter" @enter="onEnter" @before-leave="onBeforeLeave" @leave="onLeave" @after-leave="onAfterLeave" appear> <transition name="p-dialog" @before-enter="onBeforeEnter" @enter="onEnter" @before-leave="onBeforeLeave" @leave="onLeave" @after-leave="onAfterLeave" appear>
<div v-if="visible" :ref="containerRef" :class="dialogClass" v-bind="$attrs" role="dialog" :aria-labelledby="ariaLabelledById" :aria-modal="modal"> <div v-if="visible" :ref="containerRef" v-focustrap="{ disabled: !modal }" :class="dialogClass" role="dialog" :aria-labelledby="ariaLabelledById" :aria-modal="modal" v-bind="$attrs">
<div v-if="showHeader" class="p-dialog-header" @mousedown="initDrag"> <div v-if="showHeader" :ref="headerContainerRef" class="p-dialog-header" @mousedown="initDrag">
<slot name="header"> <slot name="header">
<span v-if="header" :id="ariaLabelledById" class="p-dialog-title">{{ header }}</span> <span v-if="header" :id="ariaLabelledById" class="p-dialog-title">{{ header }}</span>
</slot> </slot>
<div class="p-dialog-header-icons"> <div class="p-dialog-header-icons">
<button v-if="maximizable" v-ripple class="p-dialog-header-icon p-dialog-header-maximize p-link" @click="maximize" type="button" tabindex="-1"> <button v-if="maximizable" :ref="maximizableRef" v-ripple :autofocus="focusable" class="p-dialog-header-icon p-dialog-header-maximize p-link" @click="maximize" type="button" :tabindex="maximizable ? '0' : '-1'">
<span :class="maximizeIconClass"></span> <span :class="maximizeIconClass"></span>
</button> </button>
<button v-if="closable" v-ripple class="p-dialog-header-icon p-dialog-header-close p-link" @click="close" :aria-label="ariaCloseLabel" type="button"> <button v-if="closable" :ref="closeButtonRef" v-ripple :autofocus="focusable" class="p-dialog-header-icon p-dialog-header-close p-link" @click="close" :aria-label="closeAriaLabel" type="button" v-bind="closeButtonProps">
<span class="p-dialog-header-close-icon pi pi-times"></span> <span :class="['p-dialog-header-close-icon', closeIcon]"></span>
</button> </button>
</div> </div>
</div> </div>
<div :class="contentStyleClass" :style="contentStyle"> <div :ref="contentRef" :class="contentStyleClass" :style="contentStyle" v-bind="contentProps">
<slot></slot> <slot></slot>
</div> </div>
<div v-if="footer || $slots.footer" class="p-dialog-footer"> <div v-if="footer || $slots.footer" :ref="footerContainerRef" class="p-dialog-footer">
<slot name="footer">{{ footer }}</slot> <slot name="footer">{{ footer }}</slot>
</div> </div>
</div> </div>
@ -29,25 +29,57 @@
</template> </template>
<script> <script>
import { computed } from 'vue'; import FocusTrap from 'primevue/focustrap';
import { UniqueComponentId, DomHandler, ZIndexUtils } from 'primevue/utils';
import Ripple from 'primevue/ripple';
import Portal from 'primevue/portal'; import Portal from 'primevue/portal';
import Ripple from 'primevue/ripple';
import { DomHandler, UniqueComponentId, ZIndexUtils } from 'primevue/utils';
import { computed } from 'vue';
export default { export default {
name: 'Dialog', name: 'Dialog',
inheritAttrs: false, inheritAttrs: false,
emits: ['update:visible', 'show', 'hide', 'after-hide', 'maximize', 'unmaximize', 'dragend'], emits: ['update:visible', 'show', 'hide', 'after-hide', 'maximize', 'unmaximize', 'dragend'],
props: { props: {
header: null, header: {
footer: null, type: null,
visible: Boolean, default: null
modal: Boolean, },
contentStyle: null, footer: {
contentClass: String, type: null,
rtl: Boolean, default: null
maximizable: Boolean, },
dismissableMask: Boolean, visible: {
type: Boolean,
default: false
},
modal: {
type: Boolean,
default: null
},
contentStyle: {
type: null,
default: null
},
contentClass: {
type: String,
default: null
},
contentProps: {
type: null,
default: null
},
rtl: {
type: Boolean,
default: null
},
maximizable: {
type: Boolean,
default: false
},
dismissableMask: {
type: Boolean,
default: false
},
closable: { closable: {
type: Boolean, type: Boolean,
default: true default: true
@ -68,10 +100,6 @@ export default {
type: Boolean, type: Boolean,
default: true default: true
}, },
ariaCloseLabel: {
type: String,
default: 'close'
},
position: { position: {
type: String, type: String,
default: 'center' default: 'center'
@ -100,6 +128,22 @@ export default {
type: String, type: String,
default: 'body' default: 'body'
}, },
closeIcon: {
type: String,
default: 'pi pi-times'
},
maximizeIcon: {
type: String,
default: 'pi pi-window-maximize'
},
minimizeIcon: {
type: String,
default: 'pi pi-window-minimize'
},
closeButtonProps: {
type: null,
default: null
},
_instance: null _instance: null
}, },
provide() { provide() {
@ -110,12 +154,18 @@ export default {
data() { data() {
return { return {
containerVisible: this.visible, containerVisible: this.visible,
maximized: false maximized: false,
focusable: false
}; };
}, },
documentKeydownListener: null, documentKeydownListener: null,
container: null, container: null,
mask: null, mask: null,
content: null,
headerContainer: null,
footerContainer: null,
maximizableButton: null,
closeButton: null,
styleElement: null, styleElement: null,
dragging: null, dragging: null,
documentDragListener: null, documentDragListener: null,
@ -168,6 +218,7 @@ export default {
}, },
onLeave() { onLeave() {
this.$emit('hide'); this.$emit('hide');
this.focusable = false;
}, },
onAfterLeave() { onAfterLeave() {
if (this.autoZIndex) { if (this.autoZIndex) {
@ -185,9 +236,26 @@ export default {
} }
}, },
focus() { focus() {
let focusTarget = this.container.querySelector('[autofocus]'); const findFocusableElement = (container) => {
return container.querySelector('[autofocus]');
};
let focusTarget = this.$slots.footer && findFocusableElement(this.footerContainer);
if (!focusTarget) {
focusTarget = this.$slots.header && findFocusableElement(this.headerContainer);
if (!focusTarget) {
focusTarget = this.$slots.default && findFocusableElement(this.content);
if (!focusTarget) {
focusTarget = findFocusableElement(this.container);
}
}
}
if (focusTarget) { if (focusTarget) {
this.focusable = true;
focusTarget.focus(); focusTarget.focus();
} }
}, },
@ -216,26 +284,7 @@ export default {
} }
}, },
onKeyDown(event) { onKeyDown(event) {
if (event.which === 9) { if (event.code === 'Escape' && this.closeOnEscape) {
event.preventDefault();
let focusableElements = DomHandler.getFocusableElements(this.container);
if (focusableElements && focusableElements.length > 0) {
if (!document.activeElement) {
focusableElements[0].focus();
} else {
let focusedIndex = focusableElements.indexOf(document.activeElement);
if (event.shiftKey) {
if (focusedIndex == -1 || focusedIndex === 0) focusableElements[focusableElements.length - 1].focus();
else focusableElements[focusedIndex - 1].focus();
} else {
if (focusedIndex == -1 || focusedIndex === focusableElements.length - 1) focusableElements[0].focus();
else focusableElements[focusedIndex + 1].focus();
}
}
}
} else if (event.which === 27 && this.closeOnEscape) {
this.close(); this.close();
} }
}, },
@ -263,6 +312,21 @@ export default {
maskRef(el) { maskRef(el) {
this.mask = el; this.mask = el;
}, },
contentRef(el) {
this.content = el;
},
headerContainerRef(el) {
this.headerContainer = el;
},
footerContainerRef(el) {
this.footerContainer = el;
},
maximizableRef(el) {
this.maximizableButton = el;
},
closeButtonRef(el) {
this.closeButton = el;
},
createStyle() { createStyle() {
if (!this.styleElement) { if (!this.styleElement) {
this.styleElement = document.createElement('style'); this.styleElement = document.createElement('style');
@ -396,10 +460,10 @@ export default {
}, },
maximizeIconClass() { maximizeIconClass() {
return [ return [
'p-dialog-header-maximize-icon pi', 'p-dialog-header-maximize-icon',
{ {
'pi-window-maximize': !this.maximized, [this.maximizeIcon]: !this.maximized,
'pi-window-minimize': this.maximized [this.minimizeIcon]: this.maximized
} }
]; ];
}, },
@ -407,7 +471,10 @@ export default {
return UniqueComponentId(); return UniqueComponentId();
}, },
ariaLabelledById() { ariaLabelledById() {
return this.header != null ? this.ariaId + '_header' : null; return this.header != null || this.$attrs['aria-labelledby'] !== null ? this.ariaId + '_header' : null;
},
closeAriaLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.close : undefined;
}, },
attributeSelector() { attributeSelector() {
return UniqueComponentId(); return UniqueComponentId();
@ -417,7 +484,8 @@ export default {
} }
}, },
directives: { directives: {
ripple: Ripple ripple: Ripple,
focustrap: FocusTrap
}, },
components: { components: {
Portal: Portal Portal: Portal

View File

@ -1,6 +1,6 @@
import { VNode } from 'vue'; import { VNode } from 'vue';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
import { MenuItem } from '../menuitem'; import { MenuItem } from '../menuitem';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
type DockPositionType = 'bottom' | 'top' | 'left' | 'right' | undefined; type DockPositionType = 'bottom' | 'top' | 'left' | 'right' | undefined;
@ -53,6 +53,22 @@ export interface DockProps {
* @see DockTooltipOptions * @see DockTooltipOptions
*/ */
tooltipOptions?: DockTooltipOptions; tooltipOptions?: DockTooltipOptions;
/**
* Unique identifier of the menu.
*/
menuId?: 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.
*/
'aria-labelledby'?: string | undefined;
/**
* Establishes a string value that labels the component.
*/
'aria-label'?: string | undefined;
} }
export interface DockSlots { export interface DockSlots {
@ -65,6 +81,10 @@ export interface DockSlots {
* Custom content for item. * Custom content for item.
*/ */
item: MenuItem; item: MenuItem;
/**
* Index of the menuitem
*/
index: number;
}) => VNode[]; }) => VNode[];
/** /**
* Custom icon content. * Custom icon content.
@ -78,7 +98,18 @@ export interface DockSlots {
}) => VNode[]; }) => VNode[];
} }
export declare type DockEmits = {}; export declare type DockEmits = {
/**
* 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;
};
declare class Dock extends ClassComponent<DockProps, DockSlots, DockEmits> {} declare class Dock extends ClassComponent<DockProps, DockSlots, DockEmits> {}

View File

@ -1,6 +1,6 @@
<template> <template>
<div :class="containerClass" :style="style"> <div :class="containerClass" :style="style">
<DockSub :model="model" :templates="$slots" :exact="exact" :tooltipOptions="tooltipOptions"></DockSub> <DockSub :model="model" :templates="$slots" :exact="exact" :tooltipOptions="tooltipOptions" :position="position" :menuId="menuId" :aria-label="ariaLabel" :aria-labelledby="ariaLabelledby" :tabindex="tabindex"></DockSub>
</div> </div>
</template> </template>
@ -21,6 +21,22 @@ export default {
exact: { exact: {
type: Boolean, type: Boolean,
default: true default: true
},
menuId: {
type: String,
default: null
},
tabindex: {
type: Number,
default: 0
},
'aria-label': {
type: String,
default: null
},
'aria-labelledby': {
type: String,
default: null
} }
}, },
computed: { computed: {
@ -63,7 +79,7 @@ export default {
will-change: transform; will-change: transform;
} }
.p-dock-action { .p-dock-link {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;

View File

@ -1,41 +1,59 @@
<template> <template>
<div class="p-dock-list-container"> <div class="p-dock-list-container">
<ul ref="list" class="p-dock-list" role="menu" @mouseleave="onListMouseLeave"> <ul
<li v-for="(item, index) of model" :key="index" :class="itemClass(index)" role="none" @mouseenter="onItemMouseEnter(index)"> ref="list"
<template v-if="!templates['item']"> :id="id"
<router-link v-if="item.to && !disabled(item)" v-slot="{ navigate, href, isActive, isExactActive }" :to="item.to" custom> class="p-dock-list"
<a role="menu"
v-tooltip:[tooltipOptions]="{ value: item.label, disabled: !tooltipOptions }" :aria-orientation="position === 'bottom' || position === 'top' ? 'horizontal' : 'vertical'"
:href="href" :aria-activedescendant="focused ? focusedOptionId : undefined"
role="menuitem" :tabindex="tabindex"
:class="linkClass(item, { isActive, isExactActive })" :aria-label="ariaLabel"
:target="item.target" :aria-labelledby="ariaLabelledby"
@click="onItemClick($event, item, navigate)" @focus="onListFocus"
> @blur="onListBlur"
<template v-if="!templates['icon']"> @keydown="onListKeyDown"
<span v-ripple :class="['p-dock-action-icon', item.icon]"></span> @mouseleave="onListMouseLeave"
</template> >
<component v-else :is="templates['icon']" :item="item"></component> <template v-for="(processedItem, index) of model" :key="index">
</a> <li
</router-link> :id="getItemId(index)"
<a :class="itemClass(processedItem, index, getItemId(index))"
v-else role="menuitem"
v-tooltip:[tooltipOptions]="{ value: item.label, disabled: !tooltipOptions }" :aria-label="processedItem.label"
:href="item.url" :aria-disabled="disabled(processedItem)"
role="menuitem" @click="onItemClick($event, processedItem)"
:class="linkClass(item)" @mouseenter="onItemMouseEnter(index)"
:target="item.target" >
@click="onItemClick($event, item)" <div class="p-menuitem-content">
:tabindex="disabled(item) ? null : '0'" <template v-if="!templates['item']">
> <router-link v-if="processedItem.to && !disabled(processedItem)" v-slot="{ navigate, href, isActive, isExactActive }" :to="processedItem.to" custom>
<template v-if="!templates['icon']"> <a
<span v-ripple :class="['p-dock-action-icon', item.icon]"></span> v-tooltip:[tooltipOptions]="{ value: processedItem.label, disabled: !tooltipOptions }"
:href="href"
:class="linkClass({ isActive, isExactActive })"
:target="processedItem.target"
tabindex="-1"
aria-hidden="true"
@click="onItemActionClick($event, processedItem, navigate)"
>
<template v-if="!templates['icon']">
<span v-ripple :class="['p-dock-icon', processedItem.icon]"></span>
</template>
<component v-else :is="templates['icon']" :item="processedItem"></component>
</a>
</router-link>
<a v-else v-tooltip:[tooltipOptions]="{ value: processedItem.label, disabled: !tooltipOptions }" :href="processedItem.url" :class="linkClass()" :target="processedItem.target" tabindex="-1" aria-hidden="true">
<template v-if="!templates['icon']">
<span v-ripple :class="['p-dock-icon', processedItem.icon]"></span>
</template>
<component v-else :is="templates['icon']" :item="processedItem"></component>
</a>
</template> </template>
<component v-else :is="templates['icon']" :item="item"></component> <component v-else :is="templates['item']" :item="processedItem" :index="index"></component>
</a> </div>
</template> </li>
<component v-else :is="templates['item']" :item="item"></component> </template>
</li>
</ul> </ul>
</div> </div>
</template> </template>
@ -43,10 +61,16 @@
<script> <script>
import Ripple from 'primevue/ripple'; import Ripple from 'primevue/ripple';
import Tooltip from 'primevue/tooltip'; import Tooltip from 'primevue/tooltip';
import { DomHandler, ObjectUtils, UniqueComponentId } from 'primevue/utils';
export default { export default {
name: 'DockSub', name: 'DockSub',
emits: ['focus', 'blur'],
props: { props: {
position: {
type: String,
default: 'bottom'
},
model: { model: {
type: Array, type: Array,
default: null default: null
@ -59,42 +83,164 @@ export default {
type: Boolean, type: Boolean,
default: true default: true
}, },
tooltipOptions: null tooltipOptions: null,
menuId: {
type: String,
default: null
},
tabindex: {
type: Number,
default: 0
},
'aria-label': {
type: String,
default: null
},
'aria-labelledby': {
type: String,
default: null
}
}, },
data() { data() {
return { return {
currentIndex: -3 currentIndex: -3,
focused: false,
focusedOptionIndex: -1
}; };
}, },
methods: { methods: {
getItemId(index) {
return `${this.id}_${index}`;
},
getItemProp(processedItem, name) {
return processedItem && processedItem.item ? ObjectUtils.getItemValue(processedItem.item[name]) : undefined;
},
isSameMenuItem(event) {
return event.currentTarget && (event.currentTarget.isSameNode(event.target) || event.currentTarget.isSameNode(event.target.closest('.p-menuitem')));
},
onListMouseLeave() { onListMouseLeave() {
this.currentIndex = -3; this.currentIndex = -3;
}, },
onItemMouseEnter(index) { onItemMouseEnter(index) {
this.currentIndex = index; this.currentIndex = index;
}, },
onItemClick(event, item, navigate) { onItemActionClick(event, navigate) {
if (this.disabled(item)) { navigate && navigate(event);
event.preventDefault(); },
onItemClick(event, processedItem) {
if (this.isSameMenuItem(event)) {
const command = this.getItemProp(processedItem, 'command');
return; command && command({ originalEvent: event, item: processedItem.item });
}
if (item.command) {
item.command({
originalEvent: event,
item: item
});
}
if (item.to && navigate) {
navigate(event);
} }
}, },
itemClass(index) { onListFocus(event) {
this.focused = true;
this.changeFocusedOptionIndex(0);
this.$emit('focus', event);
},
onListBlur(event) {
this.focused = false;
this.focusedOptionIndex = -1;
this.$emit('blur', event);
},
onListKeyDown(event) {
switch (event.code) {
case 'ArrowDown': {
if (this.position === 'left' || this.position === 'right') this.onArrowDownKey();
event.preventDefault();
break;
}
case 'ArrowUp': {
if (this.position === 'left' || this.position === 'right') this.onArrowUpKey();
event.preventDefault();
break;
}
case 'ArrowRight': {
if (this.position === 'top' || this.position === 'bottom') this.onArrowDownKey();
event.preventDefault();
break;
}
case 'ArrowLeft': {
if (this.position === 'top' || this.position === 'bottom') this.onArrowUpKey();
event.preventDefault();
break;
}
case 'Home': {
this.onHomeKey();
event.preventDefault();
break;
}
case 'End': {
this.onEndKey();
event.preventDefault();
break;
}
case 'Enter':
case 'Space': {
this.onSpaceKey(event);
event.preventDefault();
break;
}
default:
break;
}
},
onArrowDownKey() {
const optionIndex = this.findNextOptionIndex(this.focusedOptionIndex);
this.changeFocusedOptionIndex(optionIndex);
},
onArrowUpKey() {
const optionIndex = this.findPrevOptionIndex(this.focusedOptionIndex);
this.changeFocusedOptionIndex(optionIndex);
},
onHomeKey() {
this.changeFocusedOptionIndex(0);
},
onEndKey() {
this.changeFocusedOptionIndex(DomHandler.find(this.$refs.list, 'li.p-dock-item:not(.p-disabled)').length - 1);
},
onSpaceKey() {
const element = DomHandler.findSingle(this.$refs.list, `li[id="${`${this.focusedOptionIndex}`}"]`);
const anchorElement = element && DomHandler.findSingle(element, '.p-dock-link');
anchorElement ? anchorElement.click() : element && element.click();
},
findNextOptionIndex(index) {
const menuitems = DomHandler.find(this.$refs.list, 'li.p-dock-item:not(.p-disabled)');
const matchedOptionIndex = [...menuitems].findIndex((link) => link.id === index);
return matchedOptionIndex > -1 ? matchedOptionIndex + 1 : 0;
},
findPrevOptionIndex(index) {
const menuitems = DomHandler.find(this.$refs.list, 'li.p-dock-item:not(.p-disabled)');
const matchedOptionIndex = [...menuitems].findIndex((link) => link.id === index);
return matchedOptionIndex > -1 ? matchedOptionIndex - 1 : 0;
},
changeFocusedOptionIndex(index) {
const menuitems = DomHandler.find(this.$refs.list, 'li.p-dock-item:not(.p-disabled)');
let order = index >= menuitems.length ? menuitems.length - 1 : index < 0 ? 0 : index;
this.focusedOptionIndex = menuitems[order].getAttribute('id');
},
itemClass(item, index, id) {
return [ return [
'p-dock-item', 'p-dock-item',
{ {
'p-focus': id === this.focusedOptionIndex,
'p-disabled': this.disabled(item),
'p-dock-item-second-prev': this.currentIndex - 2 === index, 'p-dock-item-second-prev': this.currentIndex - 2 === index,
'p-dock-item-prev': this.currentIndex - 1 === index, 'p-dock-item-prev': this.currentIndex - 1 === index,
'p-dock-item-current': this.currentIndex === index, 'p-dock-item-current': this.currentIndex === index,
@ -103,11 +249,10 @@ export default {
} }
]; ];
}, },
linkClass(item, routerProps) { linkClass(routerProps) {
return [ return [
'p-dock-action', 'p-dock-link',
{ {
'p-disabled': this.disabled(item),
'router-link-active': routerProps && routerProps.isActive, 'router-link-active': routerProps && routerProps.isActive,
'router-link-active-exact': this.exact && routerProps && routerProps.isExactActive 'router-link-active-exact': this.exact && routerProps && routerProps.isExactActive
} }
@ -117,6 +262,14 @@ export default {
return typeof item.disabled === 'function' ? item.disabled() : item.disabled; return typeof item.disabled === 'function' ? item.disabled() : item.disabled;
} }
}, },
computed: {
id() {
return this.menuId || UniqueComponentId();
},
focusedOptionId() {
return this.focusedOptionIndex !== -1 ? this.focusedOptionIndex : null;
}
},
directives: { directives: {
ripple: Ripple, ripple: Ripple,
tooltip: Tooltip tooltip: Tooltip

View File

@ -163,6 +163,21 @@ export interface DropdownProps {
* Whether the dropdown is in loading state. * Whether the dropdown is in loading state.
*/ */
loading?: boolean | undefined; loading?: boolean | undefined;
/**
* Icon to display in clear button.
* Default value is 'pi pi-times'.
*/
clearIcon?: string | undefined;
/**
* Icon to display in the dropdown.
* Default value is 'pi pi-chevron-down'.
*/
dropdownIcon?: string | undefined;
/**
* Icon to display in filter input.
* Default value is 'pi pi-search'.
*/
filterIcon?: string | undefined;
/** /**
* Icon to display in loading state. * Icon to display in loading state.
* Default value is 'pi pi-spinner pi-spin'. * Default value is 'pi pi-spinner pi-spin'.

View File

@ -1,7 +1,7 @@
import { h } from 'vue'; import { h } from 'vue';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import PrimeVue from '../config/PrimeVue'; import PrimeVue from 'primevue/config';
import Dropdown from '../dropdown/Dropdown.vue'; import Dropdown from '@/components/dropdown/Dropdown.vue';
describe('Dropdown.vue', () => { describe('Dropdown.vue', () => {
let wrapper; let wrapper;
@ -22,10 +22,9 @@ describe('Dropdown.vue', () => {
it('should Dropdown exist', () => { it('should Dropdown exist', () => {
expect(wrapper.find('.p-dropdown.p-component').exists()).toBe(true); expect(wrapper.find('.p-dropdown.p-component').exists()).toBe(true);
expect(wrapper.find('.p-dropdown-panel').exists()).toBe(true); expect(wrapper.find('.p-dropdown-panel').exists()).toBe(true);
expect(wrapper.find('.p-dropdown-empty-message').exists()).toBe(true);
expect(wrapper.find('.p-focus').exists()).toBe(false); expect(wrapper.find('.p-focus').exists()).toBe(true);
expect(wrapper.find('.p-inputwrapper-filled').exists()).toBe(false); expect(wrapper.find('.p-inputwrapper-filled').exists()).toBe(false);
expect(wrapper.find('.p-inputwrapper-focus').exists()).toBe(true); expect(wrapper.find('.p-inputwrapper-focus').exists()).toBe(true);
}); });
}); });
@ -67,6 +66,32 @@ describe('option checks', () => {
}); });
}); });
describe('clear checks', () => {
let wrapper;
beforeEach(async () => {
wrapper = mount(Dropdown, {
global: {
plugins: [PrimeVue],
stubs: {
teleport: true
}
},
props: {
clearIcon: 'pi pi-discord',
modelValue: 'value',
showClear: true
}
});
await wrapper.trigger('click');
});
it('should have correct icon', () => {
expect(wrapper.find('.p-dropdown-clear-icon').classes()).toContain('pi-discord');
});
});
describe('editable checks', () => { describe('editable checks', () => {
let wrapper; let wrapper;
@ -295,6 +320,7 @@ describe('filter checks', () => {
}, },
props: { props: {
filter: true, filter: true,
filterIcon: 'pi pi-discord',
options: [ options: [
{ name: 'Australia', code: 'AU' }, { name: 'Australia', code: 'AU' },
{ name: 'Brazil', code: 'BR' }, { name: 'Brazil', code: 'BR' },
@ -316,11 +342,13 @@ describe('filter checks', () => {
it('should make filtering', async () => { it('should make filtering', async () => {
const filterInput = wrapper.find('.p-dropdown-filter'); const filterInput = wrapper.find('.p-dropdown-filter');
const filterIcon = wrapper.find('.p-dropdown-filter-icon');
expect(filterInput.exists()).toBe(true); expect(filterInput.exists()).toBe(true);
expect(filterIcon.classes()).toContain('pi-discord');
const event = { target: { value: 'c' } }; const event = { target: { value: 'c' } };
const onFilterChange = vi.spyOn(wrapper.vm, 'onFilterChange'); const onFilterChange = jest.spyOn(wrapper.vm, 'onFilterChange');
wrapper.vm.onFilterChange(event); wrapper.vm.onFilterChange(event);
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();

View File

@ -47,7 +47,7 @@
> >
<slot name="value" :value="modelValue" :placeholder="placeholder">{{ label === 'p-emptylabel' ? '&nbsp;' : label || 'empty' }}</slot> <slot name="value" :value="modelValue" :placeholder="placeholder">{{ label === 'p-emptylabel' ? '&nbsp;' : label || 'empty' }}</slot>
</span> </span>
<i v-if="showClear && modelValue != null" class="p-dropdown-clear-icon pi pi-times" @click="onClearClick" v-bind="clearIconProps"></i> <i v-if="showClear && modelValue != null" :class="['p-dropdown-clear-icon', clearIcon]" @click="onClearClick" v-bind="clearIconProps"></i>
<div class="p-dropdown-trigger"> <div class="p-dropdown-trigger">
<slot name="indicator"> <slot name="indicator">
<span :class="dropdownIconClass" aria-hidden="true"></span> <span :class="dropdownIconClass" aria-hidden="true"></span>
@ -76,7 +76,7 @@
@input="onFilterChange" @input="onFilterChange"
v-bind="filterInputProps" v-bind="filterInputProps"
/> />
<span class="p-dropdown-filter-icon pi pi-search"></span> <span :class="['p-dropdown-filter-icon', filterIcon]" />
</div> </div>
<span role="status" aria-live="polite" class="p-hidden-accessible"> <span role="status" aria-live="polite" class="p-hidden-accessible">
{{ filterResultMessageText }} {{ filterResultMessageText }}
@ -115,12 +115,6 @@
<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-if="$slots.loader" v-slot:loader="{ options }"> <template v-if="$slots.loader" v-slot:loader="{ options }">
<slot name="loader" :options="options"></slot> <slot name="loader" :options="options"></slot>
@ -128,6 +122,12 @@
</VirtualScroller> </VirtualScroller>
</div> </div>
<slot name="footer" :value="modelValue" :options="visibleOptions"></slot> <slot name="footer" :value="modelValue" :options="visibleOptions"></slot>
<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>
<span ref="lastHiddenFocusableElementOnOverlay" role="presentation" aria-hidden="true" class="p-hidden-accessible p-hidden-focusable" :tabindex="0" @focus="onLastHiddenFocus"></span> <span ref="lastHiddenFocusableElementOnOverlay" role="presentation" aria-hidden="true" class="p-hidden-accessible p-hidden-focusable" :tabindex="0" @focus="onLastHiddenFocus"></span>
</div> </div>
</transition> </transition>
@ -136,12 +136,12 @@
</template> </template>
<script> <script>
import { ConnectedOverlayScrollHandler, ObjectUtils, DomHandler, ZIndexUtils, UniqueComponentId } from 'primevue/utils';
import OverlayEventBus from 'primevue/overlayeventbus';
import { FilterService } from 'primevue/api'; import { FilterService } from 'primevue/api';
import Ripple from 'primevue/ripple'; import OverlayEventBus from 'primevue/overlayeventbus';
import VirtualScroller from 'primevue/virtualscroller';
import Portal from 'primevue/portal'; import Portal from 'primevue/portal';
import Ripple from 'primevue/ripple';
import { ConnectedOverlayScrollHandler, DomHandler, ObjectUtils, UniqueComponentId, ZIndexUtils } from 'primevue/utils';
import VirtualScroller from 'primevue/virtualscroller';
export default { export default {
name: 'Dropdown', name: 'Dropdown',
@ -227,6 +227,18 @@ export default {
type: Boolean, type: Boolean,
default: false default: false
}, },
clearIcon: {
type: String,
default: 'pi pi-times'
},
dropdownIcon: {
type: String,
default: 'pi pi-chevron-down'
},
filterIcon: {
type: String,
default: 'pi pi-search'
},
loadingIcon: { loadingIcon: {
type: String, type: String,
default: 'pi pi-spinner pi-spin' default: 'pi pi-spinner pi-spin'
@ -386,7 +398,7 @@ export default {
}, },
onFocus(event) { onFocus(event) {
this.focused = true; this.focused = true;
this.focusedOptionIndex = this.overlayVisible && this.autoOptionFocus ? this.findFirstFocusedOptionIndex() : -1; this.focusedOptionIndex = this.focusedOptionIndex !== -1 ? this.focusedOptionIndex : this.overlayVisible && this.autoOptionFocus ? this.findFirstFocusedOptionIndex() : -1;
this.overlayVisible && this.scrollInView(this.focusedOptionIndex); this.overlayVisible && this.scrollInView(this.focusedOptionIndex);
this.$emit('focus', event); this.$emit('focus', event);
}, },
@ -434,6 +446,7 @@ export default {
break; break;
case 'Enter': case 'Enter':
case 'NumpadEnter':
this.onEnterKey(event); this.onEnterKey(event);
break; break;
@ -488,18 +501,14 @@ export default {
this.updateModel(event, null); this.updateModel(event, null);
}, },
onFirstHiddenFocus(event) { onFirstHiddenFocus(event) {
const relatedTarget = event.relatedTarget; const focusableEl = event.relatedTarget === this.$refs.focusInput ? DomHandler.getFirstFocusableElement(this.overlay, ':not(.p-hidden-focusable)') : this.$refs.focusInput;
if (relatedTarget === this.$refs.focusInput) { DomHandler.focus(focusableEl);
const firstFocusableEl = DomHandler.getFirstFocusableElement(this.overlay, ':not(.p-hidden-focusable)');
DomHandler.focus(firstFocusableEl);
} else {
DomHandler.focus(this.$refs.focusInput);
}
}, },
onLastHiddenFocus() { onLastHiddenFocus(event) {
DomHandler.focus(this.$refs.firstHiddenFocusableElementOnOverlay); const focusableEl = event.relatedTarget === this.$refs.focusInput ? DomHandler.getLastFocusableElement(this.overlay, ':not(.p-hidden-focusable)') : this.$refs.focusInput;
DomHandler.focus(focusableEl);
}, },
onOptionSelect(event, option, isHide = true) { onOptionSelect(event, option, isHide = true) {
const value = this.getOptionValue(option); const value = this.getOptionValue(option);
@ -939,12 +948,31 @@ export default {
]; ];
}, },
dropdownIconClass() { dropdownIconClass() {
return ['p-dropdown-trigger-icon', this.loading ? this.loadingIcon : 'pi pi-chevron-down']; return ['p-dropdown-trigger-icon', this.loading ? this.loadingIcon : this.dropdownIcon];
}, },
visibleOptions() { visibleOptions() {
const options = this.optionGroupLabel ? this.flatOptions(this.options) : this.options || []; const options = this.optionGroupLabel ? this.flatOptions(this.options) : this.options || [];
return this.filterValue ? FilterService.filter(options, this.searchFields, this.filterValue, this.filterMatchMode, this.filterLocale) : options; if (this.filterValue) {
const filteredOptions = FilterService.filter(options, this.searchFields, this.filterValue, this.filterMatchMode, this.filterLocale);
if (this.optionGroupLabel) {
const optionGroups = this.options || [];
const filtered = [];
optionGroups.forEach((group) => {
const filteredItems = group.items.filter((item) => filteredOptions.includes(item));
if (filteredItems.length > 0) filtered.push({ ...group, items: [...filteredItems] });
});
return this.flatOptions(filtered);
}
return filteredOptions;
}
return options;
}, },
hasSelectedOption() { hasSelectedOption() {
return ObjectUtils.isNotEmpty(this.modelValue); return ObjectUtils.isNotEmpty(this.modelValue);

View File

@ -28,7 +28,7 @@ export interface FieldsetProps {
/** /**
* Uses to pass the custom value to read for the anchor inside the component. * Uses to pass the custom value to read for the anchor inside the component.
*/ */
toggleButtonProps?: string | undefined; toggleButtonProps?: object | undefined;
} }
export interface FieldsetSlots { export interface FieldsetSlots {

View File

@ -4,7 +4,19 @@
<slot v-if="!toggleable" name="legend"> <slot v-if="!toggleable" name="legend">
<span :id="ariaId + '_header'" class="p-fieldset-legend-text">{{ legend }}</span> <span :id="ariaId + '_header'" class="p-fieldset-legend-text">{{ legend }}</span>
</slot> </slot>
<a v-if="toggleable" :id="ariaId + '_header'" v-ripple tabindex="0" role="button" :aria-controls="ariaId + '_content'" :aria-expanded="!d_collapsed" :aria-label="toggleButtonProps || legend" @click="toggle" @keydown="onKeyDown"> <a
v-if="toggleable"
:id="ariaId + '_header'"
v-ripple
tabindex="0"
role="button"
:aria-controls="ariaId + '_content'"
:aria-expanded="!d_collapsed"
:aria-label="buttonAriaLabel"
@click="toggle"
@keydown="onKeyDown"
v-bind="toggleButtonProps"
>
<span :class="iconClass"></span> <span :class="iconClass"></span>
<slot name="legend"> <slot name="legend">
<span class="p-fieldset-legend-text">{{ legend }}</span> <span class="p-fieldset-legend-text">{{ legend }}</span>
@ -22,8 +34,8 @@
</template> </template>
<script> <script>
import { UniqueComponentId } from 'primevue/utils';
import Ripple from 'primevue/ripple'; import Ripple from 'primevue/ripple';
import { UniqueComponentId } from 'primevue/utils';
export default { export default {
name: 'Fieldset', name: 'Fieldset',
@ -32,7 +44,10 @@ export default {
legend: String, legend: String,
toggleable: Boolean, toggleable: Boolean,
collapsed: Boolean, collapsed: Boolean,
toggleButtonProps: String toggleButtonProps: {
type: null,
default: null
}
}, },
data() { data() {
return { return {
@ -72,6 +87,9 @@ export default {
}, },
ariaId() { ariaId() {
return UniqueComponentId(); return UniqueComponentId();
},
buttonAriaLabel() {
return this.toggleButtonProps && this.toggleButtonProps['aria-label'] ? this.toggleButtonProps['aria-label'] : this.legend;
} }
}, },
directives: { directives: {

View File

@ -0,0 +1,58 @@
<template>
<div v-for="(file, index) of files" :key="file.name + file.type + file.size" class="p-fileupload-file">
<img role="presentation" class="p-fileupload-file-thumbnail" :alt="file.name" :src="file.objectURL" :width="previewWidth" />
<div class="p-fileupload-file-details">
<div class="p-fileupload-file-name">{{ file.name }}</div>
<span class="p-fileupload-file-size">{{ formatSize(file.size) }}</span>
<FileUploadBadge :value="badgeValue" class="p-fileupload-file-badge" :severity="badgeSeverity" />
</div>
<div class="p-fileupload-file-actions">
<FileUploadButton icon="pi pi-times" @click="$emit('remove', index)" class="p-fileupload-file-remove p-button-text p-button-danger p-button-rounded"></FileUploadButton>
</div>
</div>
</template>
<script>
import Badge from 'primevue/badge';
import Button from 'primevue/button';
export default {
emits: ['remove'],
props: {
files: {
type: Array,
default: () => []
},
badgeSeverity: {
type: String,
default: 'warning'
},
badgeValue: {
type: String,
default: null
},
previewWidth: {
type: Number,
default: 50
}
},
methods: {
formatSize(bytes) {
if (bytes === 0) {
return '0 B';
}
let k = 1000,
dm = 3,
sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
},
components: {
FileUploadButton: Button,
FileUploadBadge: Badge
}
};
</script>

View File

@ -87,6 +87,17 @@ export interface FileUploadRemoveEvent {
files: File[]; files: File[];
} }
export interface FileUploadRemoveUploadedFile {
/**
* Removed file.
*/
file: File;
/**
* Remaining files to be uploaded.
*/
files: File[];
}
export interface FileUploadProps { export interface FileUploadProps {
/** /**
* Name of the request parameter to identify the files at backend. * Name of the request parameter to identify the files at backend.
@ -202,7 +213,61 @@ export interface FileUploadProps {
export interface FileUploadSlots { export interface FileUploadSlots {
/** /**
* Custom empty template. * Custom header content template.
*/
header: (scope: {
/**
* Files to upload.
*/
files: File[];
/**
* Uploaded files.
*/
uploadedFiles: File[];
/**
* Choose function
*/
chooseCallback: () => void;
/**
* Upload function
*/
uploadCallback: () => void;
/**
* Clear function
*/
clearCallback: () => void;
}) => VNode[];
/**
* Custom uploaded content template.
*/
content: (scope: {
/**
* Files to upload.
*/
files: File[];
/**
* Uploaded files.
*/
uploadedFiles: File[];
/**
* Function to remove an uploaded file.
*/
removeUploadedFileCallback: () => void;
/**
* Function to remove a file.
*/
removeFileCallback: () => void;
/**
* Uploaded progress as number.
*/
progress: number;
/**
* Status messages about upload process.
*/
messages: string | undefined;
}) => VNode[];
/**
* Custom content when there is no selected file.
*/ */
empty: () => VNode[]; empty: () => VNode[];
} }
@ -252,6 +317,11 @@ export declare type FileUploadEmits = {
* @param {FileUploadRemoveEvent} event - Custom remove event. * @param {FileUploadRemoveEvent} event - Custom remove event.
*/ */
remove: (event: FileUploadRemoveEvent) => void; remove: (event: FileUploadRemoveEvent) => void;
/**
* Callback to invoke when a single uploaded file is removed from the uploaded file list.
* @param {FileUploadRemoveUploadedFile} event - Custom uploaded file remove event.
*/
removeUploadedFile: (event: FileUploadRemoveUploadedFile) => void;
}; };
declare class FileUpload extends ClassComponent<FileUploadProps, FileUploadSlots, FileUploadEmits> {} declare class FileUpload extends ClassComponent<FileUploadProps, FileUploadSlots, FileUploadEmits> {}

View File

@ -1,30 +1,24 @@
<template> <template>
<div v-if="isAdvanced" class="p-fileupload p-fileupload-advanced p-component"> <div v-if="isAdvanced" class="p-fileupload p-fileupload-advanced p-component">
<input ref="fileInput" type="file" @change="onFileSelect" :multiple="multiple" :accept="accept" :disabled="chooseDisabled" />
<div class="p-fileupload-buttonbar"> <div class="p-fileupload-buttonbar">
<span v-ripple :class="advancedChooseButtonClass" :style="style" @click="choose" @keydown.enter="choose" @focus="onFocus" @blur="onBlur" tabindex="0"> <slot name="header" :files="files" :uploadedFiles="uploadedFiles" :chooseCallback="choose" :uploadCallback="upload" :clearCallback="clear">
<input ref="fileInput" type="file" @change="onFileSelect" :multiple="multiple" :accept="accept" :disabled="chooseDisabled" /> <span v-ripple :class="advancedChooseButtonClass" :style="style" @click="choose" @keydown.enter="choose" @focus="onFocus" @blur="onBlur" tabindex="0">
<span :class="advancedChooseIconClass"></span> <span :class="advancedChooseIconClass"></span>
<span class="p-button-label">{{ chooseButtonLabel }}</span> <span class="p-button-label">{{ chooseButtonLabel }}</span>
</span> </span>
<FileUploadButton v-if="showUploadButton" :label="uploadButtonLabel" :icon="uploadIcon" @click="upload" :disabled="uploadDisabled" /> <FileUploadButton v-if="showUploadButton" :label="uploadButtonLabel" :icon="uploadIcon" @click="upload" :disabled="uploadDisabled" />
<FileUploadButton v-if="showCancelButton" :label="cancelButtonLabel" :icon="cancelIcon" @click="clear" :disabled="cancelDisabled" /> <FileUploadButton v-if="showCancelButton" :label="cancelButtonLabel" :icon="cancelIcon" @click="clear" :disabled="cancelDisabled" />
</slot>
</div> </div>
<div ref="content" class="p-fileupload-content" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop"> <div ref="content" class="p-fileupload-content" @dragenter="onDragEnter" @dragover="onDragOver" @dragleave="onDragLeave" @drop="onDrop">
<FileUploadProgressBar v-if="hasFiles" :value="progress" /> <slot name="content" :files="files" :uploadedFiles="uploadedFiles" :removeUploadedFileCallback="removeUploadedFile" :removeFileCallback="remove" :progress="progress" :messages="messages">
<FileUploadMessage v-for="msg of messages" :key="msg" severity="error" @close="onMessageClose">{{ msg }}</FileUploadMessage> <FileUploadProgressBar v-if="hasFiles" :value="progress" :showValue="false" />
<div v-if="hasFiles" class="p-fileupload-files"> <FileUploadMessage v-for="msg of messages" :key="msg" severity="error" @close="onMessageClose">{{ msg }}</FileUploadMessage>
<div v-for="(file, index) of files" :key="file.name + file.type + file.size" class="p-fileupload-row"> <FileContent v-if="hasFiles" :files="files" @remove="remove" :badgeValue="pendingLabel" :previewWidth="previewWidth" />
<div> <FileContent :files="uploadedFiles" @remove="removeUploadedFile" :badgeValue="completedLabel" badgeSeverity="success" :previewWidth="previewWidth" />
<img v-if="isImage(file)" role="presentation" :alt="file.name" :src="file.objectURL" :width="previewWidth" /> </slot>
</div> <div v-if="$slots.empty && !hasFiles && !hasUploadedFiles" class="p-fileupload-empty">
<div class="p-fileupload-filename">{{ file.name }}</div>
<div>{{ formatSize(file.size) }}</div>
<div>
<FileUploadButton type="button" icon="pi pi-times" @click="remove(index)" />
</div>
</div>
</div>
<div v-if="$slots.empty && !hasFiles" class="p-fileupload-empty">
<slot name="empty"></slot> <slot name="empty"></slot>
</div> </div>
</div> </div>
@ -41,14 +35,15 @@
<script> <script>
import Button from 'primevue/button'; import Button from 'primevue/button';
import ProgressBar from 'primevue/progressbar';
import Message from 'primevue/message'; import Message from 'primevue/message';
import { DomHandler } from 'primevue/utils'; import ProgressBar from 'primevue/progressbar';
import Ripple from 'primevue/ripple'; import Ripple from 'primevue/ripple';
import { DomHandler } from 'primevue/utils';
import FileContent from './FileContent.vue';
export default { export default {
name: 'FileUpload', name: 'FileUpload',
emits: ['select', 'uploader', 'before-upload', 'progress', 'upload', 'error', 'before-send', 'clear', 'remove'], emits: ['select', 'uploader', 'before-upload', 'progress', 'upload', 'error', 'before-send', 'clear', 'remove', 'remove-uploaded-file'],
props: { props: {
name: { name: {
type: String, type: String,
@ -152,7 +147,8 @@ export default {
files: [], files: [],
messages: [], messages: [],
focused: false, focused: false,
progress: null progress: null,
uploadedFiles: []
}; };
}, },
methods: { methods: {
@ -250,6 +246,7 @@ export default {
}); });
} }
this.uploadedFiles.push(...this.files);
this.clear(); this.clear();
} }
}; };
@ -379,6 +376,15 @@ export default {
files: this.files files: this.files
}); });
}, },
removeUploadedFile(index) {
let removedFile = this.uploadedFiles.splice(index, 1)[0];
this.uploadedFiles = [...this.uploadedFiles];
this.$emit('remove-uploaded-file', {
file: removedFile,
files: this.uploadedFiles
});
},
clearInputElement() { clearInputElement() {
this.$refs.fileInput.value = ''; this.$refs.fileInput.value = '';
}, },
@ -456,6 +462,9 @@ export default {
hasFiles() { hasFiles() {
return this.files && this.files.length > 0; return this.files && this.files.length > 0;
}, },
hasUploadedFiles() {
return this.uploadedFiles && this.uploadedFiles.length > 0;
},
chooseDisabled() { chooseDisabled() {
return this.disabled || (this.fileLimit && this.fileLimit <= this.files.length + this.uploadedFileCount); return this.disabled || (this.fileLimit && this.fileLimit <= this.files.length + this.uploadedFileCount);
}, },
@ -473,12 +482,19 @@ export default {
}, },
cancelButtonLabel() { cancelButtonLabel() {
return this.cancelLabel || this.$primevue.config.locale.cancel; return this.cancelLabel || this.$primevue.config.locale.cancel;
},
completedLabel() {
return this.$primevue.config.locale.completed;
},
pendingLabel() {
return this.$primevue.config.locale.pending;
} }
}, },
components: { components: {
FileUploadButton: Button, FileUploadButton: Button,
FileUploadProgressBar: ProgressBar, FileUploadProgressBar: ProgressBar,
FileUploadMessage: Message FileUploadMessage: Message,
FileContent
}, },
directives: { directives: {
ripple: Ripple ripple: Ripple
@ -491,20 +507,6 @@ export default {
position: relative; position: relative;
} }
.p-fileupload-row {
display: flex;
align-items: center;
}
.p-fileupload-row > div {
flex: 1 1 auto;
width: 25%;
}
.p-fileupload-row > div:last-child {
text-align: right;
}
.p-fileupload-content .p-progressbar { .p-fileupload-content .p-progressbar {
width: 100%; width: 100%;
position: absolute; position: absolute;
@ -517,19 +519,31 @@ export default {
overflow: hidden; overflow: hidden;
} }
.p-button.p-fileupload-choose input[type='file'] { .p-fileupload-buttonbar {
display: none; display: flex;
flex-wrap: wrap;
} }
.p-fileupload-choose.p-fileupload-choose-selected input[type='file'] { .p-fileupload > input[type='file'],
.p-fileupload-basic input[type='file'] {
display: none; display: none;
} }
.p-fileupload-filename {
word-break: break-all;
}
.p-fluid .p-fileupload .p-button { .p-fluid .p-fileupload .p-button {
width: auto; width: auto;
} }
.p-fileupload-file {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.p-fileupload-file-thumbnail {
flex-shrink: 0;
}
.p-fileupload-file-actions {
margin-left: auto;
}
</style> </style>

5
components/focustrap/FocusTrap.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
import { ObjectDirective } from 'vue';
declare const Ripple: ObjectDirective;
export default Ripple;

View File

@ -0,0 +1,114 @@
import { DomHandler, ObjectUtils } from 'primevue/utils';
function bind(el, binding) {
const { onFocusIn, onFocusOut } = binding.value || {};
el.$_pfocustrap_mutationobserver = new MutationObserver((mutationList) => {
mutationList.forEach((mutation) => {
if (mutation.type === 'childList' && !el.contains(document.activeElement)) {
const findNextFocusableElement = (el) => {
const focusableElement = DomHandler.isFocusableElement(el) ? el : DomHandler.getFirstFocusableElement(el);
return ObjectUtils.isNotEmpty(focusableElement) ? focusableElement : findNextFocusableElement(el.nextSibling);
};
DomHandler.focus(findNextFocusableElement(mutation.nextSibling));
}
});
});
el.$_pfocustrap_mutationobserver.disconnect();
el.$_pfocustrap_mutationobserver.observe(el, {
childList: true
});
el.$_pfocustrap_focusinlistener = (event) => onFocusIn && onFocusIn(event);
el.$_pfocustrap_focusoutlistener = (event) => onFocusOut && onFocusOut(event);
el.addEventListener('focusin', el.$_pfocustrap_focusinlistener);
el.addEventListener('focusout', el.$_pfocustrap_focusoutlistener);
}
function unbind(el) {
el.$_pfocustrap_mutationobserver && el.$_pfocustrap_mutationobserver.disconnect();
el.$_pfocustrap_focusinlistener && el.removeEventListener('focusin', el.$_pfocustrap_focusinlistener) && (el.$_pfocustrap_focusinlistener = null);
el.$_pfocustrap_focusoutlistener && el.removeEventListener('focusout', el.$_pfocustrap_focusoutlistener) && (el.$_pfocustrap_focusoutlistener = null);
}
function autoFocus(el, binding) {
const { autoFocusSelector = '', firstFocusableSelector = '', autoFocus = false } = binding.value || {};
let focusableElement = DomHandler.getFirstFocusableElement(el, `[autofocus]:not(.p-hidden-focusable)${autoFocusSelector}`);
autoFocus && !focusableElement && (focusableElement = DomHandler.getFirstFocusableElement(el, `:not(.p-hidden-focusable)${firstFocusableSelector}`));
DomHandler.focus(focusableElement);
}
function onFirstHiddenElementFocus(event) {
const { currentTarget, relatedTarget } = event;
const focusableElement =
relatedTarget === currentTarget.$_pfocustrap_lasthiddenfocusableelement
? DomHandler.getFirstFocusableElement(currentTarget.parentElement, `:not(.p-hidden-focusable)${currentTarget.$_pfocustrap_focusableselector}`)
: currentTarget.$_pfocustrap_lasthiddenfocusableelement;
DomHandler.focus(focusableElement);
}
function onLastHiddenElementFocus(event) {
const { currentTarget, relatedTarget } = event;
const focusableElement =
relatedTarget === currentTarget.$_pfocustrap_firsthiddenfocusableelement
? DomHandler.getLastFocusableElement(currentTarget.parentElement, `:not(.p-hidden-focusable)${currentTarget.$_pfocustrap_focusableselector}`)
: currentTarget.$_pfocustrap_firsthiddenfocusableelement;
DomHandler.focus(focusableElement);
}
function createHiddenFocusableElements(el, binding) {
const { tabIndex = 0, firstFocusableSelector = '', lastFocusableSelector = '' } = binding.value || {};
const createFocusableElement = (onFocus) => {
const element = document.createElement('span');
element.classList = 'p-hidden-accessible p-hidden-focusable';
element.tabIndex = tabIndex;
element.setAttribute('aria-hidden', 'true');
element.setAttribute('role', 'presentation');
element.addEventListener('focus', onFocus);
return element;
};
const firstFocusableElement = createFocusableElement(onFirstHiddenElementFocus);
const lastFocusableElement = createFocusableElement(onLastHiddenElementFocus);
firstFocusableElement.$_pfocustrap_lasthiddenfocusableelement = lastFocusableElement;
firstFocusableElement.$_pfocustrap_focusableselector = firstFocusableSelector;
lastFocusableElement.$_pfocustrap_firsthiddenfocusableelement = firstFocusableElement;
lastFocusableElement.$_pfocustrap_focusableselector = lastFocusableSelector;
el.prepend(firstFocusableElement);
el.append(lastFocusableElement);
}
const FocusTrap = {
mounted(el, binding) {
const { disabled } = binding.value || {};
if (!disabled) {
createHiddenFocusableElements(el, binding);
bind(el, binding);
autoFocus(el, binding);
}
},
updated(el, binding) {
const { disabled } = binding.value || {};
disabled && unbind(el);
},
unmounted(el) {
unbind(el);
}
};
export default FocusTrap;

View File

@ -0,0 +1,6 @@
{
"main": "./focustrap.cjs.js",
"module": "./focustrap.esm.js",
"unpkg": "./focustrap.min.js",
"types": "./FocusTrap.d.ts"
}

View File

@ -1,4 +1,4 @@
import { VNode } from 'vue'; import { ButtonHTMLAttributes, HTMLAttributes, VNode } from 'vue';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers'; import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
type GalleriaThumbnailsPositionType = 'bottom' | 'top' | 'left' | 'right' | undefined; type GalleriaThumbnailsPositionType = 'bottom' | 'top' | 'left' | 'right' | undefined;
@ -119,11 +119,23 @@ export interface GalleriaProps {
/** /**
* Inline style of the component on fullscreen mode. Otherwise, the 'style' property can be used. * Inline style of the component on fullscreen mode. Otherwise, the 'style' property can be used.
*/ */
containerStyle?: any; containerStyle?: any | undefined;
/** /**
* Style class of the component on fullscreen mode. Otherwise, the 'class' property can be used. * Style class of the component on fullscreen mode. Otherwise, the 'class' property can be used.
*/ */
containerClass?: any; containerClass?: any | undefined;
/**
* Uses to pass all properties of the HTMLDivElement to the container element on fullscreen mode.
*/
containerProps?: HTMLAttributes | undefined;
/**
* Uses to pass all properties of the HTMLButtonElement to the previous navigation button.
*/
prevButtonProps?: ButtonHTMLAttributes | undefined;
/**
* Uses to pass all properties of the HTMLButtonElement to the next navigation button.
*/
nextButtonProps?: ButtonHTMLAttributes | undefined;
} }
export interface GalleriaSlots { export interface GalleriaSlots {

View File

@ -1,5 +1,5 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import PrimeVue from '../config/PrimeVue'; import PrimeVue from 'primevue/config';
import Galleria from './Galleria.vue'; import Galleria from './Galleria.vue';
describe('Gallleria.vue', () => { describe('Gallleria.vue', () => {

View File

@ -1,8 +1,8 @@
<template> <template>
<Portal v-if="fullScreen"> <Portal v-if="fullScreen">
<div v-if="containerVisible" :ref="maskRef" :class="maskContentClass"> <div v-if="containerVisible" :ref="maskRef" :class="maskContentClass" :role="fullScreen ? 'dialog' : 'region'" :aria-modal="fullScreen ? 'true' : undefined">
<transition name="p-galleria" @before-enter="onBeforeEnter" @enter="onEnter" @before-leave="onBeforeLeave" @after-leave="onAfterLeave" appear> <transition name="p-galleria" @before-enter="onBeforeEnter" @enter="onEnter" @before-leave="onBeforeLeave" @after-leave="onAfterLeave" appear>
<GalleriaContent v-if="visible" :ref="containerRef" v-bind="$props" @mask-hide="maskHide" :templates="$slots" @activeitem-change="onActiveItemChange" /> <GalleriaContent v-if="visible" :ref="containerRef" v-focustrap v-bind="$props" @mask-hide="maskHide" :templates="$slots" @activeitem-change="onActiveItemChange" />
</transition> </transition>
</div> </div>
</Portal> </Portal>
@ -10,9 +10,10 @@
</template> </template>
<script> <script>
import GalleriaContent from './GalleriaContent.vue'; import FocusTrap from 'primevue/focustrap';
import { DomHandler, ZIndexUtils } from 'primevue/utils';
import Portal from 'primevue/portal'; import Portal from 'primevue/portal';
import { DomHandler, ZIndexUtils } from 'primevue/utils';
import GalleriaContent from './GalleriaContent.vue';
export default { export default {
name: 'Galleria', name: 'Galleria',
@ -107,8 +108,26 @@ export default {
type: String, type: String,
default: null default: null
}, },
containerStyle: null, containerStyle: {
containerClass: null type: null,
default: null
},
containerClass: {
type: null,
default: null
},
containerProps: {
type: null,
default: null
},
prevButtonProps: {
type: null,
default: null
},
nextButtonProps: {
type: null,
default: null
}
}, },
container: null, container: null,
mask: null, mask: null,
@ -141,6 +160,7 @@ export default {
onEnter(el) { onEnter(el) {
this.mask.style.zIndex = String(parseInt(el.style.zIndex, 10) - 1); this.mask.style.zIndex = String(parseInt(el.style.zIndex, 10) - 1);
DomHandler.addClass(document.body, 'p-overflow-hidden'); DomHandler.addClass(document.body, 'p-overflow-hidden');
this.focus();
}, },
onBeforeLeave() { onBeforeLeave() {
DomHandler.addClass(this.mask, 'p-component-overlay-leave'); DomHandler.addClass(this.mask, 'p-component-overlay-leave');
@ -163,6 +183,13 @@ export default {
}, },
maskRef(el) { maskRef(el) {
this.mask = el; this.mask = el;
},
focus() {
let focusTarget = this.container.$el.querySelector('[autofocus]');
if (focusTarget) {
focusTarget.focus();
}
} }
}, },
computed: { computed: {
@ -180,6 +207,9 @@ export default {
components: { components: {
GalleriaContent: GalleriaContent, GalleriaContent: GalleriaContent,
Portal: Portal Portal: Portal
},
directives: {
focustrap: FocusTrap
} }
}; };
</script> </script>

View File

@ -1,13 +1,14 @@
<template> <template>
<div v-if="$attrs.value && $attrs.value.length > 0" :id="id" :class="galleriaClass" :style="$attrs.containerStyle"> <div v-if="$attrs.value && $attrs.value.length > 0" :id="id" :class="galleriaClass" :style="$attrs.containerStyle" v-bind="$attrs.containerProps">
<button v-if="$attrs.fullScreen" v-ripple type="button" class="p-galleria-close p-link" @click="$emit('mask-hide')"> <button v-if="$attrs.fullScreen" v-ripple autofocus type="button" class="p-galleria-close p-link" :aria-label="closeAriaLabel" @click="$emit('mask-hide')">
<span class="p-galleria-close-icon pi pi-times"></span> <span class="p-galleria-close-icon pi pi-times"></span>
</button> </button>
<div v-if="$attrs.templates && $attrs.templates['header']" class="p-galleria-header"> <div v-if="$attrs.templates && $attrs.templates['header']" class="p-galleria-header">
<component :is="$attrs.templates['header']" /> <component :is="$attrs.templates['header']" />
</div> </div>
<div class="p-galleria-content"> <div class="p-galleria-content" :aria-live="$attrs.autoPlay ? 'polite' : 'off'">
<GalleriaItem <GalleriaItem
:id="id"
v-model:activeIndex="activeIndex" v-model:activeIndex="activeIndex"
v-model:slideShowActive="slideShowActive" v-model:slideShowActive="slideShowActive"
:value="$attrs.value" :value="$attrs.value"
@ -34,6 +35,8 @@
:isVertical="isVertical()" :isVertical="isVertical()"
:contentHeight="$attrs.verticalThumbnailViewPortHeight" :contentHeight="$attrs.verticalThumbnailViewPortHeight"
:showThumbnailNavigators="$attrs.showThumbnailNavigators" :showThumbnailNavigators="$attrs.showThumbnailNavigators"
:prevButtonProps="$attrs.prevButtonProps"
:nextButtonProps="$attrs.nextButtonProps"
@stop-slideshow="stopSlideShow" @stop-slideshow="stopSlideShow"
/> />
</div> </div>
@ -44,11 +47,10 @@
</template> </template>
<script> <script>
import Ripple from 'primevue/ripple';
import { UniqueComponentId } from 'primevue/utils'; import { UniqueComponentId } from 'primevue/utils';
import GalleriaItem from './GalleriaItem.vue'; import GalleriaItem from './GalleriaItem.vue';
import GalleriaThumbnails from './GalleriaThumbnails.vue'; import GalleriaThumbnails from './GalleriaThumbnails.vue';
import GalleriaItemSlot from './GalleriaItemSlot.vue';
import Ripple from 'primevue/ripple';
export default { export default {
name: 'GalleriaContent', name: 'GalleriaContent',
@ -130,12 +132,14 @@ export default {
indicatorPosClass, indicatorPosClass,
this.$attrs.containerClass this.$attrs.containerClass
]; ];
},
closeAriaLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.close : undefined;
} }
}, },
components: { components: {
GalleriaItem: GalleriaItem, GalleriaItem: GalleriaItem,
GalleriaThumbnails: GalleriaThumbnails, GalleriaThumbnails: GalleriaThumbnails
GalleriaItemSlot: GalleriaItemSlot
}, },
directives: { directives: {
ripple: Ripple ripple: Ripple

View File

@ -4,7 +4,7 @@
<button v-if="showItemNavigators" v-ripple type="button" :class="navBackwardClass" @click="navBackward($event)" :disabled="isNavBackwardDisabled()"> <button v-if="showItemNavigators" v-ripple type="button" :class="navBackwardClass" @click="navBackward($event)" :disabled="isNavBackwardDisabled()">
<span class="p-galleria-item-prev-icon pi pi-chevron-left"></span> <span class="p-galleria-item-prev-icon pi pi-chevron-left"></span>
</button> </button>
<div class="p-galleria-item"> <div :id="id + '_item_' + activeIndex" class="p-galleria-item" role="group" :aria-label="ariaSlideNumber(activeIndex + 1)" :aria-roledescription="ariaSlideLabel">
<component v-if="templates.item" :is="templates.item" :item="activeItem" /> <component v-if="templates.item" :is="templates.item" :item="activeItem" />
</div> </div>
<button v-if="showItemNavigators" v-ripple type="button" :class="navForwardClass" @click="navForward($event)" :disabled="isNavForwardDisabled()"> <button v-if="showItemNavigators" v-ripple type="button" :class="navForwardClass" @click="navForward($event)" :disabled="isNavForwardDisabled()">
@ -18,11 +18,14 @@
<li <li
v-for="(item, index) of value" v-for="(item, index) of value"
:key="`p-galleria-indicator-${index}`" :key="`p-galleria-indicator-${index}`"
:class="['p-galleria-indicator', { 'p-highlight': isIndicatorItemActive(index) }]"
tabindex="0" tabindex="0"
:aria-label="ariaPageLabel(index + 1)"
:aria-selected="activeIndex === index"
:aria-controls="id + '_item_' + index"
@click="onIndicatorClick(index)" @click="onIndicatorClick(index)"
@mouseenter="onIndicatorMouseEnter(index)" @mouseenter="onIndicatorMouseEnter(index)"
@keydown.enter="onIndicatorKeyDown(index)" @keydown="onIndicatorKeyDown($event, index)"
:class="['p-galleria-indicator', { 'p-highlight': isIndicatorItemActive(index) }]"
> >
<button v-if="!templates['indicator']" type="button" tabindex="-1" class="p-link"></button> <button v-if="!templates['indicator']" type="button" tabindex="-1" class="p-link"></button>
<component v-if="templates.indicator" :is="templates.indicator" :index="index" /> <component v-if="templates.indicator" :is="templates.indicator" :index="index" />
@ -73,6 +76,10 @@ export default {
templates: { templates: {
type: null, type: null,
default: null default: null
},
id: {
type: String,
default: null
} }
}, },
mounted() { mounted() {
@ -125,10 +132,24 @@ export default {
this.$emit('update:activeIndex', index); this.$emit('update:activeIndex', index);
} }
}, },
onIndicatorKeyDown(index) { onIndicatorKeyDown(event, index) {
this.stopSlideShow(); switch (event.code) {
case 'Enter':
case 'Space':
this.stopSlideShow();
this.$emit('update:activeIndex', index); this.$emit('update:activeIndex', index);
event.preventDefault();
break;
case 'ArrowDown':
case 'ArrowUp':
event.preventDefault();
break;
default:
break;
}
}, },
isIndicatorItemActive(index) { isIndicatorItemActive(index) {
return this.activeIndex === index; return this.activeIndex === index;
@ -138,6 +159,12 @@ export default {
}, },
isNavForwardDisabled() { isNavForwardDisabled() {
return !this.circular && this.activeIndex === this.value.length - 1; return !this.circular && this.activeIndex === this.value.length - 1;
},
ariaSlideNumber(value) {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.slideNumber.replace(/{slideNumber}/g, value) : undefined;
},
ariaPageLabel(value) {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.pageLabel.replace(/{page}/g, value) : undefined;
} }
}, },
computed: { computed: {
@ -159,6 +186,9 @@ export default {
'p-disabled': this.isNavForwardDisabled() 'p-disabled': this.isNavForwardDisabled()
} }
]; ];
},
ariaSlideLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.slide : undefined;
} }
}, },
directives: { directives: {

View File

@ -1,53 +0,0 @@
<script>
export default {
functional: true,
props: {
item: {
type: null,
default: null
},
index: {
type: Number,
default: 0
},
templates: {
type: null,
default: null
},
type: {
type: String,
default: null
}
},
render(createElement, context) {
const { item, index, templates, type } = context.props;
const template = templates && templates[type];
if (template) {
let content;
switch (type) {
case 'item':
case 'caption':
case 'thumbnail':
content = template({
item
});
break;
case 'indicator':
content = template({
index
});
break;
default:
content = template({});
break;
}
return content ? [content] : null;
}
return null;
}
};
</script>

View File

@ -1,11 +1,11 @@
<template> <template>
<div class="p-galleria-thumbnail-wrapper"> <div class="p-galleria-thumbnail-wrapper">
<div class="p-galleria-thumbnail-container"> <div class="p-galleria-thumbnail-container">
<button v-if="showThumbnailNavigators" v-ripple :class="navBackwardClass" @click="navBackward($event)" :disabled="isNavBackwardDisabled()" type="button"> <button v-if="showThumbnailNavigators" v-ripple :class="navBackwardClass" :disabled="isNavBackwardDisabled()" type="button" :aria-label="ariaPrevButtonLabel" @click="navBackward($event)" v-bind="prevButtonProps">
<span :class="navBackwardIconClass"></span> <span :class="navBackwardIconClass"></span>
</button> </button>
<div class="p-galleria-thumbnail-items-container" :style="{ height: isVertical ? contentHeight : '' }"> <div class="p-galleria-thumbnail-items-container" :style="{ height: isVertical ? contentHeight : '' }">
<div ref="itemsContainer" class="p-galleria-thumbnail-items" @transitionend="onTransitionEnd" @touchstart="onTouchStart($event)" @touchmove="onTouchMove($event)" @touchend="onTouchEnd($event)"> <div ref="itemsContainer" class="p-galleria-thumbnail-items" role="tablist" @transitionend="onTransitionEnd" @touchstart="onTouchStart($event)" @touchmove="onTouchMove($event)" @touchend="onTouchEnd($event)">
<div <div
v-for="(item, index) of value" v-for="(item, index) of value"
:key="`p-galleria-thumbnail-item-${index}`" :key="`p-galleria-thumbnail-item-${index}`"
@ -18,14 +18,18 @@
'p-galleria-thumbnail-item-end': lastItemActiveIndex() === index 'p-galleria-thumbnail-item-end': lastItemActiveIndex() === index
} }
]" ]"
role="tab"
:aria-selected="activeIndex === index"
:aria-controls="containerId + '_item_' + index"
@keydown="onThumbnailKeydown($event, index)"
> >
<div class="p-galleria-thumbnail-item-content" :tabindex="isItemActive(index) ? 0 : null" @click="onItemClick(index)" @keydown.enter="onItemClick(index)"> <div class="p-galleria-thumbnail-item-content" :tabindex="activeIndex === index ? '0' : '-1'" :aria-label="ariaPageLabel(index + 1)" :aria-current="activeIndex === index ? 'page' : undefined" @click="onItemClick(index)">
<component v-if="templates.thumbnail" :is="templates.thumbnail" :item="item" /> <component v-if="templates.thumbnail" :is="templates.thumbnail" :item="item" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<button v-if="showThumbnailNavigators" v-ripple :class="navForwardClass" @click="navForward($event)" :disabled="isNavForwardDisabled()" type="button"> <button v-if="showThumbnailNavigators" v-ripple :class="navForwardClass" :disabled="isNavForwardDisabled()" type="button" :aria-label="ariaNextButtonLabel" @click="navForward($event)" v-bind="nextButtonProps">
<span :class="navForwardIconClass"></span> <span :class="navForwardIconClass"></span>
</button> </button>
</div> </div>
@ -33,8 +37,8 @@
</template> </template>
<script> <script>
import { DomHandler } from 'primevue/utils';
import Ripple from 'primevue/ripple'; import Ripple from 'primevue/ripple';
import { DomHandler } from 'primevue/utils';
export default { export default {
name: 'GalleriaThumbnails', name: 'GalleriaThumbnails',
@ -83,6 +87,14 @@ export default {
templates: { templates: {
type: null, type: null,
default: null default: null
},
prevButtonProps: {
type: null,
default: null
},
nextButtonProps: {
type: null,
default: null
} }
}, },
startPos: null, startPos: null,
@ -211,7 +223,7 @@ export default {
navForward(e) { navForward(e) {
this.stopSlideShow(); this.stopSlideShow();
let nextItemIndex = this.d_activeIndex + 1; let nextItemIndex = this.d_activeIndex === this.value.length - 1 ? this.value.length - 1 : this.d_activeIndex + 1;
if (nextItemIndex + this.totalShiftedItems > this.getMedianItemIndex() && (-1 * this.totalShiftedItems < this.getTotalPageNumber() - 1 || this.circular)) { if (nextItemIndex + this.totalShiftedItems > this.getMedianItemIndex() && (-1 * this.totalShiftedItems < this.getTotalPageNumber() - 1 || this.circular)) {
this.step(-1); this.step(-1);
@ -251,6 +263,89 @@ export default {
this.$emit('update:activeIndex', selectedItemIndex); this.$emit('update:activeIndex', selectedItemIndex);
} }
}, },
onThumbnailKeydown(event, index) {
if (event.code === 'Enter' || event.code === 'Space') {
this.onItemClick(index);
event.preventDefault();
}
switch (event.code) {
case 'ArrowRight':
this.onRightKey();
break;
case 'ArrowLeft':
this.onLeftKey();
break;
case 'Home':
this.onHomeKey();
event.preventDefault();
break;
case 'End':
this.onEndKey();
event.preventDefault();
break;
case 'ArrowUp':
case 'ArrowDown':
event.preventDefault();
break;
case 'Tab':
this.onTabKey();
break;
default:
break;
}
},
onRightKey() {
const indicators = DomHandler.find(this.$refs.itemsContainer, '.p-galleria-thumbnail-item');
const activeIndex = this.findFocusedIndicatorIndex();
this.changedFocusedIndicator(activeIndex, activeIndex + 1 === indicators.length ? indicators.length - 1 : activeIndex + 1);
},
onLeftKey() {
const activeIndex = this.findFocusedIndicatorIndex();
this.changedFocusedIndicator(activeIndex, activeIndex - 1 <= 0 ? 0 : activeIndex - 1);
},
onHomeKey() {
const activeIndex = this.findFocusedIndicatorIndex();
this.changedFocusedIndicator(activeIndex, 0);
},
onEndKey() {
const indicators = DomHandler.find(this.$refs.itemsContainer, '.p-galleria-thumbnail-item');
const activeIndex = this.findFocusedIndicatorIndex();
this.changedFocusedIndicator(activeIndex, indicators.length - 1);
},
onTabKey() {
const indicators = [...DomHandler.find(this.$refs.itemsContainer, '.p-galleria-thumbnail-item')];
const highlightedIndex = indicators.findIndex((ind) => DomHandler.hasClass(ind, 'p-galleria-thumbnail-item-current'));
const activeIndicator = DomHandler.findSingle(this.$refs.itemsContainer, '.p-galleria-thumbnail-item > [tabindex="0"');
const activeIndex = indicators.findIndex((ind) => ind === activeIndicator.parentElement);
indicators[activeIndex].children[0].tabIndex = '-1';
indicators[highlightedIndex].children[0].tabIndex = '0';
},
findFocusedIndicatorIndex() {
const indicators = [...DomHandler.find(this.$refs.itemsContainer, '.p-galleria-thumbnail-item')];
const activeIndicator = DomHandler.findSingle(this.$refs.itemsContainer, '.p-galleria-thumbnail-item > [tabindex="0"]');
return indicators.findIndex((ind) => ind === activeIndicator.parentElement);
},
changedFocusedIndicator(prevInd, nextInd) {
const indicators = DomHandler.find(this.$refs.itemsContainer, '.p-galleria-thumbnail-item');
indicators[prevInd].children[0].tabIndex = '-1';
indicators[nextInd].children[0].tabIndex = '0';
indicators[nextInd].children[0].focus();
},
onTransitionEnd() { onTransitionEnd() {
if (this.$refs.itemsContainer) { if (this.$refs.itemsContainer) {
DomHandler.addClass(this.$refs.itemsContainer, 'p-items-hidden'); DomHandler.addClass(this.$refs.itemsContainer, 'p-items-hidden');
@ -384,6 +479,9 @@ export default {
}, },
isItemActive(index) { isItemActive(index) {
return this.firstItemAciveIndex() <= index && this.lastItemActiveIndex() >= index; return this.firstItemAciveIndex() <= index && this.lastItemActiveIndex() >= index;
},
ariaPageLabel(value) {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.pageLabel.replace(/{page}/g, value) : undefined;
} }
}, },
computed: { computed: {
@ -420,6 +518,12 @@ export default {
'pi-chevron-down': this.isVertical 'pi-chevron-down': this.isVertical
} }
]; ];
},
ariaPrevButtonLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.prevPageLabel : undefined;
},
ariaNextButtonLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.nextPageLabel : undefined;
} }
}, },
directives: { directives: {

View File

@ -1,10 +1,12 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import PrimeVue from 'primevue/config';
import Image from './Image.vue'; import Image from './Image.vue';
describe('Image.vue', () => { describe('Image.vue', () => {
it('should exist', () => { it('should exist', () => {
const wrapper = mount(Image, { const wrapper = mount(Image, {
global: { global: {
plugins: [PrimeVue],
stubs: { stubs: {
teleport: true teleport: true
} }
@ -21,6 +23,7 @@ describe('Image.vue', () => {
it('should preview', async () => { it('should preview', async () => {
const wrapper = mount(Image, { const wrapper = mount(Image, {
global: { global: {
plugins: [PrimeVue],
stubs: { stubs: {
teleport: true teleport: true
} }
@ -42,6 +45,8 @@ describe('Image.vue', () => {
await wrapper.setData({ maskVisible: false }); await wrapper.setData({ maskVisible: false });
expect(wrapper.find('.p-image-mask').exists()).toBe(false); setTimeout(() => {
expect(wrapper.find('.p-image-mask').exists()).toBe(false);
}, 25);
}); });
}); });

View File

@ -1,27 +1,27 @@
<template> <template>
<span :class="containerClass" :style="style"> <span :class="containerClass" :style="style">
<img v-bind="$attrs" :style="imageStyle" :class="imageClass" @error="onError" /> <img v-bind="$attrs" :style="imageStyle" :class="imageClass" @error="onError" />
<div v-if="preview" class="p-image-preview-indicator" @click="onImageClick"> <button v-if="preview" ref="previewButton" class="p-image-preview-indicator" @click="onImageClick" v-bind="previewButtonProps">
<slot name="indicator"> <slot name="indicator">
<i class="p-image-preview-icon pi pi-eye"></i> <i class="p-image-preview-icon pi pi-eye"></i>
</slot> </slot>
</div> </button>
<Portal> <Portal>
<div v-if="maskVisible" :ref="maskRef" :class="maskClass" @click="onMaskClick"> <div v-if="maskVisible" :ref="maskRef" v-focustrap role="dialog" :class="maskClass" :aria-modal="maskVisible" @click="onMaskClick" @keydown="onMaskKeydown">
<div class="p-image-toolbar"> <div class="p-image-toolbar">
<button class="p-image-action p-link" @click="rotateRight" type="button"> <button class="p-image-action p-link" @click="rotateRight" type="button" :aria-label="rightAriaLabel">
<i class="pi pi-refresh"></i> <i class="pi pi-refresh"></i>
</button> </button>
<button class="p-image-action p-link" @click="rotateLeft" type="button"> <button class="p-image-action p-link" @click="rotateLeft" type="button" :aria-label="leftAriaLabel">
<i class="pi pi-undo"></i> <i class="pi pi-undo"></i>
</button> </button>
<button class="p-image-action p-link" @click="zoomOut" type="button" :disabled="zoomDisabled"> <button class="p-image-action p-link" @click="zoomOut" type="button" :disabled="zoomDisabled" :aria-label="zoomOutAriaLabel">
<i class="pi pi-search-minus"></i> <i class="pi pi-search-minus"></i>
</button> </button>
<button class="p-image-action p-link" @click="zoomIn" type="button" :disabled="zoomDisabled"> <button class="p-image-action p-link" @click="zoomIn" type="button" :disabled="zoomDisabled" :aria-label="zoomInAriaLabel">
<i class="pi pi-search-plus"></i> <i class="pi pi-search-plus"></i>
</button> </button>
<button class="p-image-action p-link" type="button" @click="hidePreview"> <button class="p-image-action p-link" type="button" @click="hidePreview" :aria-label="closeAriaLabel" autofocus>
<i class="pi pi-times"></i> <i class="pi pi-times"></i>
</button> </button>
</div> </div>
@ -36,8 +36,9 @@
</template> </template>
<script> <script>
import { DomHandler, ZIndexUtils } from 'primevue/utils'; import FocusTrap from 'primevue/focustrap';
import Portal from 'primevue/portal'; import Portal from 'primevue/portal';
import { DomHandler, ZIndexUtils } from 'primevue/utils';
export default { export default {
name: 'Image', name: 'Image',
@ -48,10 +49,26 @@ export default {
type: Boolean, type: Boolean,
default: false default: false
}, },
class: null, class: {
style: null, type: null,
imageStyle: null, default: null
imageClass: null },
style: {
type: null,
default: null
},
imageStyle: {
type: null,
default: null
},
imageClass: {
type: null,
default: null
},
previewButtonProps: {
type: null,
default: null
}
}, },
mask: null, mask: null,
data() { data() {
@ -94,6 +111,21 @@ export default {
this.previewClick = false; this.previewClick = false;
}, },
onMaskKeydown(event) {
switch (event.code) {
case 'Escape':
this.onMaskClick();
setTimeout(() => {
DomHandler.focus(this.$refs.previewButton);
}, 25);
event.preventDefault();
break;
default:
break;
}
},
onError() { onError() {
this.$emit('error'); this.$emit('error');
}, },
@ -117,6 +149,7 @@ export default {
ZIndexUtils.set('modal', this.mask, this.$primevue.config.zIndex.modal); ZIndexUtils.set('modal', this.mask, this.$primevue.config.zIndex.modal);
}, },
onEnter() { onEnter() {
this.focus();
this.$emit('show'); this.$emit('show');
}, },
onBeforeLeave() { onBeforeLeave() {
@ -128,6 +161,13 @@ export default {
onAfterLeave(el) { onAfterLeave(el) {
ZIndexUtils.clear(el); ZIndexUtils.clear(el);
this.maskVisible = false; this.maskVisible = false;
},
focus() {
let focusTarget = this.mask.querySelector('[autofocus]');
if (focusTarget) {
focusTarget.focus();
}
} }
}, },
computed: { computed: {
@ -151,10 +191,28 @@ export default {
}, },
zoomDisabled() { zoomDisabled() {
return this.scale <= 0.5 || this.scale >= 1.5; return this.scale <= 0.5 || this.scale >= 1.5;
},
rightAriaLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.rotateRight : undefined;
},
leftAriaLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.rotateLeft : undefined;
},
zoomInAriaLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.zoomIn : undefined;
},
zoomOutAriaLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.zoomOut : undefined;
},
closeAriaLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.close : undefined;
} }
}, },
components: { components: {
Portal: Portal Portal: Portal
},
directives: {
focustrap: FocusTrap
} }
}; };
</script> </script>

View File

@ -1,4 +1,4 @@
import { VNode } from 'vue'; import { HTMLAttributes, ButtonHTMLAttributes, VNode } from 'vue';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers'; import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
export interface InplaceProps { export interface InplaceProps {
@ -14,6 +14,19 @@ export interface InplaceProps {
* When present, it specifies that the element should be disabled. * When present, it specifies that the element should be disabled.
*/ */
disabled?: boolean | undefined; disabled?: boolean | undefined;
/**
* Icon to display in the close button.
* Default value is 'pi pi-times'.
*/
closeIcon?: string | undefined;
/**
* Uses to pass all properties of the HTMLDivElement to display container.
*/
displayProps?: HTMLAttributes | undefined;
/**
* Uses to pass all properties of the HTMLButtonElement to the close button.
*/
closeButtonProps?: ButtonHTMLAttributes | undefined;
} }
export interface InplaceSlots { export interface InplaceSlots {

View File

@ -1,6 +1,18 @@
import { mount } from '@vue/test-utils'; import { config, mount } from '@vue/test-utils';
import Inplace from './Inplace.vue'; import Inplace from './Inplace.vue';
import InputText from '../inputtext/InputText.vue'; import InputText from '@/components/inputtext/InputText.vue';
config.global.mocks = {
$primevue: {
config: {
locale: {
aria: {
close: 'Close'
}
}
}
}
};
describe('Inplace.vue', () => { describe('Inplace.vue', () => {
it('should exist', () => { it('should exist', () => {
@ -64,4 +76,26 @@ describe('Inplace.vue', () => {
expect(wrapper.find('.pi.pi-times').exists()).toBe(false); expect(wrapper.find('.pi.pi-times').exists()).toBe(false);
}); });
it('should have custom close icon', async () => {
const wrapper = mount(Inplace, {
global: {
components: {
InputText
}
},
props: {
closable: true,
closeIcon: 'pi pi-discord'
},
slots: {
display: `{{'Click to Edit'}}`,
content: `<InputText autoFocus />`
}
});
await wrapper.vm.open({});
expect(wrapper.find('.pi.pi-discord').exists()).toBe(true);
});
}); });

View File

@ -1,11 +1,11 @@
<template> <template>
<div :class="containerClass"> <div :class="containerClass" aria-live="polite">
<div v-if="!d_active" :class="displayClass" :tabindex="$attrs.tabindex || '0'" @click="open" @keydown.enter="open"> <div v-if="!d_active" ref="display" :class="displayClass" :tabindex="$attrs.tabindex || '0'" role="button" @click="open" @keydown.enter="open" v-bind="displayProps">
<slot name="display"></slot> <slot name="display"></slot>
</div> </div>
<div v-else class="p-inplace-content"> <div v-else class="p-inplace-content">
<slot name="content"></slot> <slot name="content"></slot>
<IPButton v-if="closable" icon="pi pi-times" @click="close"></IPButton> <IPButton v-if="closable" :icon="closeIcon" :aria-label="closeAriaLabel" @click="close" v-bind="closeButtonProps" />
</div> </div>
</div> </div>
</template> </template>
@ -28,6 +28,18 @@ export default {
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false default: false
},
closeIcon: {
type: String,
default: 'pi pi-times'
},
displayProps: {
type: null,
default: null
},
closeButtonProps: {
type: null,
default: null
} }
}, },
data() { data() {
@ -54,6 +66,9 @@ export default {
this.$emit('close', event); this.$emit('close', event);
this.d_active = false; this.d_active = false;
this.$emit('update:active', false); this.$emit('update:active', false);
setTimeout(() => {
this.$refs.display.focus();
}, 0);
} }
}, },
computed: { computed: {
@ -62,6 +77,9 @@ export default {
}, },
displayClass() { displayClass() {
return ['p-inplace-display', { 'p-disabled': this.disabled }]; return ['p-inplace-display', { 'p-disabled': this.disabled }];
},
closeAriaLabel() {
return this.$primevue.config.locale.aria ? this.$primevue.config.locale.aria.close : undefined;
} }
}, },
components: { components: {

View File

@ -134,6 +134,11 @@ export interface InputNumberProps {
* Default value is true. * Default value is true.
*/ */
allowEmpty?: boolean | undefined; allowEmpty?: boolean | undefined;
/**
* Highlights automatically the input value.
* Default value is false.
*/
highlightOnFocus?: boolean | undefined;
/** /**
* When present, it specifies that the component should be disabled. * When present, it specifies that the component should be disabled.
*/ */

View File

@ -18,24 +18,24 @@ describe('InputNumber.vue', () => {
}); });
it('is keydown called when down and up keys pressed', async () => { it('is keydown called when down and up keys pressed', async () => {
await wrapper.vm.onInputKeyDown({ which: 38, target: { value: 1 }, preventDefault: () => {} }); await wrapper.vm.onInputKeyDown({ code: 'ArrowUp', target: { value: 1 }, preventDefault: () => {} });
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([2]); expect(wrapper.emitted()['update:modelValue'][0]).toEqual([2]);
await wrapper.vm.onInputKeyDown({ which: 40, target: { value: 2 }, preventDefault: () => {} }); await wrapper.vm.onInputKeyDown({ code: 'ArrowDown', target: { value: 2 }, preventDefault: () => {} });
expect(wrapper.emitted()['update:modelValue'][1]).toEqual([1]); expect(wrapper.emitted()['update:modelValue'][1]).toEqual([1]);
}); });
it('is keydown called when tab key pressed', async () => { it('is keydown called when tab key pressed', async () => {
await wrapper.vm.onInputKeyDown({ which: 9, target: { value: '12' }, preventDefault: () => {} }); await wrapper.vm.onInputKeyDown({ code: 'Tab', target: { value: '12' }, preventDefault: () => {} });
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([12]); expect(wrapper.emitted()['update:modelValue'][0]).toEqual([12]);
expect(wrapper.find('input.p-inputnumber-input').attributes()['aria-valuenow']).toBe('12'); expect(wrapper.find('input.p-inputnumber-input').attributes()['aria-valuenow']).toBe('12');
}); });
it('is keydown called when enter key pressed', async () => { it('is keydown called when enter key pressed', async () => {
await wrapper.vm.onInputKeyDown({ which: 13, target: { value: '12' }, preventDefault: () => {} }); await wrapper.vm.onInputKeyDown({ code: 'Enter', target: { value: '12' }, preventDefault: () => {} });
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([12]); expect(wrapper.emitted()['update:modelValue'][0]).toEqual([12]);
expect(wrapper.find('input.p-inputnumber-input').attributes()['aria-valuenow']).toBe('12'); expect(wrapper.find('input.p-inputnumber-input').attributes()['aria-valuenow']).toBe('12');
@ -60,11 +60,11 @@ describe('InputNumber.vue', () => {
it('should have min boundary', async () => { it('should have min boundary', async () => {
await wrapper.setProps({ modelValue: 95, min: 95 }); await wrapper.setProps({ modelValue: 95, min: 95 });
await wrapper.vm.onInputKeyDown({ which: 40, target: { value: 96 }, preventDefault: () => {} }); await wrapper.vm.onInputKeyDown({ code: 'ArrowDown', target: { value: 96 }, preventDefault: () => {} });
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([95]); expect(wrapper.emitted()['update:modelValue'][0]).toEqual([95]);
await wrapper.vm.onInputKeyDown({ which: 40, target: { value: 95 }, preventDefault: () => {} }); await wrapper.vm.onInputKeyDown({ code: 'ArrowDown', target: { value: 95 }, preventDefault: () => {} });
expect(wrapper.emitted()['update:modelValue'][1]).toEqual([95]); expect(wrapper.emitted()['update:modelValue'][1]).toEqual([95]);
}); });
@ -72,11 +72,11 @@ describe('InputNumber.vue', () => {
it('should have max boundary', async () => { it('should have max boundary', async () => {
await wrapper.setProps({ modelValue: 99, max: 100 }); await wrapper.setProps({ modelValue: 99, max: 100 });
await wrapper.vm.onInputKeyDown({ which: 38, target: { value: 99 }, preventDefault: () => {} }); await wrapper.vm.onInputKeyDown({ code: 'ArrowUp', target: { value: 99 }, preventDefault: () => {} });
expect(wrapper.emitted()['update:modelValue'][0]).toEqual([100]); expect(wrapper.emitted()['update:modelValue'][0]).toEqual([100]);
await wrapper.vm.onInputKeyDown({ which: 38, target: { value: 100 }, preventDefault: () => {} }); await wrapper.vm.onInputKeyDown({ code: 'ArrowUp', target: { value: 100 }, preventDefault: () => {} });
expect(wrapper.emitted()['update:modelValue'][1]).toEqual([100]); expect(wrapper.emitted()['update:modelValue'][1]).toEqual([100]);
}); });

View File

@ -35,8 +35,9 @@
</template> </template>
<script> <script>
import InputText from 'primevue/inputtext';
import Button from 'primevue/button'; import Button from 'primevue/button';
import InputText from 'primevue/inputtext';
import { DomHandler } from 'primevue/utils';
export default { export default {
name: 'InputNumber', name: 'InputNumber',
@ -130,6 +131,10 @@ export default {
type: Boolean, type: Boolean,
default: true default: true
}, },
highlightOnFocus: {
type: Boolean,
default: false
},
readonly: { readonly: {
type: Boolean, type: Boolean,
default: false default: false
@ -475,46 +480,40 @@ export default {
event.preventDefault(); event.preventDefault();
} }
switch (event.which) { switch (event.code) {
//up case 'ArrowUp':
case 38:
this.spin(event, 1); this.spin(event, 1);
event.preventDefault(); event.preventDefault();
break; break;
//down case 'ArrowDown':
case 40:
this.spin(event, -1); this.spin(event, -1);
event.preventDefault(); event.preventDefault();
break; break;
//left case 'ArrowLeft':
case 37:
if (!this.isNumeralChar(inputValue.charAt(selectionStart - 1))) { if (!this.isNumeralChar(inputValue.charAt(selectionStart - 1))) {
event.preventDefault(); event.preventDefault();
} }
break; break;
//right case 'ArrowRight':
case 39:
if (!this.isNumeralChar(inputValue.charAt(selectionStart))) { if (!this.isNumeralChar(inputValue.charAt(selectionStart))) {
event.preventDefault(); event.preventDefault();
} }
break; break;
//tab and enter case 'Tab':
case 9: case 'Enter':
case 13:
newValueStr = this.validateValue(this.parseValue(inputValue)); newValueStr = this.validateValue(this.parseValue(inputValue));
this.$refs.input.$el.value = this.formatValue(newValueStr); this.$refs.input.$el.value = this.formatValue(newValueStr);
this.$refs.input.$el.setAttribute('aria-valuenow', newValueStr); this.$refs.input.$el.setAttribute('aria-valuenow', newValueStr);
this.updateModel(event, newValueStr); this.updateModel(event, newValueStr);
break; break;
//backspace case 'Backspace': {
case 8: {
event.preventDefault(); event.preventDefault();
if (selectionStart === selectionEnd) { if (selectionStart === selectionEnd) {
@ -556,8 +555,7 @@ export default {
break; break;
} }
// del case 'Delete':
case 46:
event.preventDefault(); event.preventDefault();
if (selectionStart === selectionEnd) { if (selectionStart === selectionEnd) {
@ -598,8 +596,7 @@ export default {
break; break;
//home case 'Home':
case 36:
if (this.min) { if (this.min) {
this.updateModel(event, this.min); this.updateModel(event, this.min);
event.preventDefault(); event.preventDefault();
@ -607,8 +604,7 @@ export default {
break; break;
//end case 'End':
case 35:
if (this.max) { if (this.max) {
this.updateModel(event, this.max); this.updateModel(event, this.max);
event.preventDefault(); event.preventDefault();
@ -836,7 +832,9 @@ export default {
return index || 0; return index || 0;
}, },
onInputClick() { onInputClick() {
if (!this.readonly) { const currentValue = this.$refs.input.$el.value;
if (!this.readonly && currentValue !== DomHandler.getSelection()) {
this.initCursor(); this.initCursor();
} }
}, },
@ -869,7 +867,7 @@ export default {
}, },
handleOnInput(event, currentValue, newValue) { handleOnInput(event, currentValue, newValue) {
if (this.isValueChanged(currentValue, newValue)) { if (this.isValueChanged(currentValue, newValue)) {
this.$emit('input', { originalEvent: event, value: newValue }); this.$emit('input', { originalEvent: event, value: newValue, formattedValue: currentValue });
} }
}, },
isValueChanged(currentValue, newValue) { isValueChanged(currentValue, newValue) {
@ -978,7 +976,11 @@ export default {
this._decimal.lastIndex = 0; this._decimal.lastIndex = 0;
return decimalCharIndex !== -1 ? val1.split(this._decimal)[0] + val2.slice(decimalCharIndex) : val1; if (this.suffixChar) {
return val1.replace(this.suffixChar, '').split(this._decimal)[0] + val2.replace(this.suffixChar, '').slice(decimalCharIndex) + this.suffixChar;
} else {
return decimalCharIndex !== -1 ? val1.split(this._decimal)[0] + val2.slice(decimalCharIndex) : val1;
}
} }
return val1; return val1;
@ -1000,6 +1002,11 @@ export default {
}, },
onInputFocus(event) { onInputFocus(event) {
this.focused = true; this.focused = true;
if (!this.disabled && !this.readonly && this.$refs.input.$el.value !== DomHandler.getSelection() && this.highlightOnFocus) {
event.target.select();
}
this.$emit('focus', event); this.$emit('focus', event);
}, },
onInputBlur(event) { onInputBlur(event) {

View File

@ -36,7 +36,7 @@
/* Floating Label */ /* Floating Label */
.p-float-label { .p-float-label {
display: block; display: block;
position: relative; position: relative;
} }
.p-float-label label { .p-float-label label {
@ -52,7 +52,7 @@
.p-float-label textarea ~ label { .p-float-label textarea ~ label {
top: 1rem; top: 1rem;
} }
.p-float-label input:focus ~ label, .p-float-label input:focus ~ label,
.p-float-label input.p-filled ~ label, .p-float-label input.p-filled ~ label,
.p-float-label textarea:focus ~ label, .p-float-label textarea:focus ~ label,
@ -68,6 +68,22 @@
font-size: 12px; font-size: 12px;
} }
.p-float-label .p-placeholder,
.p-float-label input::placeholder,
.p-float-label .p-inputtext::placeholder {
opacity: 0;
transition-property: all;
transition-timing-function: ease;
}
.p-float-label .p-focus .p-placeholder,
.p-float-label input:focus::placeholder,
.p-float-label .p-inputtext:focus::placeholder {
opacity: 1;
transition-property: all;
transition-timing-function: ease;
}
.p-input-icon-left, .p-input-icon-left,
.p-input-icon-right { .p-input-icon-right {
position: relative; position: relative;
@ -85,4 +101,4 @@
.p-fluid .p-input-icon-right { .p-fluid .p-input-icon-right {
display: block; display: block;
width: 100%; width: 100%;
} }

View File

@ -155,6 +155,11 @@ export interface ListboxProps {
* Index of the element in tabbing order. * Index of the element in tabbing order.
*/ */
tabindex?: number | string | undefined; tabindex?: number | string | undefined;
/**
* Icon to display in filter input.
* Default value is 'pi pi-search'.
*/
filterIcon?: string | undefined;
/** /**
* Defines a string value that labels an interactive element. * Defines a string value that labels an interactive element.
*/ */

View File

@ -14,6 +14,7 @@ config.global.mocks = {
} }
} }
}; };
describe('Listbox.vue', () => { describe('Listbox.vue', () => {
let wrapper; let wrapper;
@ -48,4 +49,17 @@ describe('Listbox.vue', () => {
expect(wrapper.findAll('li.p-listbox-item')[0].classes()).toContain('p-highlight'); expect(wrapper.findAll('li.p-listbox-item')[0].classes()).toContain('p-highlight');
}); });
describe('filter', () => {
it('should have correct custom icon', async () => {
await wrapper.setProps({
filter: true,
filterIcon: 'pi pi-discord'
});
const icon = wrapper.find('.p-listbox-filter-icon');
expect(icon.classes()).toContain('pi-discord');
});
});
}); });

View File

@ -20,7 +20,7 @@
@keydown="onFilterKeyDown" @keydown="onFilterKeyDown"
v-bind="filterInputProps" v-bind="filterInputProps"
/> />
<span class="p-listbox-filter-icon pi pi-search"></span> <span :class="['p-listbox-filter-icon', filterIcon]" />
</div> </div>
<span role="status" aria-live="polite" class="p-hidden-accessible"> <span role="status" aria-live="polite" class="p-hidden-accessible">
{{ filterResultMessageText }} {{ filterResultMessageText }}
@ -75,12 +75,6 @@
<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-if="$slots.loader" v-slot:loader="{ options }"> <template v-if="$slots.loader" v-slot:loader="{ options }">
<slot name="loader" :options="options"></slot> <slot name="loader" :options="options"></slot>
@ -88,14 +82,20 @@
</VirtualScroller> </VirtualScroller>
</div> </div>
<slot name="footer" :value="modelValue" :options="visibleOptions"></slot> <slot name="footer" :value="modelValue" :options="visibleOptions"></slot>
<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>
<span ref="lastHiddenFocusableElement" role="presentation" aria-hidden="true" class="p-hidden-accessible p-hidden-focusable" :tabindex="!disabled ? tabindex : -1" @focus="onLastHiddenFocus"></span> <span ref="lastHiddenFocusableElement" role="presentation" aria-hidden="true" class="p-hidden-accessible p-hidden-focusable" :tabindex="!disabled ? tabindex : -1" @focus="onLastHiddenFocus"></span>
</div> </div>
</template> </template>
<script> <script>
import { DomHandler, ObjectUtils, UniqueComponentId } from 'primevue/utils';
import { FilterService } from 'primevue/api'; import { FilterService } from 'primevue/api';
import Ripple from 'primevue/ripple'; import Ripple from 'primevue/ripple';
import { DomHandler, ObjectUtils, UniqueComponentId } from 'primevue/utils';
import VirtualScroller from 'primevue/virtualscroller'; import VirtualScroller from 'primevue/virtualscroller';
export default { export default {
@ -158,6 +158,10 @@ export default {
type: String, type: String,
default: null default: null
}, },
filterIcon: {
type: String,
default: 'pi pi-search'
},
tabindex: { tabindex: {
type: Number, type: Number,
default: 0 default: 0

View File

@ -1,6 +1,6 @@
import { VNode } from 'vue'; import { VNode } from 'vue';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
import { MenuItem } from '../menuitem'; import { MenuItem } from '../menuitem';
import { ClassComponent, GlobalComponentConstructor } from '../ts-helpers';
type MegaMenuOrientationType = 'horizontal' | 'vertical' | undefined; type MegaMenuOrientationType = 'horizontal' | 'vertical' | undefined;
@ -20,6 +20,22 @@ export interface MegaMenuProps {
* Default value is true. * Default value is true.
*/ */
exact?: boolean | undefined; exact?: boolean | undefined;
/**
* When present, it specifies that the component should be disabled.
*/
disabled?: boolean | undefined;
/**
* Index of the element in tabbing order.
*/
tabindex?: number | string | undefined;
/**
* Defines a string value that labels an interactive element.
*/
'aria-label'?: string | undefined;
/**
* Identifier of the underlying menu element.
*/
'aria-labelledby'?: string | undefined;
} }
export interface MegaMenuSlots { export interface MegaMenuSlots {
@ -43,7 +59,18 @@ export interface MegaMenuSlots {
}) => VNode[]; }) => VNode[];
} }
export declare type MegaMenuEmits = {}; export declare type MegaMenuEmits = {
/**
* 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;
};
declare class MegaMenu extends ClassComponent<MegaMenuProps, MegaMenuSlots, MegaMenuEmits> {} declare class MegaMenu extends ClassComponent<MegaMenuProps, MegaMenuSlots, MegaMenuEmits> {}

Some files were not shown because too many files have changed in this diff Show More