[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:
Jean-Louis Leysens 2022-08-29 14:15:01 +02:00 committed by GitHub
parent fbd79b8349
commit d5cc164bd8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 321 additions and 3 deletions

View file

@ -31,6 +31,7 @@ const STORYBOOKS = [
'expression_reveal_image',
'expression_shape',
'expression_tagcloud',
'files',
'fleet',
'home',
'infra',

View file

@ -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',

View 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,
},
};

View 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

View file

@ -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>
</>
),
];

View 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}
/>
);
}
);

View 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';

View file

@ -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,
};
}

View file

@ -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);
});
});
});

View file

@ -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));
}

View 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';

View file

@ -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"
}
]
}