mirror of
https://github.com/The-Low-Code-Foundation/OpenNoodl.git
synced 2026-01-13 07:42:55 +01:00
Initial commit
Co-Authored-By: Eric Tuvesson <eric.tuvesson@gmail.com> Co-Authored-By: mikaeltellhed <2311083+mikaeltellhed@users.noreply.github.com> Co-Authored-By: kotte <14197736+mrtamagotchi@users.noreply.github.com> Co-Authored-By: Anders Larsson <64838990+anders-topp@users.noreply.github.com> Co-Authored-By: Johan <4934465+joolsus@users.noreply.github.com> Co-Authored-By: Tore Knudsen <18231882+torekndsn@users.noreply.github.com> Co-Authored-By: victoratndl <99176179+victoratndl@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
.Root {
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
box-sizing: border-box;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
&.has-backdrop {
|
||||
background-color: var(--theme-color-bg-1-transparent);
|
||||
}
|
||||
|
||||
&.is-locking-scroll {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&:not(.has-backdrop):not(.is-locking-scroll) {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.VisibleDialog {
|
||||
filter: drop-shadow(0 4px 15px var(--theme-color-bg-1-transparent-2));
|
||||
box-shadow: 0 0 10px -5px var(--theme-color-bg-1-transparent-2);
|
||||
position: absolute;
|
||||
width: var(--width);
|
||||
pointer-events: all;
|
||||
|
||||
.Root.is-centered & {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
animation: enter-centered var(--speed-quick) var(--easing-base) both;
|
||||
}
|
||||
|
||||
.Root:not(.is-centered) &.is-visible {
|
||||
&.is-variant-default {
|
||||
animation: enter var(--speed-quick) var(--easing-base) both;
|
||||
}
|
||||
|
||||
&.is-variant-select {
|
||||
transform: translate(var(--offsetX), var(--offsetY));
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--background);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.Arrow {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
top: var(--arrow-top);
|
||||
left: var(--arrow-left);
|
||||
pointer-events: none;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
transform: translate(-50%, -50%) rotate(45deg);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
&.is-contrast::after {
|
||||
background: var(--backgroundContrast);
|
||||
}
|
||||
}
|
||||
|
||||
.Title {
|
||||
background-color: var(--backgroundContrast);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.MeasuringContainer {
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ChildContainer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@keyframes enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(
|
||||
calc(var(--animationStartOffsetX) + var(--offsetX)),
|
||||
calc(var(--animationStartOffsetY) + var(--offsetY))
|
||||
);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(var(--offsetX), var(--offsetY));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes enter-centered {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, calc(-50% + 16px));
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { BaseDialog } from './BaseDialog';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Base Dialog',
|
||||
component: BaseDialog,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof BaseDialog>;
|
||||
|
||||
const Template: ComponentStory<typeof BaseDialog> = (args) => {
|
||||
const [isDialogVisible, setIsDialogVisible] = useState(false);
|
||||
const [reload, setReload] = useState(Date.now());
|
||||
return (
|
||||
<>
|
||||
<BaseDialog {...args} isVisible={isDialogVisible} onClose={() => setIsDialogVisible(false)}>
|
||||
I am a dialog
|
||||
</BaseDialog>
|
||||
|
||||
<p
|
||||
onMouseEnter={() => setIsDialogVisible(true)}
|
||||
onMouseLeave={() => setIsDialogVisible(false)}
|
||||
>
|
||||
Hover to show
|
||||
</p>
|
||||
<button onClick={() => setIsDialogVisible(true)}>Show dialog</button>
|
||||
<br />
|
||||
<button onClick={() => setReload(Date.now())}>Trigger reload</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {};
|
||||
@@ -0,0 +1,335 @@
|
||||
import useDocumentScrollTimestamp from '@noodl-hooks/useDocumentScrollTimestamp';
|
||||
import useWindowSize from '@noodl-hooks/useWindowSize';
|
||||
import classNames from 'classnames';
|
||||
import React, { useRef, useEffect, CSSProperties, RefObject, useState, useMemo, useLayoutEffect } from 'react';
|
||||
|
||||
import { Collapsible } from '@noodl-core-ui/components/layout/Collapsible';
|
||||
import { Portal } from '@noodl-core-ui/components/layout/Portal';
|
||||
import { Label, LabelSize } from '@noodl-core-ui/components/typography/Label';
|
||||
import { Slot, UnsafeStyleProps } from '@noodl-core-ui/types/global';
|
||||
|
||||
import css from './BaseDialog.module.scss';
|
||||
|
||||
export enum DialogRenderDirection {
|
||||
Vertical,
|
||||
Horizontal,
|
||||
Above,
|
||||
Below
|
||||
}
|
||||
|
||||
export enum BaseDialogVariant {
|
||||
Default = 'is-variant-default',
|
||||
Select = 'is-variant-select'
|
||||
}
|
||||
|
||||
export enum DialogBackground {
|
||||
Default = 'is-background-default',
|
||||
Bg1 = 'is-background-bg-1',
|
||||
Bg2 = 'is-background-bg-2',
|
||||
Bg3 = 'is-background-bg-3',
|
||||
Secondary = 'is-background-secondary',
|
||||
Transparent = 'is-background-transparent'
|
||||
}
|
||||
|
||||
export interface BaseDialogProps extends UnsafeStyleProps {
|
||||
triggerRef?: RefObject<HTMLElement>;
|
||||
renderDirection?: DialogRenderDirection;
|
||||
background?: DialogBackground;
|
||||
variant?: BaseDialogVariant;
|
||||
title?: string;
|
||||
|
||||
isLockingScroll?: boolean;
|
||||
isVisible?: boolean;
|
||||
hasBackdrop?: boolean;
|
||||
hasArrow?: boolean;
|
||||
|
||||
children?: Slot;
|
||||
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function BaseDialog(props: BaseDialogProps) {
|
||||
const [portalRoot] = useState(document.querySelector('.dialog-layer-portal-target'));
|
||||
|
||||
return (
|
||||
<Portal portalRoot={portalRoot}>
|
||||
<CoreBaseDialog {...props} />
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export function CoreBaseDialog({
|
||||
triggerRef,
|
||||
renderDirection = DialogRenderDirection.Vertical,
|
||||
background = DialogBackground.Default,
|
||||
variant = BaseDialogVariant.Default,
|
||||
title,
|
||||
|
||||
isLockingScroll,
|
||||
isVisible,
|
||||
hasBackdrop,
|
||||
hasArrow,
|
||||
|
||||
children,
|
||||
|
||||
onClose,
|
||||
|
||||
UNSAFE_className,
|
||||
UNSAFE_style
|
||||
}: BaseDialogProps) {
|
||||
const [isSelectOpen, setIsSelectOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!BaseDialogVariant.Select) return;
|
||||
// quick n dirty solution to make sure the
|
||||
// select doesnt render in an open state
|
||||
// without showing the animation
|
||||
setTimeout(() => {
|
||||
setIsSelectOpen(isVisible);
|
||||
}, 50);
|
||||
}, [isVisible]);
|
||||
|
||||
const dialogRef = useRef<HTMLDivElement>();
|
||||
const [dialogPosition, setDialogPosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
arrowX: 0,
|
||||
arrowY: 0,
|
||||
animationStartOffsetX: 0,
|
||||
animationStartOffsetY: 0,
|
||||
width: 'auto',
|
||||
speed: 250
|
||||
});
|
||||
|
||||
const windowSize = useWindowSize();
|
||||
const lastScroll = useDocumentScrollTimestamp(isVisible);
|
||||
|
||||
const arrowCompensationOffset = variant === BaseDialogVariant.Select ? 0 : 12;
|
||||
const edgeOffset = 10;
|
||||
|
||||
// calculate where to render the dialog
|
||||
useLayoutEffect(() => {
|
||||
if (!triggerRef?.current || !dialogRef?.current) return;
|
||||
|
||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||
const dialogRect = dialogRef.current.getBoundingClientRect();
|
||||
|
||||
let dialogX;
|
||||
let dialogY;
|
||||
let arrowX;
|
||||
let arrowY;
|
||||
let animationStartOffsetX = 0;
|
||||
let animationStartOffsetY = 0;
|
||||
let width = 'auto';
|
||||
let speed = Math.max(150, dialogRect.height);
|
||||
|
||||
if (variant === BaseDialogVariant.Select) width = triggerRect.width + 'px';
|
||||
|
||||
function checkAboveFirst() {
|
||||
// default position to trigger is x:centered y:above
|
||||
dialogX = triggerRect.left + triggerRect.width / 2 - dialogRect.width / 2;
|
||||
dialogY = triggerRect.top - (dialogRect.height + arrowCompensationOffset);
|
||||
arrowY = dialogRect.height;
|
||||
arrowX = dialogRect.width / 2;
|
||||
animationStartOffsetY = -10;
|
||||
|
||||
// if x:centered renders the dialog outside of viewport
|
||||
// we tack it to the side of the viewport it clips
|
||||
if (dialogX < 0) {
|
||||
dialogX = edgeOffset;
|
||||
arrowX = triggerRect.width / 2 + triggerRect.left - edgeOffset;
|
||||
} else if (dialogX + dialogRect.width > windowSize.width) {
|
||||
dialogX = windowSize.width - dialogRect.width - edgeOffset;
|
||||
arrowX = dialogRect.width - (windowSize.width - triggerRect.right) - triggerRect.width / 2 + edgeOffset;
|
||||
}
|
||||
|
||||
// if y:above is outside the viewport we do y:below
|
||||
if (dialogY - dialogRect.height - arrowCompensationOffset < 0) {
|
||||
dialogY = triggerRect.bottom + arrowCompensationOffset;
|
||||
arrowY = 0;
|
||||
animationStartOffsetY = 10;
|
||||
}
|
||||
}
|
||||
|
||||
function checkBelowFirst() {
|
||||
// default position to trigger is x:centered y:below
|
||||
dialogX = triggerRect.left + triggerRect.width / 2 - dialogRect.width / 2;
|
||||
dialogY = triggerRect.bottom + arrowCompensationOffset;
|
||||
arrowY = 0;
|
||||
arrowX = dialogRect.width / 2;
|
||||
animationStartOffsetY = 10;
|
||||
|
||||
// if x:centered renders the dialog outside of viewport
|
||||
// we tack it to the side of the viewport it clips
|
||||
if (dialogX < 0) {
|
||||
dialogX = edgeOffset;
|
||||
arrowX = triggerRect.width / 2 + triggerRect.left - edgeOffset;
|
||||
} else if (dialogX + dialogRect.width > windowSize.width) {
|
||||
dialogX = windowSize.width - dialogRect.width - edgeOffset;
|
||||
arrowX = dialogRect.width - (windowSize.width - triggerRect.right) - triggerRect.width / 2 + edgeOffset;
|
||||
}
|
||||
|
||||
// if y:below is outside the viewport we do y:above
|
||||
if (dialogY + dialogRect.height > windowSize.height) {
|
||||
dialogY = triggerRect.top - dialogRect.height - arrowCompensationOffset;
|
||||
arrowY = dialogRect.height;
|
||||
animationStartOffsetY = -10;
|
||||
}
|
||||
}
|
||||
|
||||
function checkRightFirst() {
|
||||
// default position to trigger is y:centered x:right
|
||||
dialogX = triggerRect.right + arrowCompensationOffset;
|
||||
dialogY = triggerRect.top + triggerRect.height / 2 - dialogRect.height / 2;
|
||||
arrowX = 0;
|
||||
arrowY = dialogRect.height / 2;
|
||||
animationStartOffsetX = 10;
|
||||
|
||||
// if x:right is clipping outside viewport, render as x:left
|
||||
if (dialogX + dialogRect.width > windowSize.width) {
|
||||
dialogX = triggerRect.left - dialogRect.width - arrowCompensationOffset;
|
||||
arrowX = dialogRect.width;
|
||||
animationStartOffsetX = -10;
|
||||
}
|
||||
|
||||
// if y:center clips outside viewport tack it to bottom or top
|
||||
if (dialogY + dialogRect.height > windowSize.height) {
|
||||
dialogY = windowSize.height - dialogRect.height;
|
||||
arrowY = dialogRect.height - (windowSize.height - triggerRect.top) + triggerRect.height / 2;
|
||||
} else if (dialogY < 0) {
|
||||
dialogY = 10;
|
||||
arrowY = triggerRect.top + triggerRect.height / 2;
|
||||
}
|
||||
}
|
||||
|
||||
switch (renderDirection) {
|
||||
case DialogRenderDirection.Vertical:
|
||||
case DialogRenderDirection.Above:
|
||||
checkAboveFirst();
|
||||
break;
|
||||
case DialogRenderDirection.Below:
|
||||
checkBelowFirst();
|
||||
break;
|
||||
case DialogRenderDirection.Horizontal:
|
||||
checkRightFirst();
|
||||
break;
|
||||
}
|
||||
|
||||
setDialogPosition({
|
||||
x: dialogX,
|
||||
y: dialogY,
|
||||
arrowX,
|
||||
arrowY,
|
||||
animationStartOffsetX,
|
||||
animationStartOffsetY,
|
||||
width,
|
||||
speed
|
||||
});
|
||||
}, [isVisible, windowSize, lastScroll, triggerRef?.current, dialogRef?.current]);
|
||||
|
||||
const backgroundColor = useMemo(() => {
|
||||
switch (background) {
|
||||
case DialogBackground.Bg1:
|
||||
return 'var(--theme-color-bg-1)';
|
||||
case DialogBackground.Bg2:
|
||||
return 'var(--theme-color-bg-2)';
|
||||
case DialogBackground.Bg3:
|
||||
return 'var(--theme-color-bg-3)';
|
||||
case DialogBackground.Transparent:
|
||||
return 'transparent';
|
||||
case DialogBackground.Secondary:
|
||||
return 'var(--theme-color-secondary)';
|
||||
default:
|
||||
return 'var(--theme-color-bg-4)';
|
||||
}
|
||||
}, [background]);
|
||||
|
||||
const backgroundContrastColor = useMemo(() => {
|
||||
switch (background) {
|
||||
case DialogBackground.Bg1:
|
||||
return 'var(--theme-color-bg-0)';
|
||||
case DialogBackground.Bg2:
|
||||
return 'var(--theme-color-bg-1)';
|
||||
case DialogBackground.Bg3:
|
||||
return 'var(--theme-color-bg-2)';
|
||||
case DialogBackground.Transparent:
|
||||
return 'transparent';
|
||||
case DialogBackground.Secondary:
|
||||
return 'var(--theme-color-secondary)';
|
||||
default:
|
||||
return 'var(--theme-color-bg-2)';
|
||||
}
|
||||
}, [background]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
css['Root'],
|
||||
hasBackdrop && css['has-backdrop'],
|
||||
isLockingScroll && css['is-locking-scroll'],
|
||||
typeof triggerRef === 'undefined' && css['is-centered'],
|
||||
css[variant]
|
||||
)}
|
||||
onClick={onClose}
|
||||
style={
|
||||
{
|
||||
'--offsetY': `${Math.floor(dialogPosition.y)}px`,
|
||||
'--offsetX': `${Math.floor(dialogPosition.x)}px`,
|
||||
'--animationStartOffsetX': `${dialogPosition.animationStartOffsetX}px`,
|
||||
'--animationStartOffsetY': `${dialogPosition.animationStartOffsetY}px`,
|
||||
'--background': backgroundColor,
|
||||
'--backgroundContrast': backgroundContrastColor,
|
||||
'--width': dialogPosition.width
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={classNames(css['VisibleDialog'], UNSAFE_className, isVisible && css['is-visible'], css[variant])}
|
||||
style={UNSAFE_style}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={css['MeasuringContainer']}>
|
||||
<div ref={dialogRef} style={{}} className={css['ChildContainer']}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasArrow && (
|
||||
<div
|
||||
className={classNames(css['Arrow'], title && dialogPosition.arrowY === 0 && css['is-contrast'])}
|
||||
style={
|
||||
{
|
||||
'--arrow-top': `${dialogPosition.arrowY}px`,
|
||||
'--arrow-left': `${dialogPosition.arrowX}px`
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{variant === BaseDialogVariant.Select ? (
|
||||
<Collapsible isCollapsed={!isSelectOpen} transitionMs={dialogPosition.speed}>
|
||||
<div className={css['ChildContainer']}>
|
||||
{title && (
|
||||
<div className={css['Title']}>
|
||||
<Label size={LabelSize.Medium}>{title}</Label>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</Collapsible>
|
||||
) : (
|
||||
<div className={css['ChildContainer']}>
|
||||
{title && (
|
||||
<div className={css['Title']}>
|
||||
<Label size={LabelSize.Medium}>{title}</Label>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './BaseDialog'
|
||||
@@ -0,0 +1,2 @@
|
||||
.Root {
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { Box } from './Box';
|
||||
import { Text } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Box',
|
||||
component: Box,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof Box>;
|
||||
|
||||
const Template: ComponentStory<typeof Box> = (args) => (
|
||||
<div style={{ width: 280 }}>
|
||||
<Box {...args}>
|
||||
<Text>Text</Text>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {};
|
||||
70
packages/noodl-core-ui/src/components/layout/Box/Box.tsx
Normal file
70
packages/noodl-core-ui/src/components/layout/Box/Box.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Slot, UnsafeStyleProps } from '@noodl-core-ui/types/global';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import css from './Box.module.scss';
|
||||
|
||||
export interface BoxProps extends UnsafeStyleProps {
|
||||
hasLeftSpacing?: number | boolean;
|
||||
hasRightSpacing?: number | boolean;
|
||||
hasTopSpacing?: number | boolean;
|
||||
hasBottomSpacing?: number | boolean;
|
||||
|
||||
hasXSpacing?: number | boolean;
|
||||
hasYSpacing?: number | boolean;
|
||||
|
||||
children?: Slot;
|
||||
}
|
||||
|
||||
export function Box({
|
||||
hasLeftSpacing,
|
||||
hasRightSpacing,
|
||||
hasTopSpacing,
|
||||
hasBottomSpacing,
|
||||
hasXSpacing,
|
||||
hasYSpacing,
|
||||
|
||||
UNSAFE_style,
|
||||
UNSAFE_className,
|
||||
|
||||
children
|
||||
}: BoxProps) {
|
||||
const style = {
|
||||
...UNSAFE_style
|
||||
};
|
||||
|
||||
function convert(...values: (boolean | number)[]): string {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'boolean' && value) {
|
||||
return '16px';
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value * 4 + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (hasXSpacing || hasLeftSpacing) {
|
||||
style.paddingLeft = convert(hasLeftSpacing, hasXSpacing);
|
||||
}
|
||||
|
||||
if (hasXSpacing || hasRightSpacing) {
|
||||
style.paddingRight = convert(hasRightSpacing, hasXSpacing);
|
||||
}
|
||||
|
||||
if (hasYSpacing || hasTopSpacing) {
|
||||
style.paddingTop = convert(hasTopSpacing, hasYSpacing);
|
||||
}
|
||||
|
||||
if (hasYSpacing || hasBottomSpacing) {
|
||||
style.paddingBottom = convert(hasBottomSpacing, hasYSpacing);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames([css['Root'], UNSAFE_className])} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Box';
|
||||
@@ -0,0 +1,7 @@
|
||||
.Root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { Box } from '@noodl-core-ui/components/layout/Box';
|
||||
import { CarouselIndicatorDot } from '@noodl-core-ui/components/layout/CarouselIndicatorDot';
|
||||
import { Center } from '@noodl-core-ui/components/layout/Center';
|
||||
import { Text } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Title, TitleSize, TitleVariant } from '@noodl-core-ui/components/typography/Title';
|
||||
|
||||
// @ts-expect-error PNG import
|
||||
import IMG_CarouselNodepickerPrefab from '../../../assets/images/carousel_nodepicker_prefab.png';
|
||||
import { Carousel } from './Carousel';
|
||||
|
||||
interface NodePickerSliderProps {
|
||||
subtitle: string;
|
||||
title: string;
|
||||
text: string;
|
||||
action?: {
|
||||
label: string;
|
||||
};
|
||||
}
|
||||
|
||||
function NodePickerSlider({ subtitle, title, text, action }: NodePickerSliderProps) {
|
||||
return (
|
||||
<>
|
||||
<Title isCentered variant={TitleVariant.Default}>
|
||||
{subtitle}
|
||||
</Title>
|
||||
<Title isCentered variant={TitleVariant.Highlighted} size={TitleSize.Large}>
|
||||
{title}
|
||||
</Title>
|
||||
<Box hasYSpacing>
|
||||
<Text isCentered>{text}</Text>
|
||||
</Box>
|
||||
{action && (
|
||||
<Center>
|
||||
<PrimaryButton
|
||||
label={action.label}
|
||||
size={PrimaryButtonSize.Small}
|
||||
variant={PrimaryButtonVariant.Ghost}
|
||||
hasBottomSpacing
|
||||
/>
|
||||
</Center>
|
||||
)}
|
||||
<img src={IMG_CarouselNodepickerPrefab} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
title: 'Layout/Carousel',
|
||||
component: Carousel,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof Carousel>;
|
||||
|
||||
const Template: ComponentStory<typeof Carousel> = (args) => (
|
||||
<div style={{ width: '430px' }}>
|
||||
<Carousel {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {
|
||||
items: [
|
||||
{
|
||||
slot: (
|
||||
<NodePickerSlider
|
||||
subtitle="New Feature"
|
||||
title="Introducing Prefabs"
|
||||
text="Import pre-made UI components (Prefabs) in to your project. Prefabs are build with core nodes and can easly be customized."
|
||||
action={{ label: 'Explore Prefabs' }}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
slot: (
|
||||
<NodePickerSlider
|
||||
subtitle="New Feature"
|
||||
title="Introducing Modules"
|
||||
text="Import modules in to your project. Modules are build with the SDK."
|
||||
/>
|
||||
)
|
||||
},
|
||||
{ slot: <>Test 2</> },
|
||||
{ slot: <>Test 3</> }
|
||||
],
|
||||
indicator: CarouselIndicatorDot
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Box } from '@noodl-core-ui/components/layout/Box';
|
||||
import { Center } from '@noodl-core-ui/components/layout/Center';
|
||||
import { HStack, VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Slot } from '@noodl-core-ui/types/global';
|
||||
|
||||
import css from './Carousel.module.scss';
|
||||
|
||||
export interface CarouselIndicatorBaseProps {
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface BaseCarouselProps {
|
||||
activeIndex?: number;
|
||||
items: { slot: Slot }[];
|
||||
}
|
||||
|
||||
export interface CarouselProps extends BaseCarouselProps {
|
||||
indicator: React.FunctionComponent<CarouselIndicatorBaseProps>;
|
||||
}
|
||||
|
||||
export function Carousel({ activeIndex, items, indicator }: CarouselProps) {
|
||||
const sliderRefs = useRef<HTMLDivElement[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(activeIndex || 0);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentIndex(activeIndex);
|
||||
|
||||
if (sliderRefs.current[activeIndex]) {
|
||||
sliderRefs.current[activeIndex].scrollIntoView({
|
||||
behavior: 'auto'
|
||||
});
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
if (typeof currentIndex === 'number' && sliderRefs.current[currentIndex]) {
|
||||
sliderRefs.current[currentIndex].scrollIntoView({
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div className={css['Root']}>
|
||||
<div style={{ overflow: 'hidden' }}>
|
||||
<HStack UNSAFE_style={{ width: items.length * 100 + '%' }}>
|
||||
{items.map((item, index) => (
|
||||
<VStack key={index} ref={(ref) => (sliderRefs.current[index] = ref)} UNSAFE_style={{ width: '100%' }}>
|
||||
{item.slot}
|
||||
</VStack>
|
||||
))}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{indicator && (
|
||||
<Box hasTopSpacing>
|
||||
<Center>
|
||||
<HStack hasSpacing={2}>
|
||||
{items.map((_, index) =>
|
||||
React.createElement(indicator, {
|
||||
key: index,
|
||||
isActive: index === currentIndex,
|
||||
onClick() {
|
||||
setCurrentIndex(index);
|
||||
}
|
||||
})
|
||||
)}
|
||||
</HStack>
|
||||
</Center>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Carousel';
|
||||
@@ -0,0 +1,12 @@
|
||||
.Root {
|
||||
border-radius: 50%;
|
||||
background-color: var(--theme-color-bg-3);
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 100ms;
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--theme-color-fg-muted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import { CarouselIndicatorDot } from './CarouselIndicatorDot';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Carousel Indicator Dot',
|
||||
component: CarouselIndicatorDot,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof CarouselIndicatorDot>;
|
||||
|
||||
const Template: ComponentStory<typeof CarouselIndicatorDot> = (args) => <CarouselIndicatorDot {...args} />;
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {};
|
||||
@@ -0,0 +1,12 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { CarouselIndicatorBaseProps } from '@noodl-core-ui/components/layout/Carousel/Carousel';
|
||||
|
||||
import css from './CarouselIndicatorDot.module.scss';
|
||||
|
||||
export interface CarouselIndicatorDotProps extends CarouselIndicatorBaseProps {}
|
||||
|
||||
export function CarouselIndicatorDot({ isActive, onClick }: CarouselIndicatorDotProps) {
|
||||
return <div className={classNames([css['Root'], isActive && css['is-active']])} onClick={onClick}></div>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './CarouselIndicatorDot'
|
||||
@@ -0,0 +1,5 @@
|
||||
.Root {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import { Center } from './Center';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Center',
|
||||
component: Center,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof Center>;
|
||||
|
||||
const Template: ComponentStory<typeof Center> = (args) => <Center {...args} />;
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {};
|
||||
@@ -0,0 +1,20 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { Slot, UnsafeStyleProps } from '@noodl-core-ui/types/global';
|
||||
|
||||
import css from './Center.module.scss';
|
||||
|
||||
export interface CenterProps extends UnsafeStyleProps {
|
||||
children: Slot;
|
||||
}
|
||||
|
||||
export const Center = React.forwardRef<HTMLDivElement, CenterProps>(
|
||||
({ children, UNSAFE_className, UNSAFE_style }: CenterProps, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={classNames(css['Root'], UNSAFE_className)} style={UNSAFE_style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Center';
|
||||
@@ -0,0 +1,8 @@
|
||||
.Root {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.Inner {
|
||||
display: flow-root;
|
||||
position: static;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState } from '@storybook/addons';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import { PrimaryButton, PrimaryButtonSize, PrimaryButtonVariant } from '@noodl-core-ui/components/inputs/PrimaryButton';
|
||||
import { Collapsible } from '@noodl-core-ui/components/layout/Collapsible/Collapsible';
|
||||
import { Text } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Collapsible',
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof Collapsible>;
|
||||
|
||||
const Template: ComponentStory<typeof Collapsible> = (args) => {
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
|
||||
return (
|
||||
<div style={{ width: 280 }}>
|
||||
<PrimaryButton
|
||||
variant={PrimaryButtonVariant.Muted}
|
||||
size={PrimaryButtonSize.Small}
|
||||
label="More info"
|
||||
onClick={() => setShowMore((prev) => !prev)}
|
||||
hasBottomSpacing
|
||||
/>
|
||||
|
||||
<Collapsible isCollapsed={showMore}>
|
||||
<Text>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec elit ante, imperdiet quis placerat nec, porta a
|
||||
erat. Nam dapibus dictum sagittis. Vivamus ut eros et sapien fringilla pretium a quis lectus. Donec suscipit,
|
||||
ipsum quis mollis varius, ante velit tempor augue, ac consequat risus massa eget sem. Aenean eu egestas lorem.
|
||||
Praesent quis justo dictum, consectetur enim nec, rutrum tortor. Donec elementum condimentum lacus ac
|
||||
pellentesque. Nam purus sem, fringilla finibus sapien a, ultrices aliquam ligula. Vestibulum dictum enim nec
|
||||
elit rhoncus, vel sodales ante condimentum. Pellentesque volutpat lectus eget ipsum vehicula, vel vestibulum
|
||||
metus fringilla. Nulla urna orci, fermentum non fermentum id, tempor sit amet ex. Quisque elit neque, tempor
|
||||
vel congue vehicula, hendrerit vitae metus. Maecenas dictum auctor neque in venenatis. Etiam faucibus eleifend
|
||||
urna, non tempor felis eleifend a. Suspendisse fermentum odio quis tristique gravida. Nulla facilisi.
|
||||
</Text>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Common = Template.bind({});
|
||||
@@ -0,0 +1,115 @@
|
||||
import useCallAfterNextRender from '@noodl-hooks/useCallAfterNextRender';
|
||||
import { CustomPropertyAnimation, useCustomPropertyValue } from '@noodl-hooks/useCustomPropertyValue';
|
||||
import usePrevious from '@noodl-hooks/usePrevious';
|
||||
import useTimeout from '@noodl-hooks/useTimeout';
|
||||
import useWindowSize from '@noodl-hooks/useWindowSize';
|
||||
import React, { useRef, useLayoutEffect, useState, useEffect, useMemo } from 'react';
|
||||
|
||||
import { Slot } from '@noodl-core-ui/types/global';
|
||||
|
||||
import css from './Collapsible.module.scss';
|
||||
|
||||
export interface CollapsibleProps {
|
||||
children: Slot;
|
||||
transitionMs?: number;
|
||||
easingFunction?: string;
|
||||
hasTopPadding?: boolean;
|
||||
isCollapsed: boolean;
|
||||
disableTransition?: boolean;
|
||||
}
|
||||
|
||||
type IComponentHeight = number | 'auto';
|
||||
|
||||
export function Collapsible({
|
||||
children,
|
||||
transitionMs = 400,
|
||||
easingFunction,
|
||||
isCollapsed,
|
||||
disableTransition
|
||||
}: CollapsibleProps) {
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const wasCollapsed = usePrevious(isCollapsed);
|
||||
const [componentHeight, setComponentHeight] = useState<IComponentHeight>('auto');
|
||||
const [hasFinishedOpening, setHasFinishedOpening] = useState(!isCollapsed);
|
||||
const [clearTimeout, setClearTimeout] = useState(false);
|
||||
const defaultEasing = useCustomPropertyValue(CustomPropertyAnimation.EasingBase);
|
||||
|
||||
const easing = easingFunction || defaultEasing;
|
||||
|
||||
const doAfterNextRender = useCallAfterNextRender();
|
||||
|
||||
useWindowSize();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
doAfterNextRender(() => {
|
||||
const newHeight = Math.ceil(innerRef.current?.getBoundingClientRect().height);
|
||||
if (componentHeight !== newHeight) {
|
||||
setComponentHeight(newHeight);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// We calculate the full height at 0
|
||||
// so this is needed to know if the height has changed due to calculation (in which case it should not transition)
|
||||
// or if the transition should be pretty
|
||||
const doTransition = !disableTransition || wasCollapsed !== isCollapsed;
|
||||
|
||||
const innerRefHeight = componentHeight || 'auto';
|
||||
|
||||
let height: IComponentHeight = 'auto';
|
||||
height = isCollapsed ? 0 : innerRefHeight;
|
||||
|
||||
useTimeout(
|
||||
() => {
|
||||
if (doTransition && !isCollapsed) {
|
||||
setHasFinishedOpening(true);
|
||||
}
|
||||
},
|
||||
clearTimeout ? null : transitionMs
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (doTransition) {
|
||||
if (isCollapsed) {
|
||||
setHasFinishedOpening(false);
|
||||
setClearTimeout(true);
|
||||
} else {
|
||||
setClearTimeout(false);
|
||||
}
|
||||
}
|
||||
}, [isCollapsed, doTransition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!height || isCollapsed || !innerRef.current) return undefined;
|
||||
|
||||
const resizeObserver = new ResizeObserver((el) => {
|
||||
setComponentHeight(Math.ceil(el[0].contentRect.height));
|
||||
});
|
||||
|
||||
resizeObserver.observe(innerRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [height, isCollapsed]);
|
||||
|
||||
const overflow = useMemo(() => (hasFinishedOpening ? 'visible' : 'hidden'), [hasFinishedOpening]);
|
||||
|
||||
//Unmount children if transitions are disabled. Can improve performance if there are lots of child elements
|
||||
const unmountChildren = disableTransition && isCollapsed;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css['Root']}
|
||||
style={{
|
||||
height: height,
|
||||
transition: doTransition ? `height ${transitionMs}ms ${easing}` : 'none',
|
||||
overflow
|
||||
}}
|
||||
>
|
||||
<div className={css['Inner']} ref={innerRef}>
|
||||
{unmountChildren ? null : children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Collapsible';
|
||||
116
packages/noodl-core-ui/src/components/layout/Columns/Columns.tsx
Normal file
116
packages/noodl-core-ui/src/components/layout/Columns/Columns.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { Slot, UnsafeStyleProps } from '@noodl-core-ui/types/global';
|
||||
|
||||
export interface ColumnsProps extends UnsafeStyleProps {
|
||||
hasXGap?: number | boolean;
|
||||
hasYGap?: number | boolean;
|
||||
|
||||
layoutString?: string | number;
|
||||
direction?: React.CSSProperties['flexDirection'];
|
||||
justifyContent?: React.CSSProperties['justifyContent'];
|
||||
|
||||
children: Slot;
|
||||
}
|
||||
|
||||
function toArray<T>(value: T | T[]): T[] {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function convert(...values: (boolean | number)[]): string {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'boolean' && value) {
|
||||
return '16px';
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value * 4 + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function Columns({
|
||||
hasXGap,
|
||||
hasYGap,
|
||||
|
||||
layoutString = '1 1',
|
||||
direction = 'row',
|
||||
justifyContent = 'flex-start',
|
||||
|
||||
UNSAFE_style,
|
||||
UNSAFE_className,
|
||||
|
||||
children
|
||||
}: ColumnsProps) {
|
||||
if (!children) return null;
|
||||
let columnLayout = null;
|
||||
|
||||
switch (typeof layoutString) {
|
||||
case 'string':
|
||||
columnLayout = layoutString.trim();
|
||||
break;
|
||||
|
||||
case 'number':
|
||||
columnLayout = layoutString.toString().trim();
|
||||
break;
|
||||
|
||||
default:
|
||||
columnLayout = null;
|
||||
}
|
||||
|
||||
if (!columnLayout) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// all data for childrens width calculation
|
||||
const layout = columnLayout.split(' ').map((number) => parseInt(number));
|
||||
const totalFractions = layout.reduce((a, b) => a + b, 0);
|
||||
const fractionSize = 100 / totalFractions;
|
||||
const columnAmount = layout.length;
|
||||
|
||||
const marginX = convert(hasXGap);
|
||||
const marginY = convert(hasYGap);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('columns-container', UNSAFE_className)}
|
||||
style={{
|
||||
...UNSAFE_style,
|
||||
marginTop: `calc(${marginY} * -1)`,
|
||||
marginLeft: `calc(${marginX} * -1)`,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: direction,
|
||||
width: `calc(100% + (${marginX})`,
|
||||
boxSizing: 'border-box',
|
||||
alignItems: 'stretch',
|
||||
justifyContent: justifyContent
|
||||
}}
|
||||
>
|
||||
{toArray(children).map((child, i) => {
|
||||
return (
|
||||
<div
|
||||
className="column-item"
|
||||
key={i}
|
||||
style={{
|
||||
boxSizing: 'border-box',
|
||||
paddingTop: marginY,
|
||||
paddingLeft: marginX,
|
||||
width: layout[i % columnAmount] * fractionSize + '%',
|
||||
flexShrink: 0,
|
||||
flexGrow: 0
|
||||
}}
|
||||
>
|
||||
{
|
||||
// @ts-expect-error
|
||||
React.cloneElement(child)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Columns';
|
||||
@@ -0,0 +1,22 @@
|
||||
.Loader {
|
||||
color: var(--theme-color-fg-muted);
|
||||
box-sizing: border-box;
|
||||
|
||||
&.is-loader-type-block {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
&.is-loader-type-inline {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.LoaderInner {
|
||||
height: 150px;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Conditional Container',
|
||||
argTypes: {}
|
||||
};
|
||||
|
||||
const Template = (args) => <div style={{ width: 280 }}>TODO: component exists, write stories</div>;
|
||||
|
||||
export const Common = Template.bind({});
|
||||
@@ -0,0 +1,78 @@
|
||||
import useTimeout from '@noodl-hooks/useTimeout';
|
||||
import classNames from 'classnames';
|
||||
import React, { CSSProperties, useEffect, useState } from 'react';
|
||||
|
||||
import { Slot, UnsafeStyleProps } from '@noodl-core-ui/types/global';
|
||||
|
||||
import { ActivityIndicator } from '../../common/ActivityIndicator';
|
||||
import css from './ConditionalContainer.module.scss';
|
||||
|
||||
export enum LoaderType {
|
||||
Block = 'block',
|
||||
Inline = 'inline'
|
||||
}
|
||||
|
||||
export interface ConditionalContainerProps extends UnsafeStyleProps {
|
||||
doRenderWhen: boolean;
|
||||
isLoaderHidden?: boolean;
|
||||
loaderType?: LoaderType;
|
||||
children: Slot;
|
||||
loaderStyle?: CSSProperties;
|
||||
loaderVisibilityDelayMs?: number | null;
|
||||
|
||||
UNSAFE_root_className?: string;
|
||||
UNSAFE_root_style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function ConditionalContainer({
|
||||
doRenderWhen,
|
||||
isLoaderHidden,
|
||||
loaderType = LoaderType.Block,
|
||||
children,
|
||||
loaderStyle,
|
||||
loaderVisibilityDelayMs = 1500,
|
||||
UNSAFE_root_className,
|
||||
UNSAFE_root_style,
|
||||
UNSAFE_className,
|
||||
UNSAFE_style
|
||||
}: ConditionalContainerProps) {
|
||||
const [isTimeoutCleared, setIsTimeoutCleared] = useState(false);
|
||||
const [isLoaderRendered, setIsLoaderRendered] = useState(Boolean(loaderVisibilityDelayMs) ? false : true);
|
||||
|
||||
useTimeout(
|
||||
() => {
|
||||
setIsLoaderRendered(true);
|
||||
},
|
||||
isTimeoutCleared ? null : loaderVisibilityDelayMs
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!doRenderWhen) {
|
||||
setIsTimeoutCleared(false);
|
||||
setIsLoaderRendered(true);
|
||||
} else {
|
||||
setIsLoaderRendered(false);
|
||||
setIsTimeoutCleared(true);
|
||||
}
|
||||
}, [doRenderWhen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaderHidden) return;
|
||||
|
||||
setIsLoaderRendered(false);
|
||||
setIsTimeoutCleared(true);
|
||||
}, [isLoaderHidden]);
|
||||
|
||||
return (
|
||||
<div className={classNames(css['Root'], UNSAFE_root_className)} style={UNSAFE_root_style}>
|
||||
{doRenderWhen && children}
|
||||
{isLoaderRendered && !doRenderWhen && (
|
||||
<div style={loaderStyle} className={classNames([css['Loader'], css[`is-loader-type-${loaderType}`]])}>
|
||||
<div className={classNames(css['LoaderInner'], UNSAFE_className)} style={UNSAFE_style}>
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ConditionalContainer';
|
||||
@@ -0,0 +1,41 @@
|
||||
.Root {
|
||||
display: flex;
|
||||
|
||||
&.is-direction-horizontal {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&.is-direction-vertical {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&.is-fill {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.has-left-spacing {
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
&.has-right-spacing {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
&.has-top-spacing {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
&.has-bottom-spacing {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
&.has-space-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&.has-space-between.is-direction-vertical {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { Container, ContainerDirection } from './Container';
|
||||
import { Text } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Container', // Layout scaffolding ?
|
||||
component: Container,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof Container>;
|
||||
|
||||
const Template: ComponentStory<typeof Container> = (args) => (
|
||||
<div style={{ width: 280 }}>
|
||||
<Container {...args}></Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {};
|
||||
|
||||
export const SpaceBetweenHorizontal = () => (
|
||||
/* Showcase how it is when the size is set on the parent */
|
||||
<div style={{ width: 500, height: 500 }}>
|
||||
<Container direction={ContainerDirection.Horizontal} hasSpaceBetween>
|
||||
<Container>
|
||||
<Text>Left content</Text>
|
||||
</Container>
|
||||
<Container>
|
||||
<Text>Right content</Text>
|
||||
</Container>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SpaceBetweenVertical = () => (
|
||||
/* Showcase how it is when the size is set on the parent */
|
||||
<div style={{ width: 500, height: 500 }}>
|
||||
<Container direction={ContainerDirection.Vertical} hasSpaceBetween>
|
||||
<Container>
|
||||
<Text>Top content</Text>
|
||||
</Container>
|
||||
<Container>
|
||||
<Text>Bottom content</Text>
|
||||
</Container>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Slot, UnsafeStyleProps } from '@noodl-core-ui/types/global';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import css from './Container.module.scss';
|
||||
|
||||
export enum ContainerDirection {
|
||||
Horizontal = 'horizontal',
|
||||
Vertical = 'vertical'
|
||||
}
|
||||
|
||||
export interface ContainerProps extends UnsafeStyleProps {
|
||||
direction?: ContainerDirection;
|
||||
|
||||
hasLeftSpacing?: boolean;
|
||||
hasRightSpacing?: boolean;
|
||||
hasTopSpacing?: boolean;
|
||||
hasBottomSpacing?: boolean;
|
||||
hasXSpacing?: boolean;
|
||||
hasYSpacing?: boolean;
|
||||
|
||||
isFill?: boolean;
|
||||
hasSpaceBetween?: boolean;
|
||||
|
||||
children?: Slot;
|
||||
}
|
||||
|
||||
export function Container({
|
||||
direction = ContainerDirection.Horizontal,
|
||||
|
||||
hasLeftSpacing,
|
||||
hasRightSpacing,
|
||||
hasTopSpacing,
|
||||
hasBottomSpacing,
|
||||
hasXSpacing,
|
||||
hasYSpacing,
|
||||
|
||||
isFill,
|
||||
hasSpaceBetween,
|
||||
|
||||
UNSAFE_style,
|
||||
UNSAFE_className,
|
||||
|
||||
children
|
||||
}: ContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={classNames([
|
||||
css['Root'],
|
||||
css[`is-direction-${direction}`],
|
||||
(hasXSpacing || hasLeftSpacing) && css['has-left-spacing'],
|
||||
(hasXSpacing || hasRightSpacing) && css['has-right-spacing'],
|
||||
(hasYSpacing || hasTopSpacing) && css['has-top-spacing'],
|
||||
(hasYSpacing || hasBottomSpacing) && css['has-bottom-spacing'],
|
||||
isFill && css['is-fill'],
|
||||
hasSpaceBetween && css['has-space-between'],
|
||||
UNSAFE_className
|
||||
])}
|
||||
style={UNSAFE_style}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Container';
|
||||
@@ -0,0 +1,8 @@
|
||||
.Root {
|
||||
height: 36px;
|
||||
background-color: var(--theme-color-bg-2);
|
||||
border-bottom: 2px solid var(--theme-color-bg-1);
|
||||
display: flex;
|
||||
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import { DocumentTopToolbar } from './DocumentTopToolbar';
|
||||
|
||||
export default {
|
||||
title: 'Layout/DocumentTopToolbar',
|
||||
component: DocumentTopToolbar,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof DocumentTopToolbar>;
|
||||
|
||||
const Template: ComponentStory<typeof DocumentTopToolbar> = (args) => <DocumentTopToolbar {...args} />;
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {};
|
||||
@@ -0,0 +1,20 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
|
||||
import css from './DocumentTopToolbar.module.scss';
|
||||
|
||||
export interface DocumentTopToolbarProps {
|
||||
children: ReactNode | ReactNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* <DocumentTopToolbar>
|
||||
* <Label hasLeftSpacing>Page Structure Overview</Label>
|
||||
* <div style={{ marginLeft: 'auto' }}>
|
||||
* <PrimaryButton label="Exit" variant={PrimaryButtonVariant.MutedOnLowBg} />
|
||||
* </div>
|
||||
* </DocumentTopToolbar>
|
||||
*/
|
||||
export function DocumentTopToolbar({ children }: DocumentTopToolbarProps) {
|
||||
return <div className={css.Root}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './DocumentTopToolbar';
|
||||
@@ -0,0 +1,65 @@
|
||||
.Root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.Container1 {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Container2 {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.Divider {
|
||||
position: absolute;
|
||||
z-index: 9;
|
||||
user-select: none;
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
&.is-horizontal {
|
||||
width: 6px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
cursor: col-resize;
|
||||
|
||||
.DividerBorder {
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-vertical {
|
||||
height: 6px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
cursor: row-resize;
|
||||
|
||||
.DividerBorder {
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover .DividerHighlight {
|
||||
background-color: white;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.DividerHighlight {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.DividerBorder {
|
||||
position: absolute;
|
||||
background-color: black;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { FrameDivider, FrameDividerOwner } from './FrameDivider';
|
||||
import { TestView } from '@noodl-core-ui/components/layout/TestView/TestView';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Frame Divider',
|
||||
component: FrameDivider,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof FrameDivider>;
|
||||
|
||||
const Template: ComponentStory<typeof FrameDivider> = (args) => (
|
||||
<div style={{ width: 1280, height: 800, background: 'lightgray' }}>
|
||||
<FrameDivider
|
||||
{...args}
|
||||
first={<TestView backgroundColor="#ff3f34" />}
|
||||
second={<TestView backgroundColor="#05c46b" />}
|
||||
></FrameDivider>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Horizontal = Template.bind({});
|
||||
Horizontal.args = {
|
||||
horizontal: true
|
||||
};
|
||||
|
||||
export const Vertical = Template.bind({});
|
||||
Vertical.args = {
|
||||
horizontal: false
|
||||
};
|
||||
|
||||
export const Editor3Horizontal: ComponentStory<typeof FrameDivider> = () => {
|
||||
const [firstSize, setFirstSize] = useState(343);
|
||||
const [secondSize, setSecondSize] = useState(343);
|
||||
|
||||
return (
|
||||
<div style={{ width: 1280, height: 800, background: 'lightgray' }}>
|
||||
<FrameDivider
|
||||
sizeMin={200}
|
||||
size={firstSize}
|
||||
onSizeChanged={setFirstSize}
|
||||
first={<TestView backgroundColor="#ff3f34" />}
|
||||
second={
|
||||
<FrameDivider
|
||||
onSizeChanged={setSecondSize}
|
||||
size={secondSize}
|
||||
splitOwner={FrameDividerOwner.Second}
|
||||
sizeMin={200}
|
||||
first={<TestView backgroundColor="#0fbcf9" />}
|
||||
second={<TestView backgroundColor="#05c46b" />}
|
||||
horizontal
|
||||
/>
|
||||
}
|
||||
horizontal
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const Editor3Vertical: ComponentStory<typeof FrameDivider> = () => {
|
||||
const [firstSize, setFirstSize] = useState(300);
|
||||
const [secondSize, setSecondSize] = useState(300);
|
||||
|
||||
return (
|
||||
<div style={{ width: 1280, height: 800, background: 'lightgray' }}>
|
||||
<FrameDivider
|
||||
sizeMin={200}
|
||||
sizeMax={300}
|
||||
size={firstSize}
|
||||
onSizeChanged={setFirstSize}
|
||||
first={<TestView backgroundColor="#ff3f34" />}
|
||||
second={
|
||||
<FrameDivider
|
||||
onSizeChanged={setSecondSize}
|
||||
size={secondSize}
|
||||
splitOwner={FrameDividerOwner.Second}
|
||||
sizeMin={200}
|
||||
first={<TestView backgroundColor="#0fbcf9" />}
|
||||
second={<TestView backgroundColor="#05c46b" />}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Editor2Horizontal1Vertical: ComponentStory<typeof FrameDivider> = () => {
|
||||
const [firstSize, setFirstSize] = useState(300);
|
||||
const [secondSize, setSecondSize] = useState(300);
|
||||
|
||||
return (
|
||||
<div style={{ width: 1280, height: 800, background: 'lightgray' }}>
|
||||
<FrameDivider
|
||||
size={firstSize}
|
||||
onSizeChanged={setFirstSize}
|
||||
first={<TestView backgroundColor="#ff3f34" />}
|
||||
second={
|
||||
<FrameDivider
|
||||
onSizeChanged={setSecondSize}
|
||||
size={secondSize}
|
||||
first={<TestView backgroundColor="#0fbcf9" />}
|
||||
second={<TestView backgroundColor="#05c46b" />}
|
||||
></FrameDivider>
|
||||
}
|
||||
horizontal
|
||||
></FrameDivider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,445 @@
|
||||
import { SingleSlot } from '@noodl-core-ui/types/global';
|
||||
import classNames from 'classnames';
|
||||
import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
MouseEvent
|
||||
} from 'react';
|
||||
import css from './FrameDivider.module.scss';
|
||||
|
||||
/** DOMRect without DOMRectReadOnly */
|
||||
export interface Rect {
|
||||
height: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface FrameDividerRects {
|
||||
first: Rect;
|
||||
second: Rect;
|
||||
}
|
||||
|
||||
export enum FrameDividerOwner {
|
||||
First,
|
||||
Second
|
||||
}
|
||||
|
||||
export interface FrameDividerProps {
|
||||
horizontal?: boolean;
|
||||
reverse?: boolean;
|
||||
|
||||
// TODO: Would be nice to have a way to set the split by % too
|
||||
|
||||
/** The side that is the owner of the size. */
|
||||
splitOwner?: FrameDividerOwner;
|
||||
/** splitOwner size in pixels. */
|
||||
size?: number;
|
||||
/** splitOwner minimum size in pixels. */
|
||||
sizeMin?: number;
|
||||
/** splitOwner maximum size in pixels. */
|
||||
sizeMax?: number;
|
||||
|
||||
first: SingleSlot;
|
||||
second: SingleSlot;
|
||||
|
||||
onDrag?: (bounds: FrameDividerRects) => void;
|
||||
onDragStart?: (bounds: FrameDividerRects) => void;
|
||||
onDragEnd?: (bounds: FrameDividerRects) => void;
|
||||
onResize?: (bounds: FrameDividerRects) => void;
|
||||
onSizeChanged: (size: number) => void;
|
||||
onBoundsChanged?: (bounds: DOMRect) => void;
|
||||
}
|
||||
|
||||
function setCssVariables(
|
||||
hash: string,
|
||||
owner: FrameDividerOwner,
|
||||
element: HTMLElement,
|
||||
value: number,
|
||||
horizontal: boolean,
|
||||
width: number,
|
||||
height: number
|
||||
) {
|
||||
const halfDividerSize = 3;
|
||||
|
||||
if (horizontal) {
|
||||
if (owner === FrameDividerOwner.First) {
|
||||
// Divider
|
||||
element.style.setProperty(`--frame-divider-${hash}-divider-top`, 0 + 'px');
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-divider-left`,
|
||||
Math.max(Math.min(value, width), 0) - halfDividerSize + 'px'
|
||||
);
|
||||
|
||||
// Container 1
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-1-top`, 0 + 'px');
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-1-left`, 0 + 'px');
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-container-1-width`,
|
||||
Math.max(Math.min(value, width), 0) + 'px'
|
||||
);
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-1-height`, height + 'px');
|
||||
|
||||
// Container 2
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-2-top`, 0 + 'px');
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-container-2-left`,
|
||||
Math.max(value, 0) + 'px'
|
||||
);
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-container-2-width`,
|
||||
Math.min(Math.max(width - value, 0), width) + 'px'
|
||||
);
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-2-height`, height + 'px');
|
||||
} else {
|
||||
// Divider
|
||||
element.style.setProperty(`--frame-divider-${hash}-divider-top`, 0 + 'px');
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-divider-left`,
|
||||
Math.min(Math.max(width - value, 0), width) - halfDividerSize + 'px'
|
||||
);
|
||||
|
||||
// Container 1
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-1-top`, 0 + 'px');
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-1-left`, 0 + 'px');
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-container-1-width`,
|
||||
Math.min(Math.max(width - value, 0), width) + 'px'
|
||||
);
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-1-height`, height + 'px');
|
||||
|
||||
// Container 2
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-2-top`, 0 + 'px');
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-container-2-left`,
|
||||
Math.max(width - value, 0) + 'px'
|
||||
);
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-container-2-width`,
|
||||
Math.max(Math.min(value, width), 0) + 'px'
|
||||
);
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-2-height`, height + 'px');
|
||||
}
|
||||
} else {
|
||||
if (owner === FrameDividerOwner.First) {
|
||||
// Divider
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-divider-top`,
|
||||
Math.max(Math.min(value, height), 0) - 3 + 'px'
|
||||
);
|
||||
element.style.setProperty(`--frame-divider-${hash}-divider-left`, 0 + 'px');
|
||||
|
||||
// Container 1
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-1-top`, 0 + 'px');
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-1-left`, 0 + 'px');
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-1-width`, width + 'px');
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-container-1-height`,
|
||||
Math.max(Math.min(value, height), 0) + 'px'
|
||||
);
|
||||
|
||||
// Container 2
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-container-2-top`,
|
||||
Math.max(value, 0) + 'px'
|
||||
);
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-2-left`, 0 + 'px');
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-2-width`, width + 'px');
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-container-2-height`,
|
||||
Math.min(Math.max(height - value, 0), height) + 'px'
|
||||
);
|
||||
} else {
|
||||
// Divider
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-divider-top`,
|
||||
Math.min(Math.max(height - value, 0), height) - 3 + 'px'
|
||||
);
|
||||
element.style.setProperty(`--frame-divider-${hash}-divider-left`, 0 + 'px');
|
||||
|
||||
// Container 1
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-1-top`, 0 + 'px');
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-1-left`, 0 + 'px');
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-1-width`, width + 'px');
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-container-1-height`,
|
||||
Math.min(Math.max(height - value, 0), height) + 'px'
|
||||
);
|
||||
|
||||
// Container 2
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-container-2-top`,
|
||||
Math.max(height - value, 0) + 'px'
|
||||
);
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-2-left`, 0 + 'px');
|
||||
element.style.setProperty(`--frame-divider-${hash}-container-2-width`, width + 'px');
|
||||
element.style.setProperty(
|
||||
`--frame-divider-${hash}-container-2-height`,
|
||||
Math.max(Math.min(value, height), 0) + 'px'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setCssDragVariables(hash: string, element: HTMLElement, start: boolean) {
|
||||
if (start) {
|
||||
element.style.setProperty(`--frame-divider-${hash}-pointer-events`, 'none');
|
||||
} else {
|
||||
element.style.setProperty(`--frame-divider-${hash}-pointer-events`, 'inherit');
|
||||
}
|
||||
}
|
||||
|
||||
export function FrameDivider({
|
||||
horizontal = false,
|
||||
reverse = false,
|
||||
|
||||
splitOwner = FrameDividerOwner.First,
|
||||
size = 343,
|
||||
sizeMin = 0,
|
||||
sizeMax,
|
||||
|
||||
first,
|
||||
second,
|
||||
|
||||
onDrag,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onResize,
|
||||
onSizeChanged,
|
||||
onBoundsChanged
|
||||
}: FrameDividerProps) {
|
||||
// TODO: Create a custom hook for this? (hash)
|
||||
const [hash] = useState(() =>
|
||||
Math.floor((1 + Math.random()) * 0x100000000)
|
||||
.toString(16)
|
||||
.substring(1)
|
||||
);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const resizingSize = useRef<number>(null);
|
||||
const observerRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const [bounds, setBounds] = useState<DOMRect | undefined>(undefined);
|
||||
|
||||
const dividerStyle: React.CSSProperties = {
|
||||
top: `var(--frame-divider-${hash}-divider-top)`,
|
||||
left: `var(--frame-divider-${hash}-divider-left)`
|
||||
};
|
||||
|
||||
const container1Style: React.CSSProperties = {
|
||||
top: `var(--frame-divider-${hash}-container-1-top)`,
|
||||
left: `var(--frame-divider-${hash}-container-1-left)`,
|
||||
width: `var(--frame-divider-${hash}-container-1-width)`,
|
||||
height: `var(--frame-divider-${hash}-container-1-height)`,
|
||||
// @ts-expect-error
|
||||
pointerEvents: `var(--frame-divider-${hash}-pointer-events, inherit)`
|
||||
};
|
||||
|
||||
const container2Style: React.CSSProperties = {
|
||||
top: `var(--frame-divider-${hash}-container-2-top)`,
|
||||
left: `var(--frame-divider-${hash}-container-2-left)`,
|
||||
width: `var(--frame-divider-${hash}-container-2-width)`,
|
||||
height: `var(--frame-divider-${hash}-container-2-height)`,
|
||||
// @ts-expect-error
|
||||
pointerEvents: `var(--frame-divider-${hash}-pointer-events, inherit)`
|
||||
};
|
||||
|
||||
/** Returns the current frame bounds. */
|
||||
const getFrameDividerRects = useCallback((): FrameDividerRects => {
|
||||
const actualSize = resizingSize.current || size;
|
||||
|
||||
if (horizontal) {
|
||||
const cell1: Rect = {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: actualSize,
|
||||
height: bounds.height
|
||||
};
|
||||
const cell2: Rect = {
|
||||
x: bounds.x + actualSize,
|
||||
y: bounds.y,
|
||||
width: bounds.width - actualSize,
|
||||
height: bounds.height
|
||||
};
|
||||
|
||||
return {
|
||||
first: splitOwner === FrameDividerOwner.First ? cell1 : cell2,
|
||||
second: splitOwner !== FrameDividerOwner.First ? cell1 : cell2
|
||||
};
|
||||
} else {
|
||||
const cell1 = {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: actualSize
|
||||
};
|
||||
const cell2 = {
|
||||
x: bounds.x,
|
||||
y: bounds.y + actualSize,
|
||||
width: bounds.width,
|
||||
height: bounds.height - actualSize
|
||||
};
|
||||
|
||||
return {
|
||||
first: splitOwner === FrameDividerOwner.First ? cell1 : cell2,
|
||||
second: splitOwner !== FrameDividerOwner.First ? cell1 : cell2
|
||||
};
|
||||
}
|
||||
}, [bounds, horizontal, size, splitOwner]);
|
||||
|
||||
const setBoundingClientRect = useCallback(() => {
|
||||
if (rootRef.current) {
|
||||
const newBounds = rootRef.current.getBoundingClientRect();
|
||||
|
||||
const hasChanged =
|
||||
bounds?.x !== newBounds.x ||
|
||||
bounds?.y !== newBounds.y ||
|
||||
bounds?.width !== newBounds.width ||
|
||||
bounds?.height !== newBounds.height;
|
||||
|
||||
if (hasChanged && newBounds.width !== 0 && newBounds.height !== 0) {
|
||||
setBounds(newBounds);
|
||||
onBoundsChanged && onBoundsChanged(newBounds);
|
||||
}
|
||||
}
|
||||
}, [bounds, onBoundsChanged]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (rootRef.current) {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(setBoundingClientRect);
|
||||
observer.observe(rootRef.current);
|
||||
observerRef.current = observer;
|
||||
|
||||
setBoundingClientRect();
|
||||
}
|
||||
}, [getFrameDividerRects, onResize, rootRef, setBoundingClientRect]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bounds) {
|
||||
onResize && onResize(getFrameDividerRects());
|
||||
}
|
||||
}, [bounds, getFrameDividerRects, onResize]);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
function (eventObject) {
|
||||
if (horizontal) {
|
||||
const x =
|
||||
splitOwner === FrameDividerOwner.First
|
||||
? Math.max(Math.min(eventObject.pageX - bounds.x, bounds.width), 0)
|
||||
: Math.max(Math.min(bounds.x + bounds.width - eventObject.pageX, bounds.width), 0);
|
||||
|
||||
if (sizeMax) {
|
||||
resizingSize.current = Math.min(Math.max(x, sizeMin), sizeMax);
|
||||
} else {
|
||||
resizingSize.current = Math.max(x, sizeMin);
|
||||
}
|
||||
} else {
|
||||
const y =
|
||||
splitOwner === FrameDividerOwner.First
|
||||
? Math.max(Math.min(eventObject.pageY - bounds.y, bounds.height), 0)
|
||||
: Math.max(Math.min(bounds.y + bounds.height - eventObject.pageY, bounds.height), 0);
|
||||
|
||||
if (sizeMax) {
|
||||
resizingSize.current = Math.min(Math.max(y, sizeMin), sizeMax);
|
||||
} else {
|
||||
resizingSize.current = Math.max(y, sizeMin);
|
||||
}
|
||||
}
|
||||
|
||||
setCssVariables(
|
||||
hash,
|
||||
splitOwner,
|
||||
rootRef.current,
|
||||
resizingSize.current,
|
||||
horizontal,
|
||||
bounds.width,
|
||||
bounds.height
|
||||
);
|
||||
|
||||
// TODO: throttle onDrag ?
|
||||
onDrag && onDrag(getFrameDividerRects());
|
||||
},
|
||||
[horizontal, hash, splitOwner, bounds, onDrag, getFrameDividerRects, sizeMax, sizeMin]
|
||||
);
|
||||
|
||||
const onMouseUp = useCallback(
|
||||
function () {
|
||||
onSizeChanged(resizingSize.current);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
onDragEnd && onDragEnd(getFrameDividerRects());
|
||||
setCssDragVariables(hash, rootRef.current, false);
|
||||
},
|
||||
[onMouseMove, onDragEnd, getFrameDividerRects, onSizeChanged, hash]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (bounds) {
|
||||
setCssVariables(
|
||||
hash,
|
||||
splitOwner,
|
||||
rootRef.current,
|
||||
size,
|
||||
horizontal,
|
||||
bounds.width,
|
||||
bounds.height
|
||||
);
|
||||
}
|
||||
}, [hash, bounds, horizontal, size, splitOwner, onDrag]);
|
||||
|
||||
function startDragging(eventObject: MouseEvent<HTMLDivElement>) {
|
||||
resizingSize.current = size;
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
onDragStart && onDragStart(getFrameDividerRects());
|
||||
setCssDragVariables(hash, rootRef.current, true);
|
||||
eventObject.stopPropagation();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// Check if the current size is already outside the bounds
|
||||
if (sizeMax) {
|
||||
onSizeChanged(Math.min(Math.max(size, sizeMin), sizeMax));
|
||||
} else {
|
||||
onSizeChanged(Math.max(size, sizeMin));
|
||||
}
|
||||
}, [size, sizeMax, sizeMin, onSizeChanged]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={classNames([css['Root']])}>
|
||||
<div
|
||||
style={container1Style}
|
||||
className={classNames([
|
||||
css['Container1'],
|
||||
horizontal && css['is-horizontal'],
|
||||
!horizontal && css['is-vertical']
|
||||
])}
|
||||
>
|
||||
{reverse ? second : first}
|
||||
</div>
|
||||
<div style={container2Style} className={classNames([css['Container2']])}>
|
||||
{reverse ? first : second}
|
||||
</div>
|
||||
<div
|
||||
style={dividerStyle}
|
||||
className={classNames([
|
||||
css['Divider'],
|
||||
horizontal && css['is-horizontal'],
|
||||
!horizontal && css['is-vertical']
|
||||
])}
|
||||
onMouseDown={startDragging}
|
||||
>
|
||||
<div className={classNames([css['DividerBorder']])}></div>
|
||||
<div className={classNames([css['DividerHighlight']])}></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './FrameDivider';
|
||||
@@ -0,0 +1,79 @@
|
||||
.Root {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&.is-hover,
|
||||
&:hover:not(&.is-disabled) {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
|
||||
&.is-variant-highlighted,
|
||||
&.is-variant-highlighted-shy {
|
||||
background-color: var(--theme-color-bg-4);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--theme-color-bg-1) !important;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&.is-variant-shy,
|
||||
&.is-variant-highlighted-shy {
|
||||
path {
|
||||
fill: var(--theme-color-fg-default-shy);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-variant-default-contrast,
|
||||
&.is-variant-default-contrast {
|
||||
path {
|
||||
fill: var(--theme-color-fg-default-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-showing-affix-on-hover {
|
||||
.Affix {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover .Affix {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-variant-active {
|
||||
background-color: var(--theme-color-secondary) !important;
|
||||
|
||||
.Prefix path {
|
||||
fill: var(--theme-color-on-secondary) !important;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-secondary-highlight) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Prefix,
|
||||
.Affix {
|
||||
flex-basis: 40px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.Body {
|
||||
flex: 1 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { ListItem } from './ListItem';
|
||||
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
export default {
|
||||
title: 'Layout/List Item',
|
||||
component: ListItem,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof ListItem>;
|
||||
|
||||
const Template: ComponentStory<typeof ListItem> = (args) => (
|
||||
<div style={{ width: 280 }}>
|
||||
<ListItem {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {
|
||||
icon: IconName.Home,
|
||||
text: 'Home'
|
||||
};
|
||||
|
||||
export const isDisabled = Template.bind({});
|
||||
isDisabled.args = {
|
||||
icon: IconName.Home,
|
||||
text: 'Home',
|
||||
isDisabled: true
|
||||
};
|
||||
|
||||
export const withAffix = Template.bind({});
|
||||
withAffix.args = {
|
||||
icon: IconName.Home,
|
||||
text: 'Home',
|
||||
affix: <Icon icon={IconName.ImportDown} />
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { Icon, IconName, IconProps, IconSize } from '@noodl-core-ui/components/common/Icon';
|
||||
import { Text, TextType } from '@noodl-core-ui/components/typography/Text';
|
||||
import { Slot, UnsafeStyleProps } from '@noodl-core-ui/types/global';
|
||||
|
||||
import css from './ListItem.module.scss';
|
||||
|
||||
export enum ListItemVariant {
|
||||
Default = 'default',
|
||||
DefaultContrast = 'default-contrast',
|
||||
Active = 'active',
|
||||
Shy = 'shy',
|
||||
Highlighted = 'highlighted',
|
||||
HighlightedShy = 'highlighted-shy'
|
||||
}
|
||||
|
||||
export interface ListItemProps extends UnsafeStyleProps {
|
||||
icon?: IconName;
|
||||
iconVariant?: IconProps['variant'];
|
||||
variant?: ListItemVariant;
|
||||
text: string;
|
||||
|
||||
hasHiddenIconSlot?: boolean;
|
||||
|
||||
isDisabled?: boolean;
|
||||
/** Force hover state */
|
||||
isHover?: boolean;
|
||||
|
||||
isActive?: boolean;
|
||||
|
||||
children?: Slot;
|
||||
|
||||
gutter?: number;
|
||||
|
||||
prefix?: Slot;
|
||||
affix?: Slot;
|
||||
isShowingAffixOnHover?: boolean;
|
||||
|
||||
onClick?: React.MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function ListItem({
|
||||
icon,
|
||||
iconVariant,
|
||||
variant = ListItemVariant.Default,
|
||||
text,
|
||||
|
||||
isDisabled,
|
||||
isHover,
|
||||
isActive,
|
||||
|
||||
hasHiddenIconSlot,
|
||||
|
||||
children,
|
||||
|
||||
gutter = 0,
|
||||
|
||||
prefix,
|
||||
affix,
|
||||
isShowingAffixOnHover,
|
||||
|
||||
UNSAFE_className,
|
||||
UNSAFE_style,
|
||||
|
||||
onClick
|
||||
}: ListItemProps) {
|
||||
const textVariant = listItemVariantToTextVariant(variant);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames([
|
||||
css['Root'],
|
||||
isDisabled && css['is-disabled'],
|
||||
isHover && css['is-hover'],
|
||||
css[`is-variant-${variant}`],
|
||||
isShowingAffixOnHover && css['is-showing-affix-on-hover'],
|
||||
isActive && css['is-active'],
|
||||
UNSAFE_className
|
||||
])}
|
||||
style={{
|
||||
paddingLeft: 4 * gutter + 'px',
|
||||
paddingRight: 4 * gutter + 'px',
|
||||
...(UNSAFE_style || {})
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{!hasHiddenIconSlot && (
|
||||
<div className={classNames([css['Prefix']])}>
|
||||
{Boolean(icon) && <Icon icon={icon} size={IconSize.Small} variant={iconVariant} />}
|
||||
{Boolean(prefix) && prefix}
|
||||
</div>
|
||||
)}
|
||||
<div className={classNames([css['Body']])}>
|
||||
<Text textType={textVariant}>{text}</Text>
|
||||
{children}
|
||||
</div>
|
||||
{Boolean(affix) && <div className={classNames([css['Affix']])}>{affix}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function listItemVariantToTextVariant(variant: ListItemVariant) {
|
||||
switch (variant) {
|
||||
case ListItemVariant.Shy:
|
||||
case ListItemVariant.HighlightedShy:
|
||||
return TextType.Shy;
|
||||
case ListItemVariant.DefaultContrast:
|
||||
return TextType.DefaultContrast;
|
||||
default:
|
||||
return TextType.Default;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ListItem';
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { ListItemMenu } from './ListItemMenu';
|
||||
import { IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
import { ListItemVariant } from '@noodl-core-ui/components/layout/ListItem/ListItem';
|
||||
|
||||
export default {
|
||||
title: 'Layout/List Item Menu',
|
||||
component: ListItemMenu,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof ListItemMenu>;
|
||||
|
||||
const Template: ComponentStory<typeof ListItemMenu> = (args) => (
|
||||
<div style={{ width: 280 }}>
|
||||
<ListItemMenu {...args} />
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {
|
||||
icon: IconName.Home,
|
||||
text: 'Home',
|
||||
menuItems: [
|
||||
{
|
||||
label: `Compare with main`
|
||||
},
|
||||
{
|
||||
label: `Merge into menu`
|
||||
},
|
||||
{
|
||||
label: 'Delete'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const ShyWithIcon = Template.bind({});
|
||||
ShyWithIcon.args = {
|
||||
variant: ListItemVariant.Shy,
|
||||
icon: IconName.Home,
|
||||
text: 'Home',
|
||||
menuIcon: IconName.ImportDown,
|
||||
menuItems: []
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ListItem, ListItemProps } from '@noodl-core-ui/components/layout/ListItem/ListItem';
|
||||
import { ContextMenu, ContextMenuProps } from '@noodl-core-ui/components/popups/ContextMenu';
|
||||
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
|
||||
export type ListItemMenuProps = Exclude<
|
||||
ListItemProps,
|
||||
'isShowingAffixOnHover' | 'affix' | 'isHover'
|
||||
> & {
|
||||
menuIcon?: IconName;
|
||||
menuItems: ContextMenuProps['menuItems'];
|
||||
|
||||
alwaysShowAffixIcon?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component building on top of **ListItem** to make it
|
||||
* easier to use **ContextMenu** as affix.
|
||||
*
|
||||
* When there are no **menuItems** it will fallback to show the **menuIcon**.
|
||||
*/
|
||||
export function ListItemMenu(args: ListItemMenuProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
let isShowingAffixOnHover = !isOpen;
|
||||
if (typeof args.alwaysShowAffixIcon === 'boolean' && args.alwaysShowAffixIcon) {
|
||||
isShowingAffixOnHover = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
{...args}
|
||||
isHover={isOpen}
|
||||
isShowingAffixOnHover={isShowingAffixOnHover}
|
||||
onClick={(ev) => !isOpen && args.onClick && args.onClick(ev)}
|
||||
affix={
|
||||
args.isDisabled ? null : args.menuItems.length === 0 ? (
|
||||
<Icon icon={args.menuIcon} />
|
||||
) : (
|
||||
<ContextMenu
|
||||
onOpen={() => setIsOpen(true)}
|
||||
onClose={() => setIsOpen(false)}
|
||||
menuItems={args.menuItems || []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ListItemMenu';
|
||||
@@ -0,0 +1,61 @@
|
||||
.Root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--theme-color-bg-4);
|
||||
max-width: 810px;
|
||||
max-height: 90vh;
|
||||
width: 80vw;
|
||||
overflow: hidden;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: -50px;
|
||||
right: 0;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 25px 10px var(--theme-color-bg-4);
|
||||
}
|
||||
}
|
||||
|
||||
.CloseButtonContainer {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.Header {
|
||||
padding: 20px 40px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
&.has-divider {
|
||||
border-bottom: 1px solid var(--theme-color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
.Footer {
|
||||
padding: 20px 40px 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
|
||||
&.has-divider {
|
||||
border-top: 1px solid var(--theme-color-bg-3);
|
||||
}
|
||||
}
|
||||
|
||||
.TitleWrapper {
|
||||
padding-top: 20px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
.Content {
|
||||
padding: 0 40px 40px;
|
||||
padding-top: 16px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: overlay;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Modal } from '@noodl-core-ui/components/layout/Modal/Modal';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Modal',
|
||||
argTypes: {}
|
||||
};
|
||||
|
||||
const Template = (args) => (
|
||||
<div style={{ width: 280 }}>
|
||||
<Modal isVisible {...args}>
|
||||
Content in a Modal
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Common = Template.bind({});
|
||||
|
||||
export const Header = Template.bind({});
|
||||
Header.args = {
|
||||
strapline: 'strapline',
|
||||
title: 'title',
|
||||
subtitle: 'subtitle',
|
||||
hasHeaderDivider: true
|
||||
};
|
||||
|
||||
export const Footer = Template.bind({});
|
||||
Footer.args = {
|
||||
footerSlot: <>Content in Footer</>,
|
||||
hasFooterDivider: true
|
||||
};
|
||||
|
||||
export const Full = Template.bind({});
|
||||
Full.args = {
|
||||
strapline: 'strapline',
|
||||
title: 'title',
|
||||
subtitle: 'subtitle',
|
||||
hasHeaderDivider: true,
|
||||
footerSlot: <>Content in Footer</>,
|
||||
hasFooterDivider: true
|
||||
};
|
||||
85
packages/noodl-core-ui/src/components/layout/Modal/Modal.tsx
Normal file
85
packages/noodl-core-ui/src/components/layout/Modal/Modal.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { Icon, IconName } from '@noodl-core-ui/components/common/Icon';
|
||||
import { IconButton, IconButtonVariant } from '@noodl-core-ui/components/inputs/IconButton';
|
||||
import { BaseDialog } from '@noodl-core-ui/components/layout/BaseDialog';
|
||||
import { Slot, UnsafeStyleProps } from '@noodl-core-ui/types/global';
|
||||
|
||||
import { Title } from '../../typography/Title';
|
||||
import { TitleSize, TitleVariant } from '../../typography/Title/Title';
|
||||
import css from './Modal.module.scss';
|
||||
|
||||
export interface ModalProps extends UnsafeStyleProps {
|
||||
children: Slot;
|
||||
|
||||
headerSlot?: Slot;
|
||||
hasHeaderDivider?: boolean;
|
||||
|
||||
footerSlot?: Slot;
|
||||
hasFooterDivider?: boolean;
|
||||
|
||||
strapline?: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
|
||||
isVisible: boolean;
|
||||
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export function Modal({
|
||||
children,
|
||||
headerSlot,
|
||||
hasHeaderDivider,
|
||||
footerSlot,
|
||||
hasFooterDivider,
|
||||
strapline,
|
||||
title,
|
||||
subtitle,
|
||||
isVisible,
|
||||
onClose,
|
||||
UNSAFE_className,
|
||||
UNSAFE_style
|
||||
}: ModalProps) {
|
||||
return (
|
||||
<BaseDialog hasBackdrop isVisible={isVisible} onClose={onClose}>
|
||||
<div
|
||||
className={classNames(css['Root'], isVisible && css['is-visible'], UNSAFE_className)}
|
||||
style={UNSAFE_style}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className={css['CloseButtonContainer']} data-test="close-modal-button">
|
||||
<IconButton icon={IconName.Close} onClick={onClose} variant={IconButtonVariant.Transparent} />
|
||||
</div>
|
||||
|
||||
<header className={classNames(css['Header'], hasHeaderDivider && css['has-divider'])}>
|
||||
<div className={css['TitleWrapper']}>
|
||||
{strapline && <Title hasBottomSpacing>{strapline}</Title>}
|
||||
|
||||
{title && (
|
||||
<Title size={TitleSize.Large} variant={TitleVariant.Highlighted} hasBottomSpacing={!!subtitle}>
|
||||
{title}
|
||||
</Title>
|
||||
)}
|
||||
|
||||
{subtitle && <Title>{subtitle}</Title>}
|
||||
</div>
|
||||
|
||||
{headerSlot}
|
||||
</header>
|
||||
|
||||
<div className={css['Content']}>{children}</div>
|
||||
|
||||
{footerSlot && (
|
||||
<footer className={classNames(css['Footer'], hasFooterDivider && css['has-divider'])}>{footerSlot}</footer>
|
||||
)}
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated No default imports */
|
||||
export default Modal;
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Modal';
|
||||
@@ -0,0 +1,4 @@
|
||||
.Root {
|
||||
border: 1px solid var(--theme-color-bg-3);
|
||||
border-radius: 2px;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import { ModalSection } from './ModalSection';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Modal Section',
|
||||
component: ModalSection,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof ModalSection>;
|
||||
|
||||
const Template: ComponentStory<typeof ModalSection> = (args) => <ModalSection {...args} />;
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {};
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
|
||||
import { InputLabelSection } from '@noodl-core-ui/components/inputs/InputLabelSection';
|
||||
import { Box } from '@noodl-core-ui/components/layout/Box';
|
||||
import { VStack } from '@noodl-core-ui/components/layout/Stack';
|
||||
import { Slot } from '@noodl-core-ui/types/global';
|
||||
|
||||
import css from './ModalSection.module.scss';
|
||||
|
||||
export interface ModalSectionProps {
|
||||
children: Slot;
|
||||
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function ModalSection({ children, label }: ModalSectionProps) {
|
||||
return (
|
||||
<Box hasTopSpacing={2}>
|
||||
{label && <InputLabelSection label={label} />}
|
||||
<Box hasYSpacing hasXSpacing UNSAFE_className={css['Root']}>
|
||||
<VStack hasSpacing>{children}</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ModalSection';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import { Portal } from './Portal';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Portal',
|
||||
component: Portal,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof Portal>;
|
||||
|
||||
const Template: ComponentStory<typeof Portal> = (args) => <Portal {...args} />;
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {
|
||||
portalRoot: document.querySelector('.dialog-layer-portal-target')
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Slot } from '@noodl-core-ui/types/global';
|
||||
import React, { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface PortalProps {
|
||||
children: Slot;
|
||||
portalRoot: Element;
|
||||
}
|
||||
|
||||
export function Portal({ children, portalRoot }: PortalProps) {
|
||||
const elementRoot = document.createElement('div');
|
||||
|
||||
useEffect(() => {
|
||||
portalRoot.appendChild(elementRoot);
|
||||
|
||||
return () => {
|
||||
portalRoot.removeChild(elementRoot);
|
||||
};
|
||||
}, [portalRoot, elementRoot]);
|
||||
|
||||
return createPortal(children, portalRoot);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Portal'
|
||||
@@ -0,0 +1,16 @@
|
||||
.Root {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-y: overlay;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.Container {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import { ScrollArea } from './ScrollArea';
|
||||
|
||||
export default {
|
||||
title: 'Layout/ScrollArea',
|
||||
component: ScrollArea,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof ScrollArea>;
|
||||
|
||||
const Template: ComponentStory<typeof ScrollArea> = (args) => <ScrollArea {...args} />;
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {};
|
||||
@@ -0,0 +1,18 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { Slot, UnsafeStyleProps } from '@noodl-core-ui/types/global';
|
||||
|
||||
import css from './ScrollArea.module.scss';
|
||||
|
||||
export interface ScrollAreaProps extends UnsafeStyleProps {
|
||||
children: Slot;
|
||||
}
|
||||
|
||||
export function ScrollArea({ children, UNSAFE_className, UNSAFE_style }: ScrollAreaProps) {
|
||||
return (
|
||||
<div className={classNames(css['Root'], UNSAFE_className)} style={UNSAFE_style}>
|
||||
<div className={css['Container']}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ScrollArea';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import { Text } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import { HStack } from './Stack';
|
||||
|
||||
export default {
|
||||
title: 'Layout/HStack',
|
||||
component: HStack,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof HStack>;
|
||||
|
||||
const Template: ComponentStory<typeof HStack> = (args) => (
|
||||
<div style={{ width: 280 }}>
|
||||
<HStack {...args}></HStack>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {};
|
||||
|
||||
const ListTemplate: ComponentStory<typeof HStack> = (args) => (
|
||||
/* Showcase how it is when the size is set on the parent */
|
||||
<div style={{ width: 500, height: 500 }}>
|
||||
<HStack {...args}>
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<Text>Item {i}</Text>
|
||||
))}
|
||||
</HStack>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const List = ListTemplate.bind({});
|
||||
|
||||
export const ListSpacing = ListTemplate.bind({});
|
||||
ListSpacing.args = {
|
||||
hasSpacing: true
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
.Root {
|
||||
display: flex;
|
||||
}
|
||||
61
packages/noodl-core-ui/src/components/layout/Stack/Stack.tsx
Normal file
61
packages/noodl-core-ui/src/components/layout/Stack/Stack.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import { Slot, UnsafeStyleProps } from '@noodl-core-ui/types/global';
|
||||
|
||||
import css from './Stack.module.scss';
|
||||
|
||||
function convertSpacing(options: { default: string }, ...values: (boolean | number)[]): string {
|
||||
for (const value of values) {
|
||||
if (typeof value === 'boolean' && value) {
|
||||
return options.default;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value * 4 + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface StackProps extends UnsafeStyleProps {
|
||||
direction: React.CSSProperties['flexDirection'];
|
||||
hasSpacing?: boolean | number;
|
||||
children?: Slot;
|
||||
}
|
||||
|
||||
export const Stack = React.forwardRef<HTMLDivElement, StackProps>(
|
||||
({ direction, hasSpacing, UNSAFE_style, UNSAFE_className, children }: StackProps, ref) => {
|
||||
const style: React.CSSProperties = {
|
||||
...UNSAFE_style,
|
||||
flexDirection: direction
|
||||
};
|
||||
|
||||
if (direction === 'column') {
|
||||
if (!style.width) style.width = '100%';
|
||||
} else if (direction === 'row') {
|
||||
if (!style.height) style.height = '100%';
|
||||
}
|
||||
|
||||
if (hasSpacing) {
|
||||
style.gap = convertSpacing({ default: '16px' }, hasSpacing);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={classNames([css['Root'], UNSAFE_className])} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type HStackProps = Omit<StackProps, 'direction'>;
|
||||
export const HStack = React.forwardRef<HTMLDivElement, HStackProps>((props: HStackProps, ref) => {
|
||||
return <Stack ref={ref} {...props} direction="row" />;
|
||||
});
|
||||
|
||||
export type VStackProps = Omit<StackProps, 'direction'>;
|
||||
export const VStack = React.forwardRef<HTMLDivElement, VStackProps>((props: VStackProps, ref) => {
|
||||
return <Stack ref={ref} {...props} direction="column" />;
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
import React from 'react';
|
||||
|
||||
import { Text } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
import { VStack } from '../Stack/Stack';
|
||||
|
||||
export default {
|
||||
title: 'Layout/VStack',
|
||||
component: VStack,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof VStack>;
|
||||
|
||||
const Template: ComponentStory<typeof VStack> = (args) => (
|
||||
<div style={{ width: 280 }}>
|
||||
<VStack {...args}></VStack>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {};
|
||||
|
||||
const ListTemplate: ComponentStory<typeof VStack> = (args) => (
|
||||
/* Showcase how it is when the size is set on the parent */
|
||||
<div style={{ width: 500, height: 500 }}>
|
||||
<VStack {...args}>
|
||||
{[...Array(10)].map((_, i) => (
|
||||
<Text>Item {i}</Text>
|
||||
))}
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const List = ListTemplate.bind({});
|
||||
|
||||
export const ListSpacing = ListTemplate.bind({});
|
||||
ListSpacing.args = {
|
||||
hasSpacing: true
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Stack';
|
||||
@@ -0,0 +1,133 @@
|
||||
@use '../../../styles/scss-utils/placeholder.scss' as *;
|
||||
|
||||
$_gutter: 2px;
|
||||
$_text-variant-top-padding: 15px;
|
||||
|
||||
.Root {
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.is-variant-text {
|
||||
background-color: var(--theme-color-bg-2);
|
||||
}
|
||||
}
|
||||
|
||||
.Buttons {
|
||||
display: flex;
|
||||
|
||||
.Root.is-variant-default & {
|
||||
margin-left: -$_gutter;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.Root.is-variant-text & {
|
||||
padding: $_text-variant-top-padding 15px 13px;
|
||||
}
|
||||
|
||||
.Root.is-variant-sidebar & {
|
||||
margin-left: -$_gutter;
|
||||
}
|
||||
}
|
||||
|
||||
.Button {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
.Root.is-variant-default & {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
flex-grow: 1;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 1;
|
||||
border: none;
|
||||
padding: 15px;
|
||||
margin-left: $_gutter;
|
||||
transition: background-color var(--speed-quick) var(--easing-base);
|
||||
color: var(--theme-color-fg-default);
|
||||
|
||||
> p {
|
||||
transition: color var(--speed-quick) var(--easing-base);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--theme-color-bg-2) !important;
|
||||
|
||||
> p {
|
||||
color: var(--theme-color-fg-highlight);
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--theme-color-bg-2);
|
||||
}
|
||||
}
|
||||
|
||||
.Root.is-variant-text & {
|
||||
@extend %reset-styles;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
background-color: transparent;
|
||||
margin-right: 32px;
|
||||
|
||||
> p {
|
||||
color: var(--theme-color-fg-default-shy);
|
||||
font-size: 14px;
|
||||
position: relative;
|
||||
transition: color var(--speed-quick) var(--easing-base);
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -$_text-variant-top-padding;
|
||||
height: 1px;
|
||||
background-color: var(--theme-color-fg-highlight);
|
||||
left: 0;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
transition: opacity var(--speed-quick) var(--easing-base);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active > p {
|
||||
color: var(--theme-color-fg-highlight);
|
||||
|
||||
&:after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Root.is-variant-sidebar & {
|
||||
background-color: var(--theme-color-bg-3);
|
||||
flex-grow: 1;
|
||||
flex-shrink: 0;
|
||||
flex-basis: 1;
|
||||
border: none;
|
||||
padding: 12px 16px;
|
||||
margin-left: $_gutter;
|
||||
color: var(--theme-color-fg-default);
|
||||
transition: color var(--speed-quick) var(--easing-base);
|
||||
|
||||
&.is-active {
|
||||
background-color: var(--theme-color-bg-1);
|
||||
|
||||
& > p {
|
||||
color: var(--theme-color-fg-highlight) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover > p {
|
||||
color: var(--theme-color-fg-default-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.TabContent {
|
||||
flex: 1;
|
||||
overflow: hidden overlay;
|
||||
}
|
||||
|
||||
.KeepAliveTab {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { Tabs, TabsVariant } from './Tabs';
|
||||
import { Text } from '@noodl-core-ui/components/typography/Text';
|
||||
|
||||
export default {
|
||||
title: 'Layout/Tabs',
|
||||
component: Tabs,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof Tabs>;
|
||||
|
||||
const Template: ComponentStory<typeof Tabs> = (args) => (
|
||||
<div style={{ width: 280 }}>
|
||||
<Tabs {...args}></Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {
|
||||
tabs: [
|
||||
{
|
||||
label: 'First tab',
|
||||
content: 'Some content for the first tab'
|
||||
},
|
||||
{
|
||||
label: 'Second tab',
|
||||
content: 'Second tab content!'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const VariantText = Template.bind({});
|
||||
VariantText.args = {
|
||||
variant: TabsVariant.Text,
|
||||
tabs: [
|
||||
{
|
||||
label: 'First tab',
|
||||
content: <Text>Some content for the first tab</Text>
|
||||
},
|
||||
{
|
||||
label: 'Second tab',
|
||||
content: <Text>Second tab content!</Text>
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const VariantSidebar = Template.bind({});
|
||||
VariantSidebar.args = {
|
||||
variant: TabsVariant.Sidebar,
|
||||
tabs: [
|
||||
{
|
||||
label: 'First tab',
|
||||
content: <Text>Some content for the first tab</Text>
|
||||
},
|
||||
{
|
||||
label: 'Second tab',
|
||||
content: <Text>Second tab content!</Text>
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const SettingTabsWithId = Template.bind({});
|
||||
SettingTabsWithId.args = {
|
||||
tabs: [
|
||||
{
|
||||
label: 'Same label',
|
||||
content: <Text>I am the first tab with the same name</Text>,
|
||||
id: 'tab-1'
|
||||
},
|
||||
{
|
||||
label: 'Same label',
|
||||
content: <Text>I am the second tab with the same label</Text>,
|
||||
id: 2
|
||||
}
|
||||
]
|
||||
};
|
||||
106
packages/noodl-core-ui/src/components/layout/Tabs/Tabs.tsx
Normal file
106
packages/noodl-core-ui/src/components/layout/Tabs/Tabs.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Slot, UnsafeStyleProps } from '@noodl-core-ui/types/global';
|
||||
import classNames from 'classnames';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Text } from '../../typography/Text';
|
||||
import css from './Tabs.module.scss';
|
||||
|
||||
export enum TabsVariant {
|
||||
Default = 'is-variant-default',
|
||||
Text = 'is-variant-text',
|
||||
Sidebar = 'is-variant-sidebar'
|
||||
}
|
||||
|
||||
export interface TabsTab {
|
||||
label: string;
|
||||
id?: string;
|
||||
content: Slot;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export interface TabsProps extends UnsafeStyleProps {
|
||||
tabs: TabsTab[];
|
||||
/**
|
||||
* Set the initial active tab, updating this value will not change the tab.
|
||||
*/
|
||||
initialActiveTab?: TabsTab['label'];
|
||||
/**
|
||||
* Set the active tab, updating this value will change the active tab.
|
||||
*/
|
||||
activeTab?: TabsTab['label'];
|
||||
variant?: TabsVariant;
|
||||
|
||||
keepTabsAlive?: boolean;
|
||||
|
||||
onChange?: (activeTab: string) => void;
|
||||
}
|
||||
|
||||
function getTabId(tab: TabsTab) {
|
||||
return tab.hasOwnProperty('id') ? tab.id : tab.label;
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
tabs,
|
||||
initialActiveTab,
|
||||
activeTab,
|
||||
variant = TabsVariant.Default,
|
||||
keepTabsAlive = false,
|
||||
|
||||
onChange,
|
||||
|
||||
UNSAFE_className,
|
||||
UNSAFE_style
|
||||
}: TabsProps) {
|
||||
const [activeTabId, setActiveTabId] = useState(() => initialActiveTab || getTabId(tabs[0]));
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTab) return;
|
||||
|
||||
setActiveTabId(activeTab);
|
||||
}, [activeTab]);
|
||||
|
||||
const tabWidth = `calc(${100 / tabs.length}% - 2px)`;
|
||||
|
||||
function changeTab(tab: TabsTab) {
|
||||
const tabId = getTabId(tab);
|
||||
setActiveTabId(tabId);
|
||||
onChange && onChange(tabId);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(css['Root'], css[variant], UNSAFE_className)} style={UNSAFE_style}>
|
||||
<nav className={css['Buttons']}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={getTabId(tab)}
|
||||
className={classNames([
|
||||
css['Button'],
|
||||
activeTabId === getTabId(tab) && css['is-active']
|
||||
])}
|
||||
onClick={() => changeTab(tab)}
|
||||
style={{ width: variant === TabsVariant.Text ? null : tabWidth }}
|
||||
data-test={tab.testId}
|
||||
>
|
||||
<Text>{tab.label}</Text>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={css['TabContent']}>
|
||||
{Boolean(keepTabsAlive)
|
||||
? tabs.map((tab) => {
|
||||
const tabId = getTabId(tab);
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
className={css['KeepAliveTab']}
|
||||
style={{ display: tabId === activeTabId ? 'block' : 'none' }}
|
||||
>
|
||||
{tab.content}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: tabs.find((tab) => getTabId(tab) === activeTabId).content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './Tabs';
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||
|
||||
import { TestView } from './TestView';
|
||||
|
||||
export default {
|
||||
title: 'Layout/TestView',
|
||||
component: TestView,
|
||||
argTypes: {}
|
||||
} as ComponentMeta<typeof TestView>;
|
||||
|
||||
const Template: ComponentStory<typeof TestView> = (args) => <TestView {...args} />
|
||||
|
||||
export const Common = Template.bind({});
|
||||
Common.args = {};
|
||||
@@ -0,0 +1,48 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
export function TestView({ backgroundColor }) {
|
||||
const rootRef = useRef(null);
|
||||
const observerRef = useRef(null);
|
||||
const [bounds, setBounds] = useState(undefined);
|
||||
|
||||
const onResize = useCallback(() => {
|
||||
if (rootRef.current) {
|
||||
setBounds(rootRef.current.getBoundingClientRect());
|
||||
}
|
||||
}, [rootRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (rootRef.current) {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect();
|
||||
observerRef.current = null;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(onResize);
|
||||
observer.observe(rootRef.current);
|
||||
observerRef.current = observer;
|
||||
}
|
||||
}, [rootRef, onResize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontSize: '20px',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
>
|
||||
{Boolean(bounds) && (
|
||||
<span>
|
||||
{bounds.width}x{bounds.height}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user