From 119d51d989b0d33c66e950e732a6113ea221b77b Mon Sep 17 00:00:00 2001 From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:11:02 +0300 Subject: [PATCH] feat: motion across the shell, overlays and controls (1.2.0) --- package-lock.json | 8 +- package.json | 6 +- src/components/AppShell.tsx | 8 +- src/components/Choice.tsx | 9 +- src/components/PageTransition.tsx | 9 ++ src/components/Sidebar.tsx | 14 ++- src/components/Snackbar.tsx | 6 +- src/components/Tabs.tsx | 2 +- src/index.ts | 1 + src/theme.css | 164 +++++++++++++++++++++++++++++- src/theme.ts | 18 +++- 11 files changed, 223 insertions(+), 22 deletions(-) create mode 100644 src/components/PageTransition.tsx diff --git a/package-lock.json b/package-lock.json index ae7edbc..08905c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@project-graphite/ui", - "version": "1.1.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@project-graphite/ui", - "version": "1.1.1", + "version": "1.2.0", "license": "MIT", "devDependencies": { "@emotion/cache": "11.14.0", @@ -30,8 +30,8 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/material": "^9.5.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" + "react": "^19.3.0", + "react-dom": "^19.3.0" } }, "node_modules/@babel/code-frame": { diff --git a/package.json b/package.json index 91264bb..5f7e453 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@project-graphite/ui", - "version": "1.1.1", + "version": "1.2.0", "description": "Project Graphite design tokens, theme and React components.", "license": "MIT", "repository": { @@ -32,8 +32,8 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@mui/material": "^9.5.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" + "react": "^19.3.0", + "react-dom": "^19.3.0" }, "devDependencies": { "@emotion/cache": "11.14.0", diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 5c18879..f93f814 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -62,6 +62,7 @@ export function AppShell({ }) { const Link = useLink(); const [collapsed, setCollapsed] = useState(storedCollapsed); + const activeTab = tabs.findIndex((tab) => tab.active); const toggle = () => { setCollapsed(!collapsed); storeCollapsed(!collapsed); @@ -73,7 +74,7 @@ export function AppShell({ Skip to content - + {sidebar && ( = 0} showLabels - style={{ '--tab-count': tabs.length } as CSSProperties} - value={tabs.find((tab) => tab.active)?.href ?? null} + style={{ '--tab-count': tabs.length, '--tab-index': activeTab } as CSSProperties} + value={tabs[activeTab]?.href ?? null} > {tabs.map((tab) => tab.loading ? ( diff --git a/src/components/Choice.tsx b/src/components/Choice.tsx index 1dde5d0..d36c4e9 100644 --- a/src/components/Choice.tsx +++ b/src/components/Choice.tsx @@ -1,7 +1,7 @@ import MuiCheckbox from '@mui/material/Checkbox'; import FormControlLabel from '@mui/material/FormControlLabel'; import MuiRadio from '@mui/material/Radio'; -import type { InputHTMLAttributes, ReactNode } from 'react'; +import { useState, type InputHTMLAttributes, type ReactNode } from 'react'; type ChoiceProps = Omit, 'size' | 'type'> & { label?: ReactNode }; @@ -18,14 +18,19 @@ function Choice({ ...input }: ChoiceProps & { kind: 'checkbox' | 'radio' }) { const Control = kind === 'checkbox' ? MuiCheckbox : MuiRadio; + const [pressed, setPressed] = useState(false); const control = ( { + setPressed(true); + onChange?.(event); + }} size="small" slotProps={{ input }} value={value} diff --git a/src/components/PageTransition.tsx b/src/components/PageTransition.tsx new file mode 100644 index 0000000..bb2322a --- /dev/null +++ b/src/components/PageTransition.tsx @@ -0,0 +1,9 @@ +import { ViewTransition, type ReactNode } from 'react'; + +export function PageTransition({ children, page }: { children: ReactNode; page: string }) { + return ( + + {children} + + ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 43c79dc..15ad469 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import ListItemButton from '@mui/material/ListItemButton'; -import type { ReactNode } from 'react'; +import { useLayoutEffect, useRef, useState, type ReactNode } from 'react'; import { useLink } from '../UiProvider.js'; import { useShell } from './AppShell.js'; import { Icon, type IconName } from './Icon.js'; @@ -36,8 +36,18 @@ export function Sidebar({ }) { const Link = useLink(); const { collapsed } = useShell(); + const nav = useRef(null); + const [current, setCurrent] = useState<{ height: number; top: number }>(); + + useLayoutEffect(() => { + const item = nav.current?.querySelector('.sidebar-item[aria-current="page"]'); + const next = item ? { height: item.offsetHeight, top: item.offsetTop } : undefined; + setCurrent((shown) => (shown && next && shown.top === next.top && shown.height === next.height ? shown : next)); + }, [collapsed, loading, sections]); + return ( - + + {current && } {loading ? Array.from({ length: 3 }, (_, section) => ( diff --git a/src/components/Snackbar.tsx b/src/components/Snackbar.tsx index 94d2578..73c5f70 100644 --- a/src/components/Snackbar.tsx +++ b/src/components/Snackbar.tsx @@ -1,5 +1,5 @@ import MuiButton from '@mui/material/Button'; -import Grow from '@mui/material/Grow'; +import Fade from '@mui/material/Fade'; import SnackbarContent from '@mui/material/SnackbarContent'; import { createContext, @@ -43,7 +43,7 @@ function SnackbarItem({ onDismiss, snack }: { onDismiss: (id: number) => void; s }, [leave, leaving, paused, snack.tone]); return ( - onDismiss(snack.id)}> + onDismiss(snack.id)}> @@ -80,7 +80,7 @@ function SnackbarItem({ onDismiss, snack }: { onDismiss: (id: number) => void; s onMouseLeave={() => setPaused(false)} role={snack.tone === 'error' ? 'alert' : 'none'} /> - + ); } diff --git a/src/components/Tabs.tsx b/src/components/Tabs.tsx index 754f8d5..2e33291 100644 --- a/src/components/Tabs.tsx +++ b/src/components/Tabs.tsx @@ -13,7 +13,7 @@ export function Tabs({ items, label }: { items: TabItem[]; label: string }) { return ( item.active)?.href ?? false} diff --git a/src/index.ts b/src/index.ts index 2b1f8b8..6ba1597 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ export { Menu, type MenuItem } from './components/Menu.js'; export { Meter } from './components/Meter.js'; export { OutageGate } from './components/OutageGate.js'; export { PageHeader } from './components/PageHeader.js'; +export { PageTransition } from './components/PageTransition.js'; export { Pagination } from './components/Pagination.js'; export { PickerField } from './components/PickerField.js'; export { Popover } from './components/Popover.js'; diff --git a/src/theme.css b/src/theme.css index 178bdbc..393f43a 100644 --- a/src/theme.css +++ b/src/theme.css @@ -622,6 +622,41 @@ body { backdrop-filter: blur(14px); } + .tab-bar::before { + position: absolute; + top: 0.25rem; + left: calc( + env(safe-area-inset-left) + (100% - env(safe-area-inset-left) - env(safe-area-inset-right)) / var(--tab-count) * + (var(--tab-index) + 0.5) - 1.75rem + ); + width: 3.5rem; + height: 1.75rem; + border-radius: 999px; + background: var(--color-line-soft); + content: ''; + } + + .tab-bar[data-active='false']::before { + visibility: hidden; + } + + .app-header { + border-bottom-color: var(--color-line-soft); + } + + @supports (animation-timeline: scroll()) { + .app-header { + border-bottom-color: transparent; + animation: header-lift linear both; + animation-timeline: scroll(root block); + animation-range: 0 3rem; + } + } + + .page-tabs .MuiTabs-indicator { + view-transition-name: page-tabs-indicator; + } + .skeleton { display: block; border-radius: 0.375rem; @@ -735,6 +770,7 @@ body { } .sidebar { + position: relative; display: flex; min-height: 100%; flex-direction: column; @@ -838,9 +874,24 @@ body { } .sidebar-item[aria-current='page'] { - background: var(--color-line-soft); + background: transparent; color: var(--color-ink); } + + .sidebar-indicator { + position: absolute; + top: 0; + right: 0.75rem; + left: 0.75rem; + border-radius: var(--radius-field); + background: var(--color-line-soft); + pointer-events: none; + } +} + +::view-transition-old(root), +::view-transition-new(root) { + animation: none; } @media (prefers-reduced-motion: no-preference) { @@ -849,7 +900,7 @@ body { } .page-enter { - animation: page-enter 240ms ease-out backwards; + animation: page-enter 280ms var(--ease-settle) backwards; } .fade-in { @@ -874,6 +925,77 @@ body { .avatar-button:active { transform: scale(0.95); } + + .panel-rows > * { + animation: fade-in 160ms ease-out backwards; + } + + .stagger-in > * { + animation: rise-in 320ms var(--ease-settle) backwards; + animation-delay: calc(min(sibling-index(), 12) * 40ms - 40ms); + } + + .sidebar-indicator { + transition: + translate 280ms var(--ease-settle), + height 280ms var(--ease-settle); + } + + .tab-bar::before { + transition: left 280ms var(--ease-settle); + } + + .MuiPopover-paper, + .MuiAutocomplete-paper { + animation: overlay-in 200ms var(--ease-settle); + } + + .MuiDialog-paper { + animation: rise-in 260ms var(--ease-settle); + } + + .snackbar-region .MuiSnackbarContent-root { + animation: rise-in 240ms var(--ease-settle); + } + + .MuiCheckbox-root.Mui-checked[data-pressed] .MuiSvgIcon-root { + animation: check-pop 280ms var(--ease-settle); + } + + @supports selector(::details-content) { + details { + interpolate-size: allow-keywords; + } + + details::details-content { + block-size: 0; + overflow-y: clip; + transition: + block-size 240ms var(--ease-settle), + content-visibility 240ms allow-discrete; + } + + details[open]::details-content { + block-size: auto; + } + } + + ::view-transition-old(.page-leave) { + animation: fade-out 160ms ease-in both; + } + + ::view-transition-group(page-tabs-indicator) { + animation-duration: 280ms; + animation-timing-function: var(--ease-settle); + } +} + +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + animation: none; + } } @property --scroll-x-start { @@ -911,6 +1033,14 @@ body { @keyframes page-enter { from { opacity: 0; + translate: 0 0.25rem; + } +} + +@keyframes rise-in { + from { + opacity: 0; + translate: 0 0.5rem; } } @@ -920,6 +1050,36 @@ body { } } +@keyframes fade-out { + to { + opacity: 0; + } +} + +@keyframes overlay-in { + from { + opacity: 0; + scale: 0.96; + } +} + +@keyframes check-pop { + 40% { + scale: 1.15; + } +} + +@keyframes header-lift { + from { + border-bottom-color: transparent; + } + + to { + border-bottom-color: var(--color-line-soft); + box-shadow: 0 12px 32px -24px rgb(0 0 0 / 0.8); + } +} + @keyframes shimmer { from { background-position: 200% 0; diff --git a/src/theme.ts b/src/theme.ts index 731b833..e870df3 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -1,3 +1,4 @@ +import Fade from '@mui/material/Fade'; import { createTheme, type Shadows } from '@mui/material/styles'; import type { ComponentType } from 'react'; import type { UiLinkProps } from './UiProvider.js'; @@ -5,6 +6,10 @@ import type { UiLinkProps } from './UiProvider.js'; const token = (name: string) => `var(--color-${name})`; const panel = { border: `1px solid ${token('line')}`, backgroundColor: token('surface'), boxShadow: 'var(--shadow-popover)' }; const hovered = { backgroundColor: token('line-soft'), color: token('ink') }; +const pressable = (transitions: string) => ({ + transition: `${transitions}, scale 160ms var(--ease-settle)`, + '@media (prefers-reduced-motion: no-preference)': { '&:active': { scale: '0.97' } }, +}); const notice = (tone: string) => ({ border: `1px solid color-mix(in srgb, ${token(tone)} 45%, ${token('line')})`, borderLeft: `3px solid ${token(tone)}`, @@ -41,12 +46,14 @@ export function createGraphiteTheme(link: ComponentType) { }, shadows: Array(25).fill('none') as Shadows, shape: { borderRadius: 8 }, + transitions: { duration: { enteringScreen: 220, leavingScreen: 160 } }, typography: { fontFamily: 'var(--font-sans)' }, components: { MuiButtonBase: { defaultProps: { LinkComponent: link } }, MuiButton: { defaultProps: { disableElevation: true }, styleOverrides: { + root: pressable('background-color 160ms ease, border-color 160ms ease, color 160ms ease'), contained: { '&.MuiButton-colorPrimary:hover': { backgroundColor: '#fff' }, '&.MuiButton-colorError:hover': { backgroundColor: `color-mix(in srgb, ${token('danger')} 80%, #fff)` }, @@ -55,7 +62,10 @@ export function createGraphiteTheme(link: ComponentType) { text: { color: token('muted'), '&:hover': { backgroundColor: 'transparent', color: token('ink') } }, }, }, - MuiIconButton: { styleOverrides: { root: { color: token('muted'), '&:hover': hovered } } }, + MuiIconButton: { + styleOverrides: { root: { ...pressable('background-color 160ms ease, color 160ms ease'), color: token('muted'), '&:hover': hovered } }, + }, + MuiChip: { styleOverrides: { clickable: pressable('background-color 160ms ease, border-color 160ms ease') } }, MuiOutlinedInput: { styleOverrides: { root: { @@ -71,7 +81,11 @@ export function createGraphiteTheme(link: ComponentType) { MuiFormHelperText: { styleOverrides: { root: { color: token('faint'), '&.Mui-error': { color: token('danger') } } } }, MuiSelect: { styleOverrides: { icon: { color: token('muted') } } }, MuiPaper: { styleOverrides: { root: { backgroundImage: 'none' } } }, - MuiPopover: { styleOverrides: { paper: { ...panel, borderRadius: 'var(--radius-panel)' } } }, + MuiPopover: { + defaultProps: { slots: { transition: Fade } }, + styleOverrides: { paper: { ...panel, borderRadius: 'var(--radius-panel)' } }, + }, + MuiMenu: { defaultProps: { slots: { transition: Fade } } }, MuiMenuItem: { styleOverrides: { root: {