Skip to content
Merged
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
e463256
feat(core): UIScrollView zoom constants and the zoom stage geometry
vjeux Nov 3, 2026
14f14d7
feat(Lightbox): touch zoom like the iOS Photos viewer
vjeux Oct 28, 2026
dc3a360
feat(Lightbox): swipe between gallery photos like Photos
vjeux Oct 28, 2026
70a405d
feat(core): UIScrollView zoom constants and the zoom stage geometry
vjeux Nov 3, 2026
a743218
feat(Lightbox): touch zoom like the iOS Photos viewer
vjeux Oct 28, 2026
0fc7062
Merge the updated #8148 into the Lightbox pager (replays the lane's r…
cixzhang Nov 5, 2026
b8266e6
fix(Lightbox): the zoom pan reads its begin threshold per press
vjeux Nov 6, 2026
b4fec75
Merge main into the Lightbox pager
vjeux Nov 6, 2026
91d4a1e
fix(Lightbox): a single zoomed photo gives past its edge again
vjeux Nov 6, 2026
22da53d
fix(core): a released page settles at the theme's tempo
vjeux Nov 6, 2026
32e21eb
docs(core): the tempo note names the snap paging Carousel
vjeux Nov 6, 2026
41b57e2
Merge main into the Lightbox pager
vjeux Nov 6, 2026
3ee5207
test(Lightbox): the instant-page check waits for 12 frames
vjeux Nov 6, 2026
93611a3
Merge main into the Lightbox pager
vjeux Nov 6, 2026
258ac0d
test(Lightbox): the busy page-settle proof pools six settles
vjeux Nov 6, 2026
d3bd5ce
Merge main into the Lightbox pager (v0.7.10 shipped the zoom note)
vjeux Nov 6, 2026
e0eab2a
Merge main into the Lightbox pager (keeps #10406's lift record)
vjeux Nov 7, 2026
cc382a8
Merge main into the Lightbox pager (ports #10415's busy zoom tempo)
vjeux Nov 7, 2026
17e7968
Merge main into the Lightbox pager
vjeux Nov 7, 2026
3963547
fix(Lightbox): the photo left behind resets by the page the pager names
vjeux Nov 7, 2026
51bf10b
Merge main into the Lightbox pager
vjeux Nov 7, 2026
0bb42a8
test(Lightbox): the busy settle proofs read a left-out run as none
vjeux Nov 7, 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
11 changes: 11 additions & 0 deletions .changeset/lightbox-gallery-paging.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@astryxdesign/core': patch
---

[feat] A Lightbox gallery now pages the way the iOS Photos viewer does. On a phone or tablet, drag or flick between items: the row follows your finger, and the release lands on the next or previous item, one at most. A gap of 40 px separates the items while they move. A zoomed photo pans with your finger until it reaches its edge; a new drag from that edge moves to the next item. A photo you leave zoomed returns to its fitted size once the gallery rests. Under a finger, the previous and next buttons and the arrow keys slide to the item instead of swapping it in place.

Each item's caption moves with its item, right under its photo. `onIndexChange` also reports a swipe.

Lightbox now loads its touch code with it, as Toast and Slider do. Its import grows from 51.4 KB to 99.6 KB gzip with the zoom and paging beneath, mostly shared touch modules that a page loads once; Lightbox's own code adds 14.1 KB minified.

@vjeux
7 changes: 7 additions & 0 deletions .changeset/pager-settle-tempo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@astryxdesign/core': patch
---

[fix] A page that a swipe releases now settles at the theme's `tempo`, as the rest of its motion does: in a `Carousel` given `hasSnap={useSnapPaging()}`, and in a `Lightbox` gallery. At the default tempo the settle is unchanged.

@vjeux
91 changes: 91 additions & 0 deletions apps/storybook/stories/Lightbox.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ export const ZoomWithCaption: Story = {
},
};

/**
* A gallery a phone can page through. Drag or flick between photos, with
* Photos' 40 pt gap. Pinch or double-tap a photo to zoom, and drag a zoomed
* photo to its edge before the next drag pages.
*/
export const GalleryWithZoom: Story = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
Expand All @@ -146,6 +151,64 @@ export const GalleryWithZoom: Story = {
},
};

/**
* A gallery whose captions differ in length: the photo keeps one box

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: each page now lays out its own photo above its own caption (Lightbox.tsx:250-258), so a page with a longer caption gets a smaller photo box, as the description notes. This comment says the photo keeps one box whatever captions render; rewording it to what the story shows keeps it accurate.

* whichever pages, and so whichever captions, are rendered.
*/
export const GalleryWithCaptions: Story = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>
Open gallery with captions
</button>
<Lightbox
isOpen={isOpen}
onOpenChange={setIsOpen}
media={[
{
...GALLERY_MEDIA[0],
caption:
'A winding path through the forest, past the old mill and down to the river, where the trail turns back toward the village',
},
GALLERY_MEDIA[1],
GALLERY_MEDIA[3],
{...GALLERY_MEDIA[2], caption: 'Golden hour'},
]}
hasZoom
/>
</>
);
},
};

/**
* A controlled gallery whose parent keeps its index, paging only with its own
* buttons, say while a change is confirmed. A swipe returns to the photo the
* parent shows.
*/
export const GalleryKeepsIndex: Story = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>
Open gallery that keeps its photo
</button>
<Lightbox
isOpen={isOpen}
onOpenChange={setIsOpen}
media={GALLERY_MEDIA}
index={0}
onIndexChange={() => {}}
hasZoom
/>
</>
);
},
};

export const WithCaption: Story = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
Expand All @@ -167,6 +230,34 @@ export const WithCaption: Story = {
},
};

/**
* A gallery with a video between two photos. A drag on the video's frame
* pages, and a drag that starts on its controls stays the video's.
*/
export const GalleryWithVideo: Story = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Open gallery with video</button>
<Lightbox
isOpen={isOpen}
onOpenChange={setIsOpen}
media={[
GALLERY_MEDIA[0],
{
src: 'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm',
alt: 'Flower blooming',
type: 'video',
},
GALLERY_MEDIA[1],
]}
/>
</>
);
},
};

export const Video: Story = {
render: () => {
const [isOpen, setIsOpen] = useState(false);
Expand Down
16 changes: 9 additions & 7 deletions docs/specs/AST-094-touch-presentations/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,12 +230,14 @@ a measured, sourced difference exists.
component's own record states which of its roles a gesture drives.

The pager's measured settle after a release (DEC-25) is not an exit, so FR4's
exception does not cover it. Implementation gap: it plays UIKit's measured
settle without a named role, and it doesn't yet follow `tempo` or `character`
(usePager calls solvePaging with no theme input). Implementation gap: the
pager is a gesture component, so its animated scroll to a page that the
program asks for settles on the pager's spring from rest (FR45); today it
plays a 300 ms scroll on a sinusoidal curve.
exception does not cover it. It follows the theme's `tempo`: a settle from
rest keeps its shape and takes tempo times as long, and a release still
starts at 96 % of the finger's speed, as at tempo 1. Implementation gap: it
plays UIKit's measured settle without a named role, and it doesn't yet
follow `character`.
Implementation gap: the pager is a gesture component, so its animated scroll
to a page that the program asks for settles on the pager's spring from rest
(FR45); today it plays a 300 ms scroll on a sinusoidal curve.

Implementation gap: the sheet picker wheel's snap rate (UIKit's measured
picker deceleration) plays from an internal constant and is not yet a named
Expand Down Expand Up @@ -1043,7 +1045,7 @@ Motion that FR45 moves, by component. Each component's record carries its own li
| FR6 pager | Unit and browser tests of a pager's settle start | releases just under and over 225 pt/s; a catch mid-settle; a programmatic page change mid-settle | a release under the floor whose settle starts moving, a release over it whose settle starts from rest, a catch that loses the on-screen velocity, or a programmatic page change mid-settle that starts from rest fails; a page change during a drag starts from rest |
| FR6 starved | Switch frame proofs on a starved main thread (4 workers on 2 cores); unit tests of a retarget on a settle tail and a rebase toward a new rest | a second tap mid-travel, with frames read late or its lift handled 100 ms late; a first frame committed late; a retarget once the thumb is still; a value change mid-slide, whose rebase moves the travel whole | a jump out of a thumb that is still fails; a value change mid-slide shows no step; another retarget's step within the browser's commit delay is the known limit |
| FR7, FR8 | Component browser tests and storyboards with and without Reduce Motion | entrance, exit, move; a drag and its lift under Reduce Motion; a tap-, key- and program-started close of a sheet and the Lightbox | a missing exit, any travel, scale or spring under Reduce Motion other than a coast after a throw and its settle at an edge (`design:reduced-motion` DR2), an opacity fade over 200 ms, or a tap-, key- or program-started close of a gesture component on the exit curve instead of its spring, fails |
| FR45 | Browser tests with a mouse, a pen, a trackpad and a finger at each `tempo` bound and each `character`, and with Reduce Motion | a tap, a click, a key and a programmatic change on a Switch, a menu, an alert, a sheet and a ProgressBar under a fine and a coarse pointer; a drag release, a flick and a pinch on a Switch, a Slider, a sheet, a pager and a Lightbox photo under each pointer; a tap that continues as a drag | a tap, click, key or programmatic change in a tap-classified component that plays a measured role or a spring instead of the theme's duration and easing tokens, a touch tap-started move in a gesture or gesture-capable component on a curve instead of its spring, a click, a key or a value change on Switch or SegmentedControl without a touch presentation that leaves the theme's curve, a velocity-carrying release that settles from rest or on a curve, a gesture's spring that ignores `character` or `tempo`, or a part that follows one pointer and not another, fails; the pager's measured settle, which does not yet follow `tempo` or `character`, is a recorded implementation gap |
| FR45 | Browser tests with a mouse, a pen, a trackpad and a finger at each `tempo` bound and each `character`, and with Reduce Motion | a tap, a click, a key and a programmatic change on a Switch, a menu, an alert, a sheet and a ProgressBar under a fine and a coarse pointer; a drag release, a flick and a pinch on a Switch, a Slider, a sheet, a pager and a Lightbox photo under each pointer; a tap that continues as a drag | a tap, click, key or programmatic change in a tap-classified component that plays a measured role or a spring instead of the theme's duration and easing tokens, a touch tap-started move in a gesture or gesture-capable component on a curve instead of its spring, a click, a key or a value change on Switch or SegmentedControl without a touch presentation that leaves the theme's curve, a velocity-carrying release that settles from rest or on a curve, a gesture's spring that ignores `character` or `tempo`, or a part that follows one pointer and not another, fails; the pager's measured settle, which does not yet follow `character`, is a recorded implementation gap |
| FR45 | Component source and token tests for a tap component's motion whose record names no step, with a platform measurement | a small anchored surface (a popover, a menu, a toggle, a row) and a full-screen surface (a Dialog, an AlertDialog), each with its own measurement; a tap component's overlay exit | a token cited as nearest to the measurement in the Storybook theme passes, and a tap component's overlay exit on `--duration-exit` passes; a token chosen by role alone, or nearest at Core's unthemed defaults, fails |
| FR45 | Browser frame capture of companion motion in a gesture and a tap component | a sheet's scrim, handle and content fade; a Lightbox's chrome fade; a menu's backdrop | a companion fade on its own duration fails; one driven by the main motion's progress or settle time passes |
| FR9, FR10 | Recognizer unit tests on synthetic streams and browser tests with real touch input | drag, second finger, pinch, page zoom, main-thread block | a second finger that breaks a drag, or a page pinch blocked other than by the author's `touch-action`, fails |
Expand Down
Loading
Loading