Skip to content
This repository was archived by the owner on Aug 2, 2024. It is now read-only.

Commit 7bb5391

Browse files
feat: create focus trap mixin and add it to modal (#257)
* feat: create focus trap mixin and add it to modal * feat: remove global focus trap variable
1 parent 8f2b280 commit 7bb5391

File tree

7 files changed

+203
-3
lines changed

7 files changed

+203
-3
lines changed

ui/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,8 @@
4040
"peerDependencies": {
4141
"vue": "^2.6.7",
4242
"fiori-fundamentals": "^1.4.3"
43+
},
44+
"dependencies": {
45+
"focus-trap": "^4.0.2"
4346
}
4447
}

ui/src/components/Modal/Modal.vue

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<ClickAwayContainer
99
@clickOutside="clickOutside"
1010
class="fd-modal"
11+
:tabindex="isActive ? -1 : 0"
1112
:active="isActive"
1213
>
1314
<div class="fd-modal__content" role="document">
@@ -44,17 +45,21 @@
4445
</template>
4546

4647
<script lang="ts">
47-
import Vue from "vue";
4848
// Use these types in order to cast your props. Delete if not needed.
4949
// import { PropValidator } from "vue/types/options";
5050
// import { Prop } from "vue/types/options";
5151
import ClickAwayContainer from "@/components/ClickAwayContainer";
5252
import { Button } from "@/components/Button";
53+
import { FocusTrap, mixins } from "@/mixins";
5354
54-
export default Vue.extend({
55+
export default mixins(FocusTrap).extend({
5556
name: "FdModal",
5657
mounted() {
5758
document.body.appendChild(this.$el);
59+
this.initializeFocusTrap(this.$el, {
60+
onDeactivate: this.close,
61+
initialFocus: ".fd-modal"
62+
});
5863
},
5964
destroyed() {
6065
const el = this.$el;
@@ -65,6 +70,13 @@ export default Vue.extend({
6570
}
6671
}
6772
},
73+
updated() {
74+
if (this.isActive) {
75+
this.activateFocusTrap();
76+
} else {
77+
this.deactivateFocusTrap();
78+
}
79+
},
6880
props: {
6981
active: { type: Boolean, default: false },
7082
title: { type: String, default: null }
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import Vue from "vue";
2+
import { mount, Wrapper, shallowMount } from "@vue/test-utils";
3+
import Modal from "../Modal.vue";
4+
import { FocusTrap } from "@/mixins";
5+
import createFocusTrap, { Options } from "focus-trap";
6+
7+
jest.mock("focus-trap");
8+
interface ModalWrapper extends Vue {
9+
clickOutside(): void;
10+
close(): void;
11+
activateFocusTrap(): void;
12+
deactivateFocusTrap(): void;
13+
initializeFocusTrap(element: Element, options?: Options): void;
14+
}
15+
16+
describe("Modal", () => {
17+
it("renders correctly", () => {
18+
const wrapper = mount(Modal, { mixins: [FocusTrap] });
19+
expect(wrapper.element).toMatchSnapshot();
20+
});
21+
22+
it("should update tabindex when opened", () => {
23+
const wrapper = shallowMount(Modal, {
24+
mixins: [FocusTrap],
25+
propsData: { active: true }
26+
});
27+
28+
expect(wrapper.find(".fd-modal").attributes("tabindex")).toBe("-1");
29+
});
30+
31+
it("should update tabindex when closed", () => {
32+
const wrapper = shallowMount(Modal, {
33+
mixins: [FocusTrap],
34+
propsData: { active: false }
35+
});
36+
37+
expect(wrapper.find(".fd-modal").attributes("tabindex")).toBe("0");
38+
});
39+
40+
it("should initialize focus trap while mounting", () => {
41+
const initializeFocusTrapMock = jest.fn();
42+
43+
const wrapper: Wrapper<ModalWrapper> = shallowMount(Modal, {
44+
mixins: [FocusTrap],
45+
methods: {
46+
initializeFocusTrap: initializeFocusTrapMock
47+
}
48+
}) as Wrapper<ModalWrapper>;
49+
50+
expect(initializeFocusTrapMock).toHaveBeenCalledWith(wrapper.element, {
51+
initialFocus: ".fd-modal",
52+
onDeactivate: wrapper.vm.close
53+
});
54+
});
55+
56+
describe("methods", () => {
57+
describe("initializeFocusTrap", () => {
58+
it("should create trap on given HTML element", () => {
59+
const mockElement = document.createElement("div");
60+
const wrapper = shallowMount(Modal, {
61+
mixins: [FocusTrap]
62+
}) as Wrapper<ModalWrapper>;
63+
64+
wrapper.vm.initializeFocusTrap(mockElement, {});
65+
66+
expect(createFocusTrap).toHaveBeenCalledWith(mockElement, {});
67+
});
68+
});
69+
70+
describe("activateFocusTrap", () => {
71+
it("should activate focus trap", () => {
72+
const focusTrapMock = {
73+
activate: jest.fn(),
74+
deactivate: jest.fn()
75+
};
76+
(<jest.Mock>createFocusTrap).mockReturnValue(focusTrapMock);
77+
const wrapper = shallowMount(Modal, {
78+
mixins: [FocusTrap]
79+
}) as Wrapper<ModalWrapper>;
80+
81+
wrapper.vm.activateFocusTrap();
82+
83+
expect(focusTrapMock.activate).toHaveBeenCalled();
84+
});
85+
});
86+
87+
describe("deactivateFocusTrap", () => {
88+
it("should deactivate focus trap", () => {
89+
const focusTrapMock = {
90+
activate: jest.fn(),
91+
deactivate: jest.fn()
92+
};
93+
(<jest.Mock>createFocusTrap).mockReturnValue(focusTrapMock);
94+
const wrapper = shallowMount(Modal, {
95+
mixins: [FocusTrap]
96+
}) as Wrapper<ModalWrapper>;
97+
98+
wrapper.vm.activateFocusTrap();
99+
100+
expect(focusTrapMock.activate).toHaveBeenCalled();
101+
});
102+
});
103+
});
104+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`Modal renders correctly 1`] = `
4+
<div
5+
aria-hidden="true"
6+
class="fd-ui__overlay fd-overlay fd-overlay--modal"
7+
name="fade"
8+
style="display: none;"
9+
>
10+
<div
11+
class="fd-modal"
12+
tabindex="0"
13+
>
14+
<div
15+
class="fd-modal__content"
16+
role="document"
17+
>
18+
<div
19+
class="fd-modal__header"
20+
>
21+
<h1
22+
class="fd-modal__title"
23+
>
24+
25+
</h1>
26+
27+
<button
28+
aria-label="close"
29+
class="fd-modal__close fd-button--light"
30+
/>
31+
</div>
32+
33+
<div
34+
class="fd-modal__body"
35+
/>
36+
37+
<!---->
38+
</div>
39+
</div>
40+
</div>
41+
`;

ui/src/mixins/FocusTrap.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import Vue from "vue";
2+
import createFocusTrap, { FocusTrap, Options } from "focus-trap";
3+
4+
export default Vue.extend({
5+
data() {
6+
return {
7+
fdFocusTrap: undefined as FocusTrap | undefined
8+
};
9+
},
10+
methods: {
11+
initializeFocusTrap(element: Element, options?: Options): void {
12+
const domNode = element as HTMLElement;
13+
this.fdFocusTrap = createFocusTrap(domNode, options);
14+
},
15+
activateFocusTrap(): void {
16+
if (typeof this.fdFocusTrap === "undefined") return;
17+
18+
this.fdFocusTrap.activate();
19+
},
20+
deactivateFocusTrap(): void {
21+
if (typeof this.fdFocusTrap === "undefined") return;
22+
23+
this.fdFocusTrap.deactivate();
24+
}
25+
}
26+
});

ui/src/mixins/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ export { default as withTargetLocation } from "./withTargetLocation";
66
export { default as Compactable } from "./Compactable";
77
// @ts-ignore
88
export { default as CompactableContainer } from "./CompactableContainer";
9+
export { default as FocusTrap } from "./FocusTrap";

ui/yarn.lock

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4105,6 +4105,14 @@ flush-write-stream@^1.0.0:
41054105
inherits "^2.0.3"
41064106
readable-stream "^2.3.6"
41074107

4108+
focus-trap@^4.0.2:
4109+
version "4.0.2"
4110+
resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-4.0.2.tgz#4ee2b96547c9ea0e4252a2d4b2cca68944194663"
4111+
integrity sha512-HtLjfAK7Hp2qbBtLS6wEznID1mPT+48ZnP2nkHzgjpL4kroYHg0CdqJ5cTXk+UO5znAxF5fRUkhdyfgrhh8Lzw==
4112+
dependencies:
4113+
tabbable "^3.1.2"
4114+
xtend "^4.0.1"
4115+
41084116
follow-redirects@^1.0.0:
41094117
version "1.7.0"
41104118
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.7.0.tgz#489ebc198dc0e7f64167bd23b03c4c19b5784c76"
@@ -8777,6 +8785,11 @@ symbol-tree@^3.2.2:
87778785
resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.2.tgz#ae27db38f660a7ae2e1c3b7d1bc290819b8519e6"
87788786
integrity sha1-rifbOPZgp64uHDt9G8KQgZuFGeY=
87798787

8788+
tabbable@^3.1.2:
8789+
version "3.1.2"
8790+
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-3.1.2.tgz#f2d16cccd01f400e38635c7181adfe0ad965a4a2"
8791+
integrity sha512-wjB6puVXTYO0BSFtCmWQubA/KIn7Xvajw0x0l6eJUudMG/EAiJvIUnyNX6xO4NpGrJ16lbD0eUseB9WxW0vlpQ==
8792+
87808793
87818794
version "4.0.2"
87828795
resolved "https://registry.yarnpkg.com/table/-/table-4.0.2.tgz#a33447375391e766ad34d3486e6e2aedc84d2e36"
@@ -9731,7 +9744,7 @@ [email protected]:
97319744
resolved "https://registry.yarnpkg.com/xregexp/-/xregexp-4.0.0.tgz#e698189de49dd2a18cc5687b05e17c8e43943020"
97329745
integrity sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg==
97339746

9734-
xtend@^4.0.0, xtend@~4.0.1:
9747+
xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.1:
97359748
version "4.0.1"
97369749
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af"
97379750
integrity sha1-pcbVMr5lbiPbgg77lDofBJmNY68=

0 commit comments

Comments
 (0)