diff --git a/src/components/AppNavigation/AppNavigation.module.scss b/src/components/AppNavigation/AppNavigation.module.scss index 5c8f474..07345bb 100644 --- a/src/components/AppNavigation/AppNavigation.module.scss +++ b/src/components/AppNavigation/AppNavigation.module.scss @@ -6,6 +6,7 @@ .app-navigation { --bleed: 100dvh; --indicator-thickness: 0.25rem; + --vertical-padding: 0.75rem; @include shadows.surface; @@ -17,8 +18,8 @@ box-sizing: border-box; width: 100vw; - height: calc(var(--app-bar-height) + var(--bleed)); - padding-top: var(--bleed); + height: calc(var(--app-bar-height) + var(--bleed) + env(safe-area-inset-top)); + padding-top: calc(var(--bleed) + env(safe-area-inset-top)); &-content { display: grid; @@ -27,7 +28,10 @@ box-sizing: border-box; margin: 0 auto; - padding: 0.75rem var(--page-padding) calc(0.75rem - var(--border-width)); + padding-top: calc(var(--vertical-padding) - var(--border-width)); + padding-right: calc(var(--page-padding) + env(safe-area-inset-right)); + padding-bottom: var(--vertical-padding); + padding-left: calc(var(--page-padding) + env(safe-area-inset-left)); &[data-layout="mobile"] { grid-template-areas: "navigation logo secondary"; diff --git a/src/components/AppNavigation/components/MobileNavigation/MobileNavigation.module.scss b/src/components/AppNavigation/components/MobileNavigation/MobileNavigation.module.scss index 1cf387d..5f89bea 100644 --- a/src/components/AppNavigation/components/MobileNavigation/MobileNavigation.module.scss +++ b/src/components/AppNavigation/components/MobileNavigation/MobileNavigation.module.scss @@ -32,7 +32,7 @@ display: grid; grid-template-areas: "header" "routes"; grid-template-columns: 1fr; - grid-template-rows: var(--app-bar-height) 1fr; + grid-template-rows: calc(var(--app-bar-height) + env(safe-area-inset-top)) 1fr; width: 20rem; max-width: calc(100vw - 2rem); @@ -55,7 +55,7 @@ grid-area: header; flex-shrink: 0; justify-content: space-between; - padding: 0 var(--padding-x); + padding: env(safe-area-inset-top) var(--padding-x) 0 calc(var(--padding-x) + env(safe-area-inset-left)); } &-scroll-area { @@ -66,7 +66,11 @@ display: flex; flex-direction: column; gap: 1rem; - padding: 1rem var(--padding-x); + + padding-top: 1rem; + padding-right: var(--padding-x); + padding-bottom: calc(1rem + env(safe-area-inset-bottom)); + padding-left: calc(var(--padding-x) + env(safe-area-inset-left)); } } } diff --git a/src/components/DialogManager/Dialog.module.scss b/src/components/DialogManager/Dialog.module.scss index dd0f67e..82544f4 100644 --- a/src/components/DialogManager/Dialog.module.scss +++ b/src/components/DialogManager/Dialog.module.scss @@ -31,16 +31,20 @@ } &-popup { - --dialog-usable-height: calc(100dvh - (2 * var(--page-padding))); - --dialog-usable-width: calc(100dvw - (2 * var(--page-padding))); + --dialog-offset-top: calc(var(--page-padding) + env(safe-area-inset-top)); + --dialog-offset-bottom: calc(var(--page-padding) + env(safe-area-inset-bottom)); + --dialog-offset-left: calc(var(--page-padding) + env(safe-area-inset-left)); + --dialog-offset-right: calc(var(--page-padding) + env(safe-area-inset-right)); + --dialog-usable-height: calc(100dvh - (var(--dialog-offset-top) + var(--dialog-offset-bottom))); + --dialog-usable-width: calc(100dvw - (var(--dialog-offset-left) + var(--dialog-offset-right))); --dialog-max-height: min(var(--dialog-user-max-height, 100dvh), var(--dialog-usable-height)); --dialog-max-width: min(var(--dialog-user-max-width, 100dvw), var(--dialog-usable-width)); @include shadows.elevated; position: fixed; - top: 50%; - left: 50%; + top: calc((var(--dialog-usable-height) / 2) + var(--dialog-offset-top)); + left: calc((var(--dialog-usable-width) / 2) + var(--dialog-offset-left)); transform: translate(-50%, -50%) scale(calc(1 - 0.1 * var(--nested-dialogs))); translate: 0 calc(0px + 1.25rem * var(--nested-dialogs)); @@ -52,7 +56,7 @@ box-sizing: border-box; width: max-content; - min-width: min(calc(100dvw - (2 * var(--page-padding))), 24rem); + min-width: min(var(--dialog-usable-width), 24rem); max-width: var(--dialog-max-width); max-height: var(--dialog-max-height); padding: var(--modal-padding) 0; diff --git a/src/components/Drawer/Drawer.module.scss b/src/components/Drawer/Drawer.module.scss index e474d93..b04d49b 100644 --- a/src/components/Drawer/Drawer.module.scss +++ b/src/components/Drawer/Drawer.module.scss @@ -73,6 +73,10 @@ &-popup { --bleed: 3rem; --stack-scale: calc(1 - 0.05 * var(--nested-drawers, 0)); + --inset-top: calc(var(--modal-padding) + env(safe-area-inset-top)); + --inset-right: calc(var(--modal-padding) + env(safe-area-inset-right)); + --inset-bottom: calc(var(--modal-padding) + env(safe-area-inset-bottom)); + --inset-left: calc(var(--modal-padding) + env(safe-area-inset-left)); @include shadows.elevated; @@ -247,6 +251,29 @@ justify-content: space-between; padding: var(--modal-padding) var(--modal-padding) 1rem; + // Only inset for the safe area on edges where this drawer's popup actually touches the + // screen edge for its anchor direction. + .drawer-popup[data-swipe-direction="up"] & { + padding-top: var(--inset-top); + padding-right: var(--inset-right); + padding-left: var(--inset-left); + } + + .drawer-popup[data-swipe-direction="down"] & { + padding-right: var(--inset-right); + padding-left: var(--inset-left); + } + + .drawer-popup[data-swipe-direction="left"] & { + padding-top: var(--inset-top); + padding-left: var(--inset-left); + } + + .drawer-popup[data-swipe-direction="right"] & { + padding-top: var(--inset-top); + padding-right: var(--inset-right); + } + &-close { margin: -0.25rem; } @@ -266,6 +293,29 @@ min-height: 0; padding: 0 var(--modal-padding) var(--modal-padding); + // Only inset for the safe area on edges where this drawer's popup actually touches the + // screen edge for its anchor direction. + .drawer-popup[data-swipe-direction="up"] & { + padding-right: var(--inset-right); + padding-left: var(--inset-left); + } + + .drawer-popup[data-swipe-direction="down"] & { + padding-right: var(--inset-right); + padding-bottom: var(--inset-bottom); + padding-left: var(--inset-left); + } + + .drawer-popup[data-swipe-direction="left"] & { + padding-bottom: var(--inset-bottom); + padding-left: var(--inset-left); + } + + .drawer-popup[data-swipe-direction="right"] & { + padding-right: var(--inset-right); + padding-bottom: var(--inset-bottom); + } + &[data-padding="false"] { padding: 0; } diff --git a/src/components/FooterBar/FooterBar.module.scss b/src/components/FooterBar/FooterBar.module.scss new file mode 100644 index 0000000..5e176dd --- /dev/null +++ b/src/components/FooterBar/FooterBar.module.scss @@ -0,0 +1,41 @@ +@use "../../style/layers"; +@use "../../style/flex"; +@use "../../style/shadows"; + +@layer components { + .footer-bar { + --bleed: 100dvh; + --vertical-padding: 1rem; + + @include shadows.surface; + + position: fixed; + bottom: calc(-1 * var(--bleed)); + left: 0; + + overflow: hidden; + + box-sizing: border-box; + width: 100vw; + padding-bottom: calc(var(--bleed) + env(safe-area-inset-bottom)); + + &-content { + @include flex.row($gap: 1.5rem, $yAlign: center); + + box-sizing: border-box; + margin: 0 auto; + padding-top: calc(var(--vertical-padding) - var(--border-width)); + padding-right: calc(var(--page-padding) + env(safe-area-inset-right)); + padding-bottom: var(--vertical-padding); + padding-left: calc(var(--page-padding) + env(safe-area-inset-left)); + + &[data-mobile] { + --mobile-offset: 0.5rem; + + padding-right: calc(var(--vertical-padding) + var(--mobile-offset) + env(safe-area-inset-right)); + padding-bottom: calc(var(--vertical-padding) + var(--mobile-offset)); + padding-left: calc(var(--vertical-padding) + var(--mobile-offset) + env(safe-area-inset-left)); + } + } + } +} diff --git a/src/components/FooterBar/FooterBar.module.scss.d.ts b/src/components/FooterBar/FooterBar.module.scss.d.ts new file mode 100644 index 0000000..efd548b --- /dev/null +++ b/src/components/FooterBar/FooterBar.module.scss.d.ts @@ -0,0 +1,2 @@ +export declare const footerBar: string; +export declare const footerBarContent: string; diff --git a/src/components/FooterBar/FooterBar.stories.tsx b/src/components/FooterBar/FooterBar.stories.tsx new file mode 100644 index 0000000..bbd787e --- /dev/null +++ b/src/components/FooterBar/FooterBar.stories.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { + ArrowLeft, + Save, + X, +} from 'lucide-react'; + +import { ButtonProps } from '../Button'; +import { Spinner } from '../Spinner'; +import { FooterBar, FooterBarProps } from './FooterBar'; +import { FooterBarActions } from './FooterBarActions'; + +interface StoryArgs extends FooterBarProps { + mobile?: boolean; +} + +const meta: Meta = { + title: 'Components/FooterBar', + component: FooterBar, + parameters: { + layout: 'centered', + bodyBackground: 'var(--color-page-bg)', + docs: { + story: { inline: false, height: '12rem' }, + }, + }, + argTypes: { + maxWidth: { + control: 'number', + description: 'Constrains the inner content width.', + }, + mobile: { + control: 'boolean', + description: 'Switches between desktop and mobile layouts.', + }, + portalTarget: { + table: { disable: true }, + }, + children: { + table: { disable: true }, + }, + }, + decorators: [ + (Story, { args }) => { + const [portalTarget, setPortalTarget] = useState(null); + return ( +
+ {portalTarget && } +
+ ); + }, + ], +}; + +export default meta; +type Story = StoryObj; + +const actions: { + left: ButtonProps[]; + right: ButtonProps[]; +} = { + left: [{ icon: , text: 'Cancel', variant: 'shaded' }], + right: [{ icon: , text: 'Save', variant: 'solid', intent: 'primary' }], +}; + +export const Default: Story = { + args: { + mobile: false, + }, + render: (args) => ( + + + + ), +}; + +export const MaxWidth: Story = { + args: { + mobile: false, + maxWidth: 256, + }, + render: (args) => ( + + , text: 'Cancel', variant: 'ghost' }, + ]} + right={[ + { icon: , text: 'Save', variant: 'solid', intent: 'primary' }, + ]} + /> + + ), +}; + +export const CustomElement: Story = { + args: { + mobile: false, + }, + render: (args) => ( + +
+ + Syncing changes… +
+
+ ), +}; diff --git a/src/components/FooterBar/FooterBar.tsx b/src/components/FooterBar/FooterBar.tsx new file mode 100644 index 0000000..a315673 --- /dev/null +++ b/src/components/FooterBar/FooterBar.tsx @@ -0,0 +1,59 @@ +import { + ReactElement, + ReactNode, + useEffect, + useRef, +} from 'react'; +import { createPortal } from 'react-dom'; +import clsx from 'clsx'; + +import { getStyleClassNames } from '../../utils/getStyleClassNames'; + +import styles from './FooterBar.module.scss'; + +export interface FooterBarProps { + children?: ReactNode; + className?: string; + maxWidth?: number | string; + mobile?: boolean; + portalTarget?: Element; +} + +export const FooterBar = ({ + children, + className, + maxWidth = '100vw', + mobile = false, + portalTarget = document.body, +}: FooterBarProps): ReactElement => { + const contentRef = useRef(null); + + useEffect(() => { + const content = contentRef.current; + if (!content) { + return; + } + const observer = new ResizeObserver(([entry]) => { + document.documentElement.style.setProperty('--footer-bar-height', `${entry.contentRect.height}px`); + }); + observer.observe(content); + return () => { + observer.disconnect(); + document.documentElement.style.removeProperty('--footer-bar-height'); + }; + }, []); + + return createPortal(( +
+
+ {children} +
+
+ ), portalTarget); +}; diff --git a/src/components/FooterBar/FooterBarActions.module.scss b/src/components/FooterBar/FooterBarActions.module.scss new file mode 100644 index 0000000..e2a6f2e --- /dev/null +++ b/src/components/FooterBar/FooterBarActions.module.scss @@ -0,0 +1,15 @@ +@use "../../style/layers"; +@use "../../style/flex"; + +@layer components { + .footer-bar-actions { + @include flex.row($gap: 1.5rem); + + flex: 1; + justify-content: space-between; + + &-group { + @include flex.row($gap: 0.5rem, $yAlign: center); + } + } +} diff --git a/src/components/FooterBar/FooterBarActions.module.scss.d.ts b/src/components/FooterBar/FooterBarActions.module.scss.d.ts new file mode 100644 index 0000000..a59688c --- /dev/null +++ b/src/components/FooterBar/FooterBarActions.module.scss.d.ts @@ -0,0 +1,2 @@ +export declare const footerBarActions: string; +export declare const footerBarActionsGroup: string; diff --git a/src/components/FooterBar/FooterBarActions.tsx b/src/components/FooterBar/FooterBarActions.tsx new file mode 100644 index 0000000..31ed6e0 --- /dev/null +++ b/src/components/FooterBar/FooterBarActions.tsx @@ -0,0 +1,38 @@ +import { ReactElement } from 'react'; + +import { Button, ButtonProps } from '../Button'; + +import styles from './FooterBarActions.module.scss'; + +export interface FooterBarActionsProps { + left?: ButtonProps[]; + mobile?: boolean; + right?: ButtonProps[]; +} + +export const FooterBarActions = ({ + left, + mobile = false, + right, +}: FooterBarActionsProps): ReactElement => { + const renderAction = (action: ButtonProps, index: number) => ( +