mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[Files] Image component (#139497)
* added storybook config * first pass of the image component * use storybook "action" instead of console.log * fix up some comments and fix if-else statement * address type exports * added viewport observer tests * added files plugin to storybook CI and correct JSON formatting * ensure that subscription only happens once * intersectionobserver should always be available * only run useeffect once * use React.forwardRef API rather than props * factor out the viewport observer to a hook Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
fbd79b8349
commit
d5cc164bd8
13 changed files with 321 additions and 3 deletions
|
@ -31,6 +31,7 @@ const STORYBOOKS = [
|
|||
'expression_reveal_image',
|
||||
'expression_shape',
|
||||
'expression_tagcloud',
|
||||
'files',
|
||||
'fleet',
|
||||
'home',
|
||||
'infra',
|
||||
|
|
|
@ -31,6 +31,7 @@ export const storybookAliases = {
|
|||
expression_reveal_image: 'src/plugins/expression_reveal_image/.storybook',
|
||||
expression_shape: 'src/plugins/expression_shape/.storybook',
|
||||
expression_tagcloud: 'src/plugins/chart_expressions/expression_tagcloud/.storybook',
|
||||
files: 'x-pack/plugins/files/.storybook',
|
||||
fleet: 'x-pack/plugins/fleet/.storybook',
|
||||
home: 'src/plugins/home/.storybook',
|
||||
infra: 'x-pack/plugins/infra/.storybook',
|
||||
|
|
16
x-pack/plugins/files/.storybook/main.ts
Normal file
16
x-pack/plugins/files/.storybook/main.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { defaultConfig } from '@kbn/storybook';
|
||||
|
||||
module.exports = {
|
||||
...defaultConfig,
|
||||
stories: ['../**/*.stories.tsx'],
|
||||
reactOptions: {
|
||||
strictMode: true,
|
||||
},
|
||||
};
|
20
x-pack/plugins/files/.storybook/manager.ts
Normal file
20
x-pack/plugins/files/.storybook/manager.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { addons } from '@storybook/addons';
|
||||
import { create } from '@storybook/theming';
|
||||
import { PANEL_ID } from '@storybook/addon-actions';
|
||||
|
||||
addons.setConfig({
|
||||
theme: create({
|
||||
base: 'light',
|
||||
brandTitle: 'Kibana React Storybook',
|
||||
brandUrl: 'https://github.com/elastic/kibana/tree/main/x-pack/plugins/files',
|
||||
}),
|
||||
showPanel: true.valueOf,
|
||||
selectedPanel: PANEL_ID,
|
||||
});
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ComponentStory } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { css } from '@emotion/react';
|
||||
|
||||
import { Image, Props } from './image';
|
||||
import { base64dLogo } from './image.constants.stories';
|
||||
|
||||
const defaultArgs = { alt: 'my alt text', src: `data:image/png;base64,${base64dLogo}` };
|
||||
|
||||
export default {
|
||||
title: 'components/Image',
|
||||
component: Image,
|
||||
args: defaultArgs,
|
||||
};
|
||||
|
||||
const baseStyle = css`
|
||||
width: 400px;
|
||||
`;
|
||||
|
||||
const Template: ComponentStory<typeof Image> = (props: Props) => (
|
||||
<Image css={baseStyle} {...props} ref={action('ref')} />
|
||||
);
|
||||
|
||||
export const Basic = Template.bind({});
|
||||
|
||||
export const BrokenSrc = Template.bind({});
|
||||
BrokenSrc.args = {
|
||||
src: 'broken',
|
||||
};
|
||||
|
||||
export const OffScreen = Template.bind({});
|
||||
OffScreen.args = { ...defaultArgs, onFirstVisible: action('visible') };
|
||||
OffScreen.decorators = [
|
||||
(Story) => (
|
||||
<>
|
||||
<p>Scroll down</p>
|
||||
<div
|
||||
css={css`
|
||||
margin-top: 100vh;
|
||||
`}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
];
|
49
x-pack/plugins/files/public/components/image/image.tsx
Normal file
49
x-pack/plugins/files/public/components/image/image.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import React, { MutableRefObject } from 'react';
|
||||
import type { ImgHTMLAttributes } from 'react';
|
||||
import { useViewportObserver } from './use_viewport_observer';
|
||||
|
||||
export interface Props extends ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
alt: string;
|
||||
/**
|
||||
* Emits when the image first becomes visible
|
||||
*/
|
||||
onFirstVisible?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A viewport-aware component that displays an image. This component is a very
|
||||
* thin wrapper around the img tag.
|
||||
*
|
||||
* @note Intended to be used with files like:
|
||||
*
|
||||
* ```ts
|
||||
* <Image src={file.getDownloadSrc(file)} ... />
|
||||
* ```
|
||||
*/
|
||||
export const Image = React.forwardRef<HTMLImageElement, Props>(
|
||||
({ src, alt, onFirstVisible, ...rest }, ref) => {
|
||||
const { isVisible, ref: observerRef } = useViewportObserver({ onFirstVisible });
|
||||
return (
|
||||
<img
|
||||
{...rest}
|
||||
ref={(element) => {
|
||||
observerRef(element);
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') ref(element);
|
||||
else (ref as MutableRefObject<HTMLImageElement | null>).current = element;
|
||||
}
|
||||
}}
|
||||
// TODO: We should have a lower resolution alternative to display
|
||||
src={isVisible ? src : undefined}
|
||||
alt={alt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
9
x-pack/plugins/files/public/components/image/index.ts
Normal file
9
x-pack/plugins/files/public/components/image/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { Image } from './image';
|
||||
export type { Props as ImageProps } from './image';
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { createViewportObserver } from './viewport_observer';
|
||||
|
||||
interface Args {
|
||||
onFirstVisible?: () => void;
|
||||
}
|
||||
|
||||
export function useViewportObserver({ onFirstVisible }: Args = {}) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [viewportObserver] = useState(() => createViewportObserver());
|
||||
const subscriptionRef = useRef<undefined | Subscription>();
|
||||
const ref = useCallback(
|
||||
(element: null | HTMLElement) => {
|
||||
if (element && !subscriptionRef.current) {
|
||||
subscriptionRef.current = viewportObserver.observeElement(element).subscribe(() => {
|
||||
setIsVisible(true);
|
||||
onFirstVisible?.();
|
||||
});
|
||||
}
|
||||
},
|
||||
[viewportObserver, onFirstVisible]
|
||||
);
|
||||
useEffect(() => () => subscriptionRef.current?.unsubscribe(), []);
|
||||
return {
|
||||
isVisible,
|
||||
ref,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
import { ViewportObserver } from './viewport_observer';
|
||||
|
||||
class MockIntersectionObserver implements IntersectionObserver {
|
||||
constructor(public callback: IntersectionObserverCallback, opts?: IntersectionObserverInit) {}
|
||||
disconnect = jest.fn();
|
||||
root = null;
|
||||
rootMargin = '';
|
||||
takeRecords = jest.fn();
|
||||
thresholds = [];
|
||||
observe = jest.fn();
|
||||
unobserve = jest.fn();
|
||||
}
|
||||
|
||||
describe('ViewportObserver', () => {
|
||||
let viewportObserver: ViewportObserver;
|
||||
let mockObserver: MockIntersectionObserver;
|
||||
function getTestScheduler() {
|
||||
return new TestScheduler((actual, expected) => {
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
}
|
||||
beforeEach(() => {
|
||||
viewportObserver = new ViewportObserver((cb, opts) => {
|
||||
const mo = new MockIntersectionObserver(cb, opts);
|
||||
mockObserver = mo;
|
||||
return mo;
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
test('only observes one element per instance', () => {
|
||||
viewportObserver.observeElement(document.createElement('div'));
|
||||
viewportObserver.observeElement(document.createElement('div'));
|
||||
viewportObserver.observeElement(document.createElement('div'));
|
||||
viewportObserver.observeElement(document.createElement('div'));
|
||||
expect(mockObserver.observe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('emits only once', () => {
|
||||
expect.assertions(2);
|
||||
getTestScheduler().run(({ expectObservable }) => {
|
||||
const observe$ = viewportObserver.observeElement(document.createElement('div'));
|
||||
mockObserver.callback([{ isIntersecting: true } as IntersectionObserverEntry], mockObserver);
|
||||
mockObserver.callback([{ isIntersecting: true } as IntersectionObserverEntry], mockObserver);
|
||||
mockObserver.callback([{ isIntersecting: true } as IntersectionObserverEntry], mockObserver);
|
||||
mockObserver.callback([{ isIntersecting: true } as IntersectionObserverEntry], mockObserver);
|
||||
expectObservable(observe$).toBe('(a|)', { a: undefined });
|
||||
expect(mockObserver.disconnect).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
import { once } from 'lodash';
|
||||
import { Observable, ReplaySubject } from 'rxjs';
|
||||
import { take } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* Check whether an element is visible and emit, only once, when it intersects
|
||||
* with the viewport.
|
||||
*/
|
||||
export class ViewportObserver {
|
||||
private readonly intersectionObserver: IntersectionObserver;
|
||||
private readonly intersection$ = new ReplaySubject<void>(1);
|
||||
|
||||
/**
|
||||
* @param getIntersectionObserver Inject the intersection observer as a dependency.
|
||||
*/
|
||||
constructor(
|
||||
getIntersectionObserver: (
|
||||
cb: IntersectionObserverCallback,
|
||||
opts: IntersectionObserverInit
|
||||
) => IntersectionObserver
|
||||
) {
|
||||
this.intersectionObserver = getIntersectionObserver(this.handleChange, { root: null });
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this function to start observing.
|
||||
*
|
||||
* It is callable once only per instance and will emit only once: when an
|
||||
* element's bounding rect intersects with the viewport.
|
||||
*/
|
||||
public observeElement = once((element: HTMLElement): Observable<void> => {
|
||||
this.intersectionObserver.observe(element);
|
||||
return this.intersection$.pipe(take(1));
|
||||
});
|
||||
|
||||
private handleChange = ([{ isIntersecting }]: IntersectionObserverEntry[]) => {
|
||||
if (isIntersecting) {
|
||||
this.intersection$.next(undefined);
|
||||
this.intersectionObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createViewportObserver(): ViewportObserver {
|
||||
return new ViewportObserver((cb, opts) => new IntersectionObserver(cb, opts));
|
||||
}
|
9
x-pack/plugins/files/public/components/index.ts
Normal file
9
x-pack/plugins/files/public/components/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* 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; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { Image } from './image';
|
||||
export type { ImageProps } from './image';
|
|
@ -6,9 +6,13 @@
|
|||
"declaration": true,
|
||||
"declarationMap": true
|
||||
},
|
||||
"include": ["common/**/*", "public/**/*", "server/**/*"],
|
||||
"include": ["common/**/*", "public/**/*", "server/**/*", ".storybook/**/*"],
|
||||
"references": [
|
||||
{ "path": "../../../src/core/tsconfig.json" },
|
||||
{ "path": "../security/tsconfig.json" },
|
||||
{
|
||||
"path": "../../../src/core/tsconfig.json"
|
||||
},
|
||||
{
|
||||
"path": "../security/tsconfig.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue