diff --git a/README.md b/README.md index 86647480..1f9f1834 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Several quick start options are available: -- [Download the latest release](https://github.com/coreui/coreui-vue/archive/v5.5.0.zip) +- [Download the latest release](https://github.com/coreui/coreui-vue/archive/v5.6.0.zip) - Clone the repo: `git clone https://github.com/coreui/coreui-vue.git` - Install with [npm](https://www.npmjs.com/): `npm install @coreui/vue` - Install with [yarn](https://yarnpkg.com/): `yarn add @coreui/vue` @@ -101,6 +101,7 @@ import "bootstrap/dist/css/bootstrap.min.css"; - [Vue Accordion](https://coreui.io/vue/docs/components/accordion.html) - [Vue Alert](https://coreui.io/vue/docs/components/alert.html) +- [Vue Autocomplete](https://coreui.io/vue/docs/forms/autocomplete.html) **PRO** - [Vue Avatar](https://coreui.io/vue/docs/components/avatar.html) - [Vue Badge](https://coreui.io/vue/docs/components/badge.html) - [Vue Breadcrumb](https://coreui.io/vue/docs/components/breadcrumb.html) @@ -116,6 +117,7 @@ import "bootstrap/dist/css/bootstrap.min.css"; - [Vue Date Range Picker](https://coreui.io/vue/docs/forms/date-range-picker.html) **PRO** - [Vue Dropdown](https://coreui.io/vue/docs/components/dropdown.html) - [Vue Floating Labels](https://coreui.io/vue/docs/forms/floating-labels.html) +- [Vue Focus Trap](https://coreui.io/vue/docs/components/focus-trap.html) - [Vue Footer](https://coreui.io/vue/docs/components/footer.html) - [Vue Header](https://coreui.io/vue/docs/components/header.html) - [Vue Image](https://coreui.io/vue/docs/components/image.html) @@ -134,12 +136,14 @@ import "bootstrap/dist/css/bootstrap.min.css"; - [Vue Progress](https://coreui.io/vue/docs/components/progress.html) - [Vue Radio](https://coreui.io/vue/docs/forms/radio.html) - [Vue Range](https://coreui.io/vue/docs/forms/range.html) +- [Vue Range Slider](https://coreui.io/vue/docs/forms/range-slider.html) **PRO** - [Vue Rating](https://coreui.io/vue/docs/forms/rating.html) - [Vue Select](https://coreui.io/vue/docs/forms/select.html) - [Vue Sidebar](https://coreui.io/vue/docs/components/sidebar.html) - [Vue Smart Pagination](https://coreui.io/vue/docs/components/smart-pagination.html) **PRO** - [Vue Smart Table](https://coreui.io/vue/docs/components/smart-table.html) **PRO** - [Vue Spinner](https://coreui.io/vue/docs/components/spinner.html) +- [Vue Stepper](https://coreui.io/vue/docs/forms/stepper.html) **PRO** - [Vue Switch](https://coreui.io/vue/docs/forms/switch.html) - [Vue Table](https://coreui.io/vue/docs/components/table.html) - [Vue Textarea](https://coreui.io/vue/docs/forms/textarea.html) diff --git a/lerna.json b/lerna.json index 8b1ec4d7..a4d781c9 100644 --- a/lerna.json +++ b/lerna.json @@ -1,6 +1,6 @@ { "npmClient": "yarn", "packages": ["packages/*"], - "version": "5.5.0", + "version": "5.6.0", "$schema": "node_modules/lerna/schemas/lerna-schema.json" } diff --git a/package.json b/package.json index 7b5343e7..c862edad 100644 --- a/package.json +++ b/package.json @@ -23,15 +23,15 @@ }, "devDependencies": { "@vue/vue3-jest": "29.2.6", - "eslint": "^9.28.0", - "eslint-config-prettier": "^10.1.5", - "eslint-plugin-prettier": "^5.4.1", - "eslint-plugin-unicorn": "^59.0.1", - "eslint-plugin-vue": "^10.1.0", - "globals": "^16.2.0", - "lerna": "^8.2.2", + "eslint": "^9.34.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "eslint-plugin-unicorn": "^60.0.0", + "eslint-plugin-vue": "^10.4.0", + "globals": "^16.3.0", + "lerna": "^8.2.3", "npm-run-all": "^4.1.5", - "prettier": "^3.5.3", - "typescript-eslint": "^8.33.1" + "prettier": "^3.6.2", + "typescript-eslint": "^8.41.0" } } diff --git a/packages/coreui-vue/README.md b/packages/coreui-vue/README.md index a1cfd697..aabe8d99 100644 --- a/packages/coreui-vue/README.md +++ b/packages/coreui-vue/README.md @@ -46,7 +46,7 @@ Several quick start options are available: -- [Download the latest release](https://github.com/coreui/coreui-vue/archive/v5.5.0.zip) +- [Download the latest release](https://github.com/coreui/coreui-vue/archive/v5.6.0.zip) - Clone the repo: `git clone https://github.com/coreui/coreui-vue.git` - Install with [npm](https://www.npmjs.com/): `npm install @coreui/vue` - Install with [yarn](https://yarnpkg.com/): `yarn add @coreui/vue` diff --git a/packages/coreui-vue/package.json b/packages/coreui-vue/package.json index 39b08b3a..52f613fe 100644 --- a/packages/coreui-vue/package.json +++ b/packages/coreui-vue/package.json @@ -1,6 +1,6 @@ { "name": "@coreui/vue", - "version": "5.5.0", + "version": "5.6.0", "description": "UI Components Library for Vue.js", "keywords": [ "vue", @@ -41,24 +41,24 @@ "test:update": "jest --coverage --updateSnapshot" }, "dependencies": { - "@coreui/coreui": "^5.4.0", + "@coreui/coreui": "^5.4.3", "@popperjs/core": "^2.11.8" }, "devDependencies": { - "@rollup/plugin-commonjs": "^28.0.3", + "@rollup/plugin-commonjs": "^28.0.6", "@rollup/plugin-node-resolve": "^16.0.1", - "@rollup/plugin-typescript": "^12.1.2", + "@rollup/plugin-typescript": "^12.1.4", "@types/jest": "^29.5.14", "@vue/test-utils": "^2.4.6", "@vue/vue3-jest": "29.2.6", - "cross-env": "^7.0.3", + "cross-env": "^10.0.0", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", - "rollup": "^4.41.1", + "rollup": "^4.50.0", "rollup-plugin-vue": "^6.0.0", - "ts-jest": "^29.3.4", - "typescript": "^5.8.3", - "vue": "^3.5.16", + "ts-jest": "^29.4.1", + "typescript": "^5.9.2", + "vue": "^3.5.20", "vue-types": "^6.0.0" }, "peerDependencies": { diff --git a/packages/coreui-vue/src/components/button/CButton.ts b/packages/coreui-vue/src/components/button/CButton.ts index 70f7e90d..ebb3d8c0 100644 --- a/packages/coreui-vue/src/components/button/CButton.ts +++ b/packages/coreui-vue/src/components/button/CButton.ts @@ -93,8 +93,9 @@ export const CButton = defineComponent({ { class: [ 'btn', - props.variant && props.color ? `btn-${props.variant}-${props.color}` : `btn-${props.variant}`, { + [`btn-${props.variant}-${props.color}`]: props.color && props.variant, + [`btn-${props.variant}`]: !props.color && props.variant, [`btn-${props.color}`]: props.color && !props.variant, [`btn-${props.size}`]: props.size, active: props.active, diff --git a/packages/coreui-vue/src/components/dropdown/CDropdown.ts b/packages/coreui-vue/src/components/dropdown/CDropdown.ts index 679e5d6f..997e39a3 100644 --- a/packages/coreui-vue/src/components/dropdown/CDropdown.ts +++ b/packages/coreui-vue/src/components/dropdown/CDropdown.ts @@ -1,4 +1,4 @@ -import { defineComponent, h, ref, provide, watch, PropType } from 'vue' +import { defineComponent, h, ref, provide, watch, PropType, onUnmounted, nextTick } from 'vue' import type { Placement } from '@popperjs/core' import { usePopper } from '../../composables' @@ -158,6 +158,7 @@ const CDropdown = defineComponent({ setup(props, { slots, emit }) { const dropdownToggleRef = ref() const dropdownMenuRef = ref() + const pendingKeyDownEventRef = ref(null) const popper = ref(typeof props.alignment === 'object' ? false : props.popper) const visible = ref(props.visible) @@ -176,7 +177,7 @@ const CDropdown = defineComponent({ props.placement, props.direction, props.alignment, - isRTL(dropdownMenuRef.value), + isRTL(dropdownMenuRef.value) ) as Placement, } @@ -184,16 +185,27 @@ const CDropdown = defineComponent({ () => props.visible, () => { visible.value = props.visible - }, + } ) watch(visible, () => { if (visible.value && dropdownToggleRef.value && dropdownMenuRef.value) { - popper.value && initPopper(dropdownToggleRef.value, dropdownMenuRef.value, popperConfig) + if (popper.value) { + initPopper(dropdownToggleRef.value, dropdownMenuRef.value, popperConfig) + } + window.addEventListener('mouseup', handleMouseUp) window.addEventListener('keyup', handleKeyup) dropdownToggleRef.value.addEventListener('keydown', handleKeydown) dropdownMenuRef.value.addEventListener('keydown', handleKeydown) + + if (pendingKeyDownEventRef.value) { + nextTick(() => { + handleKeydown(pendingKeyDownEventRef.value as KeyboardEvent) + pendingKeyDownEventRef.value = null + }) + } + emit('show') return } @@ -201,10 +213,14 @@ const CDropdown = defineComponent({ popper.value && destroyPopper() window.removeEventListener('mouseup', handleMouseUp) window.removeEventListener('keyup', handleKeyup) + dropdownMenuRef.value && dropdownMenuRef.value.removeEventListener('keydown', handleKeydown) + emit('hide') + }) + + onUnmounted(() => { dropdownToggleRef.value && dropdownToggleRef.value.removeEventListener('keydown', handleKeydown) dropdownMenuRef.value && dropdownMenuRef.value.removeEventListener('keydown', handleKeydown) - emit('hide') }) provide('config', { @@ -219,18 +235,14 @@ const CDropdown = defineComponent({ provide('visible', visible) provide('dropdownToggleRef', dropdownToggleRef) provide('dropdownMenuRef', dropdownMenuRef) + provide('pendingKeyDownEventRef', pendingKeyDownEventRef) const handleKeydown = (event: KeyboardEvent) => { - if ( - visible.value && - dropdownMenuRef.value && - (event.key === 'ArrowDown' || event.key === 'ArrowUp') - ) { + if (dropdownMenuRef.value && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) { event.preventDefault() const target = event.target as HTMLElement - // eslint-disable-next-line unicorn/prefer-spread const items: HTMLElement[] = Array.from( - dropdownMenuRef.value.querySelectorAll('.dropdown-item:not(.disabled):not(:disabled)'), + dropdownMenuRef.value.querySelectorAll('.dropdown-item:not(.disabled):not(:disabled)') ) getNextActiveElement(items, target, event.key === 'ArrowDown', true).focus() } @@ -243,6 +255,7 @@ const CDropdown = defineComponent({ if (event.key === 'Escape') { setVisible(false) + dropdownToggleRef.value?.focus() } } @@ -267,22 +280,20 @@ const CDropdown = defineComponent({ } } - const setVisible = (_visible?: boolean) => { + const setVisible = (_visible?: boolean, event?: KeyboardEvent) => { if (props.disabled) { return } - if (typeof _visible == 'boolean') { + if (typeof _visible === 'boolean') { + if (event) { + pendingKeyDownEventRef.value = event || null + } + visible.value = _visible - return - } - if (visible.value === true) { - visible.value = false return } - - visible.value = true } provide('setVisible', setVisible) @@ -298,11 +309,11 @@ const CDropdown = defineComponent({ props.direction === 'center' ? 'dropdown-center' : props.direction === 'dropup-center' - ? 'dropup dropup-center' - : props.direction, + ? 'dropup dropup-center' + : props.direction, ], }, - slots.default && slots.default(), + slots.default && slots.default() ) }, }) diff --git a/packages/coreui-vue/src/components/dropdown/CDropdownToggle.ts b/packages/coreui-vue/src/components/dropdown/CDropdownToggle.ts index 17fd034b..0f9b7baa 100644 --- a/packages/coreui-vue/src/components/dropdown/CDropdownToggle.ts +++ b/packages/coreui-vue/src/components/dropdown/CDropdownToggle.ts @@ -100,7 +100,7 @@ const CDropdownToggle = defineComponent({ const dropdownToggleRef = inject('dropdownToggleRef') as Ref const dropdownVariant = inject('variant') as string const visible = inject('visible') as Ref - const setVisible = inject('setVisible') as (_visible?: boolean) => void + const setVisible = inject('setVisible') as (_visible?: boolean, event?: KeyboardEvent) => void const triggers = { ...((props.trigger === 'click' || props.trigger.includes('click')) && { @@ -110,7 +110,7 @@ const CDropdownToggle = defineComponent({ return } - setVisible() + setVisible(!visible.value) }, }), ...((props.trigger === 'focus' || props.trigger.includes('focus')) && { @@ -128,6 +128,12 @@ const CDropdownToggle = defineComponent({ setVisible(false) }, }), + onkeydown: (event: KeyboardEvent) => { + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + setVisible(true, event) + } + } } const togglerProps = computed(() => { diff --git a/packages/coreui-vue/src/components/focus-trap/CFocusTrap.ts b/packages/coreui-vue/src/components/focus-trap/CFocusTrap.ts new file mode 100644 index 00000000..1f723d63 --- /dev/null +++ b/packages/coreui-vue/src/components/focus-trap/CFocusTrap.ts @@ -0,0 +1,303 @@ +import { + cloneVNode, + defineComponent, + ref, + watch, + onMounted, + onUnmounted, + type Ref, + type PropType, +} from 'vue' +import { focusableChildren } from './utils' + +const CFocusTrap = defineComponent({ + name: 'CFocusTrap', + props: { + /** + * Controls whether the focus trap is active or inactive. + * When `true`, focus will be trapped within the child element. + * When `false`, normal focus behavior is restored. + */ + active: { + type: Boolean, + default: true, + }, + + /** + * Additional container elements to include in the focus trap. + * Useful for floating elements like tooltips or popovers that are + * rendered outside the main container but should be part of the trap. + */ + additionalContainer: { + type: Object as PropType>, + default: undefined, + }, + + /** + * Controls whether to focus the first selectable element or the container itself. + * When `true`, focuses the first tabbable element within the container. + * When `false`, focuses the container element directly. + * + * This is useful for containers that should receive focus themselves, + * such as scrollable regions or custom interactive components. + */ + focusFirstElement: { + type: Boolean, + default: false, + }, + + /** + * Automatically restores focus to the previously focused element when the trap is deactivated. + * This is crucial for accessibility as it maintains the user's place in the document + * when returning from modal dialogs or overlay components. + * + * Recommended to be `true` for modal dialogs and popover components. + */ + restoreFocus: { + type: Boolean, + default: true, + }, + }, + emits: { + /** + * Emitted when the focus trap becomes active. + * Useful for triggering additional accessibility announcements or analytics. + */ + activate: () => true, + /** + * Emitted when the focus trap is deactivated. + * Can be used for cleanup, analytics, or triggering state changes. + */ + deactivate: () => true, + }, + setup(props, { emit, slots, expose }) { + const containerRef = ref(null) + const prevFocusedRef = ref(null) + const isActiveRef = ref(false) + const lastTabNavDirectionRef = ref<'forward' | 'backward'>('forward') + const tabEventSourceRef = ref(null) + + let handleKeyDown: ((event: KeyboardEvent) => void) | null = null + let handleFocusIn: ((event: FocusEvent) => void) | null = null + + const activateTrap = () => { + const container = containerRef.value + const additionalContainer = props.additionalContainer?.value || null + + if (!container) { + return + } + + prevFocusedRef.value = document.activeElement as HTMLElement | null + + // Activating... + isActiveRef.value = true + + // Set initial focus + if (props.focusFirstElement) { + const elements = focusableChildren(container) + if (elements.length > 0) { + elements[0].focus({ preventScroll: true }) + } else { + // Fallback to container if no focusable elements + container.focus({ preventScroll: true }) + } + } else { + container.focus({ preventScroll: true }) + } + + emit('activate') + + // Create event handlers + handleFocusIn = (event: FocusEvent) => { + // Only handle focus events from tab navigation + if (containerRef.value !== tabEventSourceRef.value) { + return + } + + const target = event.target as Node + + // Allow focus within container + if (target === document || target === container || container.contains(target)) { + return + } + + // Allow focus within additional elements + if ( + additionalContainer && + (target === additionalContainer || additionalContainer.contains(target)) + ) { + return + } + + // Focus escaped, bring it back + const elements = focusableChildren(container) + + if (elements.length === 0) { + container.focus({ preventScroll: true }) + } else if (lastTabNavDirectionRef.value === 'backward') { + elements.at(-1)?.focus({ preventScroll: true }) + } else { + elements[0].focus({ preventScroll: true }) + } + } + + handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab') { + return + } + + tabEventSourceRef.value = container + lastTabNavDirectionRef.value = event.shiftKey ? 'backward' : 'forward' + + if (!additionalContainer) { + return + } + + const containerElements = focusableChildren(container) + const additionalElements = focusableChildren(additionalContainer) + + if (containerElements.length === 0 && additionalElements.length === 0) { + // No focusable elements, prevent tab + event.preventDefault() + return + } + + const activeElement = document.activeElement as HTMLElement + const isInContainer = containerElements.includes(activeElement) + const isInAdditional = additionalElements.includes(activeElement) + + // Handle tab navigation between container and additional elements + if (isInContainer) { + const index = containerElements.indexOf(activeElement) + + if ( + !event.shiftKey && + index === containerElements.length - 1 && + additionalElements.length > 0 + ) { + // Tab forward from last container element to first additional element + event.preventDefault() + additionalElements[0].focus({ preventScroll: true }) + } else if (event.shiftKey && index === 0 && additionalElements.length > 0) { + // Tab backward from first container element to last additional element + event.preventDefault() + additionalElements.at(-1)?.focus({ preventScroll: true }) + } + } else if (isInAdditional) { + const index = additionalElements.indexOf(activeElement) + + if ( + !event.shiftKey && + index === additionalElements.length - 1 && + containerElements.length > 0 + ) { + // Tab forward from last additional element to first container element + event.preventDefault() + containerElements[0].focus({ preventScroll: true }) + } else if (event.shiftKey && index === 0 && containerElements.length > 0) { + // Tab backward from first additional element to last container element + event.preventDefault() + containerElements.at(-1)?.focus({ preventScroll: true }) + } + } + } + + // Add event listeners + container.addEventListener('keydown', handleKeyDown, true) + if (additionalContainer) { + additionalContainer.addEventListener('keydown', handleKeyDown, true) + } + document.addEventListener('focusin', handleFocusIn, true) + } + + const deactivateTrap = () => { + if (!isActiveRef.value) { + return + } + + // Cleanup event listeners + const container = containerRef.value + const additionalContainer = props.additionalContainer?.value || null + + if (container && handleKeyDown) { + container.removeEventListener('keydown', handleKeyDown, true) + } + if (additionalContainer && handleKeyDown) { + additionalContainer.removeEventListener('keydown', handleKeyDown, true) + } + if (handleFocusIn) { + document.removeEventListener('focusin', handleFocusIn, true) + } + + // Restore focus + if (props.restoreFocus && prevFocusedRef.value?.isConnected) { + prevFocusedRef.value.focus({ preventScroll: true }) + } + + emit('deactivate') + isActiveRef.value = false + prevFocusedRef.value = null + } + + watch( + () => props.active, + (newActive) => { + if (newActive && containerRef.value) { + activateTrap() + } else { + deactivateTrap() + } + }, + { immediate: false } + ) + + watch( + () => props.additionalContainer?.value, + () => { + if (props.active && isActiveRef.value) { + // Reactivate to update event listeners + deactivateTrap() + activateTrap() + } + } + ) + + onMounted(() => { + if (props.active && containerRef.value) { + activateTrap() + } + }) + + onUnmounted(() => { + deactivateTrap() + }) + + // Expose containerRef for parent components + expose({ + containerRef, + }) + + return () => { + const vnodes = slots.default?.() + const vnode = vnodes?.[0] + if (!vnode) return null + + const originalRef = (vnode.props as any)?.ref + + return cloneVNode(vnode, { + ref: (el) => { + containerRef.value = el as HTMLElement | null + + if (typeof originalRef === 'function') { + originalRef(el) + } else if (originalRef && typeof originalRef === 'object' && 'value' in originalRef) { + ;(originalRef as { value: any }).value = el + } + }, + }) + } + }, +}) + +export { CFocusTrap } diff --git a/packages/coreui-vue/src/components/focus-trap/index.ts b/packages/coreui-vue/src/components/focus-trap/index.ts new file mode 100644 index 00000000..598c2663 --- /dev/null +++ b/packages/coreui-vue/src/components/focus-trap/index.ts @@ -0,0 +1,10 @@ +import { App } from 'vue' +import { CFocusTrap } from './CFocusTrap' + +const CFocusTrapPlugin = { + install: (app: App): void => { + app.component(CFocusTrap.name as string, CFocusTrap) + }, +} + +export { CFocusTrapPlugin, CFocusTrap } diff --git a/packages/coreui-vue/src/components/focus-trap/utils.ts b/packages/coreui-vue/src/components/focus-trap/utils.ts new file mode 100644 index 00000000..5006da42 --- /dev/null +++ b/packages/coreui-vue/src/components/focus-trap/utils.ts @@ -0,0 +1,90 @@ +/** + * Gets all focusable child elements within a container. + * Uses a comprehensive selector to find elements that can receive focus. + * @param element - The container element to search within + * @returns Array of focusable HTML elements + */ +export const focusableChildren = (element: HTMLElement): HTMLElement[] => { + const focusableSelectors = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'textarea:not([disabled])', + 'select:not([disabled])', + 'details', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable="true"]', + ].join(',') + + const elements = [...element.querySelectorAll(focusableSelectors)] as HTMLElement[] + + return elements.filter((el) => !isDisabled(el) && isVisible(el)) +} + +/** + * Checks if an element is disabled. + * Considers various ways an element can be disabled including CSS classes and attributes. + * @param element - The HTML element to check + * @returns True if the element is disabled, false otherwise + */ +export const isDisabled = (element: HTMLElement): boolean => { + if (!element || element.nodeType !== Node.ELEMENT_NODE) { + return true + } + + if (element.classList.contains('disabled')) { + return true + } + + if ('disabled' in element && typeof element.disabled === 'boolean') { + return element.disabled + } + + return element.hasAttribute('disabled') && element.getAttribute('disabled') !== 'false' +} + +/** + * Type guard to check if an object is an Element. + * Handles edge cases including jQuery objects. + * @param object - The object to check + * @returns True if the object is an Element, false otherwise + */ +export const isElement = (object: unknown): object is Element => { + if (!object || typeof object !== 'object') { + return false + } + + return 'nodeType' in object && typeof object.nodeType === 'number' +} + +/** + * Checks if an element is visible in the DOM. + * Considers client rects and computed visibility styles, handling edge cases like details elements. + * @param element - The HTML element to check for visibility + * @returns True if the element is visible, false otherwise + */ +export const isVisible = (element: HTMLElement): boolean => { + if (!isElement(element) || element.getClientRects().length === 0) { + return false + } + + const elementIsVisible = getComputedStyle(element).getPropertyValue('visibility') === 'visible' + + // Handle `details` element as its content may falsely appear visible when it is closed + const closedDetails = element.closest('details:not([open])') + + if (!closedDetails) { + return elementIsVisible + } + + if (closedDetails !== element) { + const summary = element.closest('summary') + + // Check if summary is a direct child of the closed details + if (summary?.parentNode !== closedDetails) { + return false + } + } + + return elementIsVisible +} diff --git a/packages/coreui-vue/src/components/index.ts b/packages/coreui-vue/src/components/index.ts index d11d5eb0..6469a33e 100644 --- a/packages/coreui-vue/src/components/index.ts +++ b/packages/coreui-vue/src/components/index.ts @@ -13,6 +13,7 @@ export * from './close-button' export * from './collapse' export * from './conditional-teleport' export * from './dropdown' +export * from './focus-trap' export * from './footer' export * from './form' export * from './grid' diff --git a/packages/coreui-vue/src/components/modal/CModal.ts b/packages/coreui-vue/src/components/modal/CModal.ts index e2e1947d..bdfbb3ea 100644 --- a/packages/coreui-vue/src/components/modal/CModal.ts +++ b/packages/coreui-vue/src/components/modal/CModal.ts @@ -13,6 +13,7 @@ import { import { CBackdrop } from '../backdrop/CBackdrop' import { CConditionalTeleport } from '../conditional-teleport' +import { CFocusTrap } from '../focus-trap' import { executeAfterTransition } from '../../utils/transition' @@ -32,7 +33,7 @@ const CModal = defineComponent({ }, }, /** - * Apply a backdrop on body while offcanvas is open. + * Apply a backdrop on body while modal is open. * * @values boolean | 'static' */ @@ -162,7 +163,7 @@ const CModal = defineComponent({ () => props.visible, () => { visible.value = props.visible - }, + } ) const handleEnter = (el: RendererElement, done: () => void) => { @@ -175,13 +176,14 @@ const CModal = defineComponent({ setTimeout(() => { el.classList.add('show') }, 1) + emit('show') } const handleAfterEnter = () => { props.focus && modalRef.value?.focus() window.addEventListener('mousedown', handleMouseDown) - window.addEventListener('keyup', handleKeyUp) + window.addEventListener('keydown', handleKeyDown) } // eslint-disable-next-line unicorn/consistent-function-scoping @@ -195,33 +197,33 @@ const CModal = defineComponent({ } el.classList.remove('show') + emit('close') } const handleAfterLeave = (el: RendererElement) => { activeElementRef.value?.focus() window.removeEventListener('mousedown', handleMouseDown) - window.removeEventListener('keyup', handleKeyUp) + window.removeEventListener('keydown', handleKeyDown) el.style.display = 'none' } const handleDismiss = () => { - emit('close') + if (props.backdrop === 'static') { + modalRef.value.classList.add('modal-static') + emit('close-prevented') + setTimeout(() => { + modalRef.value.classList.remove('modal-static') + }, 300) + + return + } + visible.value = false } - const handleKeyUp = (event: KeyboardEvent) => { - if (modalContentRef.value && !modalContentRef.value.contains(event.target as HTMLElement)) { - if (props.backdrop !== 'static' && event.key === 'Escape' && props.keyboard) { - handleDismiss() - } - - if (props.backdrop === 'static') { - modalRef.value.classList.add('modal-static') - emit('close-prevented') - setTimeout(() => { - modalRef.value.classList.remove('modal-static') - }, 300) - } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape' && props.keyboard) { + handleDismiss() } } @@ -231,20 +233,11 @@ const CModal = defineComponent({ const handleMouseUp = (event: Event) => { if (modalContentRef.value && !modalContentRef.value.contains(event.target as HTMLElement)) { - if (props.backdrop !== 'static') { - handleDismiss() - } - - if (props.backdrop === 'static') { - modalRef.value.classList.add('modal-static') - setTimeout(() => { - modalRef.value.classList.remove('modal-static') - }, 300) - } + handleDismiss() } } - provide('handleDismiss', handleDismiss) + provide('visible', visible) const modal = () => h( @@ -276,12 +269,14 @@ const CModal = defineComponent({ }, ], }, - h( - 'div', - { class: ['modal-content', props.contentClassName], ref: modalContentRef }, - slots.default && slots.default(), - ), - ), + h(CFocusTrap, { active: props.focus }, () => + h( + 'div', + { class: ['modal-content', props.contentClassName], ref: modalContentRef }, + slots.default && slots.default() + ) + ) + ) ) return () => @@ -305,7 +300,7 @@ const CModal = defineComponent({ () => props.unmountOnClose ? visible.value && modal() - : withDirectives(modal(), [[vShow, visible.value]]), + : withDirectives(modal(), [[vShow, visible.value]]) ), props.backdrop && h(CBackdrop, { @@ -313,7 +308,7 @@ const CModal = defineComponent({ visible: visible.value, }), ], - }, + } ) }, }) diff --git a/packages/coreui-vue/src/components/modal/CModalHeader.ts b/packages/coreui-vue/src/components/modal/CModalHeader.ts index 108a8ff2..0f7a320b 100644 --- a/packages/coreui-vue/src/components/modal/CModalHeader.ts +++ b/packages/coreui-vue/src/components/modal/CModalHeader.ts @@ -1,4 +1,4 @@ -import { defineComponent, h, inject } from 'vue' +import { defineComponent, h, inject, Ref } from 'vue' import { CCloseButton } from '../close-button/CCloseButton' @@ -14,11 +14,13 @@ const CModalHeader = defineComponent({ }, }, setup(props, { slots }) { - const handleDismiss = inject('handleDismiss') as () => void + const visible = inject>('visible')! return () => h('span', { class: 'modal-header' }, [ slots.default && slots.default(), - props.closeButton && h(CCloseButton, { onClick: () => handleDismiss() }, ''), + props.closeButton && h(CCloseButton, { onClick: () => { + visible.value = false + } }, ''), ]) }, }) diff --git a/packages/coreui-vue/src/components/offcanvas/COffcanvas.ts b/packages/coreui-vue/src/components/offcanvas/COffcanvas.ts index 5377fa72..1c65c47f 100644 --- a/packages/coreui-vue/src/components/offcanvas/COffcanvas.ts +++ b/packages/coreui-vue/src/components/offcanvas/COffcanvas.ts @@ -1,6 +1,7 @@ import { defineComponent, h, ref, RendererElement, Transition, watch, withDirectives } from 'vue' import { CBackdrop } from '../backdrop' +import { CFocusTrap } from '../focus-trap' import { vVisible } from '../../directives/v-c-visible' import { executeAfterTransition } from '../../utils/transition' @@ -103,7 +104,7 @@ const COffcanvas = defineComponent({ () => props.visible, () => { visible.value = props.visible - }, + } ) watch(visible, () => { @@ -161,41 +162,44 @@ const COffcanvas = defineComponent({ } return () => [ - h( - Transition, - { - appear: visible.value, - css: false, - onEnter: (el, done) => handleEnter(el, done), - onAfterEnter: () => handleAfterEnter(), - onLeave: (el, done) => handleLeave(el, done), - onAfterLeave: (el) => handleAfterLeave(el), - }, - () => - withDirectives( - h( - 'div', - { - ...attrs, - class: [ - { - [`offcanvas${ - typeof props.responsive === 'boolean' ? '' : '-' + props.responsive - }`]: props.responsive, - [`offcanvas-${props.placement}`]: props.placement, - }, - attrs.class, - ], - onKeydown: (event: KeyboardEvent) => handleKeyDown(event), - ref: offcanvasRef, - role: 'dialog', - tabindex: -1, - ...(props.dark && { 'data-coreui-theme': 'dark' }), - }, - slots.default && slots.default(), - ), - [[vVisible, props.visible]], - ), + h(CFocusTrap, { active: visible.value && Boolean(props.backdrop) }, () => + h( + Transition, + { + appear: visible.value, + css: false, + onEnter: (el, done) => handleEnter(el, done), + onAfterEnter: () => handleAfterEnter(), + onLeave: (el, done) => handleLeave(el, done), + onAfterLeave: (el) => handleAfterLeave(el), + }, + () => + withDirectives( + h( + 'div', + { + ...attrs, + class: [ + { + [`offcanvas${ + typeof props.responsive === 'boolean' ? '' : '-' + props.responsive + }`]: props.responsive, + [`offcanvas-${props.placement}`]: props.placement, + }, + attrs.class, + ], + onKeydown: (event: KeyboardEvent) => handleKeyDown(event), + ref: offcanvasRef, + role: 'dialog', + tabindex: -1, + ...(props.dark && { 'data-coreui-theme': 'dark' }), + }, + slots.default && slots.default() + ), + + [[vVisible, props.visible]] + ) + ) ), props.backdrop && h(CBackdrop, { diff --git a/packages/docs/.vuepress/config.ts b/packages/docs/.vuepress/config.ts index ea00ecf0..3a05c647 100644 --- a/packages/docs/.vuepress/config.ts +++ b/packages/docs/.vuepress/config.ts @@ -19,6 +19,9 @@ export default defineUserConfig({ description: 'UI Components Library for Vue.js (Vue 3)', head: [['link', { rel: 'icon', href: `/vue/docs/favicons/favicon-96x96.png` }]], bundler: viteBundler(), + alias: { + '@example': path.resolve(__dirname, '../code-examples/'), + }, markdown: { anchor: { permalink: anchor.permalink.linkInsideHeader({ @@ -26,6 +29,10 @@ export default defineUserConfig({ placement: 'after' }), }, + importCode: { + handleImportPath: (str) => + str.replace(/^@example/, path.resolve(__dirname, '../code-examples/')), + }, }, extendsMarkdown: (md) => { md.use(include_plugin), @@ -256,6 +263,10 @@ export default defineUserConfig({ text: 'Dropdown', link: `/components/dropdown.html`, }, + { + text: 'Focus Trap', + link: `/components/focus-trap.html`, + }, { text: 'Footer', link: `/components/footer.html`, diff --git a/packages/docs/api/focus-trap/CFocusTrap.api.md b/packages/docs/api/focus-trap/CFocusTrap.api.md new file mode 100644 index 00000000..1028153f --- /dev/null +++ b/packages/docs/api/focus-trap/CFocusTrap.api.md @@ -0,0 +1,23 @@ +### CFocusTrap + +```jsx +import { CFocusTrap } from '@coreui/vue' +// or +import CFocusTrap from '@coreui/vue/src/components/focus-trap/CFocusTrap' +``` + +#### Props + +| Prop name | Description | Type | Values | Default | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------ | ------ | ------- | +| **active** | Controls whether the focus trap is active or inactive.
When `true`, focus will be trapped within the child element.
When `false`, normal focus behavior is restored. | boolean | - | true | +| **additional-container** | Additional container elements to include in the focus trap.
Useful for floating elements like tooltips or popovers that are
rendered outside the main container but should be part of the trap. | Ref | - | - | +| **focus-first-element** | Controls whether to focus the first selectable element or the container itself.
When `true`, focuses the first tabbable element within the container.
When `false`, focuses the container element directly.

This is useful for containers that should receive focus themselves,
such as scrollable regions or custom interactive components. | boolean | - | false | +| **restore-focus** | Automatically restores focus to the previously focused element when the trap is deactivated.
This is crucial for accessibility as it maintains the user's place in the document
when returning from modal dialogs or overlay components.

Recommended to be `true` for modal dialogs and popover components. | boolean | - | true | + +#### Events + +| Event name | Description | Properties | +| -------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------- | +| **activate** | Emitted when the focus trap becomes active.
Useful for triggering additional accessibility announcements or analytics. | +| **deactivate** | Emitted when the focus trap is deactivated.
Can be used for cleanup, analytics, or triggering state changes. | diff --git a/packages/docs/code-examples/focus-trap/FocusTrapBasicExample.vue b/packages/docs/code-examples/focus-trap/FocusTrapBasicExample.vue new file mode 100644 index 00000000..807df9b0 --- /dev/null +++ b/packages/docs/code-examples/focus-trap/FocusTrapBasicExample.vue @@ -0,0 +1,38 @@ + + + \ No newline at end of file diff --git a/packages/docs/code-examples/focus-trap/FocusTrapConditionalExample.vue b/packages/docs/code-examples/focus-trap/FocusTrapConditionalExample.vue new file mode 100644 index 00000000..2beb241c --- /dev/null +++ b/packages/docs/code-examples/focus-trap/FocusTrapConditionalExample.vue @@ -0,0 +1,229 @@ + + + diff --git a/packages/docs/code-examples/focus-trap/FocusTrapDropdownExample.vue b/packages/docs/code-examples/focus-trap/FocusTrapDropdownExample.vue new file mode 100644 index 00000000..2f0ada5d --- /dev/null +++ b/packages/docs/code-examples/focus-trap/FocusTrapDropdownExample.vue @@ -0,0 +1,51 @@ + + + \ No newline at end of file diff --git a/packages/docs/code-examples/focus-trap/FocusTrapEventsExample.vue b/packages/docs/code-examples/focus-trap/FocusTrapEventsExample.vue new file mode 100644 index 00000000..870bab12 --- /dev/null +++ b/packages/docs/code-examples/focus-trap/FocusTrapEventsExample.vue @@ -0,0 +1,180 @@ + + + \ No newline at end of file diff --git a/packages/docs/code-examples/focus-trap/FocusTrapFocusControlExample.vue b/packages/docs/code-examples/focus-trap/FocusTrapFocusControlExample.vue new file mode 100644 index 00000000..f8864f6f --- /dev/null +++ b/packages/docs/code-examples/focus-trap/FocusTrapFocusControlExample.vue @@ -0,0 +1,104 @@ + + + \ No newline at end of file diff --git a/packages/docs/code-examples/focus-trap/FocusTrapModalExample.vue b/packages/docs/code-examples/focus-trap/FocusTrapModalExample.vue new file mode 100644 index 00000000..e8699883 --- /dev/null +++ b/packages/docs/code-examples/focus-trap/FocusTrapModalExample.vue @@ -0,0 +1,56 @@ + + + \ No newline at end of file diff --git a/packages/docs/code-examples/focus-trap/FocusTrapRestoreFocusExample.vue b/packages/docs/code-examples/focus-trap/FocusTrapRestoreFocusExample.vue new file mode 100644 index 00000000..bc7c238f --- /dev/null +++ b/packages/docs/code-examples/focus-trap/FocusTrapRestoreFocusExample.vue @@ -0,0 +1,119 @@ + + + \ No newline at end of file diff --git a/packages/docs/code-examples/focus-trap/FocusTrapSidebarExample.vue b/packages/docs/code-examples/focus-trap/FocusTrapSidebarExample.vue new file mode 100644 index 00000000..00d212c3 --- /dev/null +++ b/packages/docs/code-examples/focus-trap/FocusTrapSidebarExample.vue @@ -0,0 +1,62 @@ + + + diff --git a/packages/docs/components/focus-trap.md b/packages/docs/components/focus-trap.md new file mode 100644 index 00000000..6d25b37e --- /dev/null +++ b/packages/docs/components/focus-trap.md @@ -0,0 +1,165 @@ +--- +title: Vue Focus Trap Component +name: Vue Focus Trap +description: Vue Focus Trap component ensures keyboard navigation stays within a designated container element. Essential for creating accessible modal dialogs, dropdown menus, and overlay components that comply with WCAG 2.1 accessibility standards. +route: /components/focus-trap/ +--- + +## Overview + +The Vue Focus Trap component is an accessibility utility that constrains keyboard focus within a specific container element. When active, it prevents Tab and Shift+Tab navigation from leaving the trapped area, ensuring users stay within the intended interactive region. This is essential for modal dialogs, dropdown menus, and other overlay components that need to maintain focus for screen reader users and keyboard navigation compliance. + +Focus traps are a critical accessibility pattern required by WCAG 2.1 guidelines for modal dialogs and temporary overlay content. By containing focus within the relevant UI section, focus traps help create predictable and accessible user experiences. + +## Key Features + +- **WCAG 2.1 Compliant**: Meets accessibility standards for focus management +- **Lightweight**: No extra DOM wrappers - uses direct slot content +- **Flexible**: Works with any slot content that can receive focus +- **Smart Focus**: Configurable first focus target and automatic focus restoration +- **Event Callbacks**: Activation and deactivation event handlers + +## Basic Usage + +The most basic implementation wraps slot content and activates the focus trap: + +:::demo + +::: +@[code vue](@example/focus-trap/FocusTrapBasicExample.vue) + +## CoreUI Components with Built-in Focus Trapping + +Most CoreUI overlay components already include Vue Focus Trap internally, so you don't need to add it manually: + +- **CModal** - Includes built-in focus trapping for modal dialogs +- **COffcanvas** - Has focus trapping for slide-out panels +- **CDropdown** - Can be enhanced with focus trapping for better accessibility + +For these components, focus trapping is handled automatically with proper focus restoration, escape key support, and WCAG 2.1 compliance. + +### Modal Dialog with CModal + +CModal includes built-in focus trapping, so you don't need to add CFocusTrap manually: + +:::demo + +::: +@[code vue](@example/focus-trap/FocusTrapModalExample.vue) + +### Enhanced Dropdown Menu + +You can enhance CDropdown with CFocusTrap for improved keyboard accessibility: + +:::demo + +::: +@[code vue](@example/focus-trap/FocusTrapDropdownExample.vue) + +### Sidebar Navigation with COffcanvas + +COffcanvas includes built-in focus trapping for slide-out navigation panels: + +:::demo + +::: +@[code vue](@example/focus-trap/FocusTrapSidebarExample.vue) + +## Focus Control Options + +### Focus First Element vs Container + +The `focus-first-element` prop controls the initial focus behavior: + +- `focus-first-element="true"`: Focuses the first tabbable element (good for menus, forms) +- `focus-first-element="false"`: Focuses the container element (good for panels, scrollable regions) + +:::demo + +::: +@[code vue](@example/focus-trap/FocusTrapFocusControlExample.vue) + +### Focus Restoration + +The `restore-focus` prop controls whether focus returns to the previously focused element when the trap deactivates. Focus on a button, then activate the trap. When you deactivate it, notice where focus returns based on the `restore-focus` setting: + +:::demo + +::: +@[code vue](@example/focus-trap/FocusTrapRestoreFocusExample.vue) + +## Event Handling + +Use the `@activate` and `@deactivate` events to trigger additional behavior such as screen reader announcements, analytics events, updating application state, or managing other UI components: + +:::demo + +::: +@[code vue](@example/focus-trap/FocusTrapEventsExample.vue) + +## Conditional Focus Trapping + +Focus traps can be conditionally activated based on application state: + +:::demo + +::: +@[code vue](@example/focus-trap/FocusTrapConditionalExample.vue) + +## Usage Guidelines + +### When to Use Focus Traps + +- **Modal Dialogs**: Always use focus traps for modal dialogs and overlays +- **Dropdown Menus**: Implement focus traps for keyboard-navigable dropdown menus +- **Slide-out Panels**: Use focus traps for temporary navigation panels or sidebars +- **Custom Overlays**: Any overlay content that should contain keyboard focus + +### Accessibility Best Practices + +1. **Always include focusable elements** within the trapped container +2. **Use `restore-focus`** for temporary overlays like modals and dropdowns +3. **Include proper ARIA attributes** on the container (`role="dialog"`, `aria-modal="true"`) +4. **Provide escape mechanisms** like Escape key handling or close buttons +5. **Test with keyboard navigation** to ensure proper focus flow + +### Container Requirements + +The slot content must meet these requirements: + +- **Focusable content**: Should contain elements that can receive focus +- **Proper structure**: Should be a single container element or have clear focus boundaries + +```vue + + +
+ + +
+
+ + + +
+ + + +
+
+``` + +## API + +!!!include(./api/focus-trap/CFocusTrap.api.md)!!! + + \ No newline at end of file diff --git a/packages/docs/components/modal.md b/packages/docs/components/modal.md index 07e19ad8..5c33caf9 100644 --- a/packages/docs/components/modal.md +++ b/packages/docs/components/modal.md @@ -85,6 +85,7 @@ Toggle a working modal demo by clicking the button below. It will slide down and ``` + ### Static backdrop If you set `backdrop` property to `static`, your modal will behave as though the backdrop is static, meaning it will not close when clicking outside it. Click the button below to try it. diff --git a/packages/docs/package.json b/packages/docs/package.json index e190d802..829574c4 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -1,6 +1,6 @@ { "name": "@coreui/vue-docs", - "version": "5.5.0", + "version": "5.6.0", "scripts": { "api": "vue-docgen -c build/docgen.config.js", "dev": "vuepress dev --clean-cache", @@ -9,7 +9,7 @@ "license": "MIT", "devDependencies": { "@coreui/chartjs": "^4.0.0", - "@coreui/coreui": "^5.4.0", + "@coreui/coreui": "^5.4.3", "@coreui/icons": "^3.0.1", "@coreui/icons-vue": "^2.2.0", "@coreui/utils": "^2.0.2", @@ -29,7 +29,7 @@ "@vuepress/utils": "2.0.0-rc.19", "markdown-it-anchor": "^9.2.0", "markdown-it-include": "^2.0.0", - "sass": "^1.89.1", + "sass": "^1.91.0", "vue-docgen-cli": "^4.79.0", "vuepress": "2.0.0-rc.19" }