Skip to content

Commit

Permalink
Work on DialogModal.
Browse files Browse the repository at this point in the history
  • Loading branch information
mkrause committed Jan 2, 2025
1 parent a40a837 commit a94260e
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 38 deletions.
127 changes: 96 additions & 31 deletions src/components/overlays/DialogModal/DialogModal.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,68 +4,99 @@

@use '../../../styling/defs.scss' as bk;

@mixin dialog-entry-exit($entry-dur: 200ms, $exit-dur: 180ms) {
// Note: `$blur` should be in px, not rem (blur effect should be independent of any font size scaling)
@mixin dialog-backdrop($entry-dur: 200ms, $exit-dur: 180ms, $blur: 2px) {
&::backdrop {
transition-property: overlay, display, background-color, backdrop-filter;
transition-timing-function: linear;
transition-behavior: allow-discrete;

// Open state styling
background-color: light-dark(rgb(0 0 0 / 10%), rgb(0 0 0 / 20%));
backdrop-filter: blur($blur) opacity(1);

// Entry transition styling
transition-duration: $entry-dur;
@starting-style {
background-color: transparent;
backdrop-filter: blur($blur) opacity(0);
}
}
// Exit transition styling
// Note: cannot nest the following inside `::backdrop`, since `:not()` needs to apply to the dialog not `::backdrop`
&:not([open])::backdrop {
transition-duration: $exit-dur;
background-color: transparent;
backdrop-filter: blur($blur) opacity(0);
}
}

@mixin dialog-fade-in($entry-dur: 200ms, $exit-dur: 180ms, $scale: 1) {
transition-property: overlay, display, opacity, scale;
transition-timing-function: ease-out;
transition-behavior: allow-discrete;
transition-duration: $exit-dur; // Exit
transition-timing-function: ease-out; // Exit

// Open state styling
opacity: 1;
scale: 1;

// Entry transition styling
transition-duration: $entry-dur;
@starting-style {
transition-duration: $entry-dur;
transition-timing-function: ease-in;
opacity: 0;
scale: 1.1;
scale: $scale;
}

// Exit transition styling
&:not([open]) {
transition-duration: $exit-dur;
transition-timing-function: ease-in;
opacity: 0;
scale: 0.98;
scale: calc(1 - (($scale - 1) * 0.2)); // Multiply by 0.2 to make the scale out effect a bit more subtle
}
}

@mixin dialog-slide-over($entry-dur: 200ms, $exit-dur: 180ms) {
transition-property: overlay, display, translate;
transition-timing-function: ease-out;
transition-behavior: allow-discrete;

// Open state styling
translate: 0;

$blur: 2px; // Should be in px, not rem (blur effect should be constant)
&::backdrop {
transition-property: overlay, display, background-color, backdrop-filter;
transition-behavior: allow-discrete;
transition-duration: $exit-dur; // Exit
transition-timing-function: ease-out; // Exit

// Open state styling
background-color: light-dark(rgb(0 0 0 / 10%), rgb(0 0 0 / 20%));
backdrop-filter: blur($blur) opacity(1);

// Entry transition styling
@starting-style {
transition-duration: $entry-dur;
transition-timing-function: ease-in;
background-color: transparent;
backdrop-filter: blur($blur) opacity(0);
}
// Entry transition styling
transition-duration: $entry-dur;
@starting-style {
translate: 80vw 0;
}

// Exit transition styling
// Note: cannot nest the following inside `::backdrop`, since `:not()` needs to apply to the dialog not `::backdrop`
&:not([open])::backdrop {
background-color: transparent;
backdrop-filter: blur($blur) opacity(0);
&:not([open]) {
transition-duration: $exit-dur;
transition-timing-function: ease-in;
translate: 80vw 0;
}
}


@layer baklava.components {
.bk-dialog-modal {
@include bk.component-base(bk-dialog-modal);

--bk-dialog-modal-entry-dur: 200ms;
--bk-dialog-modal-exit-dur: 180ms;
--bk-dialog-modal-fade-scale: 1;

// Basic modal positioning
position: fixed;
inset: 0;
max-width: 100vw;
max-height: 100vh;

// Entry and exit animations
@include dialog-entry-exit;
@include dialog-backdrop(
$entry-dur: var(--bk-dialog-modal-entry-dur),
$exit-dur: var(--bk-dialog-modal-exit-dur),
);


//
Expand All @@ -76,6 +107,26 @@
margin: auto;
width: fit-content;
height: fit-content;

@include dialog-fade-in(
$entry-dur: var(--bk-dialog-modal-entry-dur),
$exit-dur: var(--bk-dialog-modal-exit-dur),
$scale: var(--bk-dialog-modal-fade-scale),
);

max-height: calc(100vh - 5vw); // The larger the viewport width, the more breathing room in height
&.bk-dialog-modal--small {
--bk-dialog-modal-fade-scale: 1.05;
max-width: 30vw;
}
&.bk-dialog-modal--medium {
--bk-dialog-modal-fade-scale: 1.04;
max-width: 50vw;
}
&.bk-dialog-modal--large {
--bk-dialog-modal-fade-scale: 1.03;
max-width: 60vw;
}
}

&.bk-dialog-modal--full-screen {
Expand All @@ -84,6 +135,13 @@
margin: 0;
width: auto;
height: auto;

--bk-dialog-modal-entry-dur: 120ms;
--bk-dialog-modal-exit-dur: 120ms;
@include dialog-fade-in(
$entry-dur: var(--bk-dialog-modal-entry-dur),
$exit-dur: var(--bk-dialog-modal-exit-dur),
);
}

&.bk-dialog-modal--slide-over {
Expand All @@ -93,6 +151,13 @@
height: auto;

margin-inline-start: 20vw;

--bk-dialog-modal-entry-dur: 300ms;
--bk-dialog-modal-exit-dur: 250ms;
@include dialog-slide-over(
$entry-dur: var(--bk-dialog-modal-entry-dur),
$exit-dur: var(--bk-dialog-modal-exit-dur),
);
}
}
}
15 changes: 14 additions & 1 deletion src/components/overlays/DialogModal/DialogModal.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import * as React from 'react';

import type { Meta, StoryObj } from '@storybook/react';
import { LoremIpsum } from '../../../util/storybook/LoremIpsum.tsx';

import { Button } from '../../actions/Button/Button.tsx';

Expand All @@ -24,12 +25,24 @@ export default {
args: {
title: 'Modal dialog',
trigger: ({ active, activate }) => <Button variant="primary" label="Open modal" onPress={activate}/>,
children: <LoremIpsum paragraphs={15}/>,
},
render: (args) => <DialogModal {...args}/>,
} satisfies Meta<DialogModalArgs>;


export const DialogModalStandard: Story = {
export const DialogModalStandard: Story = {};

export const DialogModalSmall: Story = {
args: {
size: 'small',
},
};

export const DialogModalLarge: Story = {
args: {
size: 'large',
},
};

export const DialogModalFullScreen: Story = {
Expand Down
6 changes: 5 additions & 1 deletion src/components/overlays/DialogModal/DialogModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export type DialogModalProps = ComponentProps<typeof Dialog> & {
/** How to display the modal in the viewport. Default: 'center'. */
display?: undefined | 'center' | 'full-screen' | 'slide-over',

/** The size of the modal. Applies to modals with `display="center"`. */
/** The size of the modal. Note: does not apply to full screen modals. */
size?: undefined | 'small' | 'medium' | 'large',

/** The modal trigger. */
Expand All @@ -39,6 +39,7 @@ export const DialogModal = (props: DialogModalProps) => {
trigger,
unstyled = false,
display = 'center',
size = 'medium',
...propsRest
} = props;

Expand All @@ -56,6 +57,9 @@ export const DialogModal = (props: DialogModalProps) => {
{ [cl['bk-dialog-modal--center']]: display === 'center' },
{ [cl['bk-dialog-modal--full-screen']]: display === 'full-screen' },
{ [cl['bk-dialog-modal--slide-over']]: display === 'slide-over' },
{ [cl['bk-dialog-modal--small']]: size === 'small' },
{ [cl['bk-dialog-modal--medium']]: size === 'medium' },
{ [cl['bk-dialog-modal--large']]: size === 'large' },
dialogProps.className,
propsRest.className,
)}
Expand Down
25 changes: 20 additions & 5 deletions src/components/overlays/ModalProvider/ModalProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@

import * as React from 'react';

import { useControlledDialog, type ControlledDialogProps } from './useControlledDialog.ts';
import { useDebounce } from '../../../util/hooks/useDebounce.ts';
import { useControlledDialog, type ControlledDialogProps } from '../../util/Dialog/useControlledDialog.ts';

import cl from './ModalProvider.module.scss';

Expand All @@ -21,16 +22,30 @@ export type ModalProviderProps = {

/** Whether the modal should be active by default. */
activeDefault?: undefined | boolean,

/** How long to keep the dialog in the DOM for exit animation purposes. Default: 2 seconds. */
exitAnimationDelay?: undefined | number,
};
/**
* Provider around a trigger (e.g. button) to display a modal overlay on trigger activation.
*/
export const ModalProvider = (props: ModalProviderProps) => {
const { children, content, activeDefault = false } = props;
const {
children,
content,
activeDefault = false,
exitAnimationDelay = 100_000,
} = props;

const [active, setActiveInternal] = React.useState(activeDefault);
const [activeWithDelay, setActiveWithDelay] = useDebounce(active, exitAnimationDelay);

const [active, setActive] = React.useState(activeDefault);
const activate = React.useCallback(() => { setActive(true); }, []);
const setActive = React.useCallback((active: boolean) => {
setActiveInternal(active);
if (active) { setActiveWithDelay(true); } // Skip the delay when activating (should only be for deactivation)
}, [setActiveWithDelay]);

const activate = React.useCallback(() => { setActive(true); }, [setActive]);
const triggerProps = { active, activate };

const dialogProps = useControlledDialog({
Expand All @@ -41,7 +56,7 @@ export const ModalProvider = (props: ModalProviderProps) => {

return (
<>
{active && content(dialogProps)}
{activeWithDelay && content(dialogProps)}
{children(triggerProps)}
</>
);
Expand Down
36 changes: 36 additions & 0 deletions src/util/hooks/useDebounce.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@

import * as React from 'react';


type TimeoutRef = ReturnType<typeof globalThis.setTimeout>;
type UseDebounceResult<S> = [S, React.Dispatch<React.SetStateAction<S>>];

// Adopted from: https://github.com/uidotdev/usehooks
export const useDebounce = <S>(value: S, delay: number /* ms */): UseDebounceResult<S> => {
const [debouncedValue, setDebouncedValue] = React.useState(value);
const timeoutHandleRef = React.useRef<TimeoutRef>(null);

React.useEffect(() => {
timeoutHandleRef.current = globalThis.setTimeout(() => {
setDebouncedValue(value);
}, delay);

return () => {
const timeoutHandle = timeoutHandleRef.current;
if (timeoutHandle) {
globalThis.clearTimeout(timeoutHandle);
}
};
}, [value, delay]);

const setDebouncedManually = React.useCallback((value: React.SetStateAction<S>) => {
setDebouncedValue(value);

const timeoutHandle = timeoutHandleRef.current;
if (timeoutHandle) {
globalThis.clearTimeout(timeoutHandle);
}
}, []);

return [debouncedValue, setDebouncedManually];
};

0 comments on commit a94260e

Please sign in to comment.