From 6b19f4c0be5288d680681f5564eea7f37fd4ea5b Mon Sep 17 00:00:00 2001 From: Jenny Zhu Date: Thu, 17 Sep 2026 15:28:25 -0400 Subject: [PATCH] [FEATURE] UI customization. Add Stack component Signed-off-by: Jenny Zhu --- .../src/next/contexts/ComponentsContext.ts | 5 +- .../next/primitives/Stack/Stack.stories.tsx | 74 +++++++++++++++ .../src/next/primitives/Stack/Stack.test.tsx | 92 +++++++++++++++++++ .../src/next/primitives/Stack/Stack.tsx | 45 +++++++++ components/src/next/primitives/Stack/index.ts | 14 +++ .../src/next/primitives/Stack/stack.css | 20 ++++ components/src/next/primitives/defaults.ts | 3 +- .../src/next/primitives/exports.test.tsx | 6 +- components/src/next/primitives/index.ts | 1 + 9 files changed, 257 insertions(+), 3 deletions(-) create mode 100644 components/src/next/primitives/Stack/Stack.stories.tsx create mode 100644 components/src/next/primitives/Stack/Stack.test.tsx create mode 100644 components/src/next/primitives/Stack/Stack.tsx create mode 100644 components/src/next/primitives/Stack/index.ts create mode 100644 components/src/next/primitives/Stack/stack.css diff --git a/components/src/next/contexts/ComponentsContext.ts b/components/src/next/contexts/ComponentsContext.ts index 0a397d38..a6415e67 100644 --- a/components/src/next/contexts/ComponentsContext.ts +++ b/components/src/next/contexts/ComponentsContext.ts @@ -12,16 +12,19 @@ // limitations under the License. import { createContext } from 'react'; -import type { ComponentType, ReactNode, SVGProps } from 'react'; +import type { ComponentType, ReactNode, RefAttributes, SVGProps } from 'react'; import type { AlertProps } from '../primitives/Alert'; import type { ButtonProps } from '../primitives/Button'; import type { SpinnerProps } from '../primitives/Spinner'; +import type { StackProps } from '../primitives/Stack'; export interface PersesComponents { Button: ComponentType; Alert: ComponentType; Spinner: ComponentType; + Box: ComponentType>; + Stack: ComponentType; } export interface PersesIcons { diff --git a/components/src/next/primitives/Stack/Stack.stories.tsx b/components/src/next/primitives/Stack/Stack.stories.tsx new file mode 100644 index 00000000..f852fffb --- /dev/null +++ b/components/src/next/primitives/Stack/Stack.stories.tsx @@ -0,0 +1,74 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { Story } from '@ladle/react'; + +import { Stack } from './Stack'; + +export const Direction: Story = () => ( +
+
+

Direction: column (default)

+ +
Item 1
+
Item 2
+
Item 3
+
+
+ +
+

Direction: row

+ +
Item 1
+
Item 2
+
Item 3
+
+
+
+); +Direction.storyName = 'Direction'; + +export const SpacingTokens: Story = () => ( +
+ {(['xs', 'sm', 'md', 'lg', 'xl'] as const).map((token) => ( +
+

spacing={'{token}'}: {token}

+ +
A
+
B
+
C
+
+
+ ))} +
+); +SpacingTokens.storyName = 'Spacing Tokens'; + +export const AlignAndJustify: Story = () => ( + +
Short
+
+ Taller +
+ content +
+
End
+
+); +AlignAndJustify.storyName = 'Align and Justify'; diff --git a/components/src/next/primitives/Stack/Stack.test.tsx b/components/src/next/primitives/Stack/Stack.test.tsx new file mode 100644 index 00000000..ceb2773f --- /dev/null +++ b/components/src/next/primitives/Stack/Stack.test.tsx @@ -0,0 +1,92 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { render, screen } from '@testing-library/react'; +import type { ReactElement, ReactNode } from 'react'; + +import { ComponentsProvider } from '../../contexts/ComponentsProvider'; +import { defaultComponents, defaultIcons } from '../defaults'; +import { Stack } from './Stack'; + +function Wrapper({ children }: { children: ReactNode }): ReactElement { + return ( + + {children} + + ); +} + +describe('Stack', () => { + it('renders children', () => { + render(Content, { wrapper: Wrapper }); + expect(screen.getByText('Content')).toBeInTheDocument(); + }); + + it('applies the ps-Stack and ps-Box classes', () => { + render(Content, { wrapper: Wrapper }); + expect(screen.getByTestId('stack')).toHaveClass('ps-Stack'); + expect(screen.getByTestId('stack')).toHaveClass('ps-Box'); + }); + + it('merges additional className', () => { + render( + + Content + , + { wrapper: Wrapper }, + ); + expect(screen.getByTestId('stack')).toHaveClass('custom'); + }); + + it('defaults to a column direction', () => { + render(Content, { wrapper: Wrapper }); + expect(screen.getByTestId('stack')).toHaveStyle({ display: 'flex', flexDirection: 'column' }); + }); + + it('supports row direction', () => { + render( + + Content + , + { wrapper: Wrapper }, + ); + expect(screen.getByTestId('stack')).toHaveStyle({ flexDirection: 'row' }); + }); + + it('resolves the spacing prop to a gap CSS variable', () => { + render( + + Content + , + { wrapper: Wrapper }, + ); + expect(screen.getByTestId('stack')).toHaveStyle({ gap: 'var(--perses-spacing-lg)' }); + }); + + it('forwards alignItems and justifyContent', () => { + render( + + Content + , + { wrapper: Wrapper }, + ); + const stack = screen.getByTestId('stack'); + expect(stack).toHaveStyle({ alignItems: 'center', justifyContent: 'space-between' }); + }); + + it('forwards a ref to the underlying div', () => { + const ref = { current: null as HTMLDivElement | null }; + render(Content, { wrapper: Wrapper }); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); +}); diff --git a/components/src/next/primitives/Stack/Stack.tsx b/components/src/next/primitives/Stack/Stack.tsx new file mode 100644 index 00000000..aa15aa64 --- /dev/null +++ b/components/src/next/primitives/Stack/Stack.tsx @@ -0,0 +1,45 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import clsx from 'clsx'; +import { forwardRef } from 'react'; + +import { useComponents } from '../../contexts/ComponentsProvider'; +import type { BoxProps } from '../Box/Box'; + +import './stack.css'; + +export interface StackProps extends Omit { + direction?: 'row' | 'column'; + spacing?: BoxProps['gap']; +} + +export const Stack = forwardRef(function Stack( + { direction = 'column', spacing, className, ...rest }, + ref, +) { + const { + components: { Box }, + } = useComponents(); + + return ( + + ); +}); diff --git a/components/src/next/primitives/Stack/index.ts b/components/src/next/primitives/Stack/index.ts new file mode 100644 index 00000000..4bce5d69 --- /dev/null +++ b/components/src/next/primitives/Stack/index.ts @@ -0,0 +1,14 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export * from './Stack'; diff --git a/components/src/next/primitives/Stack/stack.css b/components/src/next/primitives/Stack/stack.css new file mode 100644 index 00000000..81e14e89 --- /dev/null +++ b/components/src/next/primitives/Stack/stack.css @@ -0,0 +1,20 @@ +/* + * Copyright The Perses Authors + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@layer perses.components { + .ps-Stack { + box-sizing: border-box; + } +} diff --git a/components/src/next/primitives/defaults.ts b/components/src/next/primitives/defaults.ts index ecfbc081..f6614d0c 100644 --- a/components/src/next/primitives/defaults.ts +++ b/components/src/next/primitives/defaults.ts @@ -16,8 +16,9 @@ import { Alert } from './Alert'; import { Button } from './Button'; import { ErrorIcon, InfoIcon, SuccessIcon, WarningIcon } from './Icon'; import { Spinner } from './Spinner'; +import { Stack } from './Stack'; -export const defaultComponents: PersesComponents = { Button, Alert, Spinner }; +export const defaultComponents: PersesComponents = { Button, Alert, Spinner, Box, Stack }; export const defaultIcons: PersesIcons = { Error: ErrorIcon, diff --git a/components/src/next/primitives/exports.test.tsx b/components/src/next/primitives/exports.test.tsx index cbb686d8..a63cf5ab 100644 --- a/components/src/next/primitives/exports.test.tsx +++ b/components/src/next/primitives/exports.test.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Alert, Button, Icon, Spinner } from './index'; +import { Alert, Box, Button, Icon, Spinner, Stack } from './index'; import type { AlertProps, AlertSeverity, @@ -21,6 +21,7 @@ import type { ButtonVariant, IconProps, SpinnerProps, + StackProps, } from './index'; describe('primitives barrel exports', () => { @@ -29,6 +30,7 @@ describe('primitives barrel exports', () => { expect(Button).toBeDefined(); expect(Icon).toBeDefined(); expect(Spinner).toBeDefined(); + expect(Stack).toBeDefined(); }); it('exports their prop types', () => { @@ -40,6 +42,7 @@ describe('primitives barrel exports', () => { const size: ButtonSize = 'md'; const iconProps: IconProps = {}; const spinnerProps: SpinnerProps = {}; + const stackProps: StackProps = {}; expect(alertProps).toBeDefined(); expect(severity).toBe('info'); @@ -49,5 +52,6 @@ describe('primitives barrel exports', () => { expect(size).toBe('md'); expect(iconProps).toBeDefined(); expect(spinnerProps).toBeDefined(); + expect(stackProps).toBeDefined(); }); }); diff --git a/components/src/next/primitives/index.ts b/components/src/next/primitives/index.ts index 27680cde..c2596fde 100644 --- a/components/src/next/primitives/index.ts +++ b/components/src/next/primitives/index.ts @@ -15,3 +15,4 @@ export * from './Alert'; export * from './Button'; export * from './Icon'; export * from './Spinner'; +export * from './Stack';