Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add KFocusTrap and use it in Kmodal #764

Closed
wants to merge 13 commits into from
78 changes: 78 additions & 0 deletions lib/KFocusTrap.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<template>

<div>
<div
v-if="!disabled"
class="focus-trap-first"
tabindex="0"
@focus="handleFirstTrapFocus"
></div>

<slot></slot>

<div
v-if="!disabled"
class="focus-trap-last"
tabindex="0"
@focus="handleLastTrapFocus"
></div>
</div>

lokesh-sagi125 marked this conversation as resolved.
Show resolved Hide resolved
</template>


<script>

/**
* This component ensures that focus moves between the first element
* and the last element of content provided by the default slot.
* In disabled state, it only renders whatever has been passed
* to the default slot, and doesn't add any focus trap behavior,
* allowing for flexible use from parent components.
*/
export default {
name: 'FocusTrap',
lokesh-sagi125 marked this conversation as resolved.
Show resolved Hide resolved
props: {
disabled: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you document this prop? You can see other components.

The docstring will then show in the auto-generated props documentation on a documentation page for this component.

type: Boolean,
required: false,
default: false,
},
},
data() {
return {
isTrapActive: false,
};
},
methods: {
handleFirstTrapFocus(e) {
e.stopPropagation();
if (!this.isTrapActive) {
// On first focus, redirect to first option, then activate trap
this.focusFirstEl();
this.isTrapActive = true;
} else {
this.focusLastEl();
}
},
handleLastTrapFocus(e) {
e.stopPropagation();
this.focusFirstEl();
},
focusFirstEl() {
this.$emit('shouldFocusFirstEl');
},
focusLastEl() {
this.$emit('shouldFocusLastEl');
},
/**
* @public
* Reset the next focus to the first focus element
*/
reset() {
this.isTrapActive = false;
},
},
};

</script>
180 changes: 91 additions & 89 deletions lib/KModal.vue
Original file line number Diff line number Diff line change
@@ -1,102 +1,104 @@
<template>

<component :is="wrapper">
<!-- Accessibility properties for the overlay -->
<transition name="modal-fade" appear>
<div
id="modal-window"
ref="modal-overlay"
class="modal-overlay"
@keyup.esc.stop="emitCancelEvent"
@keyup.enter="handleEnter"
>
<!-- KeenUiSelect targets modal by using div.modal selector -->
<KFocusTrap>
<component :is="wrapper">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We'll need to test that when component here is KOverlay (that is, when KModal's appendToOverlay prop is true), the focus trapping still works as expected.

Just from the code I'm a bit suspicious there may be trouble. Reason being that KOverlay will move the modal content to another element #k-overlay, whereas KFocusTrap will stay in its original place in the DOM.

I know I used this structure as an example in the issue, however it was meant to be just rough guidance rather than solution, apologies if that was confusing. Let's preview for both possible appendToOverlay values and see if it needs to be adjusted.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh , i should have understood the requirements properly, thanks @MisRob i will make sure to use KFocusTrap appropriately.

<!-- Accessibility properties for the overlay -->
<transition name="modal-fade" appear>
<div
ref="modal"
class="modal"
:tabindex="0"
role="dialog"
aria-labelledby="modal-title"
:style="[
modalSizeStyles,
{ background: $themeTokens.surface },
containsKSelect ? { overflowY: 'unset' } : { overflowY: 'auto' }
]"
id="modal-window"
ref="modal-overlay"
class="modal-overlay"
@keyup.esc.stop="emitCancelEvent"
@keyup.enter="handleEnter"
>

<!-- Modal Title -->
<h1
id="modal-title"
ref="title"
class="title"
<!-- KeenUiSelect targets modal by using div.modal selector -->
<div
ref="modal"
class="modal"
:tabindex="0"
role="dialog"
aria-labelledby="modal-title"
:style="[
modalSizeStyles,
{ background: $themeTokens.surface },
containsKSelect ? { overflowY: 'unset' } : { overflowY: 'auto' }
]"
>
{{ title }}
<!-- Accessible error reporting per @radina -->
<span
v-if="hasError"
class="visuallyhidden"

<!-- Modal Title -->
<h1
id="modal-title"
ref="title"
class="title"
>
{{ errorMessage }}
</span>
</h1>

<!-- Stop propagation of enter key to prevent the submit event from being emitted twice -->
<form
class="form"
@submit.prevent="emitSubmitEvent"
@keyup.enter.stop
>
<!-- Wrapper for main content -->
<div
ref="content"
class="content"
:style="[ contentSectionMaxHeight, scrollShadow ? {
borderTop: `1px solid ${$themeTokens.fineLine}`,
borderBottom: `1px solid ${$themeTokens.fineLine}`,
} : {} ]"
:class="{
'scroll-shadow': scrollShadow,
'contains-kselect': containsKSelect
}"
{{ title }}
<!-- Accessible error reporting per @radina -->
<span
v-if="hasError"
class="visuallyhidden"
>
{{ errorMessage }}
</span>
</h1>

<!-- Stop propagation of enter key to prevent the submit event from being emitted twice -->
<form
class="form"
@submit.prevent="emitSubmitEvent"
@keyup.enter.stop
>
<!-- @slot Main content of modal -->
<slot></slot>
</div>
<!-- Wrapper for main content -->
<div
ref="content"
class="content"
:style="[ contentSectionMaxHeight, scrollShadow ? {
borderTop: `1px solid ${$themeTokens.fineLine}`,
borderBottom: `1px solid ${$themeTokens.fineLine}`,
} : {} ]"
:class="{
'scroll-shadow': scrollShadow,
'contains-kselect': containsKSelect
}"
>
<!-- @slot Main content of modal -->
<slot></slot>
</div>

<div
ref="actions"
class="actions"
>
<!-- @slot Alternative buttons and actions below main content -->
<slot
v-if="$slots.actions"
name="actions"
<div
ref="actions"
class="actions"
>
</slot>
<template v-else>
<KButton
v-if="cancelText"
name="cancel"
:text="cancelText"
appearance="flat-button"
:disabled="cancelDisabled || $attrs.disabled"
@click="emitCancelEvent"
/>
<KButton
v-if="submitText"
name="submit"
:text="submitText"
:primary="true"
:disabled="submitDisabled || $attrs.disabled"
type="submit"
/>
</template>
</div>
</form>
<!-- @slot Alternative buttons and actions below main content -->
<slot
v-if="$slots.actions"
name="actions"
>
</slot>
<template v-else>
<KButton
v-if="cancelText"
name="cancel"
:text="cancelText"
appearance="flat-button"
:disabled="cancelDisabled || $attrs.disabled"
@click="emitCancelEvent"
/>
<KButton
v-if="submitText"
name="submit"
:text="submitText"
:primary="true"
:disabled="submitDisabled || $attrs.disabled"
type="submit"
/>
</template>
</div>
</form>
</div>
</div>
</div>
</transition>
</component>
</transition>
</component>
</KFocusTrap>

</template>

Expand Down
2 changes: 2 additions & 0 deletions lib/KThemePlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import KTextTruncator from './KTextTruncator';
import KLogo from './KLogo';
import KRadioButtonGroup from './KRadioButtonGroup.vue';
import KCard from './KCard';
import KFocusTrap from './KFocusTrap.vue';

import { themeTokens, themeBrand, themePalette, themeOutlineStyle } from './styles/theme';
import globalThemeState from './styles/globalThemeState';
Expand Down Expand Up @@ -159,4 +160,5 @@ export default function KThemePlugin(Vue) {
Vue.component('KTextTruncator', KTextTruncator);
Vue.component('KRadioButtonGroup', KRadioButtonGroup);
Vue.component('KCard', KCard);
Vue.component('KFocusTrap', KFocusTrap);
}
Loading