diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml
index a8ce1273fb..f5f63b647a 100644
--- a/.buildkite/pipeline.yaml
+++ b/.buildkite/pipeline.yaml
@@ -76,6 +76,11 @@ steps:
- docker#v3.0.1:
image: "matrixdotorg/riotweb-ci-e2etests-env:latest"
propagate-environment: true
+ workdir: "/workdir/matrix-react-sdk"
+ retry:
+ automatic:
+ - exit_status: 1 # retry end-to-end tests once as Puppeteer sometimes fails
+ limit: 1
- label: "🔧 Riot Tests"
agents:
@@ -83,27 +88,16 @@ steps:
# webpack loves to gorge itself on resources.
queue: "medium"
command:
- # Install chrome
- - "echo '--- Installing Chrome'"
- - "wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -"
- - "sh -c 'echo \"deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main\" >> /etc/apt/sources.list.d/google.list'"
- - "apt-get update"
- - "apt-get install -y google-chrome-stable"
# TODO: Remove hacky chmod for BuildKite
- "chmod +x ./scripts/ci/*.sh"
- "chmod +x ./scripts/*"
- - "echo '--- Installing Dependencies'"
- - "./scripts/ci/install-deps.sh"
- - "echo '--- Running initial build steps'"
- - "yarn build"
- "echo '+++ Running Tests'"
- "./scripts/ci/riot-unit-tests.sh"
- env:
- CHROME_BIN: "/usr/bin/google-chrome-stable"
plugins:
- docker#v3.0.1:
image: "node:10"
propagate-environment: true
+ workdir: "/workdir/matrix-react-sdk"
- label: "🌐 i18n"
command:
diff --git a/package.json b/package.json
index aa2cf8bf8b..8f49eed4b5 100644
--- a/package.json
+++ b/package.json
@@ -31,7 +31,7 @@
"typings": "./lib/index.d.ts",
"matrix_src_main": "./src/index.js",
"scripts": {
- "prepublish": "yarn build",
+ "prepare": "yarn build",
"i18n": "matrix-gen-i18n",
"prunei18n": "matrix-prune-i18n",
"diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json",
diff --git a/res/css/structures/_GroupView.scss b/res/css/structures/_GroupView.scss
index 4ec53a3c9a..517b8b1922 100644
--- a/res/css/structures/_GroupView.scss
+++ b/res/css/structures/_GroupView.scss
@@ -63,7 +63,7 @@ limitations under the License.
}
.mx_GroupHeader_editButton::before {
- mask-image: url('$(res)/img/icons-settings-room.svg');
+ mask-image: url('$(res)/img/feather-customised/settings.svg');
}
.mx_GroupHeader_shareButton::before {
diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss
index 5634a97c53..5b5c49f357 100644
--- a/res/css/structures/_ToastContainer.scss
+++ b/res/css/structures/_ToastContainer.scss
@@ -51,8 +51,8 @@ limitations under the License.
&.mx_Toast_hasIcon {
&::after {
content: "";
- width: 21px;
- height: 20px;
+ width: 22px;
+ height: 22px;
grid-column: 1;
grid-row: 1;
mask-size: 100%;
diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss
index fbac1e932a..d292c729dd 100644
--- a/res/css/views/rooms/_EventTile.scss
+++ b/res/css/views/rooms/_EventTile.scss
@@ -367,6 +367,11 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
opacity: 1;
}
+.mx_EventTile_e2eIcon_unknown {
+ background-image: url('$(res)/img/e2e/warning.svg');
+ opacity: 1;
+}
+
.mx_EventTile_e2eIcon_unencrypted {
background-image: url('$(res)/img/e2e/warning.svg');
opacity: 1;
@@ -415,7 +420,8 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
}
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line,
-.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line {
+.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line,
+.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line {
padding-left: 60px;
}
@@ -427,8 +433,13 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
border-left: $e2e-unverified-color 5px solid;
}
+.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line {
+ border-left: $e2e-unknown-color 5px solid;
+}
+
.mx_EventTile:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line,
-.mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line {
+.mx_EventTile:hover.mx_EventTile_unverified.mx_EventTile_info .mx_EventTile_line,
+.mx_EventTile:hover.mx_EventTile_unknown.mx_EventTile_info .mx_EventTile_line {
padding-left: 78px;
}
@@ -439,14 +450,16 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody {
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp,
-.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp {
+.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp,
+.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp {
left: 3px;
width: auto;
}
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > .mx_EventTile_e2eIcon,
-.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon {
+.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > .mx_EventTile_e2eIcon,
+.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > .mx_EventTile_e2eIcon {
display: block;
left: 41px;
}
diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss
index 45b9733faa..0d92247735 100644
--- a/res/css/views/rooms/_RoomHeader.scss
+++ b/res/css/views/rooms/_RoomHeader.scss
@@ -19,7 +19,10 @@ limitations under the License.
border-bottom: 1px solid $primary-hairline-color;
.mx_E2EIcon {
- margin: 0 5px;
+ margin: 0;
+ position: absolute;
+ bottom: 0;
+ right: -5px;
}
}
@@ -171,6 +174,7 @@ limitations under the License.
width: 28px;
height: 28px;
margin: 0 7px;
+ position: relative;
}
.mx_RoomHeader_avatar .mx_BaseAvatar_image {
diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss
index cb1137bb2f..db2c09f6f1 100644
--- a/res/css/views/rooms/_RoomTile.scss
+++ b/res/css/views/rooms/_RoomTile.scss
@@ -142,10 +142,11 @@ limitations under the License.
}
}
-// toggle menuButton and badge on hover/menu displayed
+// toggle menuButton and badge on menu displayed
.mx_RoomTile_menuDisplayed,
// or on keyboard focus of room tile
-.mx_RoomTile.focus-visible:focus-within,
+.mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:focus-within,
+// or on pointer hover
.mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:hover {
.mx_RoomTile_menuButton {
display: block;
diff --git a/res/img/icons-settings-room.svg b/res/img/icons-settings-room.svg
deleted file mode 100644
index 421eefdefa..0000000000
--- a/res/img/icons-settings-room.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index 288fb3cadc..c868c81549 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -224,6 +224,7 @@ $copy-button-url: "$(res)/img/icon_copy_message.svg";
// e2e
$e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color
+$e2e-unknown-color: #e8bf37;
$e2e-unverified-color: #e8bf37;
$e2e-warning-color: #ba6363;
diff --git a/scripts/ci/build.sh b/scripts/ci/build.sh
deleted file mode 100755
index 0b1fa23093..0000000000
--- a/scripts/ci/build.sh
+++ /dev/null
@@ -1,25 +0,0 @@
-#!/bin/bash
-#
-# script which is run by the CI build (after `yarn test`).
-#
-# clones riot-web develop and runs the tests against our version of react-sdk.
-
-set -ev
-
-RIOT_WEB_DIR=riot-web
-REACT_SDK_DIR=`pwd`
-
-yarn link
-
-scripts/fetchdep.sh vector-im riot-web
-
-pushd "$RIOT_WEB_DIR"
-
-yarn link matrix-js-sdk
-yarn link matrix-react-sdk
-
-yarn install
-
-yarn build
-
-popd
diff --git a/scripts/ci/end-to-end-tests.sh b/scripts/ci/end-to-end-tests.sh
index a592888292..9bdb512940 100755
--- a/scripts/ci/end-to-end-tests.sh
+++ b/scripts/ci/end-to-end-tests.sh
@@ -21,15 +21,16 @@ handle_error() {
trap 'handle_error' ERR
-RIOT_WEB_DIR=riot-web
-REACT_SDK_DIR=`pwd`
-
echo "--- Building Riot"
-scripts/ci/build.sh
+scripts/ci/layered-riot-web.sh
+cd ../riot-web
+riot_web_dir=`pwd`
+CI_PACKAGE=true yarn build
+cd ../matrix-react-sdk
# run end to end tests
pushd test/end-to-end-tests
-ln -s $REACT_SDK_DIR/$RIOT_WEB_DIR riot/riot-web
+ln -s $riot_web_dir riot/riot-web
# PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh
# CHROME_PATH=$(which google-chrome-stable) ./run.sh
echo "--- Install synapse & other dependencies"
diff --git a/scripts/ci/layered-riot-web.sh b/scripts/ci/layered-riot-web.sh
new file mode 100644
index 0000000000..f58794b451
--- /dev/null
+++ b/scripts/ci/layered-riot-web.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+
+# Creates an environment similar to one that riot-web would expect for
+# development. This means going one directory up (and assuming we're in
+# a directory like /workdir/matrix-react-sdk) and putting riot-web and
+# the js-sdk there.
+
+cd ../ # Assume we're at something like /workdir/matrix-react-sdk
+
+# Set up the js-sdk first
+matrix-react-sdk/scripts/fetchdep.sh matrix-org matrix-js-sdk
+pushd matrix-js-sdk
+yarn link
+yarn install
+popd
+
+# Now set up the react-sdk
+pushd matrix-react-sdk
+yarn link matrix-js-sdk
+yarn link
+yarn install
+popd
+
+# Finally, set up riot-web
+matrix-react-sdk/scripts/fetchdep.sh vector-im riot-web
+pushd riot-web
+yarn link matrix-js-sdk
+yarn link matrix-react-sdk
+yarn install
+yarn build:res
+popd
diff --git a/scripts/ci/riot-unit-tests.sh b/scripts/ci/riot-unit-tests.sh
index 215af13030..337c0fe6c3 100755
--- a/scripts/ci/riot-unit-tests.sh
+++ b/scripts/ci/riot-unit-tests.sh
@@ -6,9 +6,7 @@
set -ev
-RIOT_WEB_DIR=riot-web
-
-scripts/ci/build.sh
-pushd "$RIOT_WEB_DIR"
+scripts/ci/layered-riot-web.sh
+cd ../riot-web
+yarn build:genfiles # so the tests can run. Faster version of `build`
yarn test
-popd
diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh
index f82752bfc5..0142305797 100755
--- a/scripts/fetchdep.sh
+++ b/scripts/fetchdep.sh
@@ -17,7 +17,7 @@ clone() {
if [ -n "$branch" ]
then
echo "Trying to use $org/$repo#$branch"
- git clone git://github.com/$org/$repo.git $repo --branch "$branch" && exit 0
+ git clone git://github.com/$org/$repo.git $repo --branch "$branch" --depth 1 && exit 0
fi
}
diff --git a/src/DeviceListener.js b/src/DeviceListener.js
index 9ae6a62ab1..a4c5785db4 100644
--- a/src/DeviceListener.js
+++ b/src/DeviceListener.js
@@ -75,7 +75,7 @@ export default class DeviceListener {
if (device.deviceId == cli.deviceId) continue;
const deviceTrust = await cli.checkDeviceTrust(cli.getUserId(), device.deviceId);
- if (deviceTrust.isVerified() || this._dismissed.has(device.deviceId)) {
+ if (deviceTrust.isCrossSigningVerified() || this._dismissed.has(device.deviceId)) {
ToastStore.sharedInstance().dismissToast(toastKey(device));
} else {
ToastStore.sharedInstance().addOrReplaceToast({
diff --git a/src/Markdown.js b/src/Markdown.js
index acfea52100..437ceec88b 100644
--- a/src/Markdown.js
+++ b/src/Markdown.js
@@ -91,7 +91,7 @@ export default class Markdown {
return true;
}
- toHTML() {
+ toHTML({ externalLinks = false } = {}) {
const renderer = new commonmark.HtmlRenderer({
safe: false,
@@ -125,6 +125,24 @@ export default class Markdown {
}
};
+ renderer.link = function(node, entering) {
+ const attrs = this.attrs(node);
+ if (entering) {
+ attrs.push(['href', this.esc(node.destination)]);
+ if (node.title) {
+ attrs.push(['title', this.esc(node.title)]);
+ }
+ // Modified link behaviour to treat them all as external and
+ // thus opening in a new tab.
+ if (externalLinks) {
+ attrs.push(['target', '_blank']);
+ attrs.push(['rel', 'noopener']);
+ }
+ this.tag('a', attrs);
+ } else {
+ this.tag('/a');
+ }
+ };
renderer.html_inline = html_if_tag_allowed;
diff --git a/src/SlashCommands.js b/src/SlashCommands.js
index 20b8ba76da..2eb34576ac 100644
--- a/src/SlashCommands.js
+++ b/src/SlashCommands.js
@@ -81,6 +81,8 @@ class Command {
}
run(roomId, args) {
+ // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
+ if (!this.runFn) return;
return this.runFn.bind(this)(roomId, args);
}
@@ -905,25 +907,25 @@ const aliases = {
/**
- * Process the given text for /commands and perform them.
+ * Process the given text for /commands and return a bound method to perform them.
* @param {string} roomId The room in which the command was performed.
* @param {string} input The raw text input by the user.
- * @return {Object|null} An object with the property 'error' if there was an error
+ * @return {null|function(): Object} Function returning an object with the property 'error' if there was an error
* processing the command, or 'promise' if a request was sent out.
* Returns null if the input didn't match a command.
*/
-export function processCommandInput(roomId, input) {
+export function getCommand(roomId, input) {
// trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands
input = input.replace(/\s+$/, '');
if (input[0] !== '/') return null; // not a command
- const bits = input.match(/^(\S+?)( +((.|\n)*))?$/);
+ const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/);
let cmd;
let args;
if (bits) {
cmd = bits[1].substring(1).toLowerCase();
- args = bits[3];
+ args = bits[2];
} else {
cmd = input;
}
@@ -932,11 +934,6 @@ export function processCommandInput(roomId, input) {
cmd = aliases[cmd];
}
if (CommandMap[cmd]) {
- // if it has no runFn then its an ignored/nop command (autocomplete only) e.g `/me`
- if (!CommandMap[cmd].runFn) return null;
-
- return CommandMap[cmd].run(roomId, args);
- } else {
- return reject(_t('Unrecognised command:') + ' ' + input);
+ return () => CommandMap[cmd].run(roomId, args);
}
}
diff --git a/src/accessibility/RovingTabIndex.js b/src/accessibility/RovingTabIndex.js
new file mode 100644
index 0000000000..b481f08fe2
--- /dev/null
+++ b/src/accessibility/RovingTabIndex.js
@@ -0,0 +1,224 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, {
+ createContext,
+ useCallback,
+ useContext,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useReducer,
+} from "react";
+import PropTypes from "prop-types";
+import {Key} from "../Keyboard";
+
+/**
+ * Module to simplify implementing the Roving TabIndex accessibility technique
+ *
+ * Wrap the Widget in an RovingTabIndexContextProvider
+ * and then for all buttons make use of useRovingTabIndex or RovingTabIndexWrapper.
+ * The code will keep track of which tabIndex was most recently focused and expose that information as `isActive` which
+ * can then be used to only set the tabIndex to 0 as expected by the roving tabindex technique.
+ * When the active button gets unmounted the closest button will be chosen as expected.
+ * Initially the first button to mount will be given active state.
+ *
+ * https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
+ */
+
+const DOCUMENT_POSITION_PRECEDING = 2;
+
+const RovingTabIndexContext = createContext({
+ state: {
+ activeRef: null,
+ refs: [], // list of refs in DOM order
+ },
+ dispatch: () => {},
+});
+RovingTabIndexContext.displayName = "RovingTabIndexContext";
+
+// TODO use a TypeScript type here
+const types = {
+ REGISTER: "REGISTER",
+ UNREGISTER: "UNREGISTER",
+ SET_FOCUS: "SET_FOCUS",
+};
+
+const reducer = (state, action) => {
+ switch (action.type) {
+ case types.REGISTER: {
+ if (state.refs.length === 0) {
+ // Our list of refs was empty, set activeRef to this first item
+ return {
+ ...state,
+ activeRef: action.payload.ref,
+ refs: [action.payload.ref],
+ };
+ }
+
+ if (state.refs.includes(action.payload.ref)) {
+ return state; // already in refs, this should not happen
+ }
+
+ // find the index of the first ref which is not preceding this one in DOM order
+ let newIndex = state.refs.findIndex(ref => {
+ return ref.current.compareDocumentPosition(action.payload.ref.current) & DOCUMENT_POSITION_PRECEDING;
+ });
+
+ if (newIndex < 0) {
+ newIndex = state.refs.length; // append to the end
+ }
+
+ // update the refs list
+ return {
+ ...state,
+ refs: [
+ ...state.refs.slice(0, newIndex),
+ action.payload.ref,
+ ...state.refs.slice(newIndex),
+ ],
+ };
+ }
+ case types.UNREGISTER: {
+ // filter out the ref which we are removing
+ const refs = state.refs.filter(r => r !== action.payload.ref);
+
+ if (refs.length === state.refs.length) {
+ return state; // already removed, this should not happen
+ }
+
+ if (state.activeRef === action.payload.ref) {
+ // we just removed the active ref, need to replace it
+ // pick the ref which is now in the index the old ref was in
+ const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
+ return {
+ ...state,
+ activeRef: oldIndex >= refs.length ? refs[refs.length - 1] : refs[oldIndex],
+ refs,
+ };
+ }
+
+ // update the refs list
+ return {
+ ...state,
+ refs,
+ };
+ }
+ case types.SET_FOCUS: {
+ // update active ref
+ return {
+ ...state,
+ activeRef: action.payload.ref,
+ };
+ }
+ default:
+ return state;
+ }
+};
+
+export const RovingTabIndexProvider = ({children, handleHomeEnd, onKeyDown}) => {
+ const [state, dispatch] = useReducer(reducer, {
+ activeRef: null,
+ refs: [],
+ });
+
+ const context = useMemo(() => ({state, dispatch}), [state]);
+
+ const onKeyDownHandler = useCallback((ev) => {
+ let handled = false;
+ if (handleHomeEnd) {
+ // check if we actually have any items
+ switch (ev.key) {
+ case Key.HOME:
+ handled = true;
+ // move focus to first item
+ if (context.state.refs.length > 0) {
+ context.state.refs[0].current.focus();
+ }
+ break;
+ case Key.END:
+ handled = true;
+ // move focus to last item
+ if (context.state.refs.length > 0) {
+ context.state.refs[context.state.refs.length - 1].current.focus();
+ }
+ break;
+ }
+ }
+
+ if (handled) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ } else if (onKeyDown) {
+ return onKeyDown(ev);
+ }
+ }, [context.state, onKeyDown, handleHomeEnd]);
+
+ return
+ { _t("Unrecognised command: %(commandText)s", {commandText}) } +
+
+ { _t("You can use /help
to list available commands. " +
+ "Did you mean to send this as a message?", {}, {
+ code: t => { t }
,
+ }) }
+
+ { _t("Hint: Begin your message with //
to start it with a slash.", {}, {
+ code: t => { t }
,
+ }) }
+
/help
to list available commands. Did you mean to send this as a message?": "You can use /help
to list available commands. Did you mean to send this as a message?",
+ "Hint: Begin your message with //
to start it with a slash.": "Hint: Begin your message with //
to start it with a slash.",
+ "Send as message": "Send as message",
"Failed to connect to integration manager": "Failed to connect to integration manager",
"You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled",
"Add some now": "Add some now",
diff --git a/test/accessibility/RovingTabIndex-test.js b/test/accessibility/RovingTabIndex-test.js
new file mode 100644
index 0000000000..8be4a2976c
--- /dev/null
+++ b/test/accessibility/RovingTabIndex-test.js
@@ -0,0 +1,121 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+import Adapter from "enzyme-adapter-react-16";
+import { configure, mount } from "enzyme";
+
+import {
+ RovingTabIndexProvider,
+ RovingTabIndexWrapper,
+ useRovingTabIndex,
+} from "../../src/accessibility/RovingTabIndex";
+
+configure({ adapter: new Adapter() });
+
+const Button = (props) => {
+ const [onFocus, isActive, ref] = useRovingTabIndex();
+ return ;
+};
+
+const checkTabIndexes = (buttons, expectations) => {
+ expect(buttons.length).toBe(expectations.length);
+ for (let i = 0; i < buttons.length; i++) {
+ expect(buttons.at(i).prop("tabIndex")).toBe(expectations[i]);
+ }
+};
+
+// give the buttons keys for the fibre reconciler to not treat them all as the same
+const button1 = ;
+const button2 = ;
+const button3 = ;
+const button4 = ;
+
+describe("RovingTabIndex", () => {
+ it("RovingTabIndexProvider renders children as expected", () => {
+ const wrapper = mount(