[Dashboard] [Controls] Make floating actions keyboard accessible (#152155)

Closes https://github.com/elastic/kibana/issues/135458
Unblocks https://github.com/elastic/kibana/issues/151233

## Summary

This PR adds the floating actions back into the regular HTML tree by
removing the `EuiPortal` that surrounded them, thus making them keyboard
accessible via some added CSS.

![Mar-02-2023
11-54-13](https://user-images.githubusercontent.com/8698078/222524586-8051b8e5-fe1e-48b2-bd83-30a90f9b3417.gif)


In order to do this, however, there were a few changes that had to be
made to the overall Dashboard HTML structure. Previously, as part of
[relocating the Dashboard
scrollbar](https://github.com/elastic/kibana/pull/145628), the
scrollable section of the app was moved to the Dashboard viewport, like
so:



https://user-images.githubusercontent.com/8698078/222511861-8707917c-9edc-4292-a182-58924bb00c8a.mov


<br>

While this had a lot of visual appeal, because of the structure of the
HTML tree, the floating actions had to be moved to an `EuiPortal` as
part of this change so that they would continue to float above the top
navigation bar rather than clipping behind it alongside the other
contents of the viewport - this made it impossible to add native
keyboard accessibility since they were removed from the natural HTML
structure.

Unfortunately, by removing the `EuiPortal` in order to allow for
keyboard accessibility, this meant that the scrollable section could
**no longer** be constrained to the viewport - this is because the
`z-index` of child of a given scrollable `div` is **always relative** to
its parent, which means that the floating actions would clip behind the
top nav bar regardless of how high you set their `z-index`:

<p align="center"><img
src="https://user-images.githubusercontent.com/8698078/222518354-80f1df75-69e5-4433-a256-d0b7dc57cd97.gif"/></p>



Therefore, in order to avoid this clipping, the scrollable section had
to remain at the top of the app, like so:


https://user-images.githubusercontent.com/8698078/222512203-60a88fc5-dd68-47ba-aeab-2425afc60a67.mov


<br>

In order to keep the benefit of the top query bar remaining in place,
the top nav bar was added to a **fixed position** `div` that floats
above the contents of the viewport as the user scrolls - this ensures
that the floating actions, which are now also positioned via a `fixed`
container, can still float above the nav bar while remaining part of the
natural order of the HTML tree.

As a follow up, we should also address
https://github.com/elastic/kibana/issues/152609.

### Checklist

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
This commit is contained in:
Hannah Mudge 2023-03-06 16:31:50 -07:00 committed by GitHub
parent ec6d0f27e3
commit 8fec139c78
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 129 additions and 172 deletions

View file

@ -41,11 +41,13 @@ export const StaticByReferenceExample = ({
</EuiText>
<EuiSpacer size="m" />
<EuiPanel
className="eui-scrollBar"
hasBorder={true}
// By specifying the height of the EuiPanel, we make it so that the dashboard height is
// By specifying the height + overflow of the EuiPanel, we make it so that the dashboard height is
// constrained to the container - so, the dashboard is rendered with a vertical scrollbar
css={css`
height: 600px;
overflow-y: auto;
`}
>
<DashboardContainerRenderer

View file

@ -214,28 +214,25 @@ export const ControlFrame = ({
);
return (
<>
<FloatingActions
className={classNames('controlFrameFloatingActions', {
'controlFrameFloatingActions--twoLine': usingTwoLineLayout,
'controlFrameFloatingActions--oneLine': !usingTwoLineLayout,
})}
usingTwoLineLayout={usingTwoLineLayout}
actions={floatingActions}
isEnabled={embeddable && enableActions}
<FloatingActions
className={classNames({
'controlFrameFloatingActions--twoLine': usingTwoLineLayout,
'controlFrameFloatingActions--oneLine': !usingTwoLineLayout,
})}
actions={floatingActions}
isEnabled={embeddable && enableActions}
>
<EuiFormRow
data-test-subj="control-frame-title"
fullWidth
label={
usingTwoLineLayout
? title || ControlGroupStrings.emptyState.getTwoLineLoadingTitle()
: undefined
}
>
<EuiFormRow
data-test-subj="control-frame-title"
fullWidth
label={
usingTwoLineLayout
? title || ControlGroupStrings.emptyState.getTwoLineLoadingTitle()
: undefined
}
>
{form}
</EuiFormRow>
</FloatingActions>
</>
{form}
</EuiFormRow>
</FloatingActions>
);
};

View file

@ -202,6 +202,10 @@ $controlMinWidth: $euiSize * 14;
box-shadow: 0 0 0 1px $euiColorLightShade;
}
&--twoLine {
top: (-$euiSizeXS) !important;
}
&--fatalError {
padding: $euiSizeXS;
border-radius: $euiBorderRadius;

View file

@ -7,6 +7,12 @@
flex-direction: column;
}
.dashboardViewportWrapper {
display: flex;
flex: 1;
flex-direction: column;
}
.dshUnsavedListingItem {
margin-top: $euiSizeM;
}

View file

@ -14,6 +14,7 @@ import { ViewMode } from '@kbn/embeddable-plugin/public';
import { useExecutionContext } from '@kbn/kibana-react-plugin/public';
import { createKbnUrlStateStorage, withNotifyOnErrors } from '@kbn/kibana-utils-plugin/public';
import { css } from '@emotion/react';
import {
DashboardAppNoDataPage,
isDashboardAppInNoDataState,
@ -53,6 +54,12 @@ export function DashboardApp({
history,
}: DashboardAppProps) {
const [showNoDataPage, setShowNoDataPage] = useState<boolean>(false);
/**
* This state keeps track of the height of the top navigation bar so that padding at the
* top of the viewport can be adjusted dynamically.
*/
const [topNavHeight, setTopNavHeight] = useState(0);
useMount(() => {
(async () => setShowNoDataPage(await isDashboardAppInNoDataState()))();
});
@ -192,16 +199,27 @@ export function DashboardApp({
<>
{DashboardReduxWrapper && (
<DashboardReduxWrapper>
<DashboardTopNav redirectTo={redirectTo} embedSettings={embedSettings} />
<DashboardTopNav
onHeightChange={setTopNavHeight}
redirectTo={redirectTo}
embedSettings={embedSettings}
/>
</DashboardReduxWrapper>
)}
{getLegacyConflictWarning?.()}
<DashboardContainerRenderer
savedObjectId={savedDashboardId}
getCreationOptions={getCreationOptions}
onDashboardContainerLoaded={(container) => setDashboardContainer(container)}
/>
<div
className="dashboardViewportWrapper"
css={css`
padding-top: ${topNavHeight}px;
`}
>
<DashboardContainerRenderer
savedObjectId={savedDashboardId}
getCreationOptions={getCreationOptions}
onDashboardContainerLoaded={(container) => setDashboardContainer(container)}
/>
</div>
</>
)}
</div>

View file

@ -0,0 +1,6 @@
.dashboardTopNav {
position: fixed;
z-index: $euiZLevel2;
background: $euiPageBackgroundColor;
width: 100%;
}

View file

@ -17,7 +17,7 @@ import {
import { ViewMode } from '@kbn/embeddable-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/public';
import { EuiHorizontalRule } from '@elastic/eui';
import { EuiHorizontalRule, useResizeObserver } from '@elastic/eui';
import {
getDashboardTitle,
leaveConfirmStrings,
@ -33,14 +33,20 @@ import { useDashboardMountContext } from '../hooks/dashboard_mount_context';
import { getFullEditPath, LEGACY_DASHBOARD_APP_ID } from '../../dashboard_constants';
import { useDashboardContainerContext } from '../../dashboard_container/dashboard_container_context';
import './_dashboard_top_nav.scss';
export interface DashboardTopNavProps {
embedSettings?: DashboardEmbedSettings;
redirectTo: DashboardRedirect;
onHeightChange: (height: number) => void;
}
const LabsFlyout = withSuspense(LazyLabsFlyout, null);
export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavProps) {
export function DashboardTopNav({
embedSettings,
redirectTo,
onHeightChange,
}: DashboardTopNavProps) {
const [isChromeVisible, setIsChromeVisible] = useState(false);
const [isLabsShown, setIsLabsShown] = useState(false);
@ -116,6 +122,16 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
if (!embedSettings) setChromeVisibility(viewMode !== ViewMode.PRINT);
}, [embedSettings, setChromeVisibility, viewMode]);
/**
* Keep track of the height of the top nav bar as it changes so that the padding at the top of the
* dashboard viewport can be adjusted dynamically as it changes
*/
const resizeRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(resizeRef.current);
useEffect(() => {
onHeightChange(dimensions.height);
}, [dimensions, onHeightChange]);
/**
* populate recently accessed, and set is chrome visible.
*/
@ -214,7 +230,7 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
});
return (
<>
<div ref={resizeRef} className={'dashboardTopNav'}>
<h1
id="dashboardTitle"
className="euiScreenReaderOnly"
@ -267,6 +283,6 @@ export function DashboardTopNav({ embedSettings, redirectTo }: DashboardTopNavPr
) : null}
{viewMode === ViewMode.EDIT ? <DashboardEditingToolbar /> : null}
<EuiHorizontalRule margin="none" />
</>
</div>
);
}

View file

@ -13,8 +13,7 @@ import classNames from 'classnames';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { EuiLoadingElastic, EuiLoadingSpinner, useEuiOverflowScroll } from '@elastic/eui';
import { css } from '@emotion/react';
import { EuiLoadingElastic, EuiLoadingSpinner } from '@elastic/eui';
import {
DashboardContainerFactory,
@ -109,19 +108,13 @@ export const DashboardContainerRenderer = ({
{ 'dashboardViewport--loading': loading }
);
const viewportStyles = css`
${useEuiOverflowScroll('y', false)}
`;
const loadingSpinner = showPlainSpinner ? (
<EuiLoadingSpinner size="xxl" />
) : (
<EuiLoadingElastic size="xxl" />
);
return (
<div className={viewportClasses} css={viewportStyles}>
{loading ? loadingSpinner : <div ref={dashboardRoot} />}
</div>
<div className={viewportClasses}>{loading ? loadingSpinner : <div ref={dashboardRoot} />}</div>
);
};

View file

@ -0,0 +1,23 @@
.presentationUtil__floatingActionsWrapper {
position: relative;
.presentationUtil__floatingActions {
opacity: 0;
visibility: hidden;
// slower transition on hover leave in case the user accidentally stops hover
transition: visibility .3s, opacity .3s;
position: absolute;
right: $euiSizeXS;
top: (-$euiSizeL);
z-index: $euiZLevel9; // the highest possible z-level
}
&:hover, &:focus-within {
.presentationUtil__floatingActions {
opacity: 1;
visibility: visible;
transition: visibility .1s, opacity .1s;
}
}
}

View file

@ -5,138 +5,30 @@
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { FC, ReactElement, useCallback, useLayoutEffect, useRef, useState } from 'react';
import React, { FC, ReactElement } from 'react';
import { EuiPortal, useEuiTheme } from '@elastic/eui';
import { css } from '@emotion/react';
import classNames from 'classnames';
import './floating_actions.scss';
export interface FloatingActionsProps {
className?: string;
actions?: JSX.Element;
children: ReactElement;
isEnabled?: boolean;
usingTwoLineLayout?: boolean;
}
const hiddenActionsStyles = `
visibility: hidden;
opacity: 0;
// slower transition on hover leave in case the user accidentally stops hover
transition: visibility 0.3s, opacity 0.3s;
`;
const visibleActionsStyles = `
transition: visibility 0.1s, opacity 0.1s;
visibility: visible;
opacity: 1;
`;
interface FloatingActionsPosition {
top: number;
left: number;
}
export const FloatingActions: FC<FloatingActionsProps> = ({
className = '',
actions,
isEnabled,
usingTwoLineLayout,
children,
}) => {
const { euiTheme } = useEuiTheme();
const anchorRef = useRef<HTMLSpanElement>(null);
const actionsRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState<FloatingActionsPosition>({
top: 0,
left: 0,
});
const [areFloatingActionsVisible, setFloatingActionsVisible] = useState<boolean>(false);
const updatePosition = useCallback(() => {
if (areFloatingActionsVisible && anchorRef.current && actionsRef.current) {
const anchorBoundingRect = anchorRef.current?.getBoundingClientRect();
const actionsBoundingRect = actionsRef.current?.getBoundingClientRect();
const top =
anchorBoundingRect.top -
(usingTwoLineLayout ? parseInt(euiTheme.size.xs, 10) : parseInt(euiTheme.size.l, 10)) +
window.scrollY;
const left =
anchorBoundingRect.right -
actionsBoundingRect.width -
parseInt(euiTheme.size.xs, 10) +
window.scrollX;
if (position.top !== top || position.left !== left) {
setPosition({ top, left });
}
}
}, [
anchorRef,
actionsRef,
euiTheme.size,
position,
usingTwoLineLayout,
areFloatingActionsVisible,
]);
const showFloatingActions = useCallback(() => {
if (isEnabled && !areFloatingActionsVisible) {
setFloatingActionsVisible(true);
}
}, [isEnabled, areFloatingActionsVisible, setFloatingActionsVisible]);
const hideFloatingActions = useCallback(() => {
if (isEnabled && areFloatingActionsVisible) {
setFloatingActionsVisible(false);
}
}, [areFloatingActionsVisible, setFloatingActionsVisible, isEnabled]);
const floatingActionsStyles = css`
top: ${position.top}px;
left: ${position.left}px;
${areFloatingActionsVisible ? visibleActionsStyles : hiddenActionsStyles}
`;
useLayoutEffect(() => {
if (areFloatingActionsVisible) {
updatePosition();
window.addEventListener('scroll', updatePosition, true);
} else {
window.removeEventListener('scroll', updatePosition, true);
}
return () => window.removeEventListener('scroll', updatePosition, true);
}, [areFloatingActionsVisible, updatePosition]);
return (
<>
<span
className="floatingActions__anchor"
ref={anchorRef}
onMouseOver={showFloatingActions}
onFocus={showFloatingActions}
onMouseLeave={hideFloatingActions}
onBlur={hideFloatingActions}
>
{children}
</span>
<EuiPortal>
<div
ref={actionsRef}
className={className}
css={floatingActionsStyles}
onMouseOver={showFloatingActions}
onFocus={showFloatingActions}
onMouseLeave={hideFloatingActions}
onBlur={hideFloatingActions}
>
{actions}
</div>
</EuiPortal>
</>
<div className="presentationUtil__floatingActionsWrapper">
{children}
{isEnabled && (
<div className={classNames('presentationUtil__floatingActions', className)}>{actions}</div>
)}
</div>
);
};

View file

@ -18,15 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const dashboardAddPanel = getService('dashboardAddPanel');
const dashboardPanelActions = getService('dashboardPanelActions');
const { dashboardControls, dashboard } = getPageObjects([
'dashboardControls',
'timePicker',
'dashboard',
'settings',
'console',
'common',
'header',
]);
const { dashboardControls, dashboard } = getPageObjects(['dashboardControls', 'dashboard']);
describe('Dashboard options list creation and editing', () => {
before(async () => {
@ -137,7 +129,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await dashboardControls.optionsListEnsurePopoverIsClosed(secondId);
const newTitle = 'wow! Animal sounds?';
await testSubjects.click(`control-action-${secondId}-edit`);
await dashboardControls.editExistingControl(secondId);
await dashboardControls.controlEditorSetTitle(newTitle);
await dashboardControls.controlEditorSetWidth('small');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

View file

@ -22,10 +22,14 @@ const SAVE_TO_LIBRARY_TEST_SUBJ = 'embeddablePanelAction-saveToLibrary';
const UNLINK_FROM_LIBRARY_TEST_SUBJ = 'embeddablePanelAction-unlinkFromLibrary';
const CONVERT_TO_LENS_TEST_SUBJ = 'embeddablePanelAction-ACTION_EDIT_IN_LENS';
const DASHBOARD_TOP_OFFSET = 96 + 105; // 96 for Kibana navigation bar + 105 for dashboard top nav bar (in edit mode)
export class DashboardPanelActionsService extends FtrService {
private readonly log = this.ctx.getService('log');
private readonly testSubjects = this.ctx.getService('testSubjects');
private readonly browser = this.ctx.getService('browser');
private readonly inspector = this.ctx.getService('inspector');
private readonly testSubjects = this.ctx.getService('testSubjects');
private readonly header = this.ctx.getPageObject('header');
private readonly common = this.ctx.getPageObject('common');
private readonly dashboard = this.ctx.getPageObject('dashboard');
@ -43,9 +47,14 @@ export class DashboardPanelActionsService extends FtrService {
async toggleContextMenu(parent?: WebElementWrapper) {
this.log.debug(`toggleContextMenu(${parent})`);
await (parent ? parent.moveMouseTo() : this.testSubjects.moveMouseTo('dashboardPanelTitle'));
if (parent) {
await parent.scrollIntoViewIfNecessary(DASHBOARD_TOP_OFFSET);
await this.browser.getActions().move({ x: 0, y: 0, origin: parent._webElement }).perform();
} else {
await this.testSubjects.moveMouseTo('dashboardPanelTitle');
}
const toggleMenuItem = await this.findContextMenu(parent);
await toggleMenuItem.click();
await toggleMenuItem.click(DASHBOARD_TOP_OFFSET);
}
async expectContextMenuToBeOpen() {