Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4a280bb
fix(Layer): a dismissal hold leaves a nested layer live
vjeux Nov 11, 2026
a87867a
feat(AlertDialog): touch motion on the theme's timing
vjeux Nov 11, 2026
4aaa466
docs(specs): AlertDialog's touch motion ships
vjeux Nov 11, 2026
a709079
docs(specs): why the motion hook and the layer-root reader exist
vjeux Nov 11, 2026
6bb7eba
fix(AlertDialog): the touch entrance eases on --ease-entrance
vjeux Nov 11, 2026
3c1ab6d
test(sandbox): an AlertDialog case for a program's interruptions
vjeux Nov 11, 2026
b5a6854
test(sandbox): the interruptions case reopens inside every exit
vjeux Nov 11, 2026
7f47927
Merge main
vjeux Nov 12, 2026
25b54b3
refactor(AlertDialog): read the motion's pointer flag as committed
vjeux Nov 12, 2026
301ac13
Merge main
vjeux Nov 12, 2026
617f60f
test(Dialog): the reduced-motion entrance check reads script animations
vjeux Nov 12, 2026
836dd4c
test(AlertDialog): the turn-back check dates the close at its call
vjeux Nov 12, 2026
6f7e747
Merge main
vjeux Nov 12, 2026
c44f975
Merge main
vjeux Nov 13, 2026
c4d74bc
Merge main
vjeux Nov 13, 2026
991dbca
docs(architecture): INV16 names the dismissal hold's nested layers
vjeux Nov 13, 2026
1e2dd21
docs(core): split four comment sentences, and say a modal hosts a toast
vjeux Nov 13, 2026
8476269
Merge main
vjeux Nov 13, 2026
ccac987
docs(architecture): scope the hold's nested-layer line to a blur hold
vjeux Nov 13, 2026
21e1c3d
refactor(core): one fallback for a theme timing that does not resolve
vjeux Nov 13, 2026
d46da0f
docs(AlertDialog): the touch motion runs on a phone or tablet
vjeux Nov 13, 2026
5a5e131
Merge main
vjeux Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/alertdialog-touch-motion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[feat] AlertDialog: under a finger the alert appears and leaves the way an iOS alert does. It scales down into place from 1.2 while it and the dimming fade in, on the theme's entrance timing. When it closes it fades out with the dimming, on the theme's exit timing, and a close or a reopen midway turns back from where it is. While it appears a touch does nothing, and keys and assistive technology still act. While it leaves nothing reaches it, but a toast its action raised stays live and its words are read. With reduced motion it only cross-fades. On a phone (a compact window under a coarse pointer) the width with no `width` prop goes from 400 px to 320 px in every theme; a `width` you pass still wins. With a mouse nothing changes.

@vjeux
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
export const ALERT_DIALOG_CASES = [
'alert-over-a-toast',
'dialog-closed-after-save',
'interruptions',
] as const;

export type AlertDialogCaseId = (typeof ALERT_DIALOG_CASES)[number];
Expand All @@ -23,6 +24,8 @@ export const ALERT_DIALOG_CASE_LABELS: Readonly<
'An alert over a toast: Delete raises a toast as the alert closes',
'dialog-closed-after-save':
'A dialog closed after a save: Save raises a toast, and the dialog closes when the save is done',
interruptions:
'Interruptions: the program closes the alert while it enters, or reopens it while it leaves',
};

export function parseAlertDialogCase(value: string | null): AlertDialogCaseId {
Expand Down
68 changes: 61 additions & 7 deletions apps/sandbox/src/app/(raw)/pages/alert-dialog-cases/cases.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
/**
* @file cases.tsx
* @input A case id from caseIds.ts
* @output The case: an alert over a toast, or a dialog closed after a save
* that raised a toast
* @output The case: an alert over a toast, a dialog closed after a save
* that raised a toast, or an alert that the program interrupts
* @position The AlertDialog cases page's content. Each case is its Storybook
* story, on a sandbox page.
*/
Expand Down Expand Up @@ -110,10 +110,64 @@ function DialogClosedAfterSave() {
);
}

export function AlertDialogCase({id}: {id: AlertDialogCaseId}) {
return id === 'alert-over-a-toast' ? (
<AlertOverAToast />
) : (
<DialogClosedAfterSave />
/**
* How long after the open the program closes it, ms: inside every scale's
* entrance.
*/
const CLOSE_AFTER_MS = 150;

/**
* How long after the close the program reopens it, ms: inside every scale's
* exit (100 ms or more).
*/
const REOPEN_AFTER_MS = 50;

function Interruptions() {
const [isOpen, setIsOpen] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Synchronizes the case's timer with the page: leaving the case clears it.
useEffect(
() => () => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
}
},
[],
);
const setOpenSoon = (next: boolean, afterMs: number) => {
if (timerRef.current != null) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => setIsOpen(next), afterMs);
};
return (
<Stack gap={2}>
<Button
label="Open, then close while it enters"
onClick={() => {
setIsOpen(true);
setOpenSoon(false, CLOSE_AFTER_MS);
}}
/>
<Button label="Delete item" onClick={() => setIsOpen(true)} />
<AlertDialog
isOpen={isOpen}
onOpenChange={setIsOpen}
title="Delete item?"
description="Reopen closes this alert, then opens it again while it fades out."
actionLabel="Reopen"
onAction={() => {
setIsOpen(false);
setOpenSoon(true, REOPEN_AFTER_MS);
}}
/>
</Stack>
);
}

export function AlertDialogCase({id}: {id: AlertDialogCaseId}) {
if (id === 'alert-over-a-toast') {
return <AlertOverAToast />;
}
return id === 'interruptions' ? <Interruptions /> : <DialogClosedAfterSave />;
}
55 changes: 54 additions & 1 deletion apps/storybook/stories/AlertDialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

import {useState} from 'react';
import {useEffect, useRef, useState} from 'react';
import type {Meta, StoryObj} from '@storybook/react';
import {
AlertDialog,
useImperativeAlertDialog,
} from '@astryxdesign/core/AlertDialog';
import {Button} from '@astryxdesign/core/Button';
import {Stack} from '@astryxdesign/core/Stack';
import {Text} from '@astryxdesign/core/Text';
import {useToast} from '@astryxdesign/core/Toast';
import type {ToastDismissFn} from '@astryxdesign/core/Toast';

const meta: Meta<typeof AlertDialog> = {
title: 'Core/AlertDialog',
Expand Down Expand Up @@ -226,3 +230,52 @@ export const Inline: Story = {
onAction: () => {},
},
};

/**
* An action that raises a toast. Under a finger the alert fades out above the
* toast. The toast stays live meanwhile: its Undo button takes a tap during
* the fade, and assistive technology reads it.
*/
export const AlertOverAToast: Story = {
render: function AlertOverAToastStory() {
const [isOpen, setIsOpen] = useState(false);
const [undoCount, setUndoCount] = useState(0);
const toast = useToast();
// The story's one toast. A new delete replaces it, and leaving the story
// dismisses it.
const dismissRef = useRef<ToastDismissFn | null>(null);
useEffect(() => () => dismissRef.current?.(), []);
return (
<Stack gap={2}>
<Button
label="Delete item"
variant="destructive"
onClick={() => setIsOpen(true)}
/>
<Text>{`Undo taps: ${undoCount}`}</Text>
<AlertDialog
isOpen={isOpen}
onOpenChange={setIsOpen}
title="Delete item?"
description="This action cannot be undone. The item and all its data will be permanently removed."
actionLabel="Delete"
onAction={() => {
dismissRef.current = toast({
body: 'Item deleted.',
uniqueID: 'alert-over-a-toast',
isAutoHide: false,
endContent: (
<Button
label="Undo"
variant="secondary"
onClick={() => setUndoCount(count => count + 1)}
/>
),
});
setIsOpen(false);
}}
/>
</Stack>
);
},
};
14 changes: 11 additions & 3 deletions docs/architecture/layer-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -669,7 +669,9 @@ layers use Layer rendering without joining Escape/platform dismissal.
FR9). A `focus: 'blur'` hold, the default, keeps the shield and makes the
content inert at once, as a leaving AlertDialog (`component:AlertDialog`
FR7) and a closing BottomSheetSwitcher step (`component:BottomSheet` FR9)
are for their dismissal. The component
are for their dismissal. A blur hold leaves a nested layer, its path, and a
child live region live, such as a toast viewport that a leaving alert
hosts. The component
still decides where focus returns. A component that holds input uses this hold rather
than building its own. It is separate from INV14's presentation hold, which
keeps a layer's chosen presentation.
Expand Down Expand Up @@ -867,8 +869,14 @@ it on the surface inside the layer and run no layout while it plays.
on.
- `Layer/presentationHold.ts` owns the presentation hold: a layer's chosen
presentation, held from the open until its close settles (INV14).
- `Layer/inputHold.ts` owns the input hold, and `Layer/holdEntranceInput.ts`
its use by layers that enter through CSS (INV16).
- `Layer/inputHold.ts` owns the input hold that a touch presentation places
while it enters or leaves (`spec:AST-094` FR35), and
`Layer/holdEntranceInput.ts` its use by layers that enter through CSS
(INV16). A blur dismissal hold leaves a nested layer, its path, and a
child live region live. It finds a nested layer with `hasLayerRootWithin`, a reader of
the layer-root owner (`architecture:public-component-api` INV23). The
owner's other readers test an element or its ancestors, and the hold asks
whether a subtree holds a layer root.
- `Layer/layerStack.ts`, `Layer/useLayerDismissal.ts`, and
`Layer/LayerDepthContext.tsx` own current registration, presence, ordering, and
Escape/platform routing. The stack, the modal outlet registry, the gesture
Expand Down
Loading