chore: refines shimmer, adds to list view and CodeEditor element

This commit is contained in:
Jarrod Flesch
2023-01-18 17:04:31 -05:00
parent 213849fb32
commit 50f74fbeda
15 changed files with 398 additions and 289 deletions

View File

@@ -0,0 +1,45 @@
import React from 'react';
import Editor from '@monaco-editor/react';
import type { Props } from './types';
import { useTheme } from '../../utilities/Theme';
import './index.scss';
import { ShimmerEffect } from '../ShimmerEffect';
const baseClass = 'code-editor';
const CodeEditor: React.FC<Props> = (props) => {
const { readOnly, className, options, ...rest } = props;
const { theme } = useTheme();
const classes = [
baseClass,
className,
rest?.defaultLanguage ? `language--${rest.defaultLanguage}` : '',
].filter(Boolean).join(' ');
return (
<Editor
className={classes}
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
loading={<ShimmerEffect height="35vh" />}
options={
{
detectIndentation: true,
minimap: {
enabled: false,
},
readOnly: Boolean(readOnly),
scrollBeyondLastLine: false,
tabSize: 2,
wordWrap: 'on',
...options,
}
}
{...rest}
/>
);
};
export default CodeEditor;

View File

@@ -1,44 +1,18 @@
import React from 'react';
import Editor from '@monaco-editor/react';
import type { Props } from './types';
import { useTheme } from '../../utilities/Theme';
import React, { Suspense, lazy } from 'react';
import { ShimmerEffect } from '../ShimmerEffect';
import { Props } from './types';
import './index.scss';
const LazyEditor = lazy(() => import('./CodeEditor'));
const baseClass = 'code-editor';
const CodeEditor: React.FC<Props> = (props) => {
const { readOnly, className, options, ...rest } = props;
const { theme } = useTheme();
const classes = [
baseClass,
className,
rest?.defaultLanguage ? `language--${rest.defaultLanguage}` : '',
].filter(Boolean).join(' ');
export const CodeEditor: React.FC<Props> = (props) => {
const { height = '35vh' } = props;
return (
<Editor
height="35vh"
className={classes}
theme={theme === 'dark' ? 'vs-dark' : 'vs'}
options={
{
detectIndentation: true,
minimap: {
enabled: false,
},
readOnly: Boolean(readOnly),
scrollBeyondLastLine: false,
tabSize: 2,
wordWrap: 'on',
...options,
}
}
{...rest}
/>
<Suspense fallback={<ShimmerEffect height={height} />}>
<LazyEditor
{...props}
height={height}
/>
</Suspense>
);
};
export default CodeEditor;

View File

@@ -40,7 +40,7 @@
left: 0;
width: 100%;
height: 100%;
background-color: var(--theme-bg);
background-color: var(--theme-elevation-50);
opacity: .85;
z-index: -1;
}
@@ -54,7 +54,7 @@
&__bar {
width: 2px;
background-color: white;
background-color: var(--theme-elevation-1000);
height: 15px;
&:nth-child(1) {

View File

@@ -19,7 +19,6 @@ export const Loading: React.FC = () => {
);
};
const baseClass = 'fullscreen-loader';
type Props = {
show?: boolean;
@@ -27,6 +26,7 @@ type Props = {
overlayType?: string
}
export const FullscreenLoader: React.FC<Props> = ({ loadingText, show = true, overlayType }) => {
const baseClass = 'fullscreen-loader';
const { t } = useTranslation('general');
return (
@@ -50,6 +50,7 @@ export const FullscreenLoader: React.FC<Props> = ({ loadingText, show = true, ov
);
};
type UseLoadingOverlayToggleT = {
show: boolean;
name: string;

View File

@@ -1,26 +1,28 @@
.shimmer-effect {
position: relative;
overflow: hidden;
background-color: var(--theme-elevation-50);
&__shimmer {
&__shine {
position: absolute;
height: 100%;
scale: 2;
width: 100%;
top: 0;
left: 0;
height: 100%;
transform: translateX(-100%);
background: linear-gradient(130deg,
rgba(0, 0, 0, 0) 0%,
rgba(#1A1A1A, .5) 35%,
rgba(#252525, 1) 50%,
rgba(#1A1A1A, .5) 65%,
rgba(0, 0, 0, 0) 100%);
animation: shimmer 3s infinite;
animation: shimmer 2.5s infinite;
opacity: .75;
background: linear-gradient(100deg,
var(--theme-elevation-50) 0%,
var(--theme-elevation-50) 15%,
var(--theme-elevation-100) 45%,
var(--theme-elevation-100) 55%,
var(--theme-elevation-50) 85%,
var(--theme-elevation-50) 100%)
}
}
@keyframes shimmer {
100% {
transform: translateX(100%);
transform: translate3d(100%, 0, 0);
}
}

View File

@@ -1,12 +1,63 @@
import * as React from 'react';
import { useDelay } from '../../../hooks/useDelay';
import './index.scss';
export const ShimmerEffect: React.FC<{ children?: React.ReactNode }> = ({ children }) => {
type ShimmerEffectT = {
shimmerDelay?: number | string;
height?: number | string;
width?: number | string;
}
export const ShimmerEffect: React.FC<ShimmerEffectT> = ({ shimmerDelay = 0, height = '60px', width = '100%' }) => {
return (
<div className="shimmer-effect">
{children}
<div className="shimmer-effect__shimmer" />
<div
className="shimmer-effect"
style={{
height: typeof height === 'number' ? `${height}px` : height,
width: typeof width === 'number' ? `${width}px` : width,
}}
>
<div
className="shimmer-effect__shine"
style={{
animationDelay: `calc(${typeof shimmerDelay === 'number' ? `${shimmerDelay}ms` : shimmerDelay} + 500ms)`,
}}
/>
</div>
);
};
type StaggeredShimmersT = {
count: number;
shimmerDelay?: number | string;
height?: number | string;
width?: number | string;
className?: string;
shimmerItemClassName?: string;
renderDelay?: number;
}
export const StaggeredShimmers: React.FC<StaggeredShimmersT> = ({ count, className, shimmerItemClassName, width, height, shimmerDelay = 25, renderDelay = 500 }) => {
const shimmerDelayToPass = typeof shimmerDelay === 'number' ? `${shimmerDelay}ms` : shimmerDelay;
const { hasDelayed } = useDelay(renderDelay, true);
if (!hasDelayed) return null;
return (
<div
className={className}
>
{[...Array(count)].map((_, i) => (
<div
key={i}
className={shimmerItemClassName}
>
<ShimmerEffect
shimmerDelay={`calc(${i} * ${shimmerDelayToPass})`}
height={height}
width={width}
/>
</div>
))}
</div>
);
};

View File

@@ -1,97 +0,0 @@
import React, { useCallback } from 'react';
import { code } from '../../../../../fields/validations';
import Error from '../../Error';
import FieldDescription from '../../FieldDescription';
import Label from '../../Label';
import { Props } from './types';
import useField from '../../useField';
import withCondition from '../../withCondition';
import CodeEditor from '../../../elements/CodeEditor';
import './index.scss';
const prismToMonacoLanguageMap = {
js: 'javascript',
ts: 'typescript',
};
const baseClass = 'code-field';
const Code: React.FC<Props> = (props) => {
const {
path: pathFromProps,
name,
required,
validate = code,
admin: {
readOnly,
style,
className,
width,
language,
description,
condition,
editorOptions,
} = {},
label,
} = props;
const path = pathFromProps || name;
const memoizedValidate = useCallback((value, options) => {
return validate(value, { ...options, required });
}, [validate, required]);
const {
value,
showError,
setValue,
errorMessage,
} = useField({
path,
validate: memoizedValidate,
condition,
});
const classes = [
baseClass,
'field-type',
className,
showError && 'error',
readOnly && 'read-only',
].filter(Boolean).join(' ');
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor={`field-${path}`}
label={label}
required={required}
/>
<CodeEditor
options={editorOptions}
defaultLanguage={prismToMonacoLanguageMap[language] || language}
value={value as string || ''}
onChange={readOnly ? () => null : (val) => setValue(val)}
readOnly={readOnly}
/>
<FieldDescription
value={value}
description={description}
/>
</div>
);
};
export default withCondition(Code);

View File

@@ -1,13 +1,98 @@
import React, { Suspense, lazy } from 'react';
import { Loading } from '../../../elements/Loading';
import React, { useCallback } from 'react';
import { code } from '../../../../../fields/validations';
import Error from '../../Error';
import FieldDescription from '../../FieldDescription';
import Label from '../../Label';
import { Props } from './types';
import useField from '../../useField';
import withCondition from '../../withCondition';
import { CodeEditor } from '../../../elements/CodeEditor';
import { ShimmerEffect } from '../../../elements/ShimmerEffect';
const Code = lazy(() => import('./Code'));
import './index.scss';
const CodeField: React.FC<Props> = (props) => (
<Suspense fallback={<Loading />}>
<Code {...props} />
</Suspense>
);
const prismToMonacoLanguageMap = {
js: 'javascript',
ts: 'typescript',
};
export default CodeField;
const baseClass = 'code-field';
const Code: React.FC<Props> = (props) => {
const {
path: pathFromProps,
name,
required,
validate = code,
admin: {
readOnly,
style,
className,
width,
language,
description,
condition,
editorOptions,
} = {},
label,
} = props;
const path = pathFromProps || name;
const memoizedValidate = useCallback((value, options) => {
return validate(value, { ...options, required });
}, [validate, required]);
const {
value,
showError,
setValue,
errorMessage,
} = useField({
path,
validate: memoizedValidate,
condition,
});
const classes = [
baseClass,
'field-type',
className,
showError && 'error',
readOnly && 'read-only',
].filter(Boolean).join(' ');
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor={`field-${path}`}
label={label}
required={required}
/>
<CodeEditor
options={editorOptions}
defaultLanguage={prismToMonacoLanguageMap[language] || language}
value={value as string || ''}
onChange={readOnly ? () => null : (val) => setValue(val)}
readOnly={readOnly}
/>
<FieldDescription
value={value}
description={description}
/>
</div>
);
};
export default withCondition(Code);

View File

@@ -1,110 +0,0 @@
import React, { useCallback, useEffect, useState } from 'react';
import Error from '../../Error';
import FieldDescription from '../../FieldDescription';
import { json } from '../../../../../fields/validations';
import Label from '../../Label';
import { Props } from './types';
import useField from '../../useField';
import withCondition from '../../withCondition';
import CodeEditor from '../../../elements/CodeEditor';
import './index.scss';
const baseClass = 'json-field';
const JSONField: React.FC<Props> = (props) => {
const {
path: pathFromProps,
name,
required,
validate = json,
admin: {
readOnly,
style,
className,
width,
description,
condition,
editorOptions,
} = {},
label,
} = props;
const path = pathFromProps || name;
const [stringValue, setStringValue] = useState<string>();
const [jsonError, setJsonError] = useState<string>();
const memoizedValidate = useCallback((value, options) => {
return validate(value, { ...options, required, jsonError });
}, [validate, required, jsonError]);
const {
value,
initialValue,
showError,
setValue,
errorMessage,
} = useField<string>({
path,
validate: memoizedValidate,
condition,
});
const handleChange = useCallback((val) => {
if (readOnly) return;
setStringValue(val);
try {
setValue(JSON.parse(val.trim() || '{}'));
setJsonError(undefined);
} catch (e) {
setJsonError(e);
}
}, [readOnly, setValue, setStringValue]);
useEffect(() => {
setStringValue(JSON.stringify(initialValue, null, 2));
}, [initialValue]);
const classes = [
baseClass,
'field-type',
className,
showError && 'error',
readOnly && 'read-only',
].filter(Boolean).join(' ');
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor={`field-${path}`}
label={label}
required={required}
/>
<CodeEditor
options={editorOptions}
defaultLanguage="json"
value={stringValue}
onChange={handleChange}
readOnly={readOnly}
/>
<FieldDescription
value={value}
description={description}
/>
</div>
);
};
export default withCondition(JSONField);

View File

@@ -1,13 +1,110 @@
import React, { Suspense, lazy } from 'react';
import { Loading } from '../../../elements/Loading';
import React, { useCallback, useEffect, useState } from 'react';
import Error from '../../Error';
import FieldDescription from '../../FieldDescription';
import { json } from '../../../../../fields/validations';
import Label from '../../Label';
import { Props } from './types';
import useField from '../../useField';
import withCondition from '../../withCondition';
import { CodeEditor } from '../../../elements/CodeEditor';
const JSON = lazy(() => import('./JSON'));
import './index.scss';
const JSONField: React.FC<Props> = (props) => (
<Suspense fallback={<Loading />}>
<JSON {...props} />
</Suspense>
);
const baseClass = 'json-field';
export default JSONField;
const JSONField: React.FC<Props> = (props) => {
const {
path: pathFromProps,
name,
required,
validate = json,
admin: {
readOnly,
style,
className,
width,
description,
condition,
editorOptions,
} = {},
label,
} = props;
const path = pathFromProps || name;
const [stringValue, setStringValue] = useState<string>();
const [jsonError, setJsonError] = useState<string>();
const memoizedValidate = useCallback((value, options) => {
return validate(value, { ...options, required, jsonError });
}, [validate, required, jsonError]);
const {
value,
initialValue,
showError,
setValue,
errorMessage,
} = useField<string>({
path,
validate: memoizedValidate,
condition,
});
const handleChange = useCallback((val) => {
if (readOnly) return;
setStringValue(val);
try {
setValue(JSON.parse(val.trim() || '{}'));
setJsonError(undefined);
} catch (e) {
setJsonError(e);
}
}, [readOnly, setValue, setStringValue]);
useEffect(() => {
setStringValue(JSON.stringify(initialValue, null, 2));
}, [initialValue]);
const classes = [
baseClass,
'field-type',
className,
showError && 'error',
readOnly && 'read-only',
].filter(Boolean).join(' ');
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<Error
showError={showError}
message={errorMessage}
/>
<Label
htmlFor={`field-${path}`}
label={label}
required={required}
/>
<CodeEditor
options={editorOptions}
defaultLanguage="json"
value={stringValue}
onChange={handleChange}
readOnly={readOnly}
/>
<FieldDescription
value={value}
description={description}
/>
</div>
);
};
export default withCondition(JSONField);

View File

@@ -20,11 +20,18 @@ export const LoadingOverlayProvider: React.FC<{ children?: React.ReactNode }> =
const [loadingOverlayText, setLoadingOverlayText] = useState<string>(t('loading'));
const [overlays, dispatchOverlay] = React.useReducer(reducer, defaultLoadingOverlayState);
const { isMounted, isUnmounting, triggerRenderTimeout: suspendDisplay } = useDelayedRender({ show: overlays.isLoading });
const {
isMounted,
isUnmounting,
triggerDelayedRender,
} = useDelayedRender({
show: overlays.isLoading,
});
const toggleLoadingOverlay = React.useCallback<ToggleLoadingOverlay>(({ type, key, isLoading }) => {
if (isLoading) {
suspendDisplay();
triggerDelayedRender();
dispatchOverlay({
type: 'add',
payload: {
@@ -41,7 +48,7 @@ export const LoadingOverlayProvider: React.FC<{ children?: React.ReactNode }> =
},
});
}
}, [suspendDisplay]);
}, [triggerDelayedRender]);
return (
<Context.Provider

View File

@@ -6,8 +6,7 @@ export const defaultLoadingOverlayState = {
loaders: [],
};
// react reducer return type
export const reducer = (state: State, action: Action) => {
export const reducer = (state: State, action: Action): State => {
const loadersCopy = [...state.loaders];
const { type = 'fullscreen', key = 'user' } = action.payload;

View File

@@ -16,6 +16,7 @@ import PerPage from '../../../elements/PerPage';
import { Gutter } from '../../../elements/Gutter';
import { RelationshipProvider } from './RelationshipProvider';
import { getTranslation } from '../../../../../utilities/getTranslation';
import { StaggeredShimmers } from '../../../elements/ShimmerEffect';
import './index.scss';
@@ -96,6 +97,18 @@ const DefaultList: React.FC<Props> = (props) => {
handleSortChange={handleSortChange}
handleWhereChange={handleWhereChange}
/>
{!data.docs && (
<StaggeredShimmers
className={[
`${baseClass}__shimmer`,
upload ? `${baseClass}__shimmer--uploads` : `${baseClass}__shimmer--rows`,
].filter(Boolean).join(' ')}
count={6}
width={upload ? 'unset' : '100%'}
/>
)}
{(data.docs && data.docs.length > 0) && (
<React.Fragment>
{!upload && (

View File

@@ -55,6 +55,34 @@
margin-left: auto;
}
&__shimmer {
margin-top: base(1.75);
}
&__shimmer--rows {
>div {
margin-top: 8px;
}
}
&__shimmer--uploads {
// match upload cards
margin: base(2) -#{base(.5)};
width: calc(100% + #{$baseline});
display: flex;
flex-wrap: wrap;
>div {
min-width: 0;
width: calc(16.66%);
>div {
margin: base(.5);
padding-bottom: 110%;
}
}
}
@include mid-break {
&__wrap {
padding-top: 0;
@@ -83,5 +111,19 @@
width: 100%;
margin-bottom: $baseline;
}
&__shimmer--uploads {
>div {
width: 33.33%;
}
}
}
@include small-break {
&__shimmer--uploads {
>div {
width: 50%;
}
}
}
}

View File

@@ -21,7 +21,7 @@ type useDelayedRenderT = (props: DelayedRenderProps) => {
/** `true` if the component is unmounting. */
isUnmounting: boolean;
/** Call this function to trigger the timeout delay before rendering. */
triggerRenderTimeout: () => void;
triggerDelayedRender: () => void;
};
export const useDelayedRender: useDelayedRenderT = ({ show, delayBeforeShow = 1000, inTimeout = 500, minShowTime = 500, outTimeout = 500 }) => {
const totalMountTime = inTimeout + minShowTime + outTimeout;
@@ -57,6 +57,6 @@ export const useDelayedRender: useDelayedRenderT = ({ show, delayBeforeShow = 10
return {
isMounted,
isUnmounting,
triggerRenderTimeout: triggerDelay,
triggerDelayedRender: triggerDelay,
};
};