11<script setup>
22import { ref , reactive , computed , onUnmounted , nextTick , onMounted } from " vue" ;
33import BaseIcon from " ./BaseIcon.vue" ;
4- import { XMLNS } from " ../lib" ;
4+ import { createUid , XMLNS } from " ../lib" ;
55
66const props = defineProps ({
77 backgroundColor: { type: String },
@@ -19,6 +19,12 @@ const emit = defineEmits(["close"]);
1919const isOpen = ref (false );
2020const hasBeenOpened = ref (false );
2121const instanceKey = ref (0 );
22+ const draggableDialog = ref (null );
23+ const closeButtonElement = ref (null );
24+ const previouslyFocusedElement = ref (null );
25+ const dialogId = ` vue-ui-draggable-dialog-${ createUid ()} ` ;
26+ const dialogTitleId = ` ${ dialogId} -title` ;
27+ const dialogBodyId = ` ${ dialogId} -body` ;
2228
2329function bringToFront () {
2430 instanceKey .value += 1 ;
@@ -40,18 +46,31 @@ const modal = reactive({
4046});
4147
4248function open () {
49+ previouslyFocusedElement .value =
50+ document .activeElement instanceof HTMLElement ? document .activeElement : null ;
51+
4352 isOpen .value = true ;
4453 nextTick (() => {
4554 if (! hasBeenOpened .value ) {
4655 modal .left = Math .max (0 , window .innerWidth / 2 - modal .width / 2 );
4756 modal .top = Math .max (0 , window .innerHeight / 2 - modal .height / 2 );
4857 hasBeenOpened .value = true ;
4958 }
59+
60+ const targetToFocus = closeButtonElement .value || draggableDialog .value ;
61+ if (targetToFocus && typeof targetToFocus .focus === " function" ) {
62+ targetToFocus .focus ();
63+ }
5064 });
5165}
66+
5267function close () {
5368 isOpen .value = false ;
5469 emit (" close" );
70+
71+ if (previouslyFocusedElement .value && typeof previouslyFocusedElement .value .focus === " function" ) {
72+ previouslyFocusedElement .value .focus ();
73+ }
5574}
5675
5776defineExpose ({ open, close });
@@ -169,7 +188,7 @@ function resizeLeft(e) {
169188 let dx = pointer .x - modal .pointerStartX ;
170189 let newLeft = Math .min (
171190 Math .max (0 , modal .resizeStartLeft + dx),
172- modal .resizeStartLeft + modal .resizeStartW - 240 // min width
191+ modal .resizeStartLeft + modal .resizeStartW - 240
173192 );
174193 let newWidth = modal .resizeStartW - (newLeft - modal .resizeStartLeft );
175194 let dy = pointer .y - modal .pointerStartY ;
@@ -188,11 +207,11 @@ function endResizeLeft() {
188207}
189208
190209onMounted (() => {
191- document .addEventListener (' keydown' , onEscape);
210+ document .addEventListener (" keydown" , onEscape);
192211});
193212
194213function onEscape (e ) {
195- if (e .key && e .key === ' Escape' ) {
214+ if (e .key && e .key === " Escape" ) {
196215 close ();
197216 }
198217}
@@ -201,27 +220,72 @@ onUnmounted(() => {
201220 endDrag ();
202221 endResize ();
203222 endResizeLeft ();
204- document .removeEventListener (' keydown' , onEscape);
223+ document .removeEventListener (" keydown" , onEscape);
205224});
225+
226+ function handleDialogKeydown (event ) {
227+ if (event .key === " Tab" ) {
228+ trapFocus (event );
229+ }
230+ }
231+
232+ function trapFocus (event ) {
233+ if (! draggableDialog .value ) return ;
234+
235+ const focusableSelector =
236+ ' a[href], area[href], input:not([disabled]), select:not([disabled]), ' +
237+ ' textarea:not([disabled]), button:not([disabled]), iframe, object, embed, ' +
238+ ' [tabindex]:not([tabindex="-1"]), [contenteditable="true"]' ;
239+
240+ const focusableElements = draggableDialog .value .querySelectorAll (focusableSelector);
241+ if (! focusableElements .length ) return ;
242+
243+ const firstElement = focusableElements[0 ];
244+ const lastElement = focusableElements[focusableElements .length - 1 ];
245+
246+ if (event .shiftKey ) {
247+ if (document .activeElement === firstElement) {
248+ event .preventDefault ();
249+ lastElement .focus ();
250+ }
251+ } else {
252+ if (document .activeElement === lastElement) {
253+ event .preventDefault ();
254+ firstElement .focus ();
255+ }
256+ }
257+ }
206258< / script>
207259
208260< template>
209261 < Teleport : to= " isFullscreen ? fullscreenParent : 'body'" : key= " instanceKey" >
210- < div
211- v- if = " isOpen"
212- data- cy= " draggable-dialog"
213- class = " vue-ui-draggable-dialog"
262+ < div
263+ v- if = " isOpen"
264+ ref= " draggableDialog"
265+ data- cy= " draggable-dialog"
266+ class = " vue-ui-draggable-dialog"
214267 : style= " modalStyle"
268+ role= " dialog"
269+ : aria- modal= " true"
270+ : aria- labelledby= " dialogTitleId"
271+ : aria- describedby= " dialogBodyId"
272+ tabindex= " -1"
215273 @click .stop
274+ @keydown= " handleDialogKeydown"
216275 >
217- < div
276+ < div
218277 class = " vue-ui-draggable-dialog-header"
219278 : style= " {
220279 backgroundColor: headerBg,
221280 color: headerColor
222281 }"
223282 >
224- < span class = " drag-handle" @mousedown .stop .prevent = " initDrag" @touchstart .stop .prevent = " initDrag" >
283+ < span
284+ class = " drag-handle"
285+ aria- hidden= " true"
286+ @mousedown .stop .prevent = " initDrag"
287+ @touchstart .stop .prevent = " initDrag"
288+ >
225289 < svg
226290 : xmlns= " XMLNS"
227291 width= " 20"
@@ -240,25 +304,46 @@ onUnmounted(() => {
240304 < path d= " M19 9m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" / >
241305 < path d= " M19 15m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" / >
242306 < / svg>
243-
244-
245307 < / span>
246- < span class = " vue-ui-draggable-dialog-title" >
247- < slot name= " title" / >
308+ < span
309+ class = " vue-ui-draggable-dialog-title"
310+ : id= " dialogTitleId"
311+ >
312+ < slot name= " title" / >
248313 < / span>
249314 < div class = " draggable-dialog-actions" >
250- < slot name= " actions" / >
251- < button data- cy= " draggable-dialog-close" class = " close" @click= " close" >
252- < BaseIcon name= " close" : stroke= " headerColor" / >
315+ < slot name= " actions" / >
316+ < button
317+ ref= " closeButtonElement"
318+ data- cy= " draggable-dialog-close"
319+ class = " close"
320+ type= " button"
321+ aria- label= " Close dialog"
322+ @click= " close"
323+ >
324+ < BaseIcon name= " close" : stroke= " headerColor" / >
253325 < / button>
254326 < / div>
255327 < / div>
256- < div : class = " { 'vue-ui-draggable-dialog-body': !withPadding, 'vue-ui-draggable-dialog-body-pad': withPadding}" >
328+ < div
329+ : id= " dialogBodyId"
330+ role= " document"
331+ : class = " {
332+ 'vue-ui-draggable-dialog-body': !withPadding,
333+ 'vue-ui-draggable-dialog-body-pad': withPadding
334+ }"
335+ >
257336 < slot name= " content" / >
258337 < / div>
259- < div class = " resize-handle" @mousedown .stop .prevent = " initResize" @touchstart .stop .prevent = " initResize" / >
338+ < div
339+ class = " resize-handle"
340+ aria- hidden= " true"
341+ @mousedown .stop .prevent = " initResize"
342+ @touchstart .stop .prevent = " initResize"
343+ / >
260344 < div
261345 class = " resize-handle resize-handle-left"
346+ aria- hidden= " true"
262347 @mousedown .stop .prevent = " initResizeLeft"
263348 @touchstart .stop .prevent = " initResizeLeft"
264349 / >
@@ -280,8 +365,8 @@ onUnmounted(() => {
280365 display: flex;
281366 flex- direction: row;
282367 gap: 6px ;
283- align- items: center;
284- justify- content: center;
368+ align- items: center;
369+ justify- content: center;
285370}
286371
287372.drag - handle {
@@ -306,7 +391,7 @@ onUnmounted(() => {
306391 border: none;
307392 cursor: pointer;
308393 display: flex;
309- align- items: center;
394+ align- items: center;
310395 justify- content: center;
311396}
312397
@@ -388,4 +473,4 @@ onUnmounted(() => {
388473.vue - ui- user- options- button: focus- visible {
389474 outline: 1px solid #CCCCCC ;
390475}
391- < / style>
476+ < / style>
0 commit comments