Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
*/

export { openLazyFlyout } from './src/open_lazy_flyout';
export { openLazySystemFlyout } from './src/open_lazy_system_flyout';
export { getPanelContextMenuTriggerId } from './src/focus_helpers';
export { tracksOverlays, type TracksOverlays } from './src/tracks_overlays';
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ dependsOn:
- '@kbn/i18n'
- '@kbn/react-kibana-mount'
- '@kbn/core-mount-utils-browser'
- '@kbn/core-overlays-browser'
tags:
- shared-browser
- package
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License, v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import type React from 'react';
import { useEffect, useState } from 'react';
import type { CoreStart } from '@kbn/core/public';
import type { OverlayRef } from '@kbn/core-mount-utils-browser';
import { i18n } from '@kbn/i18n';
import { focusFirstFocusable, getPanelContextMenuTriggerId } from './focus_helpers';
import { LoadingFlyout } from './loading_flyout';
import { tracksOverlays } from './tracks_overlays';

export interface LoadContentArgs {
closeFlyout: () => void;
ariaLabelledBy: string;
}

interface LazyFlyoutContentProps extends LoadContentArgs {
core: CoreStart;
flyoutClassName: string;
loadContent: (args: LoadContentArgs) => Promise<JSX.Element | null | void>;
}

interface CreateLazyFlyoutLifecycleParams {
focusedPanelId?: string;
parentApi?: unknown;
returnFocus?: () => void;
}

const resolveAttachedElement = (element: HTMLElement | null): HTMLElement | null => {
if (!element) return null;
if (element.id) {
const refreshedElement = document.getElementById(element.id);
if (refreshedElement) return refreshedElement;
}
return document.body.contains(element) ? element : null;
};

export const createLazyFlyoutLifecycle = ({
focusedPanelId,
parentApi,
returnFocus,
}: CreateLazyFlyoutLifecycleParams) => {
const overlayTracker = tracksOverlays(parentApi) ? parentApi : undefined;
const previouslyFocusedElement =
document.activeElement instanceof HTMLElement && document.activeElement !== document.body
? document.activeElement
: null;
let flyoutRef: OverlayRef | undefined;

const closeFlyout = () => {
overlayTracker?.clearOverlays();
flyoutRef?.close();
window.requestAnimationFrame(() => {
if (returnFocus) {
setTimeout(returnFocus);
return;
}
const focusTarget =
resolveAttachedElement(previouslyFocusedElement) ??
(focusedPanelId
? document.getElementById(getPanelContextMenuTriggerId(focusedPanelId))
: null);
focusFirstFocusable(focusTarget);
});
};

return {
closeFlyout,
overlayTracker,
setFlyoutRef: (ref: OverlayRef) => {
flyoutRef = ref;
overlayTracker?.openOverlay(ref, { focusedPanelId });
},
};
};

export const LazyFlyoutContent = ({
ariaLabelledBy,
closeFlyout,
core,
flyoutClassName,
loadContent,
}: LazyFlyoutContentProps) => {
const [loadedContent, setLoadedContent] = useState<React.JSX.Element | null>(null);

useEffect(() => {
let isMounted = true;

const loadFlyoutContent = async () => {
let content: JSX.Element | null | void;
try {
content = await loadContent({ closeFlyout, ariaLabelledBy });
} catch {
content = null;
}
if (!isMounted) return;
if (content) {
setLoadedContent(content);
return;
}
closeFlyout();
core.notifications.toasts.addWarning(
i18n.translate('presentationUtils.openLazyFlyout.unableToLoad', {
defaultMessage: 'Unable to load edit flyout content',
})
);
};

loadFlyoutContent();
return () => {
isMounted = false;
};
}, [ariaLabelledBy, closeFlyout, core.notifications.toasts, loadContent]);

useEffect(() => {
if (loadedContent) {
focusFirstFocusable(document.querySelector(`.${flyoutClassName}`));
}
}, [flyoutClassName, loadedContent]);

return loadedContent ?? LoadingFlyout;
};
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,14 @@ import React from 'react';
import type { CoreStart, OverlayFlyoutOpenOptions } from '@kbn/core/public';
import { htmlIdGenerator } from '@elastic/eui';
import { toMountPoint } from '@kbn/react-kibana-mount';
import useAsync from 'react-use/lib/useAsync';
import { i18n } from '@kbn/i18n';
import { focusFirstFocusable, getPanelContextMenuTriggerId } from './focus_helpers';
import { LoadingFlyout } from './loading_flyout';
import { tracksOverlays } from './tracks_overlays';
import {
createLazyFlyoutLifecycle,
LazyFlyoutContent,
type LoadContentArgs,
} from './lazy_flyout_common';

const htmlId = htmlIdGenerator('modalTitleId');

interface LoadContentArgs {
closeFlyout: () => void;
ariaLabelledBy: string;
}

interface OpenLazyFlyoutParams {
core: CoreStart;
parentApi?: unknown;
Expand All @@ -33,17 +28,6 @@ interface OpenLazyFlyoutParams {
};
}

// Re-query by id so focus survives a re-render that replaced the node; fall back to the
// node itself while it is still attached.
const resolveAttachedElement = (el: HTMLElement | null): HTMLElement | null => {
if (!el) return null;
if (el.id) {
const refreshed = document.getElementById(el.id);
if (refreshed) return refreshed;
}
return document.body.contains(el) ? el : null;
};

/**
* Opens a flyout panel with lazily loaded content.
*
Expand All @@ -68,52 +52,23 @@ export const openLazyFlyout = (params: OpenLazyFlyoutParams) => {
const { focusedPanelId, ...flyoutProps } = allFlyoutProps ?? {};

const ariaLabelledBy = flyoutProps?.['aria-labelledby'] ?? htmlId();
const overlayTracker = tracksOverlays(parentApi) ? parentApi : undefined;
const { closeFlyout, overlayTracker, setFlyoutRef } = createLazyFlyoutLifecycle({
focusedPanelId,
parentApi,
returnFocus,
});
const panelFlyoutTypeFromParent = overlayTracker?.panelFlyoutType;
const type = flyoutProps?.type ?? panelFlyoutTypeFromParent ?? 'push';
const ownFocus = flyoutProps?.ownFocus ?? panelFlyoutTypeFromParent !== 'overlay';

const previouslyFocusedElement =
document.activeElement instanceof HTMLElement && document.activeElement !== document.body
? document.activeElement
: null;

const resolveReturnFocusTarget = () => {
// Priority: the element that had focus (re-queried by id to survive a re-render) →
// the panel's "..." toggle (for context-menu actions whose menu item is gone by the
// time an async flyout opens).
const byFocusedElement = resolveAttachedElement(previouslyFocusedElement);
if (byFocusedElement) return byFocusedElement;
return focusedPanelId
? document.getElementById(getPanelContextMenuTriggerId(focusedPanelId))
: null;
};

const restoreFocus = () => {
window.requestAnimationFrame(() => {
if (returnFocus) {
setTimeout(returnFocus);
return;
}
focusFirstFocusable(resolveReturnFocusTarget);
});
};

const onClose = () => {
overlayTracker?.clearOverlays();
flyoutRef?.close();
// Resolve lazily: closing can re-render the panel, so the trigger is looked up after
// that render (inside focusFirstFocusable's deferred callback).
restoreFocus();
};

const flyoutRef = core.overlays.openFlyout(
toMountPoint(
<LazyFlyout
closeFlyout={onClose}
<LazyFlyoutContent
closeFlyout={closeFlyout}
loadContent={loadContent}
core={core}
ariaLabelledBy={ariaLabelledBy}
flyoutClassName="kbnPresentationLazyFlyout"
/>,
core
),
Expand All @@ -127,43 +82,10 @@ export const openLazyFlyout = (params: OpenLazyFlyoutParams) => {
outsideClickCloses: true,
className: 'kbnPresentationLazyFlyout',
'aria-labelledby': ariaLabelledBy,
onClose,
onClose: closeFlyout,
...flyoutProps,
}
);
overlayTracker?.openOverlay(flyoutRef, { focusedPanelId });
setFlyoutRef(flyoutRef);
return flyoutRef;
};

function LazyFlyout({
core,
loadContent,
closeFlyout,
ariaLabelledBy,
}: LoadContentArgs & Pick<OpenLazyFlyoutParams, 'core' | 'loadContent'>) {
const [LoadedFlyout, setLoadedFlyout] = React.useState<React.JSX.Element | null>(null);
useAsync(async () => {
const editFlyoutContent = await loadContent?.({ closeFlyout, ariaLabelledBy });
if (editFlyoutContent) {
setLoadedFlyout(editFlyoutContent);
} else {
// If no content is returned, we close the flyout
closeFlyout();
core.notifications.toasts.addWarning(
i18n.translate('presentationUtils.openLazyFlyout.unableToLoad', {
defaultMessage: 'Unable to load edit flyout content',
})
);
throw new Error('Unable to load edit flyout content');
}
}, []);

React.useEffect(() => {
if (!LoadedFlyout) {
return;
}
focusFirstFocusable(document.querySelector('.kbnPresentationLazyFlyout'));
}, [LoadedFlyout]);

return LoadedFlyout ?? LoadingFlyout;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import React from 'react';
import type { CoreStart } from '@kbn/core/public';
import type { OverlayRef } from '@kbn/core-mount-utils-browser';
import { openLazySystemFlyout } from './open_lazy_system_flyout';

const overlayRef = { close: jest.fn() } as unknown as OverlayRef;
const openSystemFlyout = jest.fn(() => overlayRef);
const core = {
overlays: { openSystemFlyout },
application: { currentAppId$: { pipe: () => ({ subscribe: () => undefined }) } },
} as unknown as CoreStart;

describe('openLazySystemFlyout', () => {
beforeEach(() => jest.clearAllMocks());

it('opens a root managed flyout with the presentation defaults', () => {
const ref = openLazySystemFlyout({
core,
loadContent: async () => <div>Content</div>,
flyoutProps: { 'data-test-subj': 'managedEditor' },
});

expect(ref).toBe(overlayRef);
expect(openSystemFlyout).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
className: 'kbnPresentationLazySystemFlyout',
'data-test-subj': 'managedEditor',
isResizable: true,
session: 'start',
size: 500,
})
);
});

it('tracks the managed flyout for compatible parents', () => {
const parentApi = { openOverlay: jest.fn(), clearOverlays: jest.fn() };
openLazySystemFlyout({
core,
parentApi,
loadContent: async () => <div>Content</div>,
flyoutProps: { focusedPanelId: 'panel-1' },
});

expect(parentApi.openOverlay).toHaveBeenCalledWith(overlayRef, {
focusedPanelId: 'panel-1',
});
});
});
Loading
Loading