Skip to content

feat(lab): CircleCheckIndicator, the iOS multiple-selection circle - #9723

Merged
cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/c06-circle-check-batch
Nov 5, 2026
Merged

cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/c06-circle-check-batch

Conversation

@vjeux

@vjeux vjeux commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Batch: L-check · Lab: CircleCheckIndicator

Size: 1,655 added lines, one Lab component, 889 of them tests.

User need

iOS marks multiple selection with a circle (Mail and Files edit modes, .multiselect() lists). Astryx has only the box; rounded, it has neither UIKit's clear ring nor its checkmark.

Evidence

Recorded on #8255's code; Testing's engine runs prove this head. UIKit's circle beside this one, iOS 26.2 at 3×:

UIKit and CircleCheckIndicator circles

A held finger, iOS 26.2: a square press before, a round one after. Android 15 Chrome, that press at ½ speed:

Press before and after, iOS Safari

Android Before After
Held press before after

Every state, both sizes; then Chromium at this head, a mouse (round press, mark fading in) and a finger:

States, iOS Safari

A mouse presses and clicks the circle, Chromium

A finger taps the circle, Chromium

Problem and solution fit

  • A Lab CircleCheckIndicator a theme maps to checkbox: an empty ring, or a filled circle with a checkmark (a bar when partial). Every CheckboxInput and CheckboxList then draws it, with its semantics and focus ring.
  • Its geometry is UIKit's (iOS 26.2, two text sizes); its colours are the theme's.
  • Its mark fades over the theme's duration on every pointer, as Core's indicators do; Reduce Motion makes it instant.
  • It paints its own round press: hover and a mouse press through the row's scope marker, a touch press from the image a pressed CheckboxInput row hands down (feat(core): a first-party indicator can paint its own press #9716). A mapped menu checkbox item thus paints its press once. In a CheckboxList, a finger anywhere on the row paints it (fix(CheckboxListItem): a row answers a finger the same wherever it lands #8212).
  • In forced colors, a system-coloured border draws the ring.

Current specification

Lab code needs no record. Timing follows the theme's motion tokens and design:reduced-motion; the press uses the internal marker #9716 added.

Public delta and caller burden

A new Lab component, CircleCheckIndicator (Indicator props), with a story, a CLI discover entry and a registry route; a theme opts in with indicators: {checkbox: CircleCheckIndicator}.

Design and alternatives

  • The ring is an inset shadow, because a border snaps to whole device pixels: 1.54 px becomes 1.33 px at 3×.
  • The measurements live beside the component, not in Core's UIKit table.
  • UIKit's one-frame mark under a finger: a tap-driven change plays the theme's motion tokens on every pointer.

Supersedes #8255.

#8212, the CheckboxList row press, merged first; this head's held-label case asserts the circle paints with the row. Not in this PR: #8701 (mouse and pen, after #8682).

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

Local gates at b67198a, on main b1f604c. It is df21f7c restacked: every changed line is the same except the browser spec. The one conflict was the Lab index, where main's SelectableCardGroup export meets this one; both stay.

  • The browser spec imports from @astryxdesign/a11y-spec/playwright. Its held-label case flips with fix(CheckboxListItem): a row answers a finger the same wherever it lands #8212: "a held label presses the row, and the circle paints with it". The circle case also checks that the row holds the press.
  • Red first: on this head's build, the previous spec's "a held label presses the row, not the circle" fails, since the circle now paints the row's press. On main before fix(CheckboxListItem): a row answers a finger the same wherever it lands #8212, df21f7c's runs passed it 100 of 100.
  • The commit message now says the mark fades on every pointer, as the code and the spec do.
  • At this head, with Storybook built and stamped here: the circle's browser spec ×5 in Chromium at 4 workers, 50 of 50; ×3 in WebKit in the CI container profile (4 CPUs, 1 worker), 30 of 30.
  • pnpm build (no generated drift), verify-exports, pnpm typecheck and pnpm review:pre (0 findings): clean. ESLint on the changed files, and Prettier on the changed files CI checks: clean.
  • CI's repo checks: sync-exports --check, the CLI API types and README, token docs, the production registry gate, the docsite route-lock data, check-knowledge, lab:readiness:check, check:motion-roles and check:changesets: clean.
  • Units: the circle's 15 cases. test:node: 907 files, 16,634 tests. test:ui: 20,872 of 20,873. The one failure is main's own: CheckboxList's "holds one touch-query listener" case meets feat(List): a row's touch target grows toward 44 px where its neighbors leave room #8085's hit-area watch, a second shared listener. This PR changes no Core file, and that case is fixed separately.

@vercel

vercel Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 5, 2026 5:31am UTC

Request Review

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Nov 3, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 3, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 3, 2026
@astracat-bot

astracat-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 5, 2026, 5:46 AM UTC

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · b67198a

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
lab/CircleCheckIndicator · Storybook 4.6 kB (new) · 1.0 kB CSS (new) 256 (new) — · ✓ · —

Package size: @astryxdesign/cli 3.36 MB packed (+4 B); no new dependencies.

API changes: none in this pull request

Committed API report behind this build: cli 9 rows, core 4 rows; run pnpm api:report.

Not run on this PR: add ci:run-visual or ci:run-rtl to run them.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

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.

Approval waits on an engineering-owner decision about the component:CheckboxInput change this pull request carries, plus two fixes to that record change.

Owner decision: FR11 and AV1 are rewritten so a first-party indicator can paint its own press while CheckboxInput turns its rounded-square pressed layer off over it; the current AV1 says CheckboxInput "still paints focus and pressed feedback on the resolved root". An engineering owner records approval by commenting /approve-spec with the full head commit SHA, and the author may do so as an engineering owner.

Blocking:

  • Add a ## Writing rubric results section to the description, since the pull request changes a knowledge record (the one in #9716 covers the same edit).
  • Update the CheckboxInput record's contract-at-a-glance Behavior row, which still says CheckboxInput owns the pressed overlay, and state the new exception once in AV1 with FR11 citing it instead of repeating the sentence.

Without the circle mapped, CheckboxInput and CheckboxList render exactly as before.

[Automated review]

### Allowed variation

- **AV1 — Indicator replacement.** A theme may replace the named checkbox indicator if it preserves the Indicator prop and decorative ownership contracts; CheckboxInput still paints focus and pressed feedback on the resolved root.
- **AV1 — Indicator replacement.** A theme may replace the named checkbox indicator if it preserves the Indicator prop and decorative ownership contracts; CheckboxInput still paints focus and pressed feedback on the resolved root. A first-party replacement indicator may paint its own press from the row's hover and press (an internal marker; not public API), while CheckboxInput still paints focus.

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.

This sentence repeats FR11 word for word, and the contract-at-a-glance Behavior row still says CheckboxInput "owns its focus ring and pressed overlay". Suggest stating the exception once here, having FR11 cite AV1, and amending the Behavior row.

Comment on lines +62 to +63
// under Reduce Motion its change is instant. Both keys are spelled as
// Core spells them, because Core exports neither.

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 (INV29): the stated reason is not the constraint; Core keeps these keys literal because StyleX ranks a constant media key in a later layer. Suggest: "StyleX ranks a constant media key above a literal one. So both keys stay literal, as in Core."

Comment on lines +84 to +85
// that paints its own press. Only this row declares it, so a mapped
// indicator in a row that paints its own press paints nothing twice.

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 (INV29): hard to parse. Suggest: "Only this row declares it. A menu row paints its own press, so an indicator mapped there paints no second press."

@astracat-bot astracat-bot Bot left a comment

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.

Approval waits on an engineering-owner decision about the component:CheckboxInput change this pull request carries, plus three fixes.

Owner decision: FR11 and AV1 are rewritten so a first-party indicator can paint its own press while CheckboxInput turns its rounded-square pressed layer off over it; the current AV1 says CheckboxInput "still paints focus and pressed feedback on the resolved root". An engineering owner records approval by commenting /approve-spec with the full head commit SHA, and the author may do so as an engineering owner.

Blocking:

  • Under a finger, holding a mapped CheckboxList row on its label paints the row's press and a second press on the circle, which the built-in box and a mouse do not, so declare --_indicator-press-image only under the row's own data-astryx-press states and hold the checkbox, not the row, in the browser case that asserts the press.
  • Add a ## Writing rubric results section to the description, since the pull request changes a knowledge record.
  • Update the CheckboxInput record's contract-at-a-glance Behavior row and ownership bullet, which still say CheckboxInput owns the pressed overlay, and state the new exception once in AV1 with FR11 citing it instead of repeating the sentence.

Without the circle mapped, CheckboxInput and CheckboxList render and press exactly as before.

[Automated review]

// that paints its own press. Only this row declares it. A menu item paints
// its own press, so a mapped indicator there paints none.
indicatorPressImage: {
'--_indicator-press-image': 'var(--_press-paint-image)',

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.

Under a finger this hands down a press whenever any ancestor is pressed: --_press-paint-image follows --astryx-press-alpha, which this row inherits from a pressed CheckboxList li. With the circle mapped, holding a row's label paints the row's press and a second one on the circle, while the built-in box (whose layer is gated on this row's own data-astryx-press) and a mouse stay unpressed. Declaring it only under the row's own press states keeps the circle in step with the host layer, for example:

indicatorPressImage: {
  '--_indicator-press-image': {
    default: 'none',
    '[data-astryx-press="on"]': 'var(--_press-paint-image)',
    '[data-astryx-press="fading"]': 'var(--_press-paint-image)',
  },
},

page,
}) => {
await openStory(page, storybook(), STORY);
const at = await centreOf(page, 'Mountains');

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.

This holds the row's centre, which is the ListItem label, not the checkbox, so the case asserts the double press described on CheckboxInput.tsx:87. Holding the circle (circleCentreOf) tests the circle's own press; a second case holding the label could assert that the circle stays unpressed.

### Allowed variation

- **AV1 — Indicator replacement.** A theme may replace the named checkbox indicator if it preserves the Indicator prop and decorative ownership contracts; CheckboxInput still paints focus and pressed feedback on the resolved root.
- **AV1 — Indicator replacement.** A theme may replace the named checkbox indicator if it preserves the Indicator prop and decorative ownership contracts; CheckboxInput still paints focus and pressed feedback on the resolved root. A first-party replacement indicator may paint its own press from the row's hover and press (an internal marker; not public API), while CheckboxInput still paints focus.

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.

This sentence repeats FR11 word for word, and the first sentence of AV1, the contract-at-a-glance Behavior row (line 55) and the ownership bullet (line 82) still say CheckboxInput paints or owns the pressed overlay. Suggest stating the exception once here, having FR11 cite AV1, and amending the Behavior row and the ownership bullet.

Comment on lines +89 to +91
// `:active` fill, and the image would paint a second press. The key
// stays literal, because StyleX ranks an imported at-rule key
// differently.

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 (INV29): "ranks … differently" does not give the constraint; Core states it in motion/reducedMotion.stylex.ts. Suggest: "StyleX puts a constant media key in a later priority layer than a literal one. So the key stays literal."

@astracat-bot astracat-bot Bot left a comment

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.

Approval waits on an engineering-owner decision about the component:CheckboxInput change this pull request carries; nothing else blocks.

Owner decision: FR11 and AV1 are rewritten so a first-party indicator can paint its own press while CheckboxInput turns its rounded-square pressed layer off over it; the current AV1 says CheckboxInput "still paints focus and pressed feedback on the resolved root". An engineering owner records approval by commenting /approve-spec with the full head commit SHA, and the author may do so as an engineering owner; if the owners decline, the Core change and the record edit come out and the circle keeps CheckboxInput's square press.

Holding a CheckboxList label under a finger now presses only the row, the description reports the writing-rubric results, and the record states the exception once in AV1. Without the circle mapped, CheckboxInput and CheckboxList render and press exactly as before. Two optional comment rewrites are left inline.

[Automated review]

Comment on lines +89 to +90
// that paints its own press, but only in its own press states. A pressed
// ancestor row reaches it no more than it reaches the built-in box. The

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 (comment style): "A pressed ancestor row reaches it no more than it reaches the built-in box" is hard to parse. Suggest: "A pressed outer row hands down nothing, as for the built-in box."

Comment on lines +52 to +53
// The theme's duration on every pointer and none under Reduce Motion,
// as Core's indicators play their marks.

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 (comment style): this restates the declaration below it. Suggest deleting it, or keeping only "Core's indicators time their marks the same way."

@astracat-bot astracat-bot Bot left a comment

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.

Approval waits on an engineering-owner decision about the component:CheckboxInput change this pull request carries; nothing else blocks.

Owner decision: FR11 and AV1 are rewritten so a first-party indicator can paint its own press while CheckboxInput turns its rounded-square pressed layer off over it; the current AV1 says CheckboxInput "still paints focus and pressed feedback on the resolved root". The existing record has to be updated for this intentional change: an engineering owner records approval by commenting /approve-spec with the full head commit SHA, and the author may do so as an engineering owner. If the owners decline, the Core change and the record edit come out and the circle keeps CheckboxInput's square press.

Holding a CheckboxList label under a finger presses only the row, the description reports the writing-rubric results, and without the circle mapped CheckboxInput and CheckboxList render and press exactly as before. Optional: the comments at CheckboxInput.tsx lines 89–90 and circleCheck.stylex.ts lines 52–53 could be tightened.

[Automated review]

@vjeux
vjeux force-pushed the uikit/c06-circle-check-batch branch from bb20ec6 to df21f7c Compare November 3, 2026 16:02
@github-actions github-actions Bot removed needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval labels Nov 3, 2026

@astracat-bot astracat-bot Bot left a comment

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.

Recommending approval: CircleCheckIndicator follows the current Indicator replacement rule and CheckboxInput's exception for an indicator that paints its own press, and nothing blocks.

At this head a held mouse or finger presses the circle itself while CheckboxInput's square layer stays off, holding a CheckboxList label presses only the row, and focus, forced colors and Reduce Motion behave as the docs describe.

[Automated review]

iOS marks multiple selection with a circle: a table row's edit-mode
control and a list's `.multiselect()` accessory, an empty ring that
fills with a white checkmark. Astryx's only multiSelection indicator is
the checkbox box, and rounding it gives neither the clear ring nor
UIKit's checkmark.

CircleCheckIndicator is a multiSelection-family indicator in Lab. A
theme maps it to `checkbox`, and every CheckboxInput and CheckboxList
draws it, keeping its checkbox semantics and focus ring. Its geometry
is UIKit's, measured on iOS 26.2 at two text sizes and kept beside it;
its colours are the theme's. Its mark fades over the theme duration on
every pointer, and Reduce Motion makes that change instant. It paints
its own round press: hover and a mouse press come through the row's
scope marker, and a touch press from the image that only a
CheckboxInput row hands down, so a mapped menu checkbox item paints its
press once. In forced colors a system-coloured border draws the ring.

It comes with a story, a CLI discover entry and a registry route.
@vjeux
vjeux force-pushed the uikit/c06-circle-check-batch branch from df21f7c to b67198a Compare November 5, 2026 05:25
@cixzhang

cixzhang commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

/approve-spec b67198a

@cixzhang cixzhang added the ci:run-a11y Run the accessibility suite on this PR; write access, next push (AST-030 FR12) label Nov 5, 2026

@astracat-bot astracat-bot Bot left a comment •

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.

Recommending approval, pending CI: CI has not run on this head yet. CircleCheckIndicator follows the current Indicator replacement rule and CheckboxInput's exception for an indicator that paints its own press, and nothing blocks.

Next step: this branch is in a fork, so build, test, lint and docsite-test stop at "Waiting for a maintainer's ci:run label"; a maintainer adds the ci:run label so CI runs.

At this head a held mouse or finger paints a round press on the circle while CheckboxInput's square layer stays off, a finger on a CheckboxList label presses the row and the circle with it as the built-in box does, and focus, forced colors and Reduce Motion behave as the docs describe.

[Automated review]

@vjeux
vjeux marked this pull request as ready for review November 5, 2026 05:45
@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 5, 2026
@cixzhang
cixzhang marked this pull request as draft November 5, 2026 05:48
@cixzhang
cixzhang marked this pull request as ready for review November 5, 2026 05:48
@cixzhang
cixzhang merged commit 6f3befa into meta:main Nov 5, 2026
150 of 155 checks passed
@vjeux
vjeux deleted the uikit/c06-circle-check-batch branch November 5, 2026 08:15

This branch was successfully deployed

1 active deployment
Preview — b67198ae Deployed Nov 5, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) ci:run-a11y Run the accessibility suite on this PR; write access, next push (AST-030 FR12) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants