Skip to content

Commit

Permalink
add <SegmentedControl /> (#591)
Browse files Browse the repository at this point in the history
* f: adds segmented control

* f: fix story and adds type variant

* f: change size type to string

* f

* f: changeset

* f

* f

* f: adds all variants in story and fixes some issues

* f: use toogle group approach

* f: changes from review

* add context consumer to tabs

* cleanup

* rename

* simplify stories

---------

Co-authored-by: Pavel <14926950+prichodko@users.noreply.github.com>
  • Loading branch information
marcelines and prichodko authored Oct 17, 2024
1 parent b159258 commit 4953fe7
Show file tree
Hide file tree
Showing 8 changed files with 409 additions and 5 deletions.
5 changes: 5 additions & 0 deletions .changeset/stupid-sloths-cross.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@status-im/components': patch
---

adds segmented control component
1 change: 1 addition & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"@radix-ui/react-switch": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle-group": "^1.1.0",
"@radix-ui/react-tooltip": "^1.1.2",
"@status-im/colors": "*",
"@status-im/icons": "*",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/components/src/segmented-control/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * as SegmentedControl from './segmented-control'
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { useState } from 'react'

import { PlaceholderIcon } from '@status-im/icons/20'

import { SegmentedControl } from './'

import type { Meta, StoryObj } from '@storybook/react'

type RootProps = React.ComponentPropsWithoutRef<typeof SegmentedControl.Root>
type ItemProps = React.ComponentPropsWithoutRef<typeof SegmentedControl.Item>

const SegmentedControlVariant = (
props: Omit<ItemProps, 'value'> & {
count: number
size: RootProps['size']
variant: RootProps['variant']
},
) => {
const [value, setValue] = useState('0')

const { count, variant, size, ...itemProps } = props

return (
<SegmentedControl.Root
value={value}
onValueChange={setValue}
variant={variant}
size={size}
>
{Array(count)
.fill(null)
.map((_, index) => {
return (
<SegmentedControl.Item
{...(itemProps as ItemProps)}
key={index}
value={index.toString()}
/>
)
})}
</SegmentedControl.Root>
)
}

const SegmentedControlGroup = (
props: Omit<RootProps, 'value' | 'onValueChange'>,
) => {
const { variant = 'grey' } = props

return (
<div className="inline-flex flex-col gap-12">
<div className="flex flex-col gap-6">
<SegmentedControlVariant count={5} size="32" variant={variant}>
Tab
</SegmentedControlVariant>

<SegmentedControlVariant
count={5}
size="32"
variant={variant}
icon={<PlaceholderIcon />}
>
Tab
</SegmentedControlVariant>

<SegmentedControlVariant count={2} size="32" variant={variant}>
Tab
</SegmentedControlVariant>
<SegmentedControlVariant
count={2}
size="32"
variant={variant}
icon={<PlaceholderIcon />}
>
Tab
</SegmentedControlVariant>

<SegmentedControlVariant
count={5}
size="32"
variant={variant}
icon={<PlaceholderIcon />}
aria-label="placeholder"
/>
</div>
</div>
)
}

const meta: Meta = {
title: 'Components/Segmented Control',
render: args => {
return (
<div className="flex flex-wrap gap-5">
<div className="flex flex-wrap gap-5">
<div className="rounded-[24px] bg-white-100 p-12 dark:bg-neutral-90">
<SegmentedControlGroup {...args} />
</div>

<div className="inline-flex flex-col gap-12 rounded-[24px] bg-neutral-5 p-12 dark:bg-neutral-95">
<SegmentedControlGroup {...args} variant="darkGrey" />
</div>

<div
data-background="blur"
className="relative inline-flex flex-col gap-12 overflow-hidden rounded-[24px] p-12"
>
<div className="absolute left-0 top-0 z-10 size-full bg-blur-white/70 backdrop-blur-[20px] dark:bg-blur-neutral-80/80" />
{/* Background image */}
<div className="absolute left-0 top-0 size-full bg-[url(./assets/background-blur.png)] bg-cover bg-center bg-no-repeat" />
<div className="relative z-10">
<SegmentedControlGroup {...args} />
</div>
</div>
</div>
</div>
)
},
}

type Story = StoryObj

export const Light: Story = {}
export const Dark: Story = {
parameters: {
backgrounds: { default: 'dark' },
},
}

export default meta
210 changes: 210 additions & 0 deletions packages/components/src/segmented-control/segmented-control.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import {
cloneElement,
createContext,
forwardRef,
useContext,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'

import * as ToggleGroup from '@radix-ui/react-toggle-group'
import { cva } from 'cva'

import type { IconElement } from '../types'
import type { VariantProps } from 'cva'

type Variants = VariantProps<typeof rootStyles>

const SegmentedControlContext = createContext<
Pick<Variants, 'variant' | 'size'>
>({})

function useSegmentedControlContext() {
const context = useContext(SegmentedControlContext)

if (!context) {
throw new Error(
'useSegmentedControlContext must be used within a <SegmentedControl.Root />',
)
}

return context
}

type RootProps = Omit<ToggleGroup.ToggleGroupSingleProps, 'type'> & {
value: string
onValueChange: (value: string) => void
variant?: Variants['variant']
size?: Variants['size']
}

const rootStyles = cva({
base: 'relative flex flex-1 items-center justify-center gap-0.5 rounded-10 p-0.5',
variants: {
variant: {
grey: 'bg-neutral-10 blur:bg-neutral-80/5 blur:backdrop-blur-[20px] dark:bg-neutral-80 blur:dark:bg-white-5',
darkGrey: 'bg-neutral-20 dark:bg-neutral-90',
},
size: {
'24': 'h-6',
'32': 'h-8',
},
},
})

const activeSegmentStyles = cva({
base: 'pointer-events-none absolute inset-y-0.5 left-0 flex-1 rounded-8 transition-all duration-200 ease-out',
variants: {
variant: {
grey: 'bg-neutral-50 blur:bg-neutral-80/60 dark:bg-neutral-60 blur:dark:bg-white-20',
darkGrey: 'bg-neutral-50 dark:bg-neutral-60',
},
},
})

export const Root = forwardRef<
React.ElementRef<typeof ToggleGroup.Root>,
RootProps
>((props, ref) => {
const {
children,
variant = 'grey',
size = '32',
value,
onValueChange,
...rootProps
} = props

const rootRef = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => rootRef.current!)

const [indicatorStyle, setIndicatorStyle] = useState<React.CSSProperties>({})

useEffect(() => {
const activeButton =
rootRef.current!.querySelector<HTMLButtonElement>('[data-state="on"]')!

if (activeButton) {
setIndicatorStyle({
width: activeButton.offsetWidth,
transform: `translateX(${activeButton.offsetLeft}px)`,
})
}
}, [value])

return (
<SegmentedControlContext.Provider
value={useMemo(() => ({ size, variant }), [size, variant])}
>
<ToggleGroup.Root
{...rootProps}
ref={rootRef}
type="single"
className={rootStyles({ size, variant })}
value={value}
onValueChange={value => {
// Ensuring there is always a value
// @see https://www.radix-ui.com/primitives/docs/components/toggle-group#ensuring-there-is-always-a-value
if (value) {
onValueChange(value)
}
}}
>
<div
className={activeSegmentStyles({ variant })}
style={indicatorStyle}
/>
{children}
</ToggleGroup.Root>
</SegmentedControlContext.Provider>
)
})

Root.displayName = 'Root'

/**
* Item
*/

const itemStyles = cva({
base: [
'group relative z-10 flex flex-1 select-none items-center justify-center gap-1 whitespace-nowrap rounded-8 bg-transparent font-medium transition-all duration-300 ease-out',
'text-neutral-100 data-[state="on"]:text-white-100 dark:text-white-100',
],

variants: {
variant: {
grey: [
'data-[state="off"]:hover:bg-neutral-20 data-[state="off"]:blur:hover:bg-neutral-80/5 data-[state="off"]:dark:hover:bg-neutral-70 data-[state="off"]:dark:blur:hover:bg-white-5',
],
darkGrey: [
'data-[state="off"]:hover:bg-neutral-30 data-[state="off"]:dark:hover:bg-neutral-80',
],
},
size: {
'24': 'h-6 px-2 text-13',
'32': 'h-7 px-3 text-15',
},
},
})

const iconStyles = cva({
base: [
'size-5 text-neutral-50 group-data-[state="on"]:text-white-100 dark:text-white-40',
],
variants: {
iconOnly: {
true: '',
false: '-ml-0.5',
},
},
})

type ItemProps = Omit<
React.ComponentPropsWithoutRef<typeof ToggleGroup.Item>,
'children'
> &
(
| {
icon?: IconElement
children: React.ReactNode
}
| {
icon: IconElement
children?: never
'aria-label': string
}
)

export const Item = forwardRef<
React.ElementRef<typeof ToggleGroup.Item>,
ItemProps
>((props, ref) => {
const { icon, children, ...itemProps } = props

const { size, variant } = useSegmentedControlContext()

const iconOnly = children ? false : true

return (
<ToggleGroup.Item
{...itemProps}
ref={ref}
className={itemStyles({ size, variant })}
>
{icon && (
<>
{cloneElement(icon, {
className: iconStyles({ iconOnly }),
})}
</>
)}
{children}
</ToggleGroup.Item>
)
})

Item.displayName = 'Item'
14 changes: 12 additions & 2 deletions packages/components/src/tabs/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ type RootProps = React.ComponentProps<typeof Tabs.Root> & {

const TabsContext = createContext<Pick<RootProps, 'size' | 'variant'>>({})

function useTabsContext() {
const context = useContext(TabsContext)

if (!context) {
throw new Error('useTabsContext must be used within a <Tabs.Root />')
}

return context
}

export const Root = (props: RootProps) => {
const { size = '32', variant = 'grey', ...rootProps } = props

Expand All @@ -42,7 +52,7 @@ export const List = forwardRef<
React.ElementRef<typeof Tabs.List>,
React.ComponentPropsWithoutRef<typeof Tabs.List>
>((props, ref) => {
const { size } = useContext(TabsContext)!
const { size } = useTabsContext()

return (
<Tabs.List
Expand Down Expand Up @@ -74,7 +84,7 @@ export const Trigger = forwardRef<
>((props, ref) => {
const { children, ...rest } = props

const { size, variant } = useContext(TabsContext)!
const { size, variant } = useTabsContext()

return (
<Tabs.Trigger {...rest} ref={ref} className={tabStyles({ variant, size })}>
Expand Down
Loading

0 comments on commit 4953fe7

Please sign in to comment.