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

Keyboard nav for extensions main screen #13176

Open
wants to merge 11 commits into
base: master
Choose a base branch
from
28 changes: 7 additions & 21 deletions pkg/rancher-components/src/components/Card/Card.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { createFocusTrap, FocusTrap } from 'focus-trap';
import { useBasicSetupFocusTrap } from '@shell/composables/focusTrap';

export default defineComponent({

name: 'Card',
props: {
/**
Expand Down Expand Up @@ -56,32 +57,17 @@ export default defineComponent({
default: false,
},
},
data() {
return { focusTrapInstance: {} as FocusTrap };
},
mounted() {
if (this.triggerFocusTrap) {
this.focusTrapInstance = createFocusTrap(this.$refs.cardContainer as HTMLElement, {
escapeDeactivates: true,
allowOutsideClick: true,
});

this.$nextTick(() => {
this.focusTrapInstance.activate();
});
setup(props) {
if (props.triggerFocusTrap) {
useBasicSetupFocusTrap('#focus-trap-card-container-element');
}
},
beforeUnmount() {
if (this.focusTrapInstance && this.triggerFocusTrap) {
this.focusTrapInstance.deactivate();
}
},
}
});
</script>

<template>
<div
ref="cardContainer"
id="focus-trap-card-container-element"
class="card-container"
:class="{'highlight-border': showHighlightBorder, 'card-sticky': sticky}"
data-testid="card"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ export default defineComponent({
<input
v-else
ref="value"
role="textbox"
:class="{ 'no-label': !hasLabel }"
v-bind="$attrs"
:maxlength="_maxlength"
Expand Down
3 changes: 2 additions & 1 deletion shell/assets/styles/base/_basic.scss
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,13 @@ BODY {
INPUT,
SELECT,
TEXTAREA,
.labeled-input,
.checkbox-custom {
&:focus, &.focused {
@include form-focus;
}
}

.labeled-input,
.radio-custom,
.labeled-select,
.unlabeled-select {
Expand All @@ -76,6 +76,7 @@ TEXTAREA,
}
}

.labeled-input,
.labeled-select,
.unlabeled-select {
&.focused {
Expand Down
3 changes: 2 additions & 1 deletion shell/assets/styles/global/_form.scss
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ TEXTAREA,

@include input-status-color;

&:focus:not(.unlabeled-select):not(.labeled-select), &.focused:not(.unlabeled-select):not(.labeled-select) {
&:focus:not(.labeled-input):not(.unlabeled-select):not(.labeled-select),
&.focused:not(.labeled-input):not(.unlabeled-select):not(.labeled-select) {
@include form-focus;
}

Expand Down
4 changes: 4 additions & 0 deletions shell/assets/translations/en-us.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4380,13 +4380,17 @@ plugins:
incompatibleUiExtensionsApiVersion: "The latest version of this extension ({ version }) is not compatible with the current Extensions API version ({ required })."
incompatibleHost: 'The latest version of this extension ({ version }) has a host of "{ required }" which is not compatible with this application "{ mainHost }".'
currentInstalledVersionBlockedByKubeVersion: "This version is not compatible with the current Kubernetes version ({ kubeVersion } Vs { kubeVersionToCheck })."
closePluginPanel: Close plugin description panel
viewVersionDetails: View extension {name} version {version} details/Readme
labels:
builtin: Built-in
experimental: Experimental
third-party: Third-Party
image: Image
installing: Installing ...
uninstalling: Uninstalling ...
menu: Extensions menu
reloadRancher: Reload Rancher
descriptions:
experimental: This Extension is marked as experimental
third-party: This Extension is provided by a Third-Party
Expand Down
50 changes: 50 additions & 0 deletions shell/components/AppModal.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { DEFAULT_FOCUS_TRAP_OPTS, useBasicSetupFocusTrap, getFirstFocusableElement } from '@shell/composables/focusTrap';

export const DEFAULT_ITERABLE_NODE_SELECTOR = 'body;';

export default defineComponent({
name: 'AppModal',
Expand Down Expand Up @@ -56,6 +59,27 @@ export default defineComponent({
name: {
type: String,
default: '',
},
/**
* trigger focus trap
*/
triggerFocusTrap: {
type: Boolean,
default: false,
},
/**
* forcefully set return focus element based on this selector
*/
returnFocusSelector: {
type: String,
default: '',
},
/**
* will return focus to the first iterable node of this container select
*/
returnFocusFirstIterableNodeSelector: {
type: String,
default: DEFAULT_ITERABLE_NODE_SELECTOR,
}
},
computed: {
Expand Down Expand Up @@ -85,6 +109,31 @@ export default defineComponent({
};
}
},
setup(props) {
if (props.triggerFocusTrap) {
let opts:any = DEFAULT_FOCUS_TRAP_OPTS;

// if we have a "returnFocusFirstIterableNodeSelector" on top of "returnFocusSelector"
// then we will use "returnFocusFirstIterableNodeSelector" as a fallback of "returnFocusSelector"
if (props.returnFocusFirstIterableNodeSelector && props.returnFocusFirstIterableNodeSelector !== DEFAULT_ITERABLE_NODE_SELECTOR && props.returnFocusSelector) {
opts = {
...DEFAULT_FOCUS_TRAP_OPTS,
setReturnFocus: () => {
return document.querySelector(props.returnFocusSelector) ? props.returnFocusSelector : getFirstFocusableElement(document.querySelector(props.returnFocusFirstIterableNodeSelector));
}
};
// otherwise, if we are sure of permanent existance of "returnFocusSelector"
// we just return to that element
} else if (props.returnFocusSelector) {
opts = {
...DEFAULT_FOCUS_TRAP_OPTS,
setReturnFocus: props.returnFocusSelector
};
}

useBasicSetupFocusTrap('#modal-container-element', opts);
}
},
mounted() {
document.addEventListener('keydown', this.handleEscapeKey);
},
Expand Down Expand Up @@ -134,6 +183,7 @@ export default defineComponent({
>
<div
v-bind="$attrs"
id="modal-container-element"
ref="modalRef"
:class="customClass"
class="modal-container"
Expand Down
21 changes: 20 additions & 1 deletion shell/components/Dialog.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script>
import AsyncButton from '@shell/components/AsyncButton';
import AppModal from '@shell/components/AppModal.vue';
import AppModal, { DEFAULT_ITERABLE_NODE_SELECTOR } from '@shell/components/AppModal.vue';

export default {
emits: ['okay', 'closed'],
Expand All @@ -21,6 +21,22 @@ export default {
mode: {
type: String,
default: '',
},

/**
* forcefully set return focus element based on this selector
*/
returnFocusSelector: {
type: String,
default: '',
},

/**
* will return focus to the first iterable node of this container select
*/
returnFocusFirstIterableNodeSelector: {
type: String,
default: DEFAULT_ITERABLE_NODE_SELECTOR,
}
},

Expand Down Expand Up @@ -60,6 +76,9 @@ export default {
:name="name"
height="auto"
:scrollable="true"
:trigger-focus-trap="true"
:return-focus-selector="returnFocusSelector"
:return-focus-first-iterable-node-selector="returnFocusFirstIterableNodeSelector"
@close="closeDialog(false)"
@before-open="beforeOpen"
>
Expand Down
11 changes: 4 additions & 7 deletions shell/components/Tabbed/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -276,12 +276,11 @@ export default {
:data-testid="`btn-${tab.name}`"
:aria-controls="'#' + tab.name"
:aria-selected="tab.active"
:aria-label="tab.labelDisplay"
:aria-label="tab.labelDisplay || ''"
role="tab"
tabindex="0"
@click.prevent="select(tab.name, $event)"
@keyup.enter="select(tab.name, $event)"
@keyup.space="select(tab.name, $event)"
@keyup.enter.space="select(tab.name, $event)"
>
<span>{{ tab.labelDisplay }}</span>
<span
Expand Down Expand Up @@ -409,10 +408,8 @@ export default {

&:focus-visible {
@include focus-outline;

span {
text-decoration: underline;
}
outline-offset: -4px;
text-decoration: none;
}
}

Expand Down
2 changes: 1 addition & 1 deletion shell/components/form/LabeledSelect.vue
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ export default {
]"
:tabindex="isView || disabled ? -1 : 0"
@click="focusSearch"
@keyup.enter.space.down="focusSearch"
@keydown.enter.space.down="focusSearch"
>
<div
:class="{ 'labeled-container': true, raised, empty, [mode]: true }"
Expand Down
2 changes: 1 addition & 1 deletion shell/components/form/Select.vue
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export default {
}"
:tabindex="disabled || isView ? -1 : 0"
@click="focusSearch"
@keyup.enter.space.down="focusSearch"
@keydown.enter.space.down="focusSearch"
>
<v-select
ref="select-input"
Expand Down
68 changes: 68 additions & 0 deletions shell/composables/focusTrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* focusTrap is a composable based on the "focus-trap" package that allows us to implement focus traps
* on components for keyboard navigation is a safe and reusable way
*/
import { watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
import { createFocusTrap, FocusTrap } from 'focus-trap';

export function getFirstFocusableElement(element:any = document):any {
const focusableElements = element.querySelectorAll(
'a, button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])'
);
const filteredFocusableElements:any = [];

focusableElements.forEach((el:any) => {
if (!el.hasAttribute('disabled')) {
filteredFocusableElements.push(el);
}
});

return filteredFocusableElements.length ? filteredFocusableElements[0] : document.body;
}

export const DEFAULT_FOCUS_TRAP_OPTS = {
escapeDeactivates: true,
allowOutsideClick: true
};

export function useBasicSetupFocusTrap(focusElement: string | HTMLElement, opts:any = DEFAULT_FOCUS_TRAP_OPTS) {
let focusTrapInstance: FocusTrap;
let focusEl;

onMounted(() => {
focusEl = typeof focusElement === 'string' ? document.querySelector(focusElement) as HTMLElement : focusElement;

focusTrapInstance = createFocusTrap(focusEl, opts);

nextTick(() => {
focusTrapInstance.activate();
});
});

onBeforeUnmount(() => {
if (Object.keys(focusTrapInstance).length) {
focusTrapInstance.deactivate();
}
});
}

export function useWatcherBasedSetupFocusTrapWithDestroyIncluded(watchVar:any, focusElement: string | HTMLElement, opts:any = DEFAULT_FOCUS_TRAP_OPTS) {
let focusTrapInstance: FocusTrap;
let focusEl;

watch(watchVar, (neu) => {
if (neu) {
nextTick(() => {
focusEl = typeof focusElement === 'string' ? document.querySelector(focusElement) as HTMLElement : focusElement;

focusTrapInstance = createFocusTrap(focusEl, opts);

nextTick(() => {
focusTrapInstance.activate();
});
});
} else if (!neu && Object.keys(focusTrapInstance).length) {
focusTrapInstance.deactivate();
}
});
}
9 changes: 5 additions & 4 deletions shell/pages/c/_cluster/explorer/ConfigBadge.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,14 @@ export default {
</script>

<template>
<div
class="config-badge"
>
<div class="config-badge">
<div>
<button
class="badge-install btn btn-sm role-secondary"
data-testid="add-custom-cluster-badge"
role="button"
tabindex="0"
@click="customBadgeDialog"
@keyup.space="customBadgeDialog"
>
<i
v-clean-tooltip="tooltip"
Expand All @@ -60,6 +57,10 @@ export default {
> I {
line-height: inherit;
}

&:focus {
outline: 0;
}
}

</style>
4 changes: 3 additions & 1 deletion shell/pages/c/_cluster/uiplugins/AddExtensionRepos.vue
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export default {
branch: UI_PLUGINS_REPOS.PARTNERS.BRANCH,
}
},
isDialogActive: false,
isDialogActive: false,
returnFocusSelector: '[data-testid="extensions-page-menu"]'
};
},

Expand Down Expand Up @@ -109,6 +110,7 @@ export default {
:title="t('plugins.addRepos.title')"
mode="add"
data-testid="add-extensions-repos-modal"
:return-focus-selector="returnFocusSelector"
@okay="doAddRepos"
@closed="isDialogActive = false"
>
Expand Down
Loading
Loading