fix: focus goes outside modals with tab key navigation

This commit is contained in:
Fabio Di Stasio 2022-06-26 15:07:37 +02:00
parent 0a3a4827dd
commit e42c424a13
14 changed files with 140 additions and 23 deletions

View File

@ -7,13 +7,7 @@ export interface TableParams {
export interface ExportOptions {
schema: string;
includes: {
functions: boolean;
views: boolean;
triggers: boolean;
routines: boolean;
schedulers: boolean;
};
includes: {[key: string]: boolean};
outputFormat: 'sql' | 'sql.zip';
outputFile: string;
sqlInsertAfter: number;

View File

@ -3,7 +3,7 @@
<Teleport to="#window-content">
<div class="modal active" :class="modalSizeClass">
<a class="modal-overlay" @click="hideModal" />
<div class="modal-container">
<div ref="trapRef" class="modal-container">
<div v-if="hasHeader" class="modal-header pl-2">
<div class="modal-title h6">
<slot name="header" />
@ -47,6 +47,7 @@
</template>
<script setup lang="ts">
import { useFocusTrap } from '@/composables/useFocusTrap';
import { computed, onBeforeUnmount, PropType, useSlots } from 'vue';
const props = defineProps({
@ -65,6 +66,8 @@ const props = defineProps({
const emit = defineEmits(['confirm', 'hide']);
const slots = useSlots();
const { trapRef } = useFocusTrap();
const hasHeader = computed(() => !!slots.header);
const hasBody = computed(() => !!slots.body);
const hasDefault = computed(() => !!slots.default);

View File

@ -377,6 +377,7 @@ export default defineComponent({
// fix position when the component is created and opened at the same time
if (isOpen.value) {
setTimeout(() => {
deactivate();
adjustListPosition();
}, 50);
}

View File

@ -2,7 +2,7 @@
<Teleport to="#window-content">
<div class="modal active modal-sm">
<a class="modal-overlay" />
<div class="modal-container p-0">
<div ref="trapRef" class="modal-container p-0">
<div class="modal-header pl-2">
<div class="modal-title h6">
<div class="d-flex">
@ -57,6 +57,9 @@
<script setup lang="ts">
import { Ref, ref } from 'vue';
import { useFocusTrap } from '@/composables/useFocusTrap';
const { trapRef } = useFocusTrap();
const credentials = ref({
user: '',

View File

@ -2,7 +2,7 @@
<Teleport to="#window-content">
<div class="modal active">
<a class="modal-overlay" @click.stop="closeModal" />
<div class="modal-container p-0">
<div ref="trapRef" class="modal-container p-0">
<div class="modal-header pl-2">
<div class="modal-title h6">
<div class="d-flex">
@ -67,6 +67,7 @@ import { computed, onBeforeUnmount, Ref, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useNotificationsStore } from '@/stores/notifications';
import { useWorkspacesStore } from '@/stores/workspaces';
import { useFocusTrap } from '@/composables/useFocusTrap';
import Schema from '@/ipc-api/Schema';
import BaseSelect from '@/components/BaseSelect.vue';
@ -83,6 +84,8 @@ const { getSelected: selectedWorkspace } = storeToRefs(workspacesStore);
const { getWorkspace, getDatabaseVariable } = workspacesStore;
const { trapRef } = useFocusTrap();
const firstInput: Ref<HTMLInputElement> = ref(null);
const database = ref({
name: '',

View File

@ -2,7 +2,7 @@
<Teleport to="#window-content">
<div class="modal active">
<a class="modal-overlay" @click.stop="closeModal" />
<div class="modal-container p-0">
<div ref="trapRef" class="modal-container p-0">
<div class="modal-header pl-2">
<div class="modal-title h6">
<div class="d-flex">
@ -270,9 +270,10 @@ import { ipcRenderer } from 'electron';
import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n';
import { SchemaInfos } from 'common/interfaces/antares';
import { ExportState, TableParams } from 'common/interfaces/exporter';
import { ExportOptions, ExportState, TableParams } from 'common/interfaces/exporter';
import { useNotificationsStore } from '@/stores/notifications';
import { useWorkspacesStore } from '@/stores/workspaces';
import { useFocusTrap } from '@/composables/useFocusTrap';
import Application from '@/ipc-api/Application';
import Schema from '@/ipc-api/Schema';
import { Customizations } from 'common/interfaces/customizations';
@ -290,6 +291,8 @@ const workspacesStore = useWorkspacesStore();
const { getSelected: selectedWorkspace } = storeToRefs(workspacesStore);
const { trapRef } = useFocusTrap();
const {
getWorkspace,
refreshSchema
@ -300,11 +303,13 @@ const isRefreshing = ref(false);
const progressPercentage = ref(0);
const progressStatus = ref('');
const tables: Ref<TableParams[]> = ref([]);
const options = ref({
const options: Ref<ExportOptions> = ref({
schema: props.selectedSchema,
includes: {} as {[key: string]: boolean},
outputFormat: 'sql',
outputFile: '',
outputFormat: 'sql' as 'sql' | 'sql.zip',
sqlInsertAfter: 250,
sqlInsertDivider: 'bytes'
sqlInsertDivider: 'bytes' as 'bytes' | 'rows'
});
const basePath = ref('');

View File

@ -2,7 +2,7 @@
<Teleport to="#window-content">
<div class="modal active">
<a class="modal-overlay" @click.stop="closeModal" />
<div class="modal-container p-0">
<div ref="trapRef" class="modal-container p-0">
<div class="modal-header pl-2">
<div class="modal-title h6">
<div class="d-flex">
@ -105,6 +105,7 @@ import { storeToRefs } from 'pinia';
import { TEXT, LONG_TEXT, NUMBER, FLOAT, DATE, TIME, DATETIME, BLOB, BIT } from 'common/fieldTypes';
import { useNotificationsStore } from '@/stores/notifications';
import { useWorkspacesStore } from '@/stores/workspaces';
import { useFocusTrap } from '@/composables/useFocusTrap';
import Tables from '@/ipc-api/Tables';
import FakerSelect from '@/components/FakerSelect.vue';
import BaseSelect from '@/components/BaseSelect.vue';
@ -124,6 +125,8 @@ const { getSelected: selectedWorkspace } = storeToRefs(workspacesStore);
const { getWorkspace } = workspacesStore;
const { trapRef } = useFocusTrap();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const localRow: Ref<{[key: string]: any}> = ref({});
const fieldsToExclude = ref([]);

View File

@ -2,7 +2,7 @@
<Teleport to="#window-content">
<div class="modal active">
<a class="modal-overlay" @click.stop="closeModal" />
<div class="modal-container p-0 pb-4">
<div ref="trapRef" class="modal-container p-0 pb-4">
<div class="modal-header pl-2">
<div class="modal-title h6">
<div class="d-flex">
@ -104,6 +104,7 @@ import * as moment from 'moment';
import { ConnectionParams } from 'common/interfaces/antares';
import { HistoryRecord, useHistoryStore } from '@/stores/history';
import { useConnectionsStore } from '@/stores/connections';
import { useFocusTrap } from '@/composables/useFocusTrap';
import BaseVirtualScroll from '@/components/BaseVirtualScroll.vue';
const { t } = useI18n();
@ -111,6 +112,8 @@ const { t } = useI18n();
const { getHistoryByWorkspace, deleteQueryFromHistory } = useHistoryStore();
const { getConnectionName } = useConnectionsStore();
const { trapRef } = useFocusTrap();
const props = defineProps({
connection: Object as Prop<ConnectionParams>
});

View File

@ -2,7 +2,7 @@
<Teleport to="#window-content">
<div class="modal active">
<a class="modal-overlay" @click.stop="closeModal" />
<div class="modal-container p-0">
<div ref="trapRef" class="modal-container p-0">
<div class="modal-header pl-2">
<div class="modal-title h6">
<div class="d-flex">
@ -70,6 +70,7 @@ import { computed, onBeforeUnmount, Ref, ref } from 'vue';
import { storeToRefs } from 'pinia';
import { useNotificationsStore } from '@/stores/notifications';
import { useWorkspacesStore } from '@/stores/workspaces';
import { useFocusTrap } from '@/composables/useFocusTrap';
import Schema from '@/ipc-api/Schema';
import BaseSelect from '@/components/BaseSelect.vue';
@ -80,6 +81,8 @@ const { getSelected: selectedWorkspace } = storeToRefs(workspacesStore);
const { getWorkspace, getDatabaseVariable } = workspacesStore;
const { trapRef } = useFocusTrap();
const emit = defineEmits(['reload', 'close']);
const firstInput: Ref<HTMLInputElement> = ref(null);

View File

@ -12,7 +12,7 @@
@close-context="closeContext"
/>
<a class="modal-overlay" @click.stop="closeModal" />
<div class="modal-container p-0 pb-4">
<div ref="trapRef" class="modal-container p-0 pb-4">
<div class="modal-header pl-2">
<div class="modal-title h6">
<div class="d-flex">
@ -138,6 +138,7 @@ import { Component, computed, onBeforeUnmount, onMounted, onUpdated, Prop, Ref,
import { ConnectionParams } from 'common/interfaces/antares';
import { arrayToFile } from '../libs/arrayToFile';
import { useNotificationsStore } from '@/stores/notifications';
import { useFocusTrap } from '@/composables/useFocusTrap';
import Schema from '@/ipc-api/Schema';
import { useConnectionsStore } from '@/stores/connections';
import BaseVirtualScroll from '@/components/BaseVirtualScroll.vue';
@ -147,6 +148,8 @@ import ModalProcessesListContext from '@/components/ModalProcessesListContext.vu
const { addNotification } = useNotificationsStore();
const { getConnectionName } = useConnectionsStore();
const { trapRef } = useFocusTrap();
const props = defineProps({
connection: Object as Prop<ConnectionParams>
});

View File

@ -2,7 +2,7 @@
<Teleport to="#window-content">
<div id="settings" class="modal active">
<a class="modal-overlay c-hand" @click="closeModal" />
<div class="modal-container">
<div ref="trapRef" class="modal-container">
<div class="modal-header pl-2">
<div class="modal-title h6">
<div class="d-flex">
@ -309,6 +309,7 @@ import { useI18n } from 'vue-i18n';
import { useApplicationStore } from '@/stores/application';
import { useSettingsStore } from '@/stores/settings';
import { useWorkspacesStore } from '@/stores/workspaces';
import { useFocusTrap } from '@/composables/useFocusTrap';
import { localesNames } from '@/i18n/supported-locales';
import ModalSettingsUpdate from '@/components/ModalSettingsUpdate.vue';
import ModalSettingsChangelog from '@/components/ModalSettingsChangelog.vue';
@ -322,6 +323,8 @@ const applicationStore = useApplicationStore();
const settingsStore = useSettingsStore();
const workspacesStore = useWorkspacesStore();
const { trapRef } = useFocusTrap({ disableAutofocus: true });
const {
selectedSettingTab,
updateStatus

View File

@ -5,6 +5,7 @@
tabindex="0"
:style="{'height': resultsSize+'px'}"
@blur="deselectRows"
@focus="hasFocus = true"
@keyup.delete="showDeleteConfirmModal"
@keydown.esc="deselectRows"
>
@ -152,6 +153,7 @@ const resultsSize = ref(0);
const localResults: Ref<QueryResult<any>[]> = ref([]);
const isContext = ref(false);
const isDeleteConfirmModal = ref(false);
const hasFocus = ref(false);
const contextEvent = ref(null);
const selectedCell = ref(null);
const selectedRows = ref([]);
@ -455,6 +457,7 @@ const deselectRows = () => {
if (!isEditingRow.value) {
selectedRows.value = [];
selectedField.value = null;
hasFocus.value = false;
}
};
@ -519,6 +522,9 @@ const onKey = async (e: KeyboardEvent) => {
if (!props.isSelected)
return;
if (!hasFocus.value)
return;
if (isEditingRow.value)
return;
@ -626,7 +632,13 @@ const scrollToCell = (el: HTMLElement) => {
scrollElement.value.scrollLeft = el.offsetLeft - scrollElement.value.clientWidth + el.clientWidth;
};
defineExpose({ applyUpdate, refreshScroller, resetSort, resizeResults, downloadTable });
defineExpose({
applyUpdate,
refreshScroller,
resetSort,
resizeResults,
downloadTable
});
watch(() => props.results, () => {
setLocalResults();

View File

@ -0,0 +1,81 @@
import { customRef, ref } from 'vue';
const focusableElementsSelector =
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const useFocusTrap = (args?: {disableAutofocus?: boolean}) => {
let localArgs = {
disableAutofocus: false
};
if (args) {
localArgs = {
...localArgs,
...args
};
}
let focusableElements: NodeListOf<HTMLInputElement>;
let $firstFocusable: HTMLElement;
let $lastFocusable: HTMLElement;
const isInitiated = ref(false);
const trapRef = customRef((track, trigger) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let $trapEl: any = null;
return {
get () {
track();
return $trapEl;
},
set (value) {
$trapEl = value;
value ? initFocusTrap() : clearFocusTrap();
trigger();
}
};
});
function keyHandler (e: KeyboardEvent) {
const isTabPressed = e.key === 'Tab';
if (!isTabPressed) return;
if (e.shiftKey) {
if (document.activeElement === $firstFocusable) {
$lastFocusable.focus();
e.preventDefault();
}
}
else {
if (document.activeElement === $lastFocusable) {
$firstFocusable.focus();
e.preventDefault();
}
}
}
function initFocusTrap () {
if (!trapRef.value || isInitiated.value) return;
focusableElements = (trapRef.value as HTMLElement).querySelectorAll(
focusableElementsSelector
);
$firstFocusable = focusableElements[0];
$lastFocusable = focusableElements[focusableElements.length - 1];
document.addEventListener('keydown', keyHandler);
isInitiated.value = true;
if (!localArgs.disableAutofocus) $firstFocusable.focus();
}
function clearFocusTrap () {
document.removeEventListener('keydown', keyHandler);
}
return {
trapRef,
initFocusTrap,
clearFocusTrap
};
};
export { useFocusTrap };

View File

@ -1,6 +1,6 @@
import { ipcRenderer } from 'electron';
import { unproxify } from '../libs/unproxify';
import { IpcResponse/*, EventInfos, QueryResult, RoutineInfos, TableInfos, TriggerInfos */ } from 'common/interfaces/antares';
import { ClientCode, IpcResponse/*, EventInfos, QueryResult, RoutineInfos, TableInfos, TriggerInfos */ } from 'common/interfaces/antares';
import { ExportOptions } from 'common/interfaces/exporter';
import { ImportOptions } from 'common/interfaces/importer';
@ -110,7 +110,7 @@ export default class {
return ipcRenderer.invoke('raw-query', unproxify(params));
}
static export (params: { uid: string; type: string; tables: string; options: ExportOptions }): Promise<IpcResponse> {
static export (params: ExportOptions & {uid: string; type: ClientCode}): Promise<IpcResponse> {
return ipcRenderer.invoke('export', unproxify(params));
}