Skip to content

Commit

Permalink
feat(Chip): new component (#4307)
Browse files Browse the repository at this point in the history
* feat: new chip component

* fix: feedback

* fix: feedback

* fix: text tag
  • Loading branch information
lisalupi authored Oct 24, 2024
1 parent 43b448f commit 82f8ecc
Show file tree
Hide file tree
Showing 14 changed files with 1,528 additions and 0 deletions.
5 changes: 5 additions & 0 deletions .changeset/sharp-shoes-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ultraviolet/ui": minor
---

New component `<Chip />`
9 changes: 9 additions & 0 deletions packages/ui/src/components/Chip/ChipContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { RefObject } from 'react'
import { createContext } from 'react'

type ContextType = {
isActive: boolean
disabled: boolean
iconRef?: RefObject<HTMLButtonElement>
}
export const ChipContext = createContext<ContextType | undefined>(undefined)
81 changes: 81 additions & 0 deletions packages/ui/src/components/Chip/ChipIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import styled from '@emotion/styled'
import * as Icon from '@ultraviolet/icons'
import { useContext } from 'react'
import type { PascalToCamelCaseWithoutSuffix } from 'src/types'
import { ChipContext } from './ChipContext'

const Container = styled.button`
background: none;
border: none;
cursor: pointer;
padding: 0;
border-radius: ${({ theme }) => theme.radii.default};
&[data-has-onclick="true"][data-active="false"]:hover{
background-color: ${({ theme }) => theme.colors.neutral.backgroundStrongHover};
}
&[data-has-onclick="true"][data-active="true"]:hover{
background-color: ${({ theme }) => theme.colors.primary.backgroundStrong};
}
&[data-disabled="true"]{
cursor: not-allowed;
}
`

type IconType = PascalToCamelCaseWithoutSuffix<keyof typeof Icon, 'Icon'>

type ChipIconType = {
/**
* Add an icon in the chip
*/
name: IconType
onClick?: () => void
'data-testid'?: string
}

export const ChipIcon = ({
name,
onClick,
'data-testid': dataTestId,
}: ChipIconType) => {
const context = useContext(ChipContext)

if (!context) {
throw new Error('Chip.Icon can only be used inside a Chip component')
}

const { disabled, isActive, iconRef } = context

const IconUsed =
Icon[
`${
(name as string).charAt(0).toUpperCase() + (name as string).slice(1)
}Icon` as keyof typeof Icon
]

return (
<Container
onClick={event => {
if (!disabled && onClick) {
event.stopPropagation()
onClick()
}
}}
data-testid={dataTestId}
data-disabled={disabled}
data-active={isActive}
data-has-onclick={!!onClick && !disabled}
as={onClick ? 'button' : 'div'}
ref={iconRef}
>
<IconUsed
size="small"
prominence={isActive ? 'stronger' : 'default'}
sentiment="neutral"
disabled={disabled}
/>
</Container>
)
}
24 changes: 24 additions & 0 deletions packages/ui/src/components/Chip/__stories__/Disabled.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { StoryFn } from '@storybook/react'
import { Chip } from '..'
import { Stack } from '../../Stack'

export const Disabled: StoryFn<typeof Chip> = ({ ...args }) => (
<Stack direction="row" gap={1}>
<Chip {...args} disabled>
Disabled inactive
<Chip.Icon name="close" onClick={() => alert('Deleted')} />
</Chip>
<Chip {...args} disabled active>
Disabled active
<Chip.Icon name="close" onClick={() => alert('Deleted')} />
</Chip>
</Stack>
)
Disabled.parameters = {
docs: {
description: {
story:
'A disabled chip can be active or not. OnClick inside a `Chip.Icon` is deactivated if the chip is disabled',
},
},
}
122 changes: 122 additions & 0 deletions packages/ui/src/components/Chip/__stories__/Groups.stories.tsx.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { StoryFn } from '@storybook/react'
import { useState } from 'react'
import { Chip } from '..'
import { Stack } from '../../Stack'
import { Text } from '../../Text'

export const Groups: StoryFn<typeof Chip> = ({ ...args }) => {
const [singleSelected, setSingleSelected] = useState(-1)
const [multiSelected, setMultiSelected] = useState<number[]>([])

return (
<Stack direction="column" gap={3}>
<Stack gap={1}>
<Text as="h1" variant="heading">
Single-select group
</Text>
<Stack direction="row" gap={1}>
<Chip
{...args}
active={singleSelected === 0}
onClick={() =>
singleSelected === 0
? setSingleSelected(-1)
: setSingleSelected(0)
}
>
All
</Chip>
<Chip
{...args}
active={singleSelected === 1}
onClick={() =>
singleSelected === 1
? setSingleSelected(-1)
: setSingleSelected(1)
}
>
Product
</Chip>
<Chip
{...args}
active={singleSelected === 2}
onClick={() =>
singleSelected === 2
? setSingleSelected(-1)
: setSingleSelected(2)
}
>
Actions
</Chip>
<Chip
{...args}
active={singleSelected === 3}
onClick={() =>
singleSelected === 3
? setSingleSelected(-1)
: setSingleSelected(3)
}
>
Resources
</Chip>
</Stack>
Selected chip: {singleSelected === -1 ? 'none' : singleSelected}
</Stack>
<Stack gap={1}>
<Text as="h1" variant="heading">
Muli-select group
</Text>
<Stack direction="row" gap={1}>
<Chip
{...args}
active={multiSelected.includes(0)}
onClick={() =>
multiSelected.includes(0)
? setMultiSelected([])
: setMultiSelected([...multiSelected, 0])
}
>
All (18)
</Chip>
<Chip
{...args}
active={multiSelected.includes(1) || multiSelected.includes(0)}
onClick={() =>
multiSelected.includes(1)
? setMultiSelected(multiSelected.filter(id => id !== 1))
: setMultiSelected([...multiSelected, 1])
}
>
Product (2)
</Chip>
<Chip
{...args}
active={multiSelected.includes(0) || multiSelected.includes(2)}
onClick={() =>
multiSelected.includes(2)
? setMultiSelected(multiSelected.filter(id => id !== 2))
: setMultiSelected([...multiSelected, 2])
}
>
Actions (4)
</Chip>
<Chip
{...args}
active={multiSelected.includes(3) || multiSelected.includes(0)}
onClick={() =>
multiSelected.includes(3)
? setMultiSelected(multiSelected.filter(id => id !== 3))
: setMultiSelected([...multiSelected, 3])
}
>
Resources (12)
</Chip>
</Stack>
Selected chip{multiSelected.length > 1 ? 's' : null}:{' '}
{multiSelected.includes(0)
? `1 2 3`
: multiSelected.map(id => `${id} `)}
</Stack>
</Stack>
)
}
24 changes: 24 additions & 0 deletions packages/ui/src/components/Chip/__stories__/Icons.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { StoryFn } from '@storybook/react'
import { Chip } from '..'
import { Stack } from '../../Stack'

export const Icons: StoryFn<typeof Chip> = ({ ...args }) => (
<Stack direction="row" gap={1}>
<Chip {...args}>
Trailing icon
<Chip.Icon name="close" onClick={() => alert('Deleted')} />
</Chip>
<Chip {...args}>
<Chip.Icon name="filter" />
Leading icon
</Chip>
</Stack>
)
Icons.parameters = {
docs: {
description: {
story:
'To add an icon on the chip, use `Chip.Icon` inside the children of `Chip`.',
},
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Template } from './Template.stories'

export const Playground = Template.bind({})
14 changes: 14 additions & 0 deletions packages/ui/src/components/Chip/__stories__/Size.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { StoryFn } from '@storybook/react'
import { Chip } from '..'
import { Stack } from '../../Stack'

export const Size: StoryFn<typeof Chip> = ({ ...args }) => (
<Stack direction="row" gap={1}>
<Chip {...args} size="medium">
Medium (default)
</Chip>
<Chip {...args} size="large">
Large
</Chip>
</Stack>
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { StoryFn } from '@storybook/react'
import { Chip } from '..'

export const Template: StoryFn<typeof Chip> = ({ ...args }) => (
<Chip {...args}>Default text</Chip>
)
23 changes: 23 additions & 0 deletions packages/ui/src/components/Chip/__stories__/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { Meta } from '@storybook/react'
import { Chip } from '..'

export default {
component: Chip,
subcomponents: {
'Chip.Icon': Chip.Icon,
},
decorators: [
StoryComponent => (
<div style={{ display: 'flex', flexDirection: 'column' }}>
<StoryComponent />
</div>
),
],
title: 'Components/Badges/Chip',
} as Meta<typeof Chip>

export { Playground } from './Playground.stories'
export { Icons } from './Icons.stories'
export { Groups } from './Groups.stories.tsx'
export { Size } from './Size.stories'
export { Disabled } from './Disabled.stories'
Loading

0 comments on commit 82f8ecc

Please sign in to comment.