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:
Michael Cartner
2024-01-26 11:52:55 +01:00
commit b9c60b07dc
2789 changed files with 868795 additions and 0 deletions

View File

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

View File

@@ -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 = {};

View File

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

View File

@@ -0,0 +1 @@
export * from './BaseDialog'

View File

@@ -0,0 +1,2 @@
.Root {
}

View File

@@ -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 = {};

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

View File

@@ -0,0 +1 @@
export * from './Box';

View File

@@ -0,0 +1,7 @@
.Root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
}

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from './Carousel';

View File

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

View File

@@ -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 = {};

View File

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

View File

@@ -0,0 +1 @@
export * from './CarouselIndicatorDot'

View File

@@ -0,0 +1,5 @@
.Root {
display: flex;
justify-content: center;
align-items: center;
}

View File

@@ -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 = {};

View File

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

View File

@@ -0,0 +1 @@
export * from './Center';

View File

@@ -0,0 +1,8 @@
.Root {
display: block;
}
.Inner {
display: flow-root;
position: static;
}

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from './Collapsible';

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

View File

@@ -0,0 +1 @@
export * from './Columns';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from './ConditionalContainer';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from './Container';

View File

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

View File

@@ -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 = {};

View File

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

View File

@@ -0,0 +1 @@
export * from './DocumentTopToolbar';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from './FrameDivider';

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from './ListItem';

View File

@@ -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: []
};

View File

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

View File

@@ -0,0 +1 @@
export * from './ListItemMenu';

View File

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

View File

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

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

View File

@@ -0,0 +1 @@
export * from './Modal';

View File

@@ -0,0 +1,4 @@
.Root {
border: 1px solid var(--theme-color-bg-3);
border-radius: 2px;
}

View File

@@ -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 = {};

View File

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

View File

@@ -0,0 +1 @@
export * from './ModalSection';

View File

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

View File

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

View File

@@ -0,0 +1 @@
export * from './Portal'

View File

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

View File

@@ -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 = {};

View File

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

View File

@@ -0,0 +1 @@
export * from './ScrollArea';

View File

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

View File

@@ -0,0 +1,3 @@
.Root {
display: flex;
}

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

View File

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

View File

@@ -0,0 +1 @@
export * from './Stack';

View File

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

View File

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

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

View File

@@ -0,0 +1 @@
export * from './Tabs';

View File

@@ -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 = {};

View File

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