From 131de483508ed31807f845642e08339277b7c846 Mon Sep 17 00:00:00 2001 From: Jean-Marc Millet Date: Wed, 12 Aug 2026 17:46:06 +0200 Subject: [PATCH] improvement(spacing): let Stack flip direction from CSS via stackBelow (CUI-38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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'   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) --- src/lib/spacing.tsx | 89 ++++++++++++++++++++++++++++--------- stories/spacing.stories.tsx | 60 ++++++++++++++++++++++++- 2 files changed, 128 insertions(+), 21 deletions(-) diff --git a/src/lib/spacing.tsx b/src/lib/spacing.tsx index 780d9a9472..3af60cdc1a 100644 --- a/src/lib/spacing.tsx +++ b/src/lib/spacing.tsx @@ -1,5 +1,5 @@ import { Children, HTMLAttributes, HTMLProps, ReactNode } from 'react'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { Box, BoxComponentProps } from './components/box/Box'; export const spacing = { @@ -33,39 +33,89 @@ export const spacing = { f40: '40px', }; -const HSeparator = styled.div` - background: ${(props) => props.theme.border}; +// The two separator treatments. A row Stack draws a full-height rule between +// its children; a column Stack draws a short dash. They are different shapes, +// not one shape on two axes, so a single element cannot serve both by CSS alone +// — which is why `stackBelow` below restyles the element rather than relying on +// the flip. +const ruleSeparator = css` width: 1px; align-self: stretch; flex-shrink: 0; - margin: ${spacing.r12} 0px; + margin: ${spacing.r12} 0; `; -const VSeparator = styled.div` - height: 1px; +const dashSeparator = css` width: ${spacing.r24}; + height: 1px; + align-self: auto; + margin: 0; +`; + +// One element for both directions. `Stack` used to pick between two components +// at render time, which meant a CSS direction change left the separators +// pointing the wrong way — CSS can restyle an element but cannot swap which one +// React rendered. +const Separator = styled.div<{ + $direction: 'vertical' | 'horizontal'; + $stackBelow?: number; +}>` background: ${(props) => props.theme.border}; + ${(props) => + props.$direction === 'horizontal' ? ruleSeparator : dashSeparator} + + ${(props) => + props.$direction === 'horizontal' && + props.$stackBelow !== undefined && + css` + @container responsive (max-width: ${props.$stackBelow}px) { + ${dashSeparator} + } + `} `; -const Separator = ({ type }: { type?: 'vertical' | 'horizontal' }) => { - return ( - <> - {type === 'horizontal' &&  } - {type === 'vertical' &&  } - - ); -}; +// Layout lives here rather than on `Box`'s props so the container query below +// isn't fighting styled-system output for specificity. +const StackBox = styled(Box)<{ + $direction: 'vertical' | 'horizontal'; + $stackBelow?: number; +}>` + display: flex; + flex-direction: ${(props) => + props.$direction === 'horizontal' ? 'row' : 'column'}; + align-items: ${(props) => + props.$direction === 'horizontal' ? 'center' : 'normal'}; + + ${(props) => + props.$direction === 'horizontal' && + props.$stackBelow !== undefined && + css` + @container responsive (max-width: ${props.$stackBelow}px) { + flex-direction: column; + align-items: normal; + } + `} +`; export const Stack = ({ gap, direction, withSeparators, + stackBelow, children, ...rest }: { gap?: keyof typeof spacing; direction?: 'vertical' | 'horizontal'; withSeparators?: boolean; + /** + * Below this container width (px) a horizontal Stack becomes vertical, and + * its separators become the vertical treatment along with it. Requires an + * ancestor that establishes the `responsive` container — ``. + * Without one the query never matches and the Stack stays horizontal. + * Ignored when `direction="vertical"`. + */ + stackBelow?: number; children: ReactNode[]; container?: boolean; } & HTMLAttributes) => { @@ -75,10 +125,9 @@ export const Stack = ({ const numberOfChildren = Children.count(children); return ( - @@ -87,12 +136,12 @@ export const Stack = ({ <> {node} {withSeparators && nodeIndex + 1 !== numberOfChildren && ( - + )} ); })} - + ); }; diff --git a/stories/spacing.stories.tsx b/stories/spacing.stories.tsx index 16e1ab04df..38cdd0bcca 100644 --- a/stories/spacing.stories.tsx +++ b/stories/spacing.stories.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useTheme } from 'styled-components'; +import styled, { useTheme } from 'styled-components'; import { Icon } from '../src/lib/components/icon/Icon.component'; import { EmphaseText, @@ -113,6 +113,64 @@ export const StackStory = { }, }; +// `stackBelow` queries the `responsive` container, so an ancestor has to +// establish one. `` does this in app code. +const ResizableContainer = styled.div` + container-type: inline-size; + container-name: responsive; + resize: horizontal; + overflow: auto; + min-width: 240px; + max-width: 100%; + width: 700px; + background: ${(props) => props.theme.backgroundLevel2}; + padding: ${spacing.r16}; +`; + +export const StackBelowStory = { + name: 'stackBelow — direction follows the container width', + render: ({}) => ( + <> +

Drag the bottom-right handle below 500px

+ + One prop. The Stack flips from row to column in CSS only — no re-render + — and the separators switch from the row treatment (a full-height rule) + to the column treatment (a short dash) along with it. + + + + + + + 12 + Accounts + + + + + + 148 + Buckets + + + + + + 3 + Endpoints + + + + + + ), +}; + export const WrapStory = { render: ({}) => { const theme = useTheme();