[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.  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)
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -202,6 +202,10 @@ $controlMinWidth: $euiSize * 14;
|
|||
box-shadow: 0 0 0 1px $euiColorLightShade;
|
||||
}
|
||||
|
||||
&--twoLine {
|
||||
top: (-$euiSizeXS) !important;
|
||||
}
|
||||
|
||||
&--fatalError {
|
||||
padding: $euiSizeXS;
|
||||
border-radius: $euiBorderRadius;
|
||||
|
|
|
@ -7,6 +7,12 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboardViewportWrapper {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dshUnsavedListingItem {
|
||||
margin-top: $euiSizeM;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
.dashboardTopNav {
|
||||
position: fixed;
|
||||
z-index: $euiZLevel2;
|
||||
background: $euiPageBackgroundColor;
|
||||
width: 100%;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
|
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 71 KiB |
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 69 KiB |
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 67 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 48 KiB |
|
@ -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() {
|
||||
|
|