Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit6eec183

Browse files
committed
feat(CFocusTrap): new component initial release
1 parent131e562 commit6eec183

17 files changed

+1483
-45
lines changed
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
import{
2+
cloneVNode,
3+
defineComponent,
4+
ref,
5+
watch,
6+
onMounted,
7+
onUnmounted,
8+
typeRef,
9+
typePropType,
10+
}from'vue'
11+
import{focusableChildren}from'./utils'
12+
13+
constCFocusTrap=defineComponent({
14+
name:'CFocusTrap',
15+
props:{
16+
/**
17+
* Controls whether the focus trap is active or inactive.
18+
* When `true`, focus will be trapped within the child element.
19+
* When `false`, normal focus behavior is restored.
20+
*/
21+
active:{
22+
type:Boolean,
23+
default:true,
24+
},
25+
26+
/**
27+
* Additional container elements to include in the focus trap.
28+
* Useful for floating elements like tooltips or popovers that are
29+
* rendered outside the main container but should be part of the trap.
30+
*/
31+
additionalContainer:{
32+
type:ObjectasPropType<Ref<HTMLElement|null>>,
33+
default:undefined,
34+
},
35+
36+
/**
37+
* Controls whether to focus the first selectable element or the container itself.
38+
* When `true`, focuses the first tabbable element within the container.
39+
* When `false`, focuses the container element directly.
40+
*
41+
* This is useful for containers that should receive focus themselves,
42+
* such as scrollable regions or custom interactive components.
43+
*/
44+
focusFirstElement:{
45+
type:Boolean,
46+
default:false,
47+
},
48+
49+
/**
50+
* Automatically restores focus to the previously focused element when the trap is deactivated.
51+
* This is crucial for accessibility as it maintains the user's place in the document
52+
* when returning from modal dialogs or overlay components.
53+
*
54+
* Recommended to be `true` for modal dialogs and popover components.
55+
*/
56+
restoreFocus:{
57+
type:Boolean,
58+
default:true,
59+
},
60+
},
61+
emits:{
62+
/**
63+
* Emitted when the focus trap becomes active.
64+
* Useful for triggering additional accessibility announcements or analytics.
65+
*/
66+
activate:()=>true,
67+
/**
68+
* Emitted when the focus trap is deactivated.
69+
* Can be used for cleanup, analytics, or triggering state changes.
70+
*/
71+
deactivate:()=>true,
72+
},
73+
setup(props,{ emit, slots, expose}){
74+
constcontainerRef=ref<HTMLElement|null>(null)
75+
constprevFocusedRef=ref<HTMLElement|null>(null)
76+
constisActiveRef=ref<boolean>(false)
77+
constlastTabNavDirectionRef=ref<'forward'|'backward'>('forward')
78+
consttabEventSourceRef=ref<HTMLElement|null>(null)
79+
80+
lethandleKeyDown:((event:KeyboardEvent)=>void)|null=null
81+
lethandleFocusIn:((event:FocusEvent)=>void)|null=null
82+
83+
constactivateTrap=()=>{
84+
constcontainer=containerRef.value
85+
constadditionalContainer=props.additionalContainer?.value||null
86+
87+
if(!container){
88+
return
89+
}
90+
91+
prevFocusedRef.value=document.activeElementasHTMLElement|null
92+
93+
// Activating...
94+
isActiveRef.value=true
95+
96+
// Set initial focus
97+
if(props.focusFirstElement){
98+
constelements=focusableChildren(container)
99+
if(elements.length>0){
100+
elements[0].focus({preventScroll:true})
101+
}else{
102+
// Fallback to container if no focusable elements
103+
container.focus({preventScroll:true})
104+
}
105+
}else{
106+
container.focus({preventScroll:true})
107+
}
108+
109+
emit('activate')
110+
111+
// Create event handlers
112+
handleFocusIn=(event:FocusEvent)=>{
113+
// Only handle focus events from tab navigation
114+
if(containerRef.value!==tabEventSourceRef.value){
115+
return
116+
}
117+
118+
consttarget=event.targetasNode
119+
120+
// Allow focus within container
121+
if(target===document||target===container||container.contains(target)){
122+
return
123+
}
124+
125+
// Allow focus within additional elements
126+
if(
127+
additionalContainer&&
128+
(target===additionalContainer||additionalContainer.contains(target))
129+
){
130+
return
131+
}
132+
133+
// Focus escaped, bring it back
134+
constelements=focusableChildren(container)
135+
136+
if(elements.length===0){
137+
container.focus({preventScroll:true})
138+
}elseif(lastTabNavDirectionRef.value==='backward'){
139+
elements.at(-1)?.focus({preventScroll:true})
140+
}else{
141+
elements[0].focus({preventScroll:true})
142+
}
143+
}
144+
145+
handleKeyDown=(event:KeyboardEvent)=>{
146+
if(event.key!=='Tab'){
147+
return
148+
}
149+
150+
tabEventSourceRef.value=container
151+
lastTabNavDirectionRef.value=event.shiftKey ?'backward' :'forward'
152+
153+
if(!additionalContainer){
154+
return
155+
}
156+
157+
constcontainerElements=focusableChildren(container)
158+
constadditionalElements=focusableChildren(additionalContainer)
159+
160+
if(containerElements.length===0&&additionalElements.length===0){
161+
// No focusable elements, prevent tab
162+
event.preventDefault()
163+
return
164+
}
165+
166+
constactiveElement=document.activeElementasHTMLElement
167+
constisInContainer=containerElements.includes(activeElement)
168+
constisInAdditional=additionalElements.includes(activeElement)
169+
170+
// Handle tab navigation between container and additional elements
171+
if(isInContainer){
172+
constindex=containerElements.indexOf(activeElement)
173+
174+
if(
175+
!event.shiftKey&&
176+
index===containerElements.length-1&&
177+
additionalElements.length>0
178+
){
179+
// Tab forward from last container element to first additional element
180+
event.preventDefault()
181+
additionalElements[0].focus({preventScroll:true})
182+
}elseif(event.shiftKey&&index===0&&additionalElements.length>0){
183+
// Tab backward from first container element to last additional element
184+
event.preventDefault()
185+
additionalElements.at(-1)?.focus({preventScroll:true})
186+
}
187+
}elseif(isInAdditional){
188+
constindex=additionalElements.indexOf(activeElement)
189+
190+
if(
191+
!event.shiftKey&&
192+
index===additionalElements.length-1&&
193+
containerElements.length>0
194+
){
195+
// Tab forward from last additional element to first container element
196+
event.preventDefault()
197+
containerElements[0].focus({preventScroll:true})
198+
}elseif(event.shiftKey&&index===0&&containerElements.length>0){
199+
// Tab backward from first additional element to last container element
200+
event.preventDefault()
201+
containerElements.at(-1)?.focus({preventScroll:true})
202+
}
203+
}
204+
}
205+
206+
// Add event listeners
207+
container.addEventListener('keydown',handleKeyDown,true)
208+
if(additionalContainer){
209+
additionalContainer.addEventListener('keydown',handleKeyDown,true)
210+
}
211+
document.addEventListener('focusin',handleFocusIn,true)
212+
}
213+
214+
constdeactivateTrap=()=>{
215+
if(!isActiveRef.value){
216+
return
217+
}
218+
219+
// Cleanup event listeners
220+
constcontainer=containerRef.value
221+
constadditionalContainer=props.additionalContainer?.value||null
222+
223+
if(container&&handleKeyDown){
224+
container.removeEventListener('keydown',handleKeyDown,true)
225+
}
226+
if(additionalContainer&&handleKeyDown){
227+
additionalContainer.removeEventListener('keydown',handleKeyDown,true)
228+
}
229+
if(handleFocusIn){
230+
document.removeEventListener('focusin',handleFocusIn,true)
231+
}
232+
233+
// Restore focus
234+
if(props.restoreFocus&&prevFocusedRef.value?.isConnected){
235+
prevFocusedRef.value.focus({preventScroll:true})
236+
}
237+
238+
emit('deactivate')
239+
isActiveRef.value=false
240+
prevFocusedRef.value=null
241+
}
242+
243+
watch(
244+
()=>props.active,
245+
(newActive)=>{
246+
if(newActive&&containerRef.value){
247+
activateTrap()
248+
}else{
249+
deactivateTrap()
250+
}
251+
},
252+
{immediate:false}
253+
)
254+
255+
watch(
256+
()=>props.additionalContainer?.value,
257+
()=>{
258+
if(props.active&&isActiveRef.value){
259+
// Reactivate to update event listeners
260+
deactivateTrap()
261+
activateTrap()
262+
}
263+
}
264+
)
265+
266+
onMounted(()=>{
267+
if(props.active&&containerRef.value){
268+
activateTrap()
269+
}
270+
})
271+
272+
onUnmounted(()=>{
273+
deactivateTrap()
274+
})
275+
276+
// Expose containerRef for parent components
277+
expose({
278+
containerRef,
279+
})
280+
281+
return()=>
282+
slots.default?.().map((slot)=>
283+
cloneVNode(slot,{
284+
ref:(el)=>{
285+
containerRef.value=elasHTMLElement|null
286+
},
287+
})
288+
)
289+
},
290+
})
291+
292+
export{CFocusTrap}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import{App}from'vue'
2+
import{CFocusTrap}from'./CFocusTrap'
3+
4+
constCFocusTrapPlugin={
5+
install:(app:App):void=>{
6+
app.component(CFocusTrap.nameasstring,CFocusTrap)
7+
},
8+
}
9+
10+
export{CFocusTrapPlugin,CFocusTrap}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Gets all focusable child elements within a container.
3+
* Uses a comprehensive selector to find elements that can receive focus.
4+
*@param element - The container element to search within
5+
*@returns Array of focusable HTML elements
6+
*/
7+
exportconstfocusableChildren=(element:HTMLElement):HTMLElement[]=>{
8+
constfocusableSelectors=[
9+
'a[href]',
10+
'button:not([disabled])',
11+
'input:not([disabled])',
12+
'textarea:not([disabled])',
13+
'select:not([disabled])',
14+
'details',
15+
'[tabindex]:not([tabindex="-1"])',
16+
'[contenteditable="true"]',
17+
].join(',')
18+
19+
constelements=[...element.querySelectorAll<HTMLElement>(focusableSelectors)]asHTMLElement[]
20+
21+
returnelements.filter((el)=>!isDisabled(el)&&isVisible(el))
22+
}
23+
24+
/**
25+
* Checks if an element is disabled.
26+
* Considers various ways an element can be disabled including CSS classes and attributes.
27+
*@param element - The HTML element to check
28+
*@returns True if the element is disabled, false otherwise
29+
*/
30+
exportconstisDisabled=(element:HTMLElement):boolean=>{
31+
if(!element||element.nodeType!==Node.ELEMENT_NODE){
32+
returntrue
33+
}
34+
35+
if(element.classList.contains('disabled')){
36+
returntrue
37+
}
38+
39+
if('disabled'inelement&&typeofelement.disabled==='boolean'){
40+
returnelement.disabled
41+
}
42+
43+
returnelement.hasAttribute('disabled')&&element.getAttribute('disabled')!=='false'
44+
}
45+
46+
/**
47+
* Type guard to check if an object is an Element.
48+
* Handles edge cases including jQuery objects.
49+
*@param object - The object to check
50+
*@returns True if the object is an Element, false otherwise
51+
*/
52+
exportconstisElement=(object:unknown):object isElement=>{
53+
if(!object||typeofobject!=='object'){
54+
returnfalse
55+
}
56+
57+
return'nodeType'inobject&&typeofobject.nodeType==='number'
58+
}
59+
60+
/**
61+
* Checks if an element is visible in the DOM.
62+
* Considers client rects and computed visibility styles, handling edge cases like details elements.
63+
*@param element - The HTML element to check for visibility
64+
*@returns True if the element is visible, false otherwise
65+
*/
66+
exportconstisVisible=(element:HTMLElement):boolean=>{
67+
if(!isElement(element)||element.getClientRects().length===0){
68+
returnfalse
69+
}
70+
71+
constelementIsVisible=getComputedStyle(element).getPropertyValue('visibility')==='visible'
72+
73+
// Handle `details` element as its content may falsely appear visible when it is closed
74+
constclosedDetails=element.closest('details:not([open])')
75+
76+
if(!closedDetails){
77+
returnelementIsVisible
78+
}
79+
80+
if(closedDetails!==element){
81+
constsummary=element.closest('summary')
82+
83+
// Check if summary is a direct child of the closed details
84+
if(summary?.parentNode!==closedDetails){
85+
returnfalse
86+
}
87+
}
88+
89+
returnelementIsVisible
90+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp