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 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 { basicSetupFocusTrap } from '@shell/composables/focusTrap';
Copy link
Member

Choose a reason for hiding this comment

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

It's a common Vue convention for composable functions to start with use. I think that useFocusTrap would be a more idiomatic name for this function.


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) {
basicSetupFocusTrap('#focus-trap-card-container');
}
},
beforeUnmount() {
if (this.focusTrapInstance && this.triggerFocusTrap) {
this.focusTrapInstance.deactivate();
}
},
}
});
</script>

<template>
<div
ref="cardContainer"
id="focus-trap-card-container"
Copy link
Member

Choose a reason for hiding this comment

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

Is there any reason we can't stick with the ref? I think that it is preferred over an id when working with component instances.

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 @@ -350,6 +350,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 @@ -4358,13 +4358,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
20 changes: 20 additions & 0 deletions shell/components/AppModal.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { watcherBasedSetupFocusTrap } from '@shell/composables/focusTrap';

export default defineComponent({
name: 'AppModal',
Expand Down Expand Up @@ -56,6 +57,20 @@ export default defineComponent({
name: {
type: String,
default: '',
},
/**
* Modal visibility (used for focus-trap)
*/
modalVisibility: {
type: Boolean,
default: false,
},
Comment on lines +61 to +67
Copy link
Member

Choose a reason for hiding this comment

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

The modalVisibility looks redundant to me.. Shouldn't modal instances be conditionally rendered already?

/**
* trigger focus trap
*/
triggerFocusTrap: {
type: Boolean,
default: false,
}
},
computed: {
Expand Down Expand Up @@ -85,6 +100,11 @@ export default defineComponent({
};
}
},
created() {
if (this.triggerFocusTrap) {
watcherBasedSetupFocusTrap(() => this.modalVisibility, '.modal-container');
}
},
Comment on lines +103 to +107
Copy link
Member

Choose a reason for hiding this comment

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

In pkg/rancher-components/src/components/Card/Card.vue, we do the following

  setup(props) {
    if (props.triggerFocusTrap) {
      basicSetupFocusTrap('#focus-trap-card-container');
    }

Is there any reason why we can't do the same here? It looks like we would be able to drop the modalVisibility prop if we were to achieve this.

mounted() {
document.addEventListener('keydown', this.handleEscapeKey);
},
Expand Down
2 changes: 2 additions & 0 deletions shell/components/Dialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export default {
:name="name"
height="auto"
:scrollable="true"
:modal-visibility="!closed"
:trigger-focus-trap="true"
@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
71 changes: 71 additions & 0 deletions shell/composables/focusTrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* 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 basicSetupFocusTrap(containerSelector: string) {
let focusTrapInstance = {} as FocusTrap;
Copy link
Member

Choose a reason for hiding this comment

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

I think that we can just specify the type:

Suggested change
let focusTrapInstance = {} as FocusTrap;
let focusTrapInstance: FocusTrap;


onMounted(() => {
focusTrapInstance = createFocusTrap(document.querySelector(containerSelector) as HTMLElement, {
escapeDeactivates: true,
allowOutsideClick: true,
});

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

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

export function watcherBasedSetupFocusTrapWithDestroyIncluded(watchVar:any, containerSelector: string) {
let focusTrapInstance = {} as FocusTrap;

watch(watchVar, (neu) => {
if (neu) {
nextTick(() => {
focusTrapInstance = createFocusTrap(document.querySelector(containerSelector) as HTMLElement, {
escapeDeactivates: true,
allowOutsideClick: true,
});
nextTick(() => {
focusTrapInstance.activate();
});
});
} else if (!neu && focusTrapInstance) {
focusTrapInstance.deactivate();
}
});
}

export function watcherBasedSetupFocusTrap(watchVar:any, containerSelector: string) {
let focusTrapInstance = {} as FocusTrap;

watch(watchVar, (neu) => {
if (neu) {
nextTick(() => {
focusTrapInstance = createFocusTrap(document.querySelector(containerSelector) as HTMLElement, {
escapeDeactivates: true,
allowOutsideClick: true,
});
nextTick(() => {
focusTrapInstance.activate();
});
});
}
}, { immediate: true });

onBeforeUnmount(() => {
if (focusTrapInstance) {
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>
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,8 @@ export default {
name="catalogLoadDialog"
height="auto"
:scrollable="true"
:modal-visibility="showModal"
:trigger-focus-trap="true"
@close="closeDialog()"
>
<Loading
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export default {
name="uninstallCatalogDialog"
height="auto"
:scrollable="true"
:modal-visibility="showModal"
:trigger-focus-trap="true"
@close="closeDialog(false)"
>
<div
Expand Down
4 changes: 3 additions & 1 deletion shell/pages/c/_cluster/uiplugins/CatalogList/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,12 @@ export default {
<template #header-left>
<div>
<button
class="btn bg-primary mr-10"
class="btn role-primary mr-10"
type="button"
aria-haspopup="dialog"
data-testid="extensions-catalog-load-dialog"
role="button"
:aria-label="t('plugins.manageCatalog.imageLoad.load')"
@click="$emit('showCatalogLoadDialog')"
>
{{ t('plugins.manageCatalog.imageLoad.load') }}
Expand Down
4 changes: 3 additions & 1 deletion shell/pages/c/_cluster/uiplugins/DeveloperInstallDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export default {
},

watch: {
name(neu, old) {
name(neu) {
if (this.canModifyLocation) {
this.location = `/pkg/${ neu }/${ neu }.umd.min.js`;
}
Expand Down Expand Up @@ -154,6 +154,8 @@ export default {
name="developerInstallPluginDialog"
height="auto"
:scrollable="true"
:modal-visibility="showModal"
:trigger-focus-trap="true"
@close="closeDialog()"
>
<div class="plugin-install-dialog">
Expand Down
2 changes: 2 additions & 0 deletions shell/pages/c/_cluster/uiplugins/InstallDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ export default {
name="installPluginDialog"
height="auto"
:scrollable="true"
:modal-visibility="showModal"
:trigger-focus-trap="true"
@close="closeDialog(false)"
>
<div
Expand Down
Loading
Loading