Skip to content

improvement(spacing): let Stack flip direction from CSS via stackBelow (CUI-38) - #1182

Open
JeanMarcMilletScality wants to merge 1 commit into
development/1.0from
improvement/CUI-38-self-orienting-separator
Open

improvement(spacing): let Stack flip direction from CSS via stackBelow (CUI-38)#1182
JeanMarcMilletScality wants to merge 1 commit into
development/1.0from
improvement/CUI-38-self-orienting-separator

Conversation

@JeanMarcMilletScality

@JeanMarcMilletScality JeanMarcMilletScality commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

TL;DRStack gains stackBelow={px}: below that container width a horizontal Stack becomes vertical, and its separators switch to the vertical treatment along with it. Nothing renders differently today — the prop is opt-in.

Context / Why

Stack picks its separator element from the direction prop, at render time. A CSS flex-direction flip — which is how container-query-driven responsive layouts work — leaves the separators pointing the wrong way. A consuming application hits this the moment it needs a row of columns to collapse into a stack as its container narrows — the row cannot flip, so the content clips silently instead. CUI-38

🧩 Approach

This PR replaces an earlier revision that made the row separator self-orienting. That version used flex-basis for the main axis and align-self for the cross axis, so a CSS flip turned the rule into a full-width one. Design rejected it: a column Stack must keep its short dash. The two treatments are different shapes, not one shape on two axes — and CSS cannot query a parent's flex-direction — so the component has to emit the query itself.

Both treatments now live in one element as two css blocks, and Stack gains stackBelow:

const Separator = styled.div`
  background: ${(props) => props.theme.border};
  ${(props) => (props.$direction === 'horizontal' ? ruleSeparator : dashSeparator)}

  ${(props) => props.$stackBelow !== undefined && css`
    @container responsive (max-width: ${props.$stackBelow}px) {
      ${dashSeparator}                 // ←
    }
  `}
`;

One element is what makes this possible at all: CSS can restyle an element, but it cannot swap which element React rendered.

This follows a convention the library already has — a numeric breakpoint the component turns into an @container responsive query:

Component Prop / constant Below the width
Button iconOnly={number} label collapses to icon + tooltip
Form STACK_BELOW label/field flip to a stacked column
Stack stackBelow={number} (new) direction flips to vertical

Without an ancestor establishing that container (<Box container>), the query never matches and the Stack stays horizontal — the same graceful no-op as iconOnly.

Layout moved off Box's styled-system props onto a styled(Box) wrapper so the container query doesn't have to out-specify them. This is what removes the && specificity hack a consumer previously needed.

Verified no-op in both default directions

Rendered the old and new Stack side by side with ServerStyleSheet, then diffed the computed declarations per element (not the raw stylesheet, which differs only in class bookkeeping and rule splitting):

Default direction Container Separator
horizontal identical (4 declarations) identical (5 declarations)
vertical identical (4 declarations) align-self: auto and margin: 0 added

Those two additions are the CSS initial values for a flex item, so the computed style is unchanged. They exist only so the shared dashSeparator block resets what ruleSeparator sets when the container query swaps treatments.

The &nbsp; text nodes are dropped from both separators. Each treatment sets explicit dimensions, so neither depends on content for its size.

Why the vertical dash had to stay — beyond design's ruling, a full-width rule in a page Form duplicates the header's own border-bottom: 1px solid theme.border (Form.component.tsx:102): same weight, same colour, near-same width, so a section separator becomes indistinguishable from the form title's rule.

📷 Screenshots

🔧 Usage

No existing Stack usage changes. What changes is how a consumer opts into a responsive direction — before, they hand-wrote the query and fought Box for specificity:

Before — what the earlier revision of this PR required:

const ResponsiveStack = styled(Stack)`
  @container (max-width: 500px) {
    && {                          // ← doubles specificity to beat Box's flex-direction
      flex-direction: column;
      align-items: stretch;       // ← must be overridden too, separately
    }
  }
`;

<ResponsiveStack withSeparators gap="r24"></ResponsiveStack>

After — from the story added in this PR:

<Stack withSeparators gap="r24" stackBelow={500}></Stack>   // ←

Two notes for consumers:

  • An ancestor must establish the responsive container — <Box container>. Without one the query never matches and the Stack stays horizontal.
  • stackBelow is ignored when direction="vertical". Flipping a vertical Stack to a row in CSS is still unsupported — pre-existing behaviour, unchanged here.

🔍 Review focus

  • 🟡 Moderatesrc/lib/spacing.tsx › StackBox — layout (display, flex-direction, align-items) moved off Box's styled-system props onto a styled(Box) wrapper. This is the change most able to affect existing call sites: every Stack in every repo now gets its layout from a different class. The computed-declaration diff above is the evidence it's a no-op; gap still comes from Box, so the two classes now split what one used to emit.
  • 🟡 Moderatesrc/lib/spacing.tsx › SeparatorruleSeparator/dashSeparator must stay mutually resetting. dashSeparator restores align-self and margin precisely because ruleSeparator sets them; dropping either reset silently breaks the flipped state, and container queries don't evaluate in jsdom so no unit test will catch it.
  • Minorsrc/lib/spacing.tsx › StackstackBelow is additive and Separator was never exported, so no public API is removed.

🧪 How to test

  1. npm run storybookComponents/Styling/Spacing Utils › stackBelow — direction follows the container width.
  2. Drag the container's bottom-right resize handle below 500px. The stack flips to a column and the separators become short dashes; drag back above 500px and they return to full-height rules. Nothing re-renders — only CSS changes.
  3. Regression check on Stack Story: both the Banner example (horizontal) and the Vertical divided example must be pixel-identical to development/1.0.
  4. Same for the vertical production sites under Templates/Form (page-form, tab-form, form-with-accordion) — section separators should be unchanged short dashes, still clearly subordinate to the form title's rule.

🚧 Follow-up

  • Downstream pickup is a normal @scality/core-ui version bump once this is released; the integration details live in the consuming application's own ticket.
  • The consumer layout that prompted design's ruling on the dash should be reviewed with them in its narrow state before the downstream change merges.
  • A vertical Stack still can't be flipped to a row in CSS. No consumer needs it today. Not yet ticketed.
  • Wrap is deliberately untouched — it does not wrap despite its name, deferred pending a usage audit. Not yet ticketed.

🔗 References

What changed

src/lib/spacing.tsxHSeparator/VSeparator/Separator({ type }) collapse into one Separator styled component carrying both treatments as ruleSeparator and dashSeparator css blocks. A new StackBox = styled(Box) holds the layout so the container query doesn't fight styled-system for specificity. Stack gains stackBelow?: number and passes it, with direction, to both as transient props (verified not to reach the DOM).

stories/spacing.stories.tsx — replaces the earlier ContainerQueryDirectionFlip story with StackBelowStory, which uses the prop instead of a hand-written query. Its container sets container-name: responsive explicitly, documenting the requirement.

Deliberately not in this PR: Wrap (out of scope per CUI-38), any restyle of existing vertical separators, and any Form change.

tsc --noEmit clean, npm run build clean, npm run lint clean, suite green.

@bert-e

bert-e commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Hello jeanmarcmilletscality,

My role is to assist you with the merge of this
pull request. Please type @bert-e help to get information
on this process, or consult the user documentation.

Available options
name description privileged authored
/after_pull_request Wait for the given pull request id to be merged before continuing with the current one.
/bypass_author_approval Bypass the pull request author's approval
/bypass_build_status Bypass the build and test status
/bypass_commit_size Bypass the check on the size of the changeset TBA
/bypass_incompatible_branch Bypass the check on the source branch prefix
/bypass_jira_check Bypass the Jira issue check
/bypass_peer_approval Bypass the pull request peers' approval
/bypass_leader_approval Bypass the pull request leaders' approval
/approve Instruct Bert-E that the author has approved the pull request. ✍️
/create_pull_requests Allow the creation of integration pull requests.
/create_integration_branches Allow the creation of integration branches.
/no_octopus Prevent Wall-E from doing any octopus merge and use multiple consecutive merge instead
/unanimity Change review acceptance criteria from one reviewer at least to all reviewers
/wait Instruct Bert-E not to run until further notice.
Available commands
name description privileged
/help Print Bert-E's manual in the pull request.
/status Print Bert-E's current status in the pull request.
/clear Remove all comments from Bert-E from the history TBA
/retry Re-start a fresh build TBA
/build Re-start a fresh build TBA
/force_reset Delete integration branches & pull requests, and restart merge process from the beginning.
/reset Try to remove integration branches unless there are commits on them which do not appear on the source branch.

Status report is not available.

@bert-e

bert-e commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

@JeanMarcMilletScality
JeanMarcMilletScality force-pushed the improvement/CUI-38-self-orienting-separator branch from 2dfb90d to f5e87ef Compare August 12, 2026 16:41
@JeanMarcMilletScality JeanMarcMilletScality changed the title improvement(spacing): make Stack separators self-orienting (CUI-38) improvement(spacing): make row Stack separators self-orienting (CUI-38) Aug 12, 2026
@JeanMarcMilletScality
JeanMarcMilletScality marked this pull request as draft August 13, 2026 07:21
@JeanMarcMilletScality
JeanMarcMilletScality force-pushed the improvement/CUI-38-self-orienting-separator branch from f5e87ef to 6010197 Compare August 26, 2026 13:17
@bert-e

bert-e commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

@JeanMarcMilletScality JeanMarcMilletScality changed the title improvement(spacing): make row Stack separators self-orienting (CUI-38) improvement(spacing): let Stack flip direction from CSS via stackBelow (CUI-38) Aug 26, 2026
…w (CUI-38)

Stack picked its separator element from the direction prop at render time,
injecting HSeparator (a vertical rule) or VSeparator (a 24px horizontal dash).
A CSS flex-direction flip — how container-query-driven responsive layouts work —
left the separators pointing the wrong way, blocking any consuming
application that needs a row to collapse into a column as its container
narrows.

An earlier revision made the row separator self-orienting, so that a CSS flip
turned it into a full-width rule. Design rejected that: a column Stack must keep
its short dash. The two treatments are different shapes rather than one shape on
two axes, and CSS cannot query a parent's flex-direction, so the component has
to emit the query itself.

Both treatments now live in one element as two css blocks, and Stack gains
stackBelow: below that container width a horizontal Stack flips its direction,
its align-items and its separators together. Consumers set one number instead of
hand-writing a container query and fighting styled-system for specificity.

This follows the convention already used by Button (iconOnly={number}) and Form
(STACK_BELOW): a numeric breakpoint the component turns into an
@container responsive query. Without an ancestor establishing that container the
query never matches and the Stack stays horizontal, the same graceful no-op as
iconOnly.

Layout moved off Box's styled-system props onto a styled(Box) wrapper so the
query does not have to out-specify them.

Verified by rendering old and new Stack side by side and diffing the computed
declarations per element: in both default directions the container and separator
are identical, except that the column separator now also states align-self: auto
and margin: 0 — the initial values it must restore when the query swaps
treatments. The separators' &nbsp; text nodes are dropped; both treatments set
explicit dimensions, so they render the same without them.

stackBelow is opt-in, so no existing call site changes. No public API removal:
Separator was never exported.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@JeanMarcMilletScality
JeanMarcMilletScality force-pushed the improvement/CUI-38-self-orienting-separator branch from 6010197 to 131de48 Compare August 28, 2026 13:23
@bert-e

bert-e commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Waiting for approval

The following approvals are needed before I can proceed with the merge:

  • the author

  • one peer

Peer approvals must include at least 1 approval from the following list:

@JeanMarcMilletScality
JeanMarcMilletScality marked this pull request as ready for review August 28, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants