Skip to content

Commit 5befe59

Browse files
Focus prioritized UI in on modal open (#4707)
* wip * wip * retry * switch promise timeout for nexttick * focus considerations for media uploader * remove logs * restore focus to last selected item after prop change * make sure we have modal * split button focus, widget priority button * wip * fix line * lint * remove conditional rendering * flex media manager containers * Fix media manager not handling search results properly (sometimes) * cleanup * changelog fix --------- Co-authored-by: Miro Yovchev <[email protected]>
1 parent 4834b40 commit 5befe59

File tree

20 files changed

+132
-37
lines changed

20 files changed

+132
-37
lines changed

CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44

55
### Adds
66

7+
* Elements inside modals can have a `data-apos-focus-priority` attribute that prioritizes them inside the focusable elements list.
8+
* Modals will continute trying to find focusable elements until an element marked `data-apos-focus-priority` appears or the max retry threshold is reached.
9+
* Takes care of an edge case where Media Manager would duplicate search results.
710
* Modules can now have a `before: "module-name"` property in their configuration to run (initialization) before another module.
811

912
### Fixes
10-
13+
1114
* Modifies the `AposAreaMenu.vue` component to set the `disabled` attribute to `true` if the max number of widgets have been added in an area with `expanded: true`.
1215
* `pnpm: true` option in `app.js` is no longer breaking the application.
1316
* Remove unused `vue-template-compiler` dependency.

modules/@apostrophecms/area/ui/apos/components/AposAreaExpandedMenu.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
<button
2727
v-for="(item, itemIndex) in group.widgets"
2828
:key="itemIndex"
29+
:data-apos-focus-priority="itemIndex === 0 ? true : null"
2930
class="apos-widget"
3031
@click="add(item)"
3132
>
@@ -259,6 +260,7 @@ export default {
259260
& {
260261
padding: 0;
261262
border: none;
263+
border-radius: var(--a-border-radius);
262264
background: none;
263265
text-align: inherit;
264266
}
@@ -294,6 +296,12 @@ export default {
294296
}
295297
}
296298
299+
&:focus,
300+
&:active {
301+
outline: 2px solid var(--a-primary-light-40);
302+
outline-offset: 4px;
303+
}
304+
297305
&:hover {
298306
cursor: pointer;
299307
// stylelint-disable max-nesting-depth

modules/@apostrophecms/command-menu/ui/apos/components/AposCommandMenuShortcut.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
<header class="apos-modal__header">
1313
<div class="apos-modal__header__main">
1414
<AposButton
15+
:attrs="{'data-apos-focus-priority': true}"
1516
type="default"
1617
:title="$t('apostrophe:commandMenuEsc')"
1718
:icon-only="true"

modules/@apostrophecms/doc-type/ui/apos/components/AposDocEditor.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
<AposButton
1313
type="default"
1414
label="apostrophe:cancel"
15+
:attrs="{'data-apos-focus-priority': isPriorityButton('cancel')}"
1516
@click="confirmAndCancel"
1617
/>
1718
</template>
@@ -42,6 +43,7 @@
4243
:label="saveLabel"
4344
:disabled="saveDisabled"
4445
:tooltip="errorTooltip"
46+
:attrs="{'data-apos-focus-priority': isPriorityButton('save')}"
4547
@click="onRestore"
4648
/>
4749
<AposButtonSplit
@@ -51,6 +53,7 @@
5153
:disabled="saveDisabled"
5254
:tooltip="errorTooltip"
5355
:selected="savePreference"
56+
:attrs="{'data-apos-focus-priority': isPriorityButton('splitSave')}"
5457
@click="saveHandler($event)"
5558
/>
5659
</template>
@@ -883,6 +886,12 @@ export default {
883886
}
884887
885888
return body;
889+
},
890+
isPriorityButton(name) {
891+
const priority = this.restoreOnly ? 'save'
892+
: this.saveMenu ? 'splitSave'
893+
: this.saveDisabled ? 'cancel' : null;
894+
return name === priority || null;
886895
}
887896
}
888897
};

modules/@apostrophecms/i18n/ui/apos/components/AposI18nLocalize.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,13 +261,15 @@
261261
<template #footer>
262262
<AposButton
263263
v-if="isLastStep()"
264+
:attrs="{'data-apos-focus-priority': true}"
264265
type="primary"
265266
label="apostrophe:localizeContent"
266267
:disabled="!complete() || wizard.busy"
267268
@click="submit"
268269
/>
269270
<AposButton
270271
v-else
272+
:attrs="{'data-apos-focus-priority': true}"
271273
type="primary"
272274
icon="arrow-right-icon"
273275
:modifiers="['icon-right']"

modules/@apostrophecms/image/ui/apos/components/AposImageRelationshipEditor.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
type="primary"
2020
label="apostrophe:update"
2121
:disabled="docFields.hasErrors"
22+
:attrs="{'data-apos-focus-priority': true}"
2223
@click="submit"
2324
/>
2425
</template>
@@ -94,7 +95,6 @@
9495
<template #main>
9596
<div ref="cropperContainer" class="apos-image-cropper__container">
9697
<AposImageCropper
97-
v-if="containerHeight"
9898
:attachment="item.attachment"
9999
:doc-fields="docFields"
100100
:aspect-ratio="aspectRatio"

modules/@apostrophecms/image/ui/apos/components/AposMediaManager.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
type="primary"
3838
:label="saveRelationshipLabel"
3939
:disabled="!!relationshipErrors"
40+
:attrs="{'data-apos-focus-priority': true}"
4041
@click="saveRelationship"
4142
/>
4243
</template>
@@ -373,6 +374,7 @@ export default {
373374
if (Array.isArray(tagList)) {
374375
this.tagList = tagList;
375376
}
377+
this.items = [];
376378
this.currentPage = currentPage;
377379
this.totalPages = totalPages;
378380
for (const item of items) {
@@ -610,7 +612,8 @@ export default {
610612
}
611613
612614
:deep(.apos-modal__body-inner) {
613-
overflow: hidden;
615+
display: flex;
616+
flex-direction: column;
614617
height: 100%;
615618
}
616619

modules/@apostrophecms/image/ui/apos/components/AposMediaManagerDisplay.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
/>
3636
</div>
3737
<button
38-
:id="`btn-${item._id}`"
38+
:id="`btn-${item._id.replaceAll(':', '-')}`"
3939
:disabled="
4040
item._id === 'placeholder' || canSelect(item._id) === false
4141
"
@@ -168,6 +168,13 @@ export default {
168168
async isLastPage(val) {
169169
await this.$nextTick();
170170
this.$emit('set-load-ref', this.$refs.scrollLoad);
171+
},
172+
async checked(newVal) {
173+
if (newVal.length) {
174+
await this.$nextTick();
175+
const target = newVal[newVal.length - 1];
176+
this.$el.querySelector(`#btn-${target.replaceAll(':', '-')}`).focus();
177+
}
171178
}
172179
},
173180
mounted() {

modules/@apostrophecms/image/ui/apos/components/AposMediaUploader.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<label
33
class="apos-media-manager-display__cell apos-media-uploader"
44
:class="{'apos-media-uploader--enabled': !disabled}"
5-
:disabled="disabled"
5+
:disabled="disabled ? disabled : null"
66
@drop.prevent="uploadMedia"
77
@dragover.prevent=""
88
@dragenter="incrementDragover"
@@ -12,6 +12,7 @@
1212
class="apos-media-uploader__inner"
1313
:class="{'apos-is-dragging': dragover}"
1414
tabindex="0"
15+
data-apos-focus-priority
1516
@keydown="onUploadDragAndDropKeyDown"
1617
>
1718
<AposCloudUploadIcon
@@ -231,6 +232,10 @@ export default {
231232
const isEnterPressed = e.key === 'Enter' || e.code === 'Enter' || e.code === 'NumpadEnter';
232233
const isSpaceBarPressed = e.keyCode === 32 || e.code === 'Space';
233234
235+
if (isSpaceBarPressed) {
236+
e.preventDefault();
237+
}
238+
234239
if (isEnterPressed || isSpaceBarPressed) {
235240
this.create();
236241
}

modules/@apostrophecms/modal/ui/apos/components/AposModal.vue

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
@leave="onLeave"
77
>
88
<section
9-
v-if="modal.active"
9+
v-show="modal.active"
1010
ref="modalEl"
1111
:class="classes"
1212
role="dialog"
@@ -39,7 +39,11 @@
3939
<AposSpinner :weight="'heavy'" class="apos-busy__spinner" />
4040
</div>
4141
</template>
42-
<template v-else>
42+
<div
43+
v-show="!renderingElements && !modal.busy"
44+
class="apos-modal__content"
45+
data-apos-test="modal-content"
46+
>
4347
<header v-if="!modal.disableHeader" class="apos-modal__header">
4448
<div class="apos-modal__header__main">
4549
<div v-if="hasSlot('secondaryControls')" class="apos-modal__controls--secondary">
@@ -72,17 +76,23 @@
7276
<slot class="apos-modal__breadcrumbs" name="breadcrumbs" />
7377
</div>
7478
</header>
75-
<div class="apos-modal__main" :class="gridModifier">
79+
<div
80+
class="apos-modal__main"
81+
:class="gridModifier"
82+
>
7683
<slot name="leftRail" />
7784
<slot name="main" />
7885
<slot name="rightRail" />
7986
</div>
80-
<footer v-if="hasSlot('footer')" class="apos-modal__footer">
87+
<footer
88+
v-if="hasSlot('footer')"
89+
class="apos-modal__footer"
90+
>
8191
<div class="apos-modal__footer__inner">
8292
<slot name="footer" />
8393
</div>
8494
</footer>
85-
</template>
95+
</div>
8696
</div>
8797
</transition>
8898
</section>
@@ -110,8 +120,8 @@ const {
110120
cycleElementsToFocus,
111121
focusElement,
112122
focusLastModalFocusedElement,
113-
isElementVisible,
114-
storeFocusedElement
123+
storeFocusedElement,
124+
findPriorityElementOrFirst
115125
} = useAposFocus();
116126
117127
const props = defineProps({
@@ -134,6 +144,9 @@ const store = useModalStore();
134144
const slots = useSlots();
135145
const emit = defineEmits([ 'inactive', 'esc', 'show-modal', 'no-modal', 'ready' ]);
136146
const modalEl = ref(null);
147+
const findPriorityFocusElementRetryMax = ref(3);
148+
const currentPriorityFocusElementRetry = ref(0);
149+
const renderingElements = ref(true);
137150
const currentLocale = ref(store.activeModal?.locale || apos.i18n.locale);
138151
139152
const transitionType = computed(() => {
@@ -216,6 +229,8 @@ onMounted(async () => {
216229
await nextTick();
217230
if (shouldTrapFocus.value) {
218231
trapFocus();
232+
} else {
233+
renderingElements.value = false;
219234
}
220235
store.updateModalData(props.modalData.id, { modalEl: modalEl.value });
221236
window.addEventListener('keydown', onKeydown);
@@ -249,29 +264,44 @@ function onLeave() {
249264
emit('no-modal');
250265
}
251266
252-
function trapFocus() {
253-
const elementSelectors = [
254-
'[tabindex]',
255-
'[href]',
256-
'input',
257-
'select',
258-
'textarea',
259-
'button'
260-
];
261-
262-
const selector = elementSelectors
263-
.map(addExcludingAttributes)
264-
.join(', ');
265-
266-
const elementsToFocus = [ ...modalEl.value.querySelectorAll(selector) ]
267-
.filter(isElementVisible);
268-
269-
store.updateModalData(props.modalData.id, { elementsToFocus });
270-
271-
focusElement(props.modalData.focusedElement, props.modalData.elementsToFocus[0]);
267+
async function trapFocus() {
268+
if (modalEl?.value) {
269+
const elementSelectors = [
270+
'[tabindex]',
271+
'[href]',
272+
'input',
273+
'select',
274+
'textarea',
275+
'button',
276+
'[data-apos-focus-priority]'
277+
];
278+
279+
const selector = elementSelectors
280+
.map(addExcludingAttributes)
281+
.join(', ');
282+
283+
const elementsToFocus = [ ...modalEl.value.querySelectorAll(selector) ];
284+
285+
store.updateModalData(props.modalData.id, { elementsToFocus });
286+
287+
const firstElementToFocus = findPriorityElementOrFirst(elementsToFocus);
288+
const foundPriorityElement = firstElementToFocus?.hasAttribute('data-apos-focus-priority');
289+
290+
// // Components render at various times and can't be counted on to be available on modal's mount
291+
// // Update the trap focus list until a data-apos-focus-priority element is found or the retry limit is reached
292+
if (!foundPriorityElement && findPriorityFocusElementRetryMax.value > currentPriorityFocusElementRetry.value) {
293+
await new Promise(resolve => setTimeout(resolve, 50));
294+
currentPriorityFocusElementRetry.value++;
295+
trapFocus();
296+
return;
297+
}
298+
renderingElements.value = false;
299+
await nextTick();
300+
focusElement(props.modalData.focusedElement, firstElementToFocus);
301+
}
272302
273303
function addExcludingAttributes(element) {
274-
return `${element}:not([tabindex="-1"]):not([disabled]):not([type="hidden"]):not([aria-hidden])`;
304+
return `${element}:not([tabindex="-1"]):not([disabled]):not([type="hidden"]):not([aria-hidden]):not(.apos-sr-only)`;
275305
}
276306
}
277307
@@ -372,6 +402,12 @@ function close() {
372402
height: 100%;
373403
}
374404
405+
.apos-modal__content {
406+
display: flex;
407+
flex-direction: column;
408+
height: 100%;
409+
}
410+
375411
.apos-modal__main {
376412
display: grid;
377413
flex-grow: 1;

0 commit comments

Comments
 (0)