Skip to content

Commit

Permalink
Keyboard nav for extensions main screen (#13176)
Browse files Browse the repository at this point in the history
* working on focus trap composable

* working on slide in panel refactor

* add change so that we dont have a change on the component diff

* fix slidein extension details panel key nav

* fine tune cluster badge trigger btn

* finish work on extensions page

* fix lint errors

* address pr comments

* fix problems of propagation

* update return focus for some modals in the extensions main screen

* remove dead code + fix focus selector return for add extensions repo and developer install modals
  • Loading branch information
aalves08 authored Feb 11, 2025
1 parent 807c637 commit 0c411e9
Show file tree
Hide file tree
Showing 22 changed files with 440 additions and 166 deletions.
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

0 comments on commit 0c411e9

Please sign in to comment.