Skip to content
90 changes: 90 additions & 0 deletions apps/ui/src/components/app-toasts/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { __ } from '@wordpress/i18n';
import { Button, Notice } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useEffect } from 'react';
import {
dismissToast,
notifyRendererMounted,
notifyRendererUnmounted,
pauseToastExpiry,
resumeToastExpiry,
useQueuedToastCount,
useVisibleToasts,
} from '@/data/app-messages';
import styles from './style.module.css';

export function AppToasts( {
className,
fit = 'row',
}: {
className?: string;
// 'row' stretches toasts to the shelf width (sidebar footer, matching the
// site rows); 'content' lets each toast hug its text up to the shelf's
// max-width (the floating collapsed shelf).
fit?: 'row' | 'content';
} ) {
const toasts = useVisibleToasts();
const queuedCount = useQueuedToastCount();

useEffect( () => {
notifyRendererMounted();
return () => notifyRendererUnmounted();
}, [] );

return (
Comment thread
katinthehatsite marked this conversation as resolved.
<div
className={ clsx( fit === 'content' && styles.shelfHug, className ) }
aria-live="polite"
aria-relevant="additions"
>
{ toasts.length > 0 ? (
<div className={ styles.stack }>
{ toasts.map( ( item ) => (
<div
key={ item.id }
className={ styles.cell }
data-leaving={ item.leaving ? '' : undefined }
>
<div
className={ styles.toast }
onMouseEnter={ () => pauseToastExpiry( item.id ) }
onMouseLeave={ () => resumeToastExpiry( item.id ) }
>
<Notice.Root intent={ item.intent } className={ styles.notice }>
<Notice.Title>{ item.title }</Notice.Title>
{ item.description ? (
<Notice.Description>{ item.description }</Notice.Description>
) : null }
{ item.action ? (
<Notice.Actions>
<Button
size="small"
variant="solid"
tone="neutral"
className={ styles.actionButton }
onClick={ item.action.onClick }
>
{ item.action.label }
</Button>
</Notice.Actions>
) : null }
<Notice.CloseIcon
label={ __( 'Dismiss' ) }
onClick={ () => dismissToast( item.id ) }
/>
</Notice.Root>
</div>
</div>
) ) }
{ queuedCount > 0 ? <div className={ styles.queuePeek } aria-hidden="true" /> : null }
{ queuedCount > 1 ? (
<div
className={ clsx( styles.queuePeek, styles.queuePeekDeeper ) }
aria-hidden="true"
/>
) : null }
</div>
) : null }
</div>
);
}
145 changes: 145 additions & 0 deletions apps/ui/src/components/app-toasts/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
.stack {
display: flex;
flex-direction: column;
margin-top: calc(-1 * var(--wpds-dimension-gap-sm));
}

.shelfHug .stack {
width: fit-content;
max-width: 100%;
}

.cell {
display: grid;
grid-template-rows: 1fr;
margin-top: var(--wpds-dimension-gap-sm);
transition:
grid-template-rows 180ms ease,
margin-top 180ms ease;
}

@starting-style {
.cell {
grid-template-rows: 0fr;
margin-top: 0;
}
}

.cell[data-leaving] {
grid-template-rows: 0fr;
margin-top: 0;
}

/* Notice's `container-type: inline-size` zeroes intrinsic width, breaking
fit-content sizing. Reset it — the container query it powers doesn't
apply at toast widths. */
.shelfHug .notice {
width: fit-content;
max-width: 100%;
container-type: normal;
}

.toast {
min-height: 0;
overflow: clip;
overflow-clip-margin: 12px;
animation: toast-enter 160ms ease-out;
transition: opacity 160ms ease, transform 160ms ease;
}

.cell[data-leaving] .toast {
opacity: 0;
transform: translateY(4px);
pointer-events: none;
}

.notice {
width: 100%;
box-sizing: border-box;
text-wrap: pretty;
--wp-ui-notice-background-color: var(--wpds-color-bg-surface-neutral-strong);
--wp-ui-notice-border-color: transparent;
--wp-ui-notice-text-color: var(--wpds-color-fg-content-neutral);
box-shadow:
0 0 0 1px color-mix(in srgb, var(--wpds-color-fg-content-neutral) 14%, transparent),
var(--wpds-elevation-md);
padding-block: 6px;
padding-inline: var(--wpds-dimension-padding-sm);
border-radius: 6px;
}

.notice [class*='heading'] {
line-height: 16px;
}

.notice [class*='__description'] {
font-size: var(--wpds-typography-font-size-xs);
line-height: 16px;
color: var(--wpds-color-fg-content-neutral-weak);
}

/* Button's solid-variant sets these properties on the element itself, so
parent-level overrides don't win — must be set here directly. */
.actionButton {
--wp-ui-button-background-color: color-mix(
in srgb,
var(--wpds-color-fg-content-neutral) 10%,
transparent
);
--wp-ui-button-background-color-active: color-mix(
in srgb,
var(--wpds-color-fg-content-neutral) 16%,
transparent
);
--wp-ui-button-foreground-color: var(--wpds-color-fg-content-neutral);
--wp-ui-button-foreground-color-active: var(--wpds-color-fg-content-neutral);
--wp-ui-button-border-color: transparent;
--wp-ui-button-border-color-active: transparent;
}

.notice > svg {
margin-top: 2px;
}

.queuePeek {
height: 8px;
margin-top: 3px;
margin-inline: 8px;
background-color: var(--wpds-color-bg-surface-neutral-strong);
border-radius: 0 0 6px 6px;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--wpds-color-fg-content-neutral) 14%, transparent);
opacity: 0.8;
}

.queuePeekDeeper {
margin-inline: 16px;
opacity: 0.5;
}

.shelfHug .queuePeek {
margin-inline: 0 16px;
}

.shelfHug .queuePeekDeeper {
margin-inline: 0 32px;
}

@keyframes toast-enter {
from {
opacity: 0;
transform: translateY(6px);
}

to {
opacity: 1;
transform: translateY(0);
}
}

@media (prefers-reduced-motion: reduce) {
.toast,
.cell {
animation: none;
transition: none;
}
}
3 changes: 3 additions & 0 deletions apps/ui/src/components/sidebar-layout/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { __ } from '@wordpress/i18n';
import { IconButton } from '@wordpress/ui';
import { clsx } from 'clsx';
import { useCallback, useEffect, useState } from 'react';
import { AppToasts } from '@/components/app-toasts';
import { ResizeHandle, ResizeOverlay } from '@/components/resize-handle';
import { SidebarHeader } from '@/components/sidebar-header';
import { SiteList } from '@/components/site-list';
Expand Down Expand Up @@ -47,6 +48,7 @@ export function SidebarLayout( { children }: { children: ReactNode } ) {
<SidebarHeader onToggleSidebar={ toggleSidebar } />
<SiteList />
<div className={ styles.sidebarFooter }>
{ ! collapsed ? <AppToasts className={ styles.sidebarToasts } /> : null }
<UserMenu />
</div>
</aside>
Expand Down Expand Up @@ -81,6 +83,7 @@ export function SidebarLayout( { children }: { children: ReactNode } ) {
</div>
) : null }
{ children }
{ collapsed ? <AppToasts className={ styles.floatingToasts } fit="content" /> : null }
</main>
{ sidebarResize.isResizing ? <ResizeOverlay /> : null }
</div>
Expand Down
14 changes: 14 additions & 0 deletions apps/ui/src/components/sidebar-layout/style.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,17 @@
.floatingToggleFlush {
left: var(--wpds-dimension-padding-xl);
}

.sidebarToasts {
padding: var(--wpds-dimension-padding-sm) calc(var(--wpds-dimension-padding-lg) - var(--wpds-dimension-padding-sm)) 0;
}

.floatingToasts {
position: absolute;
bottom: calc(52px + var(--app-main-composer-height, 0px));
left: var(--wpds-dimension-padding-xl);
width: min(320px, calc(100% - 2 * var(--wpds-dimension-padding-xl)));
z-index: 110;
pointer-events: auto;
-webkit-app-region: no-drag;
}
Loading