From 0f0e550bdd626bb7867662545be5826a1927458c Mon Sep 17 00:00:00 2001 From: Jared Lewis <17649602+jrd-lewis@users.noreply.github.com> Date: Thu, 31 Jul 2025 16:51:32 -0400 Subject: [PATCH 1/9] Update CButton.ts --- packages/coreui-vue/src/components/button/CButton.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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, From 39b9c5a2020a42ae8a7cab684f14418f304362e7 Mon Sep 17 00:00:00 2001 From: mrholek Date: Sat, 9 Aug 2025 15:32:04 +0200 Subject: [PATCH 2/9] docs: update the list of available components --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 86647480..ee29f9de 100644 --- a/README.md +++ b/README.md @@ -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) @@ -134,12 +135,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) From f594bd7914f69c0a0512a997e0f8e3c0765bd6c3 Mon Sep 17 00:00:00 2001 From: mrholek Date: Mon, 1 Sep 2025 18:14:39 +0200 Subject: [PATCH 3/9] chore: update dependencies and devDependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @coreui/coreui ^5.4.0 → ^5.4.3 @rollup/plugin-commonjs ^28.0.3 → ^28.0.6 @rollup/plugin-typescript ^12.1.2 → ^12.1.4 cross-env ^7.0.3 → ^10.0.0 eslint ^9.28.0 → ^9.34.0 eslint-config-prettier ^10.1.5 → ^10.1.8 eslint-plugin-prettier ^5.4.1 → ^5.5.4 eslint-plugin-unicorn ^59.0.1 → ^60.0.0 eslint-plugin-vue ^10.1.0 → ^10.4.0 globals ^16.2.0 → ^16.3.0 lerna ^8.2.2 → ^8.2.3 prettier ^3.5.3 → ^3.6.2 rollup ^4.41.1 → ^4.50.0 sass ^1.89.1 → ^1.91.0 ts-jest ^29.3.4 → ^29.4.1 typescript ^5.8.3 → ^5.9.2 typescript-eslint ^8.33.1 → ^8.41.0 vue ^3.5.16 → ^3.5.20 --- packages/coreui-vue/package.json | 16 ++++++++-------- packages/docs/package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/coreui-vue/package.json b/packages/coreui-vue/package.json index 39b08b3a..c17eff11 100644 --- a/packages/coreui-vue/package.json +++ b/packages/coreui-vue/package.json @@ -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/docs/package.json b/packages/docs/package.json index e190d802..22861659 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -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" } From 131e562b69cfa39815a219d08a6e74031a97661b Mon Sep 17 00:00:00 2001 From: mrholek Date: Mon, 1 Sep 2025 18:23:22 +0200 Subject: [PATCH 4/9] chore: update dependencies and devDependencies --- package.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) 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" } } From 6eec1831c7ffdef26d9539226913268a59e49169 Mon Sep 17 00:00:00 2001 From: mrholek Date: Tue, 2 Sep 2025 01:37:12 +0200 Subject: [PATCH 5/9] feat(CFocusTrap): new component initial release --- .../src/components/focus-trap/CFocusTrap.ts | 292 ++++++++++++++++++ .../src/components/focus-trap/index.ts | 10 + .../src/components/focus-trap/utils.ts | 90 ++++++ packages/coreui-vue/src/components/index.ts | 1 + .../coreui-vue/src/components/modal/CModal.ts | 21 +- .../src/components/offcanvas/COffcanvas.ts | 76 ++--- packages/docs/.vuepress/config.ts | 11 + .../docs/api/focus-trap/CFocusTrap.api.md | 23 ++ .../focus-trap/FocusTrapBasicExample.vue | 38 +++ .../FocusTrapConditionalExample.vue | 229 ++++++++++++++ .../focus-trap/FocusTrapDropdownExample.vue | 51 +++ .../focus-trap/FocusTrapEventsExample.vue | 180 +++++++++++ .../FocusTrapFocusControlExample.vue | 104 +++++++ .../focus-trap/FocusTrapModalExample.vue | 56 ++++ .../FocusTrapRestoreFocusExample.vue | 119 +++++++ .../focus-trap/FocusTrapSidebarExample.vue | 62 ++++ packages/docs/components/focus-trap.md | 165 ++++++++++ 17 files changed, 1483 insertions(+), 45 deletions(-) create mode 100644 packages/coreui-vue/src/components/focus-trap/CFocusTrap.ts create mode 100644 packages/coreui-vue/src/components/focus-trap/index.ts create mode 100644 packages/coreui-vue/src/components/focus-trap/utils.ts create mode 100644 packages/docs/api/focus-trap/CFocusTrap.api.md create mode 100644 packages/docs/code-examples/focus-trap/FocusTrapBasicExample.vue create mode 100644 packages/docs/code-examples/focus-trap/FocusTrapConditionalExample.vue create mode 100644 packages/docs/code-examples/focus-trap/FocusTrapDropdownExample.vue create mode 100644 packages/docs/code-examples/focus-trap/FocusTrapEventsExample.vue create mode 100644 packages/docs/code-examples/focus-trap/FocusTrapFocusControlExample.vue create mode 100644 packages/docs/code-examples/focus-trap/FocusTrapModalExample.vue create mode 100644 packages/docs/code-examples/focus-trap/FocusTrapRestoreFocusExample.vue create mode 100644 packages/docs/code-examples/focus-trap/FocusTrapSidebarExample.vue create mode 100644 packages/docs/components/focus-trap.md 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..19f18699 --- /dev/null +++ b/packages/coreui-vue/src/components/focus-trap/CFocusTrap.ts @@ -0,0 +1,292 @@ +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 () => + slots.default?.().map((slot) => + cloneVNode(slot, { + ref: (el) => { + containerRef.value = el as HTMLElement | null + }, + }) + ) + }, +}) + +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..3c5f327b 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' @@ -162,7 +163,7 @@ const CModal = defineComponent({ () => props.visible, () => { visible.value = props.visible - }, + } ) const handleEnter = (el: RendererElement, done: () => void) => { @@ -276,12 +277,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 +308,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 +316,7 @@ const CModal = defineComponent({ visible: visible.value, }), ], - }, + } ) }, }) diff --git a/packages/coreui-vue/src/components/offcanvas/COffcanvas.ts b/packages/coreui-vue/src/components/offcanvas/COffcanvas.ts index 5377fa72..1b6cd000 100644 --- a/packages/coreui-vue/src/components/offcanvas/COffcanvas.ts +++ b/packages/coreui-vue/src/components/offcanvas/COffcanvas.ts @@ -4,6 +4,7 @@ import { CBackdrop } from '../backdrop' import { vVisible } from '../../directives/v-c-visible' import { executeAfterTransition } from '../../utils/transition' +import { CFocusTrap } from '../focus-trap' const COffcanvas = defineComponent({ name: 'COffcanvas', @@ -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 From 9dae4d7dff4ea67ef4297497fa5760467b57e627 Mon Sep 17 00:00:00 2001 From: mrholek Date: Tue, 2 Sep 2025 12:41:59 +0200 Subject: [PATCH 6/9] refactor(CModal): enhance handling of click outside and key events --- .../coreui-vue/src/components/modal/CModal.ts | 48 ++++++++----------- .../src/components/modal/CModalHeader.ts | 8 ++-- packages/docs/components/modal.md | 1 + 3 files changed, 26 insertions(+), 31 deletions(-) diff --git a/packages/coreui-vue/src/components/modal/CModal.ts b/packages/coreui-vue/src/components/modal/CModal.ts index 3c5f327b..bdfbb3ea 100644 --- a/packages/coreui-vue/src/components/modal/CModal.ts +++ b/packages/coreui-vue/src/components/modal/CModal.ts @@ -33,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' */ @@ -176,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 @@ -196,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() } } @@ -232,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( 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/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. From 67b6f9a6db55d3b5bf7c96703cf6e1850ade1f5c Mon Sep 17 00:00:00 2001 From: mrholek Date: Tue, 2 Sep 2025 12:45:02 +0200 Subject: [PATCH 7/9] refactor(CFocusTrap): expose container reference properly --- .../src/components/focus-trap/CFocusTrap.ts | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/coreui-vue/src/components/focus-trap/CFocusTrap.ts b/packages/coreui-vue/src/components/focus-trap/CFocusTrap.ts index 19f18699..1f723d63 100644 --- a/packages/coreui-vue/src/components/focus-trap/CFocusTrap.ts +++ b/packages/coreui-vue/src/components/focus-trap/CFocusTrap.ts @@ -278,14 +278,25 @@ const CFocusTrap = defineComponent({ containerRef, }) - return () => - slots.default?.().map((slot) => - cloneVNode(slot, { - ref: (el) => { - containerRef.value = el as HTMLElement | null - }, - }) - ) + 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 + } + }, + }) + } }, }) From 61ee9b701de4e6c10c5a6236803fedcd05a9be54 Mon Sep 17 00:00:00 2001 From: mrholek Date: Tue, 2 Sep 2025 13:44:50 +0200 Subject: [PATCH 8/9] refactor(CDropdown): improve arrow keys handling --- .../src/components/dropdown/CDropdown.ts | 57 +++++++++++-------- .../components/dropdown/CDropdownToggle.ts | 10 +++- 2 files changed, 42 insertions(+), 25 deletions(-) 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(() => { From eac3a7922eecb01da3c167ef015952fbe2e7af5f Mon Sep 17 00:00:00 2001 From: mrholek Date: Tue, 2 Sep 2025 16:49:08 +0200 Subject: [PATCH 9/9] release: v5.6.0 --- README.md | 3 ++- lerna.json | 2 +- packages/coreui-vue/README.md | 2 +- packages/coreui-vue/package.json | 2 +- packages/coreui-vue/src/components/offcanvas/COffcanvas.ts | 2 +- packages/docs/package.json | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ee29f9de..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` @@ -117,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) 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/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 c17eff11..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", diff --git a/packages/coreui-vue/src/components/offcanvas/COffcanvas.ts b/packages/coreui-vue/src/components/offcanvas/COffcanvas.ts index 1b6cd000..1c65c47f 100644 --- a/packages/coreui-vue/src/components/offcanvas/COffcanvas.ts +++ b/packages/coreui-vue/src/components/offcanvas/COffcanvas.ts @@ -1,10 +1,10 @@ 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' -import { CFocusTrap } from '../focus-trap' const COffcanvas = defineComponent({ name: 'COffcanvas', diff --git a/packages/docs/package.json b/packages/docs/package.json index 22861659..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",