mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 12:25:05 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb72872c5c | ||
|
|
3cd7690113 |
@@ -14,6 +14,7 @@ import { connectSeedClient } from "../support/helpers/seed-client";
|
||||
import { createTempGitRepo } from "../support/helpers/workspace";
|
||||
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
|
||||
import {
|
||||
archivedToast,
|
||||
archiveFromRowButton,
|
||||
createGatedTeardown,
|
||||
expectDirectoryPresence,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
resizeSidebarToMinimum,
|
||||
rowArchiveButton,
|
||||
rowKebab,
|
||||
scrollSidebarListToEnd,
|
||||
undoButton,
|
||||
workspaceRow,
|
||||
workspaceTitleWidth,
|
||||
@@ -91,22 +93,24 @@ test.describe("Workspace archive undo", () => {
|
||||
expect(kebabBox.x - (buttonBox.x + buttonBox.width)).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
test("the archived toast sits over the sidebar above its footer, and only Undo presses", async ({
|
||||
test("the archived toast covers the footer's icon line instead of the list, and only Undo presses", async ({
|
||||
page,
|
||||
}) => {
|
||||
const worktree = await seedWorktree(page, "archive-toast-sidebar");
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
|
||||
const [toastBox, footerBox] = await Promise.all([
|
||||
page.getByTestId("workspace-archived-toast").boundingBox(),
|
||||
page.getByTestId("sidebar-footer").boundingBox(),
|
||||
const [toastBox, lineBox, listBox] = await Promise.all([
|
||||
archivedToast(page).boundingBox(),
|
||||
page.getByTestId("sidebar-footer-bottom-line").boundingBox(),
|
||||
page.getByTestId("sidebar-project-workspace-list-scroll").boundingBox(),
|
||||
]);
|
||||
if (!toastBox || !footerBox) throw new Error("Toast or footer is not laid out");
|
||||
expect(toastBox.x).toBeGreaterThanOrEqual(footerBox.x);
|
||||
expect(toastBox.x + toastBox.width).toBeLessThanOrEqual(footerBox.x + footerBox.width);
|
||||
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(footerBox.y);
|
||||
expect(footerBox.y - (toastBox.y + toastBox.height)).toBeLessThanOrEqual(16);
|
||||
if (!toastBox || !lineBox || !listBox) throw new Error("Toast, footer or list is not laid out");
|
||||
expect(toastBox.y).toBeGreaterThanOrEqual(listBox.y + listBox.height);
|
||||
expect(toastBox.y).toBeGreaterThanOrEqual(lineBox.y);
|
||||
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(lineBox.y + lineBox.height);
|
||||
expect(toastBox.x).toBeGreaterThanOrEqual(lineBox.x);
|
||||
expect(toastBox.x + toastBox.width).toBeLessThanOrEqual(lineBox.x + lineBox.width);
|
||||
|
||||
await expect(page.getByTestId("app-toast-message")).toHaveCSS("cursor", "auto");
|
||||
await expect(undoButton(page)).toHaveCSS("cursor", "pointer");
|
||||
@@ -119,6 +123,41 @@ test.describe("Workspace archive undo", () => {
|
||||
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
|
||||
});
|
||||
|
||||
test("a long list ends just below its last row, and no row moves when the toast leaves", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.clock.install({ time: Date.now() });
|
||||
const fillers: Array<{ path: string; cleanup: () => Promise<void> }> = [];
|
||||
const fillerProjectIds: string[] = [];
|
||||
try {
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const repo = await createTempGitRepo(`archive-toast-filler-${index}-`);
|
||||
fillers.push(repo);
|
||||
fillerProjectIds.push((await openProjectViaDaemon(client, repo.path)).projectId);
|
||||
}
|
||||
const worktree = await seedWorktree(page, "archive-toast-long-list");
|
||||
|
||||
const resting = await scrollSidebarListToEnd(page);
|
||||
expect(resting.listBottom - resting.rowBottoms.at(-1)!).toBeLessThan(resting.rowHeight);
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
await page.mouse.move(0, 0);
|
||||
const withToast = await scrollSidebarListToEnd(page);
|
||||
const toastBox = await archivedToast(page).boundingBox();
|
||||
if (!toastBox) throw new Error("Toast is not laid out");
|
||||
expect(toastBox.y).toBeGreaterThanOrEqual(withToast.listBottom);
|
||||
|
||||
await outlastUndoWindow(page);
|
||||
await expect(archivedToast(page)).toHaveCount(0);
|
||||
expect(await scrollSidebarListToEnd(page)).toEqual(withToast);
|
||||
} finally {
|
||||
for (const projectId of fillerProjectIds) {
|
||||
await client.removeProject(projectId).catch(() => undefined);
|
||||
}
|
||||
for (const repo of fillers) await repo.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("an archive that fails after the sidebar was replaced still shows its error", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -146,13 +185,16 @@ test.describe("Workspace archive undo", () => {
|
||||
|
||||
await archiveFromRowButton(page, worktree.workspaceId);
|
||||
|
||||
const [toastBox, undoBox] = await Promise.all([
|
||||
page.getByTestId("workspace-archived-toast").boundingBox(),
|
||||
const [toastBox, undoBox, lineBox] = await Promise.all([
|
||||
archivedToast(page).boundingBox(),
|
||||
undoButton(page).boundingBox(),
|
||||
page.getByTestId("sidebar-footer-bottom-line").boundingBox(),
|
||||
]);
|
||||
if (!toastBox || !undoBox) throw new Error("Toast is not laid out");
|
||||
if (!toastBox || !undoBox || !lineBox) throw new Error("Toast is not laid out");
|
||||
expect(undoBox.x + undoBox.width).toBeLessThanOrEqual(toastBox.x + toastBox.width);
|
||||
expect(undoBox.y + undoBox.height).toBeLessThanOrEqual(toastBox.y + toastBox.height);
|
||||
// The wrapped card grows upward from the footer's bottom edge, never past the window.
|
||||
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(lineBox.y + lineBox.height);
|
||||
});
|
||||
|
||||
test("archiving by shortcut with the sidebar collapsed shows the toast over the app", async ({
|
||||
|
||||
@@ -162,3 +162,38 @@ export function createGatedTeardown(): GatedTeardown {
|
||||
release: () => writeFileSync(releasePath, "release\n"),
|
||||
};
|
||||
}
|
||||
|
||||
export function archivedToast(page: Page) {
|
||||
return page.getByTestId("workspace-archived-toast");
|
||||
}
|
||||
|
||||
export interface SidebarListLayout {
|
||||
/** Bottom edge of the list's visible area. */
|
||||
listBottom: number;
|
||||
/** Bottom edges of the visible project and workspace rows, top to bottom. */
|
||||
rowBottoms: number[];
|
||||
rowHeight: number;
|
||||
}
|
||||
|
||||
/** Scrolls the sidebar's workspace list to its end and reads where its rows sit. */
|
||||
export async function scrollSidebarListToEnd(page: Page): Promise<SidebarListLayout> {
|
||||
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
await list.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
return list.evaluate((element) => {
|
||||
const visible = element.getBoundingClientRect();
|
||||
const rows = Array.from(
|
||||
element.querySelectorAll<HTMLElement>(
|
||||
'[data-testid^="sidebar-workspace-row-"],[data-testid^="sidebar-project-row-"]',
|
||||
),
|
||||
)
|
||||
.map((row) => row.getBoundingClientRect())
|
||||
.filter((row) => row.height > 0 && row.bottom > visible.top && row.top < visible.bottom);
|
||||
return {
|
||||
listBottom: visible.bottom,
|
||||
rowBottoms: rows.map((row) => row.bottom).sort((a, b) => a - b),
|
||||
rowHeight: Math.min(...rows.map((row) => row.height)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,7 +38,11 @@ import {
|
||||
import { HostPicker } from "@/components/hosts/host-picker";
|
||||
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
|
||||
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { SidebarToastRegion } from "@/components/sidebar/sidebar-toast-region";
|
||||
import {
|
||||
SidebarToastHost,
|
||||
SidebarToastRegion,
|
||||
SidebarToastSlot,
|
||||
} from "@/components/sidebar/sidebar-toast";
|
||||
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
||||
import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight";
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
@@ -259,24 +263,26 @@ export const LeftSidebar = memo(function LeftSidebar({
|
||||
return (
|
||||
<>
|
||||
<RetainedPanelActivity active={active}>
|
||||
{isCompactLayout ? (
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
{...handlers}
|
||||
active={active}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
/>
|
||||
) : (
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
{...handlers}
|
||||
insetsTop={insets.top}
|
||||
active={active}
|
||||
overlay={desktopOverlay}
|
||||
/>
|
||||
)}
|
||||
<SidebarToastHost active={active}>
|
||||
{isCompactLayout ? (
|
||||
<MobileSidebar
|
||||
{...sharedProps}
|
||||
{...handlers}
|
||||
active={active}
|
||||
insetsTop={insets.top}
|
||||
insetsBottom={insets.bottom}
|
||||
closeSidebar={showMobileAgent}
|
||||
/>
|
||||
) : (
|
||||
<DesktopSidebar
|
||||
{...sharedProps}
|
||||
{...handlers}
|
||||
insetsTop={insets.top}
|
||||
active={active}
|
||||
overlay={desktopOverlay}
|
||||
/>
|
||||
)}
|
||||
</SidebarToastHost>
|
||||
</RetainedPanelActivity>
|
||||
{importSessionSheet}
|
||||
</>
|
||||
@@ -496,6 +502,7 @@ function SidebarFooter({
|
||||
shortcutKeys={settingsKeys}
|
||||
/>
|
||||
) : null}
|
||||
<SidebarToastSlot />
|
||||
</View>
|
||||
</View>
|
||||
</UsageSidebarRoot>
|
||||
@@ -647,7 +654,7 @@ function MobileSidebar({
|
||||
<SidebarSeparator />
|
||||
|
||||
<SidebarViewSlot dismiss={handleWorkspacePress}>
|
||||
<SidebarToastRegion active={active}>
|
||||
<SidebarToastRegion>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
@@ -849,7 +856,7 @@ function DesktopSidebar({
|
||||
</View>
|
||||
|
||||
<SidebarViewSlot dismiss={closeSidebar}>
|
||||
<SidebarToastRegion active={active}>
|
||||
<SidebarToastRegion>
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
) : (
|
||||
@@ -1006,6 +1013,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// Buttons sit edge to edge; their own inset spaces the glyphs.
|
||||
// As tall as its buttons from the first frame, which places none until the row has measured.
|
||||
sidebarFooter: {
|
||||
// Anchors the sidebar's toast, which covers this line while it shows.
|
||||
position: "relative",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
minHeight: mapLayout(density.iconButton, (height) => height + theme.spacing[1.5] * 2),
|
||||
|
||||
@@ -2592,9 +2592,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
|
||||
// Schedules icon across the divider; their layout boxes have different insets.
|
||||
paddingTop: 2,
|
||||
// Keep the last row scrollable above the sidebar toast, including a wrapped Undo row.
|
||||
// This space stays present across toast changes so the scroll range never jumps.
|
||||
paddingBottom: theme.spacing[24],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
projectListContainer: {
|
||||
width: "100%",
|
||||
|
||||
@@ -1101,8 +1101,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
|
||||
paddingTop: 2,
|
||||
// Match project grouping: permanent room to scroll the final row above sidebar toasts.
|
||||
paddingBottom: theme.spacing[24],
|
||||
paddingBottom: theme.spacing[4],
|
||||
},
|
||||
pinnedSection: {
|
||||
marginBottom: theme.spacing[1],
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import { View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ToastViewport, useToastHost, type ToastApi } from "@/components/toast-host";
|
||||
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
|
||||
|
||||
/**
|
||||
* Shows toasts raised by the sidebar's list over its bottom edge, just above the footer.
|
||||
*
|
||||
* A collapsed sidebar stays mounted and its rows still handle shortcuts, so while it is hidden
|
||||
* their toasts go to the app's host instead. The sidebar's API stays stable across open and close
|
||||
* so that toggling the panel doesn't re-render every row.
|
||||
*/
|
||||
export function SidebarToastRegion({ active, children }: { active: boolean; children: ReactNode }) {
|
||||
const appToast = useToast();
|
||||
const { api: sidebarToast, toast, dismiss } = useToastHost();
|
||||
const activeRef = useRef(active);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = active;
|
||||
return () => {
|
||||
// An archive holds on to this API, so a failure that lands later needs a host on screen.
|
||||
activeRef.current = false;
|
||||
// A toast must not outlive its visibility: its dismissal is what ends an archive's undo.
|
||||
dismiss();
|
||||
};
|
||||
}, [active, dismiss]);
|
||||
|
||||
const api = useMemo<ToastApi>(() => {
|
||||
const visibleHost = () => (activeRef.current ? sidebarToast : appToast);
|
||||
return {
|
||||
show: (content, options) => visibleHost().show(content, options),
|
||||
copied: (label) => visibleHost().copied(label),
|
||||
error: (message) => visibleHost().error(message),
|
||||
};
|
||||
}, [appToast, sidebarToast]);
|
||||
|
||||
return (
|
||||
<View style={styles.region}>
|
||||
<ToastApiProvider api={api}>{children}</ToastApiProvider>
|
||||
<ToastViewport toast={toast} onDismiss={dismiss} placement="sidebar" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
region: {
|
||||
position: "relative",
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { View } from "react-native";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { i18n as testI18n } from "@/i18n/i18next";
|
||||
import type { ToastApi } from "@/components/toast-host";
|
||||
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
|
||||
import { SidebarToastHost, SidebarToastRegion, SidebarToastSlot } from "./sidebar-toast";
|
||||
|
||||
// Toasts read their copy through i18n, which this import initialises.
|
||||
void testI18n;
|
||||
|
||||
// App sources compile against the classic JSX runtime, which expects React on the global.
|
||||
beforeEach(() => vi.stubGlobal("React", React));
|
||||
|
||||
const footerLineStyle = { position: "relative", height: 40 } as const;
|
||||
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
let listToast: ToastApi | null = null;
|
||||
const appToast: ToastApi = {
|
||||
show: () => () => {},
|
||||
copied: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount());
|
||||
container?.remove();
|
||||
root = null;
|
||||
container = null;
|
||||
listToast = null;
|
||||
});
|
||||
|
||||
function WorkspaceList() {
|
||||
listToast = useToast();
|
||||
return <View testID="list" />;
|
||||
}
|
||||
|
||||
function Sidebar({ showList }: { showList: boolean }) {
|
||||
return (
|
||||
<ToastApiProvider api={appToast}>
|
||||
<SidebarToastHost active>
|
||||
{showList ? (
|
||||
<SidebarToastRegion>
|
||||
<WorkspaceList />
|
||||
</SidebarToastRegion>
|
||||
) : null}
|
||||
<View testID="footer-line" style={footerLineStyle}>
|
||||
<SidebarToastSlot />
|
||||
</View>
|
||||
</SidebarToastHost>
|
||||
</ToastApiProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function renderSidebar(showList: boolean) {
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
}
|
||||
act(() => root?.render(<Sidebar showList={showList} />));
|
||||
}
|
||||
|
||||
function footerText(): string {
|
||||
return container?.querySelector('[data-testid="footer-line"]')?.textContent ?? "";
|
||||
}
|
||||
|
||||
it("shows a toast raised by the workspace list in the footer line", () => {
|
||||
renderSidebar(true);
|
||||
|
||||
act(() => {
|
||||
listToast?.show("Workspace archived", { durationMs: null });
|
||||
});
|
||||
|
||||
expect(footerText()).toBe("Workspace archived");
|
||||
});
|
||||
|
||||
it("ends the list's toast, and with it an archive's undo, when the list is replaced", async () => {
|
||||
renderSidebar(true);
|
||||
const onDismiss = vi.fn();
|
||||
act(() => {
|
||||
listToast?.show("Workspace archived", { durationMs: null, onDismiss });
|
||||
});
|
||||
|
||||
renderSidebar(false);
|
||||
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
// The card fades out before it leaves the footer.
|
||||
await vi.waitFor(() => expect(footerText()).toBe(""));
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from "react";
|
||||
import {
|
||||
ToastViewport,
|
||||
useToastHost,
|
||||
type ToastApi,
|
||||
type ToastState,
|
||||
} from "@/components/toast-host";
|
||||
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
|
||||
|
||||
/**
|
||||
* The sidebar's toast. Toasts raised by the workspace list cover the footer's icon line, which the
|
||||
* user isn't using while they work through the list, so no row is covered and the list keeps no
|
||||
* room in reserve for them.
|
||||
*
|
||||
* `SidebarToastHost` wraps the whole sidebar. The list raises toasts inside `SidebarToastRegion`,
|
||||
* and the footer shows them in `SidebarToastSlot`.
|
||||
*/
|
||||
interface SidebarToast {
|
||||
api: ToastApi;
|
||||
toast: ToastState | null;
|
||||
dismiss: () => void;
|
||||
}
|
||||
|
||||
const SidebarToastContext = createContext<SidebarToast | null>(null);
|
||||
|
||||
function useSidebarToast(): SidebarToast {
|
||||
const value = useContext(SidebarToastContext);
|
||||
if (!value) throw new Error("Sidebar toasts need a SidebarToastHost");
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* A collapsed sidebar stays mounted and its rows still handle shortcuts, so while it is hidden
|
||||
* their toasts go to the app's host instead. The sidebar's API stays stable across open and close
|
||||
* so that toggling the panel doesn't re-render every row.
|
||||
*/
|
||||
export function SidebarToastHost({ active, children }: { active: boolean; children: ReactNode }) {
|
||||
const appToast = useToast();
|
||||
const { api: sidebarToast, toast, dismiss } = useToastHost();
|
||||
const activeRef = useRef(active);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = active;
|
||||
return () => {
|
||||
// An archive holds on to this API, so a failure that lands later needs a host on screen.
|
||||
activeRef.current = false;
|
||||
// A toast must not outlive its visibility: its dismissal is what ends an archive's undo.
|
||||
dismiss();
|
||||
};
|
||||
}, [active, dismiss]);
|
||||
|
||||
const api = useMemo<ToastApi>(() => {
|
||||
const visibleHost = () => (activeRef.current ? sidebarToast : appToast);
|
||||
return {
|
||||
show: (content, options) => visibleHost().show(content, options),
|
||||
copied: (label) => visibleHost().copied(label),
|
||||
error: (message) => visibleHost().error(message),
|
||||
};
|
||||
}, [appToast, sidebarToast]);
|
||||
|
||||
const value = useMemo(() => ({ api, toast, dismiss }), [api, toast, dismiss]);
|
||||
return <SidebarToastContext.Provider value={value}>{children}</SidebarToastContext.Provider>;
|
||||
}
|
||||
|
||||
/** Sends toasts raised by the workspace list to the sidebar. */
|
||||
export function SidebarToastRegion({ children }: { children: ReactNode }) {
|
||||
const { api, dismiss } = useSidebarToast();
|
||||
// Replacing the list, with a plugin view for example, ends its toast and so its undo.
|
||||
useEffect(() => dismiss, [dismiss]);
|
||||
return <ToastApiProvider api={api}>{children}</ToastApiProvider>;
|
||||
}
|
||||
|
||||
/** Shows the sidebar's toast over the footer line it is rendered in. */
|
||||
export function SidebarToastSlot() {
|
||||
const { toast, dismiss } = useSidebarToast();
|
||||
return <ToastViewport toast={toast} onDismiss={dismiss} placement="sidebar" />;
|
||||
}
|
||||
@@ -229,16 +229,21 @@ export function ToastViewport({
|
||||
collapsable={false}
|
||||
>
|
||||
<View
|
||||
style={placement === "sidebar" ? styles.sidebarWidthBoundary : styles.widthBoundary}
|
||||
style={placement === "sidebar" ? animatedStyles.sidebarLine : styles.widthBoundary}
|
||||
pointerEvents="box-none"
|
||||
collapsable={false}
|
||||
>
|
||||
<View
|
||||
style={animatedStyles.surface}
|
||||
style={placement === "sidebar" ? null : animatedStyles.surface}
|
||||
testID={toast.testID ?? "app-toast"}
|
||||
accessibilityRole="alert"
|
||||
>
|
||||
<ToastCard toast={toast} onPointerEnter={pauseDismiss} onPointerLeave={resumeDismiss} />
|
||||
<ToastCard
|
||||
toast={toast}
|
||||
fillsLine={placement === "sidebar"}
|
||||
onPointerEnter={pauseDismiss}
|
||||
onPointerLeave={resumeDismiss}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
@@ -253,10 +258,12 @@ export function ToastViewport({
|
||||
|
||||
function ToastCard({
|
||||
toast,
|
||||
fillsLine,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
}: {
|
||||
toast: ToastState;
|
||||
fillsLine: boolean;
|
||||
onPointerEnter: () => void;
|
||||
onPointerLeave: () => void;
|
||||
}) {
|
||||
@@ -278,6 +285,7 @@ function ToastCard({
|
||||
onPointerLeave={onPointerLeave}
|
||||
style={[
|
||||
styles.toast,
|
||||
fillsLine ? styles.toastFillsLine : null,
|
||||
toast.variant === "info" ? styles.toastInfo : null,
|
||||
toast.variant === "warning" ? styles.toastWarning : null,
|
||||
toast.variant === "error" ? styles.toastError : null,
|
||||
@@ -289,7 +297,10 @@ function ToastCard({
|
||||
{toast.content}
|
||||
</Text>
|
||||
) : (
|
||||
<View testID="app-toast-message" style={styles.contentSlot}>
|
||||
<View
|
||||
testID="app-toast-message"
|
||||
style={[styles.contentSlot, fillsLine ? styles.contentFillsLine : null]}
|
||||
>
|
||||
{toast.content}
|
||||
</View>
|
||||
)}
|
||||
@@ -305,14 +316,20 @@ const animatedStyles = NativeStyleSheet.create({
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
},
|
||||
// Covers the line the slot is rendered in, so the toast hides that line's controls rather than
|
||||
// anything above it.
|
||||
sidebarContainer: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: SPACING[2],
|
||||
right: SPACING[2],
|
||||
bottom: SPACING[2],
|
||||
zIndex: OVERLAY_Z.toast,
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
},
|
||||
// Centred on the line while it fits. A taller card, wrapped at the narrowest sidebar, loses its
|
||||
// auto margins and grows upward from the line's bottom edge rather than past the window.
|
||||
sidebarLine: { marginVertical: "auto" },
|
||||
surface: { alignSelf: "center", maxWidth: "100%" },
|
||||
});
|
||||
|
||||
@@ -322,10 +339,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
maxWidth: TOAST_MAX_WIDTH,
|
||||
alignItems: "center",
|
||||
},
|
||||
sidebarWidthBoundary: {
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
},
|
||||
toast: {
|
||||
alignSelf: "center",
|
||||
maxWidth: "100%",
|
||||
@@ -340,6 +353,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
...theme.shadow.md,
|
||||
},
|
||||
// Fits the footer's pointer-sized icon line.
|
||||
toastFillsLine: {
|
||||
alignSelf: "stretch",
|
||||
paddingVertical: theme.spacing[1],
|
||||
},
|
||||
toastInfo: {
|
||||
borderColor: theme.colors.palette.blue[300],
|
||||
},
|
||||
@@ -357,6 +375,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
contentFillsLine: {
|
||||
flex: 1,
|
||||
},
|
||||
message: {
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
|
||||
@@ -34,6 +34,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
// Undo sits at the far edge when the card spans the sidebar.
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
// Cancels the badge's own inset, which would otherwise add to the button's gap.
|
||||
|
||||
Reference in New Issue
Block a user