microsoft / microsoft/fluentui-react-native

Components: Align Button and ToggleButton contracts against upstream

Open
#4,215 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
1.4k
Forks
179
Avg merge
16h 17m
Merged PRs (30d)
30

Description

Summary

The local button component carries a selected prop and full toggle-button semantics that upstream removed from button at v1.4.0 and placed in a dedicated toggle-button. Decide now whether the agentic button treats that as a permanent, documented deviation or migrates selected out into toggle-button. The same v1.4.0 upstream change also renamed the Radius axis to Shape with a two-value set (Rounded/Circular), and split the single icon + iconPosition slot into independent leading and trailing icon slots. Those three drifts affect both components in tandem, so they must be decided together rather than left to the toggle-button generation task. The toggle-button task is explicitly blocked here until an owner decision covers all three axes.

Inferred. If the decision is not made before generation starts, the package will simultaneously expose Button's externally driven selected and ToggleButton's self-driving selected/defaultSelected/onSelectedChange as two overlapping toggle patterns with no documented boundary, and any later cleanup becomes a breaking change.

Goal

Record a written owner decision for each of the three drifted axes, update the local button contract and SPEC.md to reflect that decision, and provide the exact scope statement that the toggle-button generation task needs to implement selected, icon slots, and shape consistently with button.

Stage

Stages 0 and 1 (scope decision through beta foundations). Stage 0 chooses the
Button and ToggleButton contract boundary; Stage 1 applies the Button-side API,
spec, test, story, and migration consequences. This decision is a prerequisite
for the Wave 1 toggle-button generation task and must be resolved before any
beta-readiness work on button claims spec fidelity.

Why it matters

Observed. The local button SPEC.md describes the Selected axis, the Regular-to-Filled icon swap, and the font-weight swap as first-class variant axes. The upstream button spec at d334acf removed all of that and placed it in toggle-button. Nothing in the local repository documents that difference or records it as a deliberate choice.

Observed. The local button test suite has 15 cases, 7 of which directly exercise the toggle axis: icon-swap selection, ghost-label reflow prevention, externally driven selection semantics, and accessibility state. Those tests will become misleading the moment a generated toggle-button next to them claims the same Selected axis.

Observed. toggle-button.md already flags this as an open risk: "This task and the button divergence are two halves of one decision. Landing toggle-button without deciding button's selected axis leaves two overlapping toggle APIs in one package."

Inferred. The spec-ingestion drift report (spec-ingestion-and-generation.md) will record button as drifted on all three axes. Without an explicit owner disposition, any automated drift check cannot distinguish "accepted deviation" from "unresolved divergence", and button blocks the drift gate permanently.

Observed current state

Local button: the three drifted axes

Observed. From
packages/agentic/components/src/components/button/button.types.ts:

Axis Local contract Upstream contract at d334acf
Selected selected?: boolean in ButtonStateProps; selectedIcon slot for icon swap Removed from button; moved to toggle-button as self-driving selected/defaultSelected/onSelectedChange
Shape shape: 'rounded' | 'square' | 'circle' Shape: Rounded | Circular (no square)
Icon slots Single icon: OptionalSlot<Icon> + iconPosition: 'before' | 'after' + selectedIcon: OptionalSlot<Icon> Independent leadingIcon and trailingIcon slots; icon swap is intrinsic to toggle-button

Observed. From
packages/agentic/components/src/components/button/useButton.ts:
selection is externally driven. isToggleButton = selected !== undefined. The button
never changes selected on press; the caller owns the value and updates it from
onPress. When isToggleButton is true, accessibilityState.checked = selected is
set and a contentHidden ghost slot is added to prevent label reflow on weight swap.

Observed. From
packages/agentic/components/src/components/button/renderButton.tsx:
the active icon is resolved as state.selected ? (state.selectedIcon ?? state.icon) : state.icon,
and the ghost label via LayoutStableText is rendered only when isToggleButton && ContentHidden.

Observed. From
packages/agentic/components/src/components/button/button.test.tsx:
seven of the fifteen test cases exercise toggle behavior directly.

Upstream toggle-button: the target contract

Observed. From
toggle-button.md, citing upstream
x3-design/fluent-design @
d334acf,
plugins/components/skills/toggle-button/SKILL.md:

  • Axis: Selected (True / False). Upstream toggle-button is self-driving: it owns
    state through the selected / defaultSelected / onSelectedChange triple and
    useToggleState, unlike button's externally driven selected.
  • Icon swap: Regular-to-Filled icon swap on selected=True. No separate selectedIcon
    slot; the swap is intrinsic to the component.
  • Label: font-weight swap (Regular at rest, Semibold when selected), with the ghost
    Semibold node reserving layout width.
  • Shape: Rounded | Circular only, matching the current upstream button.
  • Icon slots: independent leadingIcon and trailingIcon, matching the current
    upstream button.
FURN V1 ToggleButton: a third naming variant

Observed. From
packages/components/Button/src/ToggleButton/ToggleButton.types.ts:
the legacy component uses checked?: boolean and defaultChecked?: boolean (extending
ButtonProps). This differs from both the local agentic selected and the upstream
selected/defaultSelected pair. Migration guidance must account for the FURN V1
naming so consumers on the legacy package are not doubly confused.

Coverage that changes under each option

Observed. Button's toggle-related test cases in
button.test.tsx:

Test case Toggle-specific
uses the regular icon while a toggle button is not selected Yes
uses selected semantics, selected icon, and a ghost label to prevent reflow Yes
renders selection without changing it on press Yes
reserves the selected label width in both selected states so toggling cannot reflow Yes
omits selection semantics when no selection prop is supplied Yes
places the icon after content and applies user styles last (touches iconPosition) Indirect
renders a persistent dual-ring focus visual (touches selected path) Indirect

If selected migrates out (Decision A2), those cases move to toggle-button and
button loses the ghost label, selectedIcon slot, isToggleButton state, and all
contentHidden render logic.

Scope

This task is a decision task, not a generation task. Its artifact is a written
owner decision for each of the three axes, applied to button/SPEC.md and the
button.types.ts comment block, and a companion scope statement that the
toggle-button task will execute.

Decision A: The selected axis

Two options:

Option A1 -- Intentional deviation. Local button keeps selected as an
externally driven prop. Rationale: the caller owns state; a press is an action, not a
state change. The behavior is well-tested, documented, and consistent with the
button.types.ts comment ("Selection is externally driven: the button never changes
it on press, because a press is an action rather than a state change"). toggle-button
is generated as a self-driving wrapper whose onSelectedChange calls a user-supplied
handler and updates internal state. The two components coexist with a documented
boundary: button.selected for caller-owned toggle state, toggle-button for
component-owned toggle state. The deviation is recorded in button/SPEC.md with the
upstream reference and the rationale.

Option A2 -- Migrate. Remove selected, selectedIcon, isToggleButton,
contentHidden, and all ghost-label logic from button. The toggle-button
generation task inherits the full toggle contract from upstream. This is a breaking
change for any code using button.selected. The migration note must explain the
replacement pattern for both the local agentic button.selected and the FURN V1
ToggleButton.checked/defaultChecked APIs.

Decision B: The shape axis

Two options:

Option B1 -- Keep 'square'. Local button retains
shape: 'rounded' | 'square' | 'circle'. square is a local extension with no
upstream counterpart; the deviation is recorded in SPEC.md. toggle-button aligns
with upstream and exposes only 'rounded' | 'circular', with a note that button is
a superset.

Option B2 -- Align with upstream. Remove 'square' from ButtonShape and
rename 'circle' to 'circular'. useButton.ts currently defaults shape to 'circle'
for icon-only buttons; that default must be updated. This is a breaking change for
any code using shape="square" or shape="circle". toggle-button and button
then share the same shape vocabulary.

Decision C: The icon-slot layout

Two options:

Option C1 -- Keep icon + iconPosition + selectedIcon. The local single-icon
layout is retained. toggle-button inherits the same slots and adds the intrinsic
swap behavior on top. The deviation from upstream's independent leadingIcon /
trailingIcon is documented.

Option C2 -- Adopt independent leadingIcon and trailingIcon. Removes
iconPosition from ButtonStateProps and splits the icon slot into leadingIcon
and trailingIcon. The selectedIcon slot is also dropped; the icon swap for
toggle-button is achieved through the component's own state. toggle-button then
aligns fully with upstream's slot layout. This is a breaking change.

Inferred. Decision C is the least urgent of the three because the current
icon + iconPosition layout works correctly on both platforms and no blocking
downstream component depends on independent slots. If Decision A2 is chosen, the
selectedIcon removal is bundled and Decision C can be made in the same window.

Out of scope

  • Implementing toggle-button. This task only defines the scope statement for
    toggle-button.md; execution stays in that
    task.
  • Resolving the drift detection mechanism. Recording the decision as an accepted
    deviation in the drift report is the responsibility of
    spec-ingestion-and-generation.md.
  • Updating any FURN V1 (packages/components/Button) implementation. Migration
    guidance is documentation only; no changes to the legacy package.
  • Deciding whether the button type test gap (see
    component-test-strategy.md) is addressed here or in
    the coverage task. Either task may add button.types.test.ts; this task governs
    only the contract change.

Deliverables

  1. A written owner decision for each of the three axes (A, B, C), recorded in the
    Risks and open decisions section of this file and in button/SPEC.md front matter
    or a Deviations section.
  2. If Decision A1: an Accepted deviation record in button/SPEC.md citing the
    upstream reference, the local rationale, and the upstream revision at which the
    divergence was introduced.
  3. If Decision A2: a PR removing selected, selectedIcon, isToggleButton,
    contentHidden, ghost-label render logic, and the toggle-specific tests from
    button, with a migration comment in the changeset. The toggle-button scope
    statement in this document is updated to reflect inherited behavior.
  4. For Decision B and C: equivalent SPEC.md deviation records or API changes, scoped
    analogously to A1 vs A2.
  5. An updated scope statement for toggle-button.md
    that specifies exactly which behaviors toggle-button must implement vs inherit or
    extend from button, resolving the open risk items in that document.
  6. A changeset if any source files in packages/agentic/components are modified.

Acceptance criteria

  • Each of the three axes has a recorded owner decision in this document and in
    button/SPEC.md.
  • If A1: button/SPEC.md has an explicit deviation record with the upstream
    reference and rationale.
  • If A2: button.types.ts, useButton.ts, renderButton.tsx, the test file,
    and stories no longer reference selected, selectedIcon, isToggleButton, or
    contentHidden; the changeset includes a migration note.
  • toggle-button.md is updated: the
    block imposed by this task is lifted, and the scope section carries a precise
    statement for the selected axis, shape values, and icon-slot layout the
    generated component must implement.
  • yarn workspace @fluentui-react-native/components build, lint, and test
    pass after any source changes.
  • If Decision B2 or C2: no shape="square", shape="circle", or iconPosition
    references survive in src/components/button/.

Dependencies and ordering

  • Blocks toggle-button.md: the
    toggle-button generation task must not start until this alignment task is
    resolved. toggle-button.md open risk 1
    ("This task and the button divergence are two halves of one decision") and the
    scope item ("Resolve the relationship with the existing agentic button...") are
    directly answered here.
  • Informs spec-ingestion-and-generation.md:
    once each axis has an owner decision, the drift report can classify the button
    divergence as "accepted deviation" or "resolved" rather than "unresolved divergence".
  • Informs readiness-model.md: the provisional assessment
    holds button at experimental because of the spec drift. A Decision A1 record
    (accepted deviation, documented) may satisfy the spec-fidelity axis for readiness
    purposes.
  • Informed by component-test-strategy.md: the
    coverage contract governs how many tests button must retain after any removal.
    Decision A2 removes toggle-specific cases; the remaining set must still meet the
    minimum contract defined there.
  • Independent of focus-zone.md and the overlay wave.

Risks and open decisions

  1. Decision A (selected axis): OPEN. Choose between intentional deviation (A1)
    and migration (A2). The downstream impact is asymmetric: A1 is non-breaking but
    leaves two toggle patterns in one package; A2 aligns with upstream but breaks
    current consumers of button.selected.
  2. Decision B (shape axis): OPEN. Retaining 'square' is the non-breaking path.
    Removing it aligns fully with upstream but breaks any code using shape="square".
    Inferred. Renaming 'circle' to 'circular' is also required for full
    alignment, affecting the useButton.ts icon-only default.
  3. Decision C (icon-slot layout): OPEN. Independent leading/trailing slots follow
    upstream and match the toggle-button contract, but the migration cost is higher
    than for the other two axes because iconPosition is a first-class prop with story
    controls. Inferred. C2 is most natural to bundle with A2 if that path is chosen,
    because removing selectedIcon is part of both.
  4. FURN V1 naming. The legacy ToggleButton uses checked/defaultChecked.
    Any migration documentation must name both the agentic button.selected and the
    FURN V1 pattern as the two APIs being replaced, to avoid confusing consumers on the
    legacy package.

Evidence and references

Claim Kind Source
Local button carries selected, selectedIcon, iconPosition, shape: 'rounded' | 'square' | 'circle' Observed button.types.ts
Selection is externally driven; isToggleButton = selected !== undefined Observed useButton.ts
Icon swap and ghost label in render Observed renderButton.tsx
Seven toggle-specific test cases Observed button.test.tsx
Upstream button removed Selected, split icon slots, renamed Radius to Shape Observed Components README; spec-ingestion-and-generation.md
Upstream toggle-button SKILL.md at pinned revision Observed plugins/components/skills/toggle-button/SKILL.md at x3-design/fluent-design @ d334acf
Upstream button SKILL.md at pinned revision Observed plugins/components/skills/button/SKILL.md at x3-design/fluent-design @ d334acf
FURN V1 ToggleButton uses checked/defaultChecked Observed ToggleButton/ToggleButton.types.ts
toggle-button task flags this as open risk 1 Observed toggle-button.md
button held at experimental due to spec drift Inferred readiness-model.md

Sources retrieved 2026-08-21.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with packages/agentic/components/src/components/button/button.types.ts, useButton.ts, renderButton.tsx, button.test.tsx, and the button SPEC.md, then compare the cited upstream contracts and legacy ToggleButton types. Record an owner decision for selected, shape, and icon slots, apply it to the button contract and spec, and provide the companion scope statement for toggle-button.

Written by the indexing model from the issue text.

Assessment

Tech stack
react-native, typescript
Domain
documentation, frontend, mobile-dev
Issue type
Refactor
Difficulty
5/5
Estimated time
Over a week
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.