diff --git a/static/app/components/core/layout/container.tsx b/static/app/components/core/layout/container.tsx index ba160d676730e2..7e9a5edb33ee26 100644 --- a/static/app/components/core/layout/container.tsx +++ b/static/app/components/core/layout/container.tsx @@ -64,7 +64,8 @@ export type ContainerElement = | 'section' | 'span' | 'summary' - | 'ul'; + | 'ul' + | 'hr'; type ContainerPropsWithChildren = ContainerLayoutProps & { diff --git a/static/app/components/core/layout/flex.tsx b/static/app/components/core/layout/flex.tsx index 3c4585ca384632..e80f03e40ae795 100644 --- a/static/app/components/core/layout/flex.tsx +++ b/static/app/components/core/layout/flex.tsx @@ -38,7 +38,8 @@ interface FlexLayoutProps { wrap?: Responsive<'nowrap' | 'wrap' | 'wrap-reverse'>; } -type FlexProps = ContainerProps & FlexLayoutProps; +export type FlexProps = ContainerProps & + FlexLayoutProps; export const Flex = styled(Container, { shouldForwardProp: prop => { diff --git a/static/app/components/core/layout/grid.mdx b/static/app/components/core/layout/grid.mdx index 907131be953588..7d42008bcbdd15 100644 --- a/static/app/components/core/layout/grid.mdx +++ b/static/app/components/core/layout/grid.mdx @@ -13,6 +13,10 @@ import APIReference from '!!type-loader!sentry/components/core/layout/grid'; export const types = {Grid: APIReference.Grid}; +export function CustomComponent(props) { + return
; +} + The `Grid` component is a layout component that extends the `Container` component with CSS grid properties. ## Basic Usage @@ -42,7 +46,7 @@ The `Grid` implements composition via rend padding="md" > {props => ( -
+ rend > Footer -
+ )} @@ -94,12 +98,12 @@ The `Grid` implements composition via
rend padding="md" > {props => ( -
+ Header Sidebar Main Content Footer -
+ )} ``` diff --git a/static/app/components/core/layout/index.tsx b/static/app/components/core/layout/index.tsx index be159e5763ffb1..cad4f7123afd7b 100644 --- a/static/app/components/core/layout/index.tsx +++ b/static/app/components/core/layout/index.tsx @@ -1,3 +1,4 @@ export {Container} from './container'; export {Flex} from './flex'; export {Grid} from './grid'; +export {Stack} from './stack'; diff --git a/static/app/components/core/layout/stack.mdx b/static/app/components/core/layout/stack.mdx new file mode 100644 index 00000000000000..386c662ec5cdd7 --- /dev/null +++ b/static/app/components/core/layout/stack.mdx @@ -0,0 +1,247 @@ +--- +title: Stack +description: A simplified layout component built on Flex that provides easy vertical stacking with responsive props and spacing controls. +source: 'sentry/components/core/layout/stack' +resources: + js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/layout/stack.tsx +--- + +import {Container, Stack} from 'sentry/components/core/layout'; +import * as Storybook from 'sentry/stories'; + +import APIReference from '!!type-loader!sentry/components/core/layout/stack'; + +export const types = {Stack: APIReference.Stack}; + +export function CustomComponent(props) { + return
; +} + +The `Stack` component is a simplified layout component built on top of the `Flex` component. It provides a focused API for common stacking layouts with only the essential props: `direction`, `align`, `justify`, and `gap`. By default, Stack uses column direction making it perfect for vertical layouts. + +## Basic Usage + +To create a basic vertical stack, wrap elements in `` and they will be laid out vertically using flexbox. + +```jsx + +
Item 1
+
Item 2
+
Item 3
+
+``` + +### When to Use Stack vs Flex + +Use `Stack` when you need a simple, focused layout component for common stacking patterns. It's perfect for: + +- Vertical lists or forms +- Simple horizontal arrangements +- Basic responsive layouts + +Use `Flex` when you need the full power of flexbox with properties like `wrap`, `flex`, `inline`, or complex alignment scenarios. + +### Composition + +The `Stack` implements composition via
render prop pattern. + + + + {props => ( + + + First Item + + + Second Item + + + Third Item + + + )} + + +```jsx + + {props => ( + +
First Item
+
Second Item
+
Third Item
+
+ )} +
+``` + +### Specifying the DOM Node via `as` prop + +The `Stack` component renders a `div` element by default, but you can specify the DOM node to render by passing a `as` prop. + +```tsx + + Basic stack content + +``` + +### Stack Properties + +Stack provides a focused set of layout properties: `direction` (defaults to 'column'), `gap`, `justify`, and `align`. These properties influence the layout of its children while maintaining simplicity. + +Like other layout components, `Stack` inherits all spacing props like `m`, `p`, `mt`, `mb`, `ml`, `mr`, `pt`, `pb`, `pl`, `pr` and implements responsive props so that the layout can be changed per breakpoint. + +#### Column Direction (Default) + + + + + Item 1 + + + Item 2 + + + Item 3 + + + +```jsx + +
Item 1
+
Item 2
+
Item 3
+
+``` + +#### Row Direction + + + + + Item 1 + + + Item 2 + + + Item 3 + + + +```jsx + +
Item 1
+
Item 2
+
Item 3
+
+``` + +#### Spacing + +The `Stack` `gap` property follows the same spacing system as other layout components. + + + {['xs', 'sm', 'md', 'lg', 'xl', '2xl'].map(size => ( + + {size} gap + + + Item 1 + + + Item 2 + + + Item 3 + + + + ))} + +```jsx + +
Item 1
+
Item 2
+
Item 3
+
+``` + +#### Responsive Props + +All props support responsive values using breakpoint objects. Breakpoints are: `xs`, `sm`, `md`, `lg`, `xl`, `2xl`. + +Example of a responsive stack that uses a static gap, but changes direction based on the breakpoint. + + + + + Responsive + + + Stack + + + Layout + + + 🔥 + + + +```jsx + +
Responsive
+
Stack
+
Layout
+
🔥
+
+``` + +If a prop is not specified for a breakpoint, the value will **not** be inherited from the previous breakpoint. + +### Stack Separator + +The `Stack` component provides a `Stack.Separator` subcomponent that can be used to add visual separators between stack items. The `Stack.Separator` automatically inherits the orientation from its parent Stack component through React context: + +- When Stack `direction` is `row` or `row-reverse` → Separator orientation becomes `horizontal` +- When Stack `direction` is `column` or `column-reverse` → Separator orientation becomes `vertical` + +This automatic orientation inheritance means separators adapt seamlessly to responsive direction changes without requiring manual orientation props. The separator uses the `border` prop (defaults to `'primary'`) and automatically applies the correct styling based on the inherited orientation. + + + + + First Item + + + + Second Item + + + + Third Item + + + +```jsx + +
First Item
+ +
Second Item
+ +
Third Item
+
+``` diff --git a/static/app/components/core/layout/stack.spec.tsx b/static/app/components/core/layout/stack.spec.tsx new file mode 100644 index 00000000000000..9fff2c0c9fd316 --- /dev/null +++ b/static/app/components/core/layout/stack.spec.tsx @@ -0,0 +1,135 @@ +import React, {createRef} from 'react'; + +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {Stack} from 'sentry/components/core/layout/stack'; + +describe('Stack', () => { + it('renders children', () => { + render(Hello); + expect(screen.getByText('Hello')).toBeInTheDocument(); + }); + + it('implements render prop', () => { + render( +
+ {props =>

Hello

}
+
+ ); + + expect(screen.getByText('Hello')?.tagName).toBe('P'); + expect(screen.getByText('Hello').parentElement?.tagName).toBe('SECTION'); + }); + + it('render prop guards against invalid attributes', () => { + render( + // @ts-expect-error - aria-activedescendant should be set on the child element + + {/* @ts-expect-error - this should be a React.ElementType */} + {props =>

Hello

} +
+ ); + + expect(screen.getByText('Hello')).not.toHaveAttribute('aria-activedescendant'); + }); + + it('render prop type is correctly inferred', () => { + // Incompatible className type - should be string + function Child({className}: {className: 'invalid'}) { + return

Hello

; + } + + render( + + {/* @ts-expect-error - className is incompatible */} + {props => } + + ); + }); + + it('passes attributes to the underlying element', () => { + render(Hello); + expect(screen.getByTestId('container')).toBeInTheDocument(); + }); + + it('renders as a different element if specified', () => { + render(Hello); + expect(screen.getByText('Hello').tagName).toBe('SECTION'); + }); + + it('does not bleed attributes to the underlying element', () => { + render(Hello); + expect(screen.getByText('Hello')).not.toHaveAttribute('radius'); + }); + + it('does not bleed stack attributes to the underlying element', () => { + render( + + Hello + + ); + + expect(screen.getByText('Hello')).not.toHaveAttribute('align'); + expect(screen.getByText('Hello')).not.toHaveAttribute('justify'); + expect(screen.getByText('Hello')).not.toHaveAttribute('gap'); + expect(screen.getByText('Hello')).not.toHaveAttribute('direction'); + }); + + it('allows settings native html attributes', () => { + render(Hello); + expect(screen.getByText('Hello')).toHaveStyle({color: 'red'}); + }); + + it('attaches ref to the underlying element', () => { + const ref = createRef(); + render( + + Hello + + ); + expect(ref.current).toBeInTheDocument(); + expect(ref.current?.tagName).toBe('OL'); + }); + + it('reuses class names for the same props', () => { + render( + + + First Stack + + + Second Stack + + + ); + + const firstStack = screen.getByText('First Stack').className; + const secondStack = screen.getByText('Second Stack').className; + expect(firstStack).toEqual(secondStack); + }); + + it('row orientation = vertical separator', () => { + render( + +
Item 1
+ +
+ ); + + expect(screen.getByRole('separator')).toHaveAttribute('aria-orientation', 'vertical'); + }); + + it('column orientation = horizontal separator', () => { + render( + +
Item 1
+ +
+ ); + + expect(screen.getByRole('separator')).toHaveAttribute( + 'aria-orientation', + 'horizontal' + ); + }); +}); diff --git a/static/app/components/core/layout/stack.tsx b/static/app/components/core/layout/stack.tsx new file mode 100644 index 00000000000000..b7b11fe0572666 --- /dev/null +++ b/static/app/components/core/layout/stack.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import styled from '@emotion/styled'; + +import {Separator, type SeparatorProps} from 'sentry/components/core/separator'; + +import type {ContainerElement} from './container'; +import {Flex, type FlexProps} from './flex'; +import {useResponsivePropValue} from './styles'; + +type StackLayoutProps = Pick< + FlexProps, + 'align' | 'direction' | 'gap' | 'justify' | 'wrap' +>; + +type StackProps = StackLayoutProps & FlexProps; + +const StackComponent = styled( + ({ + direction = 'column', + ...props + }: StackProps) => { + const responsiveDirection = useResponsivePropValue(direction); + return ( + + + + ); + } +)>` + /** + * This cast is required because styled-components does not preserve the generic signature of the wrapped component. + * By default, the generic type parameter is lost, so we use 'as unknown as' to restore the correct typing. + * https://github.com/styled-components/styled-components/issues/1803 + */ +` as unknown as ( + props: StackProps +) => React.ReactElement; + +function getOrientationFromDirection( + direction: NonNullable +): 'horizontal' | 'vertical' { + switch (direction) { + case 'row': + case 'row-reverse': + return 'horizontal'; + case 'column': + case 'column-reverse': + return 'vertical'; + default: + throw new TypeError('No Stack Direction was provided'); + } +} + +const OrientationContext = React.createContext<'horizontal' | 'vertical'>('horizontal'); +function useOrientation(): 'horizontal' | 'vertical' { + return React.useContext(OrientationContext); +} + +type StackSeparatorProps = Omit; + +const StackSeparator = styled((props: StackSeparatorProps) => { + const orientation = useOrientation(); + + return ( + + ); +})``; + +export const Stack = Object.assign(StackComponent, { + Separator: StackSeparator, +}); diff --git a/static/app/components/core/layout/styles.spec.tsx b/static/app/components/core/layout/styles.spec.tsx new file mode 100644 index 00000000000000..6935b2c89288d7 --- /dev/null +++ b/static/app/components/core/layout/styles.spec.tsx @@ -0,0 +1,349 @@ +import {ThemeProvider} from '@emotion/react'; +import {ThemeFixture} from 'sentry-fixture/theme'; + +import {act, renderHook} from 'sentry-test/reactTestingLibrary'; + +import { + type Breakpoint, + type Responsive, + useActiveBreakpoint, + useResponsivePropValue, +} from './styles'; + +const theme = ThemeFixture(); + +// Mock window.matchMedia +const mockMatchMedia = (matches: boolean) => ({ + matches, + media: '', + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), +}); + +// Helper function to create a wrapper with theme +const createWrapper = () => { + return function Wrapper({children}: {children: React.ReactNode}) { + return {children}; + }; +}; + +// Helper to set up media query mocks for specific breakpoints +const setupMediaQueries = (breakpointMatches: Partial>) => { + const originalMatchMedia = window.matchMedia; + + window.matchMedia = jest.fn((query: string) => { + // Extract breakpoint from media query + const breakpointMatch = query.match(/min-width:\s*(.+?)\)/); + const breakpointValue = breakpointMatch?.[1]; + + // Map breakpoint values to breakpoint names + const breakpointName = Object.entries(theme.breakpoints).find( + ([_, value]) => value === breakpointValue + )?.[0]; + + const matches = breakpointName + ? (breakpointMatches[breakpointName as Breakpoint] ?? false) + : false; + + return mockMatchMedia(matches); + }); + + return () => { + window.matchMedia = originalMatchMedia; + }; +}; + +describe('useResponsivePropValue', () => { + it('returns identity for non-responsive values', () => { + const {result} = renderHook(() => useResponsivePropValue('hello'), { + wrapper: createWrapper(), + }); + + expect(result.current).toBe('hello'); + }); + + it('window matches breakpoint = breakpoint value', () => { + const cleanup = setupMediaQueries({ + xs: true, + sm: true, + md: true, + lg: false, + }); + + const responsiveValue: Responsive = { + xs: 'extra-small', + sm: 'small', + md: 'medium', + }; + + const {result} = renderHook(() => useResponsivePropValue(responsiveValue), { + wrapper: createWrapper(), + }); + + expect(result.current).toBe('medium'); + cleanup(); + }); + + it('window > largest breakpoint = largest breakpoint value', () => { + const cleanup = setupMediaQueries({ + lg: false, + xl: true, + }); + + const responsiveValue: Responsive = { + xs: 'extra-small', + sm: 'small', + md: 'medium', + }; + + const {result} = renderHook(() => useResponsivePropValue(responsiveValue), { + wrapper: createWrapper(), + }); + + expect(result.current).toBe('medium'); + cleanup(); + }); + + it('window < smallest breakpoint = smallest breakpoint value', () => { + const cleanup = setupMediaQueries({ + xs: true, + sm: false, + }); + + const responsiveValue: Responsive = { + sm: 'small', + }; + + const {result} = renderHook(() => useResponsivePropValue(responsiveValue), { + wrapper: createWrapper(), + }); + + expect(result.current).toBe('small'); + cleanup(); + }); + + it('window > smallest breakpoint and < largest breakpoint = smallest matching breakpoint value', () => { + const cleanup = setupMediaQueries({ + xs: false, + sm: false, + md: true, + lg: false, + }); + + const responsiveValue: Responsive = { + sm: 'small', + lg: 'large', + }; + + const {result} = renderHook(() => useResponsivePropValue(responsiveValue), { + wrapper: createWrapper(), + }); + + expect(result.current).toBe('small'); + cleanup(); + }); + + it('handles undefined values in breakpoint', () => { + const cleanup = setupMediaQueries({ + xs: true, + md: true, + }); + + const responsiveValue: Responsive = { + xs: 'small', + sm: undefined, + md: 'medium', + lg: undefined, + }; + + const {result} = renderHook(() => useResponsivePropValue(responsiveValue), { + wrapper: createWrapper(), + }); + + expect(result.current).toBe('medium'); + cleanup(); + }); + + it('throws an error when no breakpoints are defined in responsive prop', () => { + expect(() => + renderHook(() => useResponsivePropValue({}), { + wrapper: createWrapper(), + }) + ).toThrow('Responsive prop must contain at least one breakpoint'); + }); +}); + +describe('useActiveBreakpoint', () => { + // We use min-width, so the only breakpoint that will match will be xs. + // Fallback to xs here mimics how we treat the smallest breakpoint in responsive props + // by doing max-width and min-width and essentially establishing a min value. + it('returns xs as fallback when no breakpoints match', () => { + const cleanup = setupMediaQueries({ + xs: false, + sm: false, + md: false, + lg: false, + xl: false, + }); + + const {result} = renderHook(() => useActiveBreakpoint(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBe('xs'); + cleanup(); + }); + + it('returns the largest matching breakpoint', () => { + const cleanup = setupMediaQueries({ + xs: true, + sm: true, + md: true, + lg: false, + xl: false, + }); + + const {result} = renderHook(() => useActiveBreakpoint(), { + wrapper: createWrapper(), + }); + + expect(result.current).toBe('md'); + cleanup(); + }); + + it('sets up media queries for all breakpoints', () => { + const matchMediaSpy = jest.fn(() => mockMatchMedia(false)); + window.matchMedia = matchMediaSpy; + + renderHook(() => useActiveBreakpoint(), { + wrapper: createWrapper(), + }); + + // Should create media queries for all breakpoints (in reverse order) + expect(matchMediaSpy).toHaveBeenCalledTimes(5); + expect(matchMediaSpy).toHaveBeenCalledWith(`(min-width: ${theme.breakpoints.xl})`); + expect(matchMediaSpy).toHaveBeenCalledWith(`(min-width: ${theme.breakpoints.lg})`); + expect(matchMediaSpy).toHaveBeenCalledWith(`(min-width: ${theme.breakpoints.md})`); + expect(matchMediaSpy).toHaveBeenCalledWith(`(min-width: ${theme.breakpoints.sm})`); + expect(matchMediaSpy).toHaveBeenCalledWith(`(min-width: ${theme.breakpoints.xs})`); + }); + + it('uses correct breakpoint order (largest first)', () => { + const cleanup = setupMediaQueries({ + xs: true, + sm: true, + md: true, + lg: true, + xl: true, + }); + + const {result} = renderHook(() => useActiveBreakpoint(), { + wrapper: createWrapper(), + }); + + // Should return xl (largest) when all are active + expect(result.current).toBe('xl'); + cleanup(); + }); + + it('updates value when media queries change', () => { + const mediaQueryListeners: Record void>> = {}; + const mockQueries: Record = {}; + + // Set up mock that tracks listeners + window.matchMedia = jest.fn((query: string) => { + const mockQuery = { + matches: query === `(min-width: ${theme.breakpoints.md})`, + media: query, + addEventListener: jest.fn((_event: string, listener: () => void) => { + if (!mediaQueryListeners[query]) { + mediaQueryListeners[query] = []; + } + mediaQueryListeners[query].push(listener); + }), + removeEventListener: jest.fn((_event: string, listener: () => void) => { + if (mediaQueryListeners[query]) { + mediaQueryListeners[query] = mediaQueryListeners[query].filter( + l => l !== listener + ); + } + }), + addListener: jest.fn(), + removeListener: jest.fn(), + dispatchEvent: jest.fn(), + onchange: null, + }; + + mockQueries[query] = mockQuery; + return mockQuery; + }); + + const {result} = renderHook( + () => useResponsivePropValue({xs: 'small', md: 'medium', lg: 'large'}), + { + wrapper: createWrapper(), + } + ); + + // Initially query matches 'medium' + expect(result.current).toBe('medium'); + + // Simulate large breakpoint becoming active + act(() => { + const mdQuery = `(min-width: ${theme.breakpoints.lg})`; + if (mockQueries[mdQuery]) { + mockQueries[mdQuery].matches = true; + } + + // Trigger all listeners for the md query + if (mediaQueryListeners[mdQuery]) { + mediaQueryListeners[mdQuery].forEach(listener => listener()); + } + }); + + expect(result.current).toBe('large'); + }); + + it('calls AbortController.abort() on unmount', () => { + const addEventListenerSpy = jest.fn(); + + const abortController = { + abort: jest.fn(), + signal: { + aborted: false, + onabort: jest.fn(), + }, + } as unknown as AbortController; + + const mockAbortController = jest.fn(() => abortController); + window.AbortController = mockAbortController; + + window.matchMedia = jest.fn(() => ({ + matches: false, + media: '', + addEventListener: addEventListenerSpy, + removeEventListener: jest.fn(), + addListener: jest.fn(), + removeListener: jest.fn(), + onchange: null, + dispatchEvent: jest.fn(), + })); + + const {unmount} = renderHook( + () => useResponsivePropValue({xs: 'small', md: 'medium'}), + { + wrapper: createWrapper(), + } + ); + + // Sets up listeners for all breakpoints + expect(addEventListenerSpy).toHaveBeenCalledTimes(5); + unmount(); + // Removes listeners for all breakpoints + expect(abortController.abort).toHaveBeenCalledTimes(1); + }); +}); diff --git a/static/app/components/core/layout/styles.tsx b/static/app/components/core/layout/styles.tsx index 95f3e20988b38e..5c0767f44eef2e 100644 --- a/static/app/components/core/layout/styles.tsx +++ b/static/app/components/core/layout/styles.tsx @@ -1,4 +1,10 @@ -import {css, type DO_NOT_USE_ChonkTheme, type SerializedStyles} from '@emotion/react'; +import {useCallback, useMemo, useSyncExternalStore} from 'react'; +import { + css, + type DO_NOT_USE_ChonkTheme, + type SerializedStyles, + useTheme, +} from '@emotion/react'; import type {Theme} from 'sentry/utils/theme'; import {isChonkTheme} from 'sentry/utils/theme/withChonk'; @@ -9,11 +15,7 @@ export function rc( value: Responsive | undefined, theme: Theme, // Optional resolver function to transform the value before it is applied to the CSS property. - resolver?: ( - value: T, - breakpoint: Breakpoint | undefined, - theme: Theme - ) => string | number + resolver?: (value: T, breakpoint: Breakpoint | undefined, theme: Theme) => string ): SerializedStyles | undefined { if (!value) { return undefined; @@ -63,7 +65,7 @@ const BREAKPOINT_ORDER: readonly Breakpoint[] = ['xs', 'sm', 'md', 'lg', 'xl']; export type RadiusSize = keyof DO_NOT_USE_ChonkTheme['radius']; export type SpacingSize = keyof Theme['space']; export type Border = keyof Theme['tokens']['border']; -type Breakpoint = keyof Theme['breakpoints']; +export type Breakpoint = keyof Theme['breakpoints']; // @TODO(jonasbadalic): audit for memory usage and linting performance issues. // These may not be trivial to infer as we are dealing with n^4 complexity @@ -141,3 +143,126 @@ export function getSpacing( .map(size => resolveSpacing(size as SpacingSize, theme)) .join(' '); } + +/** + * Hook that resolves responsive values to their current breakpoint value. + * Mirrors the behavior of the rc() function but returns the resolved value + * instead of generating CSS media queries. + */ +type ResponsiveValue = T extends Responsive ? U : never; +export function useResponsivePropValue>( + prop: T +): ResponsiveValue { + const activeBreakpoint = useActiveBreakpoint(); + + // Only resolve the active breakpoint if the prop is responsive, else ignore it. + if (!isResponsive(prop)) { + return prop as ResponsiveValue; + } + + if (Object.keys(prop).length === 0) { + throw new Error('Responsive prop must contain at least one breakpoint'); + } + + // If the active breakpoint exists in the prop, return it + if (prop[activeBreakpoint] !== undefined) { + return prop[activeBreakpoint]; + } + + let value: ResponsiveValue | undefined; + + const activeIndex = BREAKPOINT_ORDER.indexOf(activeBreakpoint); + + // If we don't have an exact match, find the next smallest breakpoint + for (let i = activeIndex - 1; i >= 0; i--) { + const smallerBreakpoint = BREAKPOINT_ORDER[i]!; + if (prop[smallerBreakpoint] !== undefined) { + value = prop[smallerBreakpoint]; + break; + } + } + + // If no smaller breakpoint found, then window < smallest breakpoint, so we need to find the first larger breakpoint + if (value === undefined) { + for (let i = activeIndex + 1; i < BREAKPOINT_ORDER.length; i++) { + const largerBreakpoint = BREAKPOINT_ORDER[i]!; + if (prop[largerBreakpoint] !== undefined) { + value = prop[largerBreakpoint]; + break; + } + } + } + + return value as ResponsiveValue; +} + +export function useActiveBreakpoint(): Breakpoint { + const theme = useTheme(); + + const mediaQueries = useMemo(() => { + if (typeof window === 'undefined' || !window.matchMedia) { + return []; + } + + const queries: Array<{breakpoint: Breakpoint; query: MediaQueryList}> = []; + + // Iterate in reverse so that we always find the largest breakpoint + for (let i = BREAKPOINT_ORDER.length - 1; i >= 0; i--) { + const bp = BREAKPOINT_ORDER[i]; + + if (bp === undefined) { + continue; + } + + queries.push({ + breakpoint: bp, + query: window.matchMedia(`(min-width: ${theme.breakpoints[bp]})`), + }); + } + + return queries; + }, [theme.breakpoints]); + + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (!mediaQueries.length) { + return () => {}; + } + + const controller = new AbortController(); + + for (const query of mediaQueries) { + query.query.addEventListener('change', onStoreChange, { + signal: controller.signal, + }); + } + + return () => controller.abort(); + }, + [mediaQueries] + ); + + return useSyncExternalStore(subscribe, () => findLargestBreakpoint(mediaQueries)); +} + +function findLargestBreakpoint( + queries: Array<{breakpoint: Breakpoint; query: MediaQueryList}> +): Breakpoint { + // Find the largest active breakpoint with a defined value + // This mirrors the logic in rc() function + for (const query of queries) { + if (query === undefined) { + continue; + } + + if (!query.query.matches) { + continue; + } + + return query.breakpoint; + } + + // Since we use min width, the only remaining breakpoint that we might have missed is ` element with flexible styling options and supports both horizontal and vertical orientations. + +## Basic Usage + +The Separator component requires an `orientation` prop to determine its layout direction. + +```jsx +// Horizontal separator (default styling) + + +// Vertical separator + +``` + +## Horizontal Separators + +Horizontal separators are ideal for dividing content vertically, such as between sections or list items. + + + + + Content above separator + + + + Content below separator + + + + +```jsx + +
Content above separator
+ +
Content below separator
+
+``` + +## Vertical Separators + +Vertical separators work well for dividing content horizontally, typically within flex layouts. + + + + + Left content + + + + Right content + + + + +```jsx + +
Left content
+ +
Right content
+
+``` + +## Border Styling + +The `border` prop accepts any border token from the theme, allowing you to customize the separator's visual weight and color. + + + + + Horizontal Orientation + + + Primary border + + + + Secondary border + + + + Success border + + + + Warning border + + + + Danger border + + + + + + Vertical Orientation + + + Primary border + + + + Secondary border + + + + Success border + + + + Warning border + + + + Danger border + + + + + + + +```jsx +
+ Horizontal Orientation + + + Example border + + + +
+
+ Vertical Orientation + + + Example border + + + +
+``` diff --git a/static/app/components/core/separator/separator.spec.tsx b/static/app/components/core/separator/separator.spec.tsx new file mode 100644 index 00000000000000..29cc62e500ba62 --- /dev/null +++ b/static/app/components/core/separator/separator.spec.tsx @@ -0,0 +1,20 @@ +import {render, screen} from 'sentry-test/reactTestingLibrary'; + +import {Separator} from 'sentry/components/core/separator'; + +describe('Separator', () => { + it('should render a horizontal Separator', () => { + render(); + expect(screen.getByRole('separator')).toBeInTheDocument(); + }); + + it('should render a vertical Separator', () => { + render(); + expect(screen.getByRole('separator')).toBeInTheDocument(); + }); + + it('does not allow children', () => { + // @ts-expect-error children are not allowed + expect(() => render(Hello)).toThrow(); + }); +}); diff --git a/static/app/components/core/separator/separator.tsx b/static/app/components/core/separator/separator.tsx new file mode 100644 index 00000000000000..1c9e5eeae0e019 --- /dev/null +++ b/static/app/components/core/separator/separator.tsx @@ -0,0 +1,42 @@ +import styled from '@emotion/styled'; + +import type {ContainerProps} from 'sentry/components/core/layout/container'; +import {rc} from 'sentry/components/core/layout/styles'; + +export type SeparatorProps = Pick & { + orientation: 'horizontal' | 'vertical'; + children?: never; +} & Omit, 'aria-orientation'>; + +const omitSeparatorProps = new Set(['border']); + +/** + * We require a wrapper if we want to use the orientation context, + * otherwise the styling won't work as override needs to come from the parent. + */ +export const Separator = styled( + ({orientation, ...props}: SeparatorProps) => { + return
; + }, + { + shouldForwardProp: prop => { + return !omitSeparatorProps.has(prop as any); + }, + } +)` + width: ${p => (p.orientation === 'horizontal' ? 'auto' : '1px')}; + height: ${p => (p.orientation === 'horizontal' ? '1px' : 'auto')}; + + flex-shrink: 0; + align-self: stretch; + + margin: 0; + border: none; + ${p => + rc( + p.orientation === 'horizontal' ? 'border-bottom' : 'border-left', + p.border, + p.theme, + v => `1px solid ${p.theme.tokens.border[v]} !important` + )}; +`;