From 82f8eccb176a9377a7af07c1a6057366698a7d28 Mon Sep 17 00:00:00 2001 From: lisalupi <106706307+lisalupi@users.noreply.github.com> Date: Thu, 24 Oct 2024 16:21:55 +0200 Subject: [PATCH] feat(Chip): new component (#4307) * feat: new chip component * fix: feedback * fix: feedback * fix: text tag --- .changeset/sharp-shoes-report.md | 5 + .../ui/src/components/Chip/ChipContext.tsx | 9 + packages/ui/src/components/Chip/ChipIcon.tsx | 81 ++ .../Chip/__stories__/Disabled.stories.tsx | 24 + .../Chip/__stories__/Groups.stories.tsx.tsx | 122 +++ .../Chip/__stories__/Icons.stories.tsx | 24 + .../Chip/__stories__/Playground.stories.tsx | 3 + .../Chip/__stories__/Size.stories.tsx | 14 + .../Chip/__stories__/Template.stories.tsx | 6 + .../Chip/__stories__/index.stories.tsx | 23 + .../__snapshots__/index.test.tsx.snap | 970 ++++++++++++++++++ .../components/Chip/__tests__/index.test.tsx | 94 ++ packages/ui/src/components/Chip/index.tsx | 152 +++ packages/ui/src/components/index.ts | 1 + 14 files changed, 1528 insertions(+) create mode 100644 .changeset/sharp-shoes-report.md create mode 100644 packages/ui/src/components/Chip/ChipContext.tsx create mode 100644 packages/ui/src/components/Chip/ChipIcon.tsx create mode 100644 packages/ui/src/components/Chip/__stories__/Disabled.stories.tsx create mode 100644 packages/ui/src/components/Chip/__stories__/Groups.stories.tsx.tsx create mode 100644 packages/ui/src/components/Chip/__stories__/Icons.stories.tsx create mode 100644 packages/ui/src/components/Chip/__stories__/Playground.stories.tsx create mode 100644 packages/ui/src/components/Chip/__stories__/Size.stories.tsx create mode 100644 packages/ui/src/components/Chip/__stories__/Template.stories.tsx create mode 100644 packages/ui/src/components/Chip/__stories__/index.stories.tsx create mode 100644 packages/ui/src/components/Chip/__tests__/__snapshots__/index.test.tsx.snap create mode 100644 packages/ui/src/components/Chip/__tests__/index.test.tsx create mode 100644 packages/ui/src/components/Chip/index.tsx diff --git a/.changeset/sharp-shoes-report.md b/.changeset/sharp-shoes-report.md new file mode 100644 index 0000000000..cdb8289772 --- /dev/null +++ b/.changeset/sharp-shoes-report.md @@ -0,0 +1,5 @@ +--- +"@ultraviolet/ui": minor +--- + +New component `` diff --git a/packages/ui/src/components/Chip/ChipContext.tsx b/packages/ui/src/components/Chip/ChipContext.tsx new file mode 100644 index 0000000000..a7d726090a --- /dev/null +++ b/packages/ui/src/components/Chip/ChipContext.tsx @@ -0,0 +1,9 @@ +import type { RefObject } from 'react' +import { createContext } from 'react' + +type ContextType = { + isActive: boolean + disabled: boolean + iconRef?: RefObject +} +export const ChipContext = createContext(undefined) diff --git a/packages/ui/src/components/Chip/ChipIcon.tsx b/packages/ui/src/components/Chip/ChipIcon.tsx new file mode 100644 index 0000000000..d3b520bdb8 --- /dev/null +++ b/packages/ui/src/components/Chip/ChipIcon.tsx @@ -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 + +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 ( + { + 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} + > + + + ) +} diff --git a/packages/ui/src/components/Chip/__stories__/Disabled.stories.tsx b/packages/ui/src/components/Chip/__stories__/Disabled.stories.tsx new file mode 100644 index 0000000000..0bf269ec93 --- /dev/null +++ b/packages/ui/src/components/Chip/__stories__/Disabled.stories.tsx @@ -0,0 +1,24 @@ +import type { StoryFn } from '@storybook/react' +import { Chip } from '..' +import { Stack } from '../../Stack' + +export const Disabled: StoryFn = ({ ...args }) => ( + + + Disabled inactive + alert('Deleted')} /> + + + Disabled active + alert('Deleted')} /> + + +) +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', + }, + }, +} diff --git a/packages/ui/src/components/Chip/__stories__/Groups.stories.tsx.tsx b/packages/ui/src/components/Chip/__stories__/Groups.stories.tsx.tsx new file mode 100644 index 0000000000..f4bb99ad90 --- /dev/null +++ b/packages/ui/src/components/Chip/__stories__/Groups.stories.tsx.tsx @@ -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 = ({ ...args }) => { + const [singleSelected, setSingleSelected] = useState(-1) + const [multiSelected, setMultiSelected] = useState([]) + + return ( + + + + Single-select group + + + + singleSelected === 0 + ? setSingleSelected(-1) + : setSingleSelected(0) + } + > + All + + + singleSelected === 1 + ? setSingleSelected(-1) + : setSingleSelected(1) + } + > + Product + + + singleSelected === 2 + ? setSingleSelected(-1) + : setSingleSelected(2) + } + > + Actions + + + singleSelected === 3 + ? setSingleSelected(-1) + : setSingleSelected(3) + } + > + Resources + + + Selected chip: {singleSelected === -1 ? 'none' : singleSelected} + + + + Muli-select group + + + + multiSelected.includes(0) + ? setMultiSelected([]) + : setMultiSelected([...multiSelected, 0]) + } + > + All (18) + + + multiSelected.includes(1) + ? setMultiSelected(multiSelected.filter(id => id !== 1)) + : setMultiSelected([...multiSelected, 1]) + } + > + Product (2) + + + multiSelected.includes(2) + ? setMultiSelected(multiSelected.filter(id => id !== 2)) + : setMultiSelected([...multiSelected, 2]) + } + > + Actions (4) + + + multiSelected.includes(3) + ? setMultiSelected(multiSelected.filter(id => id !== 3)) + : setMultiSelected([...multiSelected, 3]) + } + > + Resources (12) + + + Selected chip{multiSelected.length > 1 ? 's' : null}:{' '} + {multiSelected.includes(0) + ? `1 2 3` + : multiSelected.map(id => `${id} `)} + + + ) +} diff --git a/packages/ui/src/components/Chip/__stories__/Icons.stories.tsx b/packages/ui/src/components/Chip/__stories__/Icons.stories.tsx new file mode 100644 index 0000000000..8852c9156a --- /dev/null +++ b/packages/ui/src/components/Chip/__stories__/Icons.stories.tsx @@ -0,0 +1,24 @@ +import type { StoryFn } from '@storybook/react' +import { Chip } from '..' +import { Stack } from '../../Stack' + +export const Icons: StoryFn = ({ ...args }) => ( + + + Trailing icon + alert('Deleted')} /> + + + + Leading icon + + +) +Icons.parameters = { + docs: { + description: { + story: + 'To add an icon on the chip, use `Chip.Icon` inside the children of `Chip`.', + }, + }, +} diff --git a/packages/ui/src/components/Chip/__stories__/Playground.stories.tsx b/packages/ui/src/components/Chip/__stories__/Playground.stories.tsx new file mode 100644 index 0000000000..1bb1300883 --- /dev/null +++ b/packages/ui/src/components/Chip/__stories__/Playground.stories.tsx @@ -0,0 +1,3 @@ +import { Template } from './Template.stories' + +export const Playground = Template.bind({}) diff --git a/packages/ui/src/components/Chip/__stories__/Size.stories.tsx b/packages/ui/src/components/Chip/__stories__/Size.stories.tsx new file mode 100644 index 0000000000..679457bf0a --- /dev/null +++ b/packages/ui/src/components/Chip/__stories__/Size.stories.tsx @@ -0,0 +1,14 @@ +import type { StoryFn } from '@storybook/react' +import { Chip } from '..' +import { Stack } from '../../Stack' + +export const Size: StoryFn = ({ ...args }) => ( + + + Medium (default) + + + Large + + +) diff --git a/packages/ui/src/components/Chip/__stories__/Template.stories.tsx b/packages/ui/src/components/Chip/__stories__/Template.stories.tsx new file mode 100644 index 0000000000..bd626fa4ec --- /dev/null +++ b/packages/ui/src/components/Chip/__stories__/Template.stories.tsx @@ -0,0 +1,6 @@ +import type { StoryFn } from '@storybook/react' +import { Chip } from '..' + +export const Template: StoryFn = ({ ...args }) => ( + Default text +) diff --git a/packages/ui/src/components/Chip/__stories__/index.stories.tsx b/packages/ui/src/components/Chip/__stories__/index.stories.tsx new file mode 100644 index 0000000000..25ed36a0de --- /dev/null +++ b/packages/ui/src/components/Chip/__stories__/index.stories.tsx @@ -0,0 +1,23 @@ +import type { Meta } from '@storybook/react' +import { Chip } from '..' + +export default { + component: Chip, + subcomponents: { + 'Chip.Icon': Chip.Icon, + }, + decorators: [ + StoryComponent => ( +
+ +
+ ), + ], + title: 'Components/Badges/Chip', +} as Meta + +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' diff --git a/packages/ui/src/components/Chip/__tests__/__snapshots__/index.test.tsx.snap b/packages/ui/src/components/Chip/__tests__/__snapshots__/index.test.tsx.snap new file mode 100644 index 0000000000..8b61c0eb29 --- /dev/null +++ b/packages/ui/src/components/Chip/__tests__/__snapshots__/index.test.tsx.snap @@ -0,0 +1,970 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Checkbox > renders correctly 1`] = ` + + .emotion-0 { + font-size: 12px; + font-family: Inter,Asap,sans-serif; + font-weight: 400; + letter-spacing: 0; + line-height: 16px; + text-transform: none; + -webkit-text-decoration: none; + text-decoration: none; +} + +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + gap: 8px; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-box-flex-wrap: nowrap; + -webkit-flex-wrap: nowrap; + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + padding: 4px 16px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + border-radius: 16px; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + background-color: #ffffff; + cursor: pointer; + border: 1px solid #e9eaeb; + text-align: center; + color: #3f4250; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-2[data-disabled="false"]:hover { + background-color: #e9eaeb; + border-color: #92959d; + color: #222638; +} + +.emotion-2[data-disabled="true"] { + background-color: #f3f3f4; + border-color: #d9dadd; + color: #b5b7bd; + cursor: not-allowed; +} + +.emotion-2[data-active="true"] { + background-color: #8c40ef; + border-color: #8c40ef; + color: #ffffff; +} + +.emotion-2[data-active="true"][data-disabled="false"]:hover { + background-color: #792dd4; + border-color: #792dd4; + color: #f9f9fa; +} + +.emotion-2[data-active="true"][data-disabled="true"] { + background-color: #e5dbfd; + border: none; +} + +.emotion-2[data-size='medium'] { + height: 24px; + padding: 4px 12px; +} + +.emotion-2[data-size='large'] { + height: 32px; + padding: 4px 16px; +} + +.emotion-2[data-trailing-icon="true"] { + padding-right: 8px; +} + +
+
+
+ test +
+
+
+
+`; + +exports[`Checkbox > renders correctly active 1`] = ` + + .emotion-0 { + font-size: 12px; + font-family: Inter,Asap,sans-serif; + font-weight: 400; + letter-spacing: 0; + line-height: 16px; + text-transform: none; + -webkit-text-decoration: none; + text-decoration: none; +} + +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + gap: 8px; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-box-flex-wrap: nowrap; + -webkit-flex-wrap: nowrap; + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + padding: 4px 16px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + border-radius: 16px; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + background-color: #ffffff; + cursor: pointer; + border: 1px solid #e9eaeb; + text-align: center; + color: #3f4250; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-2[data-disabled="false"]:hover { + background-color: #e9eaeb; + border-color: #92959d; + color: #222638; +} + +.emotion-2[data-disabled="true"] { + background-color: #f3f3f4; + border-color: #d9dadd; + color: #b5b7bd; + cursor: not-allowed; +} + +.emotion-2[data-active="true"] { + background-color: #8c40ef; + border-color: #8c40ef; + color: #ffffff; +} + +.emotion-2[data-active="true"][data-disabled="false"]:hover { + background-color: #792dd4; + border-color: #792dd4; + color: #f9f9fa; +} + +.emotion-2[data-active="true"][data-disabled="true"] { + background-color: #e5dbfd; + border: none; +} + +.emotion-2[data-size='medium'] { + height: 24px; + padding: 4px 12px; +} + +.emotion-2[data-size='large'] { + height: 32px; + padding: 4px 16px; +} + +.emotion-2[data-trailing-icon="true"] { + padding-right: 8px; +} + +.emotion-4 { + background: none; + border: none; + cursor: pointer; + padding: 0; + border-radius: 4px; +} + +.emotion-4[data-has-onclick="true"][data-active="false"]:hover { + background-color: #d9dadd; +} + +.emotion-4[data-has-onclick="true"][data-active="true"]:hover { + background-color: #8c40ef; +} + +.emotion-4[data-disabled="true"] { + cursor: not-allowed; +} + +.emotion-6 { + vertical-align: middle; + fill: #ffffff; + height: 16px; + width: 16px; + min-width: 16px; + min-height: 16px; +} + +.emotion-6 .fillStroke { + stroke: #ffffff; + fill: none; +} + +
+
+
+ test +
+ + + +
+
+
+
+
+`; + +exports[`Checkbox > renders correctly active disabled 1`] = ` + + .emotion-0 { + font-size: 12px; + font-family: Inter,Asap,sans-serif; + font-weight: 400; + letter-spacing: 0; + line-height: 16px; + text-transform: none; + -webkit-text-decoration: none; + text-decoration: none; +} + +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + gap: 8px; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-box-flex-wrap: nowrap; + -webkit-flex-wrap: nowrap; + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + padding: 4px 16px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + border-radius: 16px; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + background-color: #ffffff; + cursor: pointer; + border: 1px solid #e9eaeb; + text-align: center; + color: #3f4250; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-2[data-disabled="false"]:hover { + background-color: #e9eaeb; + border-color: #92959d; + color: #222638; +} + +.emotion-2[data-disabled="true"] { + background-color: #f3f3f4; + border-color: #d9dadd; + color: #b5b7bd; + cursor: not-allowed; +} + +.emotion-2[data-active="true"] { + background-color: #8c40ef; + border-color: #8c40ef; + color: #ffffff; +} + +.emotion-2[data-active="true"][data-disabled="false"]:hover { + background-color: #792dd4; + border-color: #792dd4; + color: #f9f9fa; +} + +.emotion-2[data-active="true"][data-disabled="true"] { + background-color: #e5dbfd; + border: none; +} + +.emotion-2[data-size='medium'] { + height: 24px; + padding: 4px 12px; +} + +.emotion-2[data-size='large'] { + height: 32px; + padding: 4px 16px; +} + +.emotion-2[data-trailing-icon="true"] { + padding-right: 8px; +} + +.emotion-4 { + background: none; + border: none; + cursor: pointer; + padding: 0; + border-radius: 4px; +} + +.emotion-4[data-has-onclick="true"][data-active="false"]:hover { + background-color: #d9dadd; +} + +.emotion-4[data-has-onclick="true"][data-active="true"]:hover { + background-color: #8c40ef; +} + +.emotion-4[data-disabled="true"] { + cursor: not-allowed; +} + +.emotion-6 { + vertical-align: middle; + fill: #f3f3f4; + height: 16px; + width: 16px; + min-width: 16px; + min-height: 16px; +} + +.emotion-6 .fillStroke { + stroke: #f3f3f4; + fill: none; +} + +
+
+
+ test +
+ + + +
+
+
+
+
+`; + +exports[`Checkbox > renders correctly disabled 1`] = ` + + .emotion-0 { + font-size: 12px; + font-family: Inter,Asap,sans-serif; + font-weight: 400; + letter-spacing: 0; + line-height: 16px; + text-transform: none; + -webkit-text-decoration: none; + text-decoration: none; +} + +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + gap: 8px; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-box-flex-wrap: nowrap; + -webkit-flex-wrap: nowrap; + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + padding: 4px 16px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + border-radius: 16px; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + background-color: #ffffff; + cursor: pointer; + border: 1px solid #e9eaeb; + text-align: center; + color: #3f4250; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-2[data-disabled="false"]:hover { + background-color: #e9eaeb; + border-color: #92959d; + color: #222638; +} + +.emotion-2[data-disabled="true"] { + background-color: #f3f3f4; + border-color: #d9dadd; + color: #b5b7bd; + cursor: not-allowed; +} + +.emotion-2[data-active="true"] { + background-color: #8c40ef; + border-color: #8c40ef; + color: #ffffff; +} + +.emotion-2[data-active="true"][data-disabled="false"]:hover { + background-color: #792dd4; + border-color: #792dd4; + color: #f9f9fa; +} + +.emotion-2[data-active="true"][data-disabled="true"] { + background-color: #e5dbfd; + border: none; +} + +.emotion-2[data-size='medium'] { + height: 24px; + padding: 4px 12px; +} + +.emotion-2[data-size='large'] { + height: 32px; + padding: 4px 16px; +} + +.emotion-2[data-trailing-icon="true"] { + padding-right: 8px; +} + +.emotion-4 { + background: none; + border: none; + cursor: pointer; + padding: 0; + border-radius: 4px; +} + +.emotion-4[data-has-onclick="true"][data-active="false"]:hover { + background-color: #d9dadd; +} + +.emotion-4[data-has-onclick="true"][data-active="true"]:hover { + background-color: #8c40ef; +} + +.emotion-4[data-disabled="true"] { + cursor: not-allowed; +} + +.emotion-6 { + vertical-align: middle; + fill: #b5b7bd; + height: 16px; + width: 16px; + min-width: 16px; + min-height: 16px; +} + +.emotion-6 .fillStroke { + stroke: #b5b7bd; + fill: none; +} + +
+
+
+ test +
+ + + +
+
+
+
+
+`; + +exports[`Checkbox > renders correctly large 1`] = ` + + .emotion-0 { + font-size: 14px; + font-family: Inter,Asap,sans-serif; + font-weight: 400; + letter-spacing: 0; + line-height: 20px; + text-transform: none; + -webkit-text-decoration: none; + text-decoration: none; +} + +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + gap: 8px; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-box-flex-wrap: nowrap; + -webkit-flex-wrap: nowrap; + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + padding: 4px 16px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + border-radius: 16px; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + background-color: #ffffff; + cursor: pointer; + border: 1px solid #e9eaeb; + text-align: center; + color: #3f4250; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-2[data-disabled="false"]:hover { + background-color: #e9eaeb; + border-color: #92959d; + color: #222638; +} + +.emotion-2[data-disabled="true"] { + background-color: #f3f3f4; + border-color: #d9dadd; + color: #b5b7bd; + cursor: not-allowed; +} + +.emotion-2[data-active="true"] { + background-color: #8c40ef; + border-color: #8c40ef; + color: #ffffff; +} + +.emotion-2[data-active="true"][data-disabled="false"]:hover { + background-color: #792dd4; + border-color: #792dd4; + color: #f9f9fa; +} + +.emotion-2[data-active="true"][data-disabled="true"] { + background-color: #e5dbfd; + border: none; +} + +.emotion-2[data-size='medium'] { + height: 24px; + padding: 4px 12px; +} + +.emotion-2[data-size='large'] { + height: 32px; + padding: 4px 16px; +} + +.emotion-2[data-trailing-icon="true"] { + padding-right: 8px; +} + +.emotion-4 { + background: none; + border: none; + cursor: pointer; + padding: 0; + border-radius: 4px; +} + +.emotion-4[data-has-onclick="true"][data-active="false"]:hover { + background-color: #d9dadd; +} + +.emotion-4[data-has-onclick="true"][data-active="true"]:hover { + background-color: #8c40ef; +} + +.emotion-4[data-disabled="true"] { + cursor: not-allowed; +} + +.emotion-6 { + vertical-align: middle; + fill: #3f4250; + height: 16px; + width: 16px; + min-width: 16px; + min-height: 16px; +} + +.emotion-6 .fillStroke { + stroke: #3f4250; + fill: none; +} + +
+
+
+ test +
+ + + +
+
+
+
+
+`; + +exports[`Checkbox > renders correctly wiht icon 1`] = ` + + .emotion-0 { + font-size: 12px; + font-family: Inter,Asap,sans-serif; + font-weight: 400; + letter-spacing: 0; + line-height: 16px; + text-transform: none; + -webkit-text-decoration: none; + text-decoration: none; +} + +.emotion-2 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + gap: 8px; + -webkit-flex-direction: row; + -ms-flex-direction: row; + flex-direction: row; + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + -webkit-box-pack: center; + -ms-flex-pack: center; + -webkit-justify-content: center; + justify-content: center; + -webkit-box-flex-wrap: nowrap; + -webkit-flex-wrap: nowrap; + -ms-flex-wrap: nowrap; + flex-wrap: nowrap; + padding: 4px 16px; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + border-radius: 16px; + width: -webkit-fit-content; + width: -moz-fit-content; + width: fit-content; + background-color: #ffffff; + cursor: pointer; + border: 1px solid #e9eaeb; + text-align: center; + color: #3f4250; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.emotion-2[data-disabled="false"]:hover { + background-color: #e9eaeb; + border-color: #92959d; + color: #222638; +} + +.emotion-2[data-disabled="true"] { + background-color: #f3f3f4; + border-color: #d9dadd; + color: #b5b7bd; + cursor: not-allowed; +} + +.emotion-2[data-active="true"] { + background-color: #8c40ef; + border-color: #8c40ef; + color: #ffffff; +} + +.emotion-2[data-active="true"][data-disabled="false"]:hover { + background-color: #792dd4; + border-color: #792dd4; + color: #f9f9fa; +} + +.emotion-2[data-active="true"][data-disabled="true"] { + background-color: #e5dbfd; + border: none; +} + +.emotion-2[data-size='medium'] { + height: 24px; + padding: 4px 12px; +} + +.emotion-2[data-size='large'] { + height: 32px; + padding: 4px 16px; +} + +.emotion-2[data-trailing-icon="true"] { + padding-right: 8px; +} + +.emotion-4 { + background: none; + border: none; + cursor: pointer; + padding: 0; + border-radius: 4px; +} + +.emotion-4[data-has-onclick="true"][data-active="false"]:hover { + background-color: #d9dadd; +} + +.emotion-4[data-has-onclick="true"][data-active="true"]:hover { + background-color: #8c40ef; +} + +.emotion-4[data-disabled="true"] { + cursor: not-allowed; +} + +.emotion-6 { + vertical-align: middle; + fill: #3f4250; + height: 16px; + width: 16px; + min-width: 16px; + min-height: 16px; +} + +.emotion-6 .fillStroke { + stroke: #3f4250; + fill: none; +} + +
+
+
+ + test +
+
+
+
+`; diff --git a/packages/ui/src/components/Chip/__tests__/index.test.tsx b/packages/ui/src/components/Chip/__tests__/index.test.tsx new file mode 100644 index 0000000000..f2eadbcaf7 --- /dev/null +++ b/packages/ui/src/components/Chip/__tests__/index.test.tsx @@ -0,0 +1,94 @@ +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { renderWithTheme, shouldMatchEmotionSnapshot } from '@utils/test' +import { describe, expect, test, vi } from 'vitest' +import { Chip } from '..' + +describe('Checkbox', () => { + test('renders correctly', () => shouldMatchEmotionSnapshot(test)) + test('renders correctly wiht icon', () => + shouldMatchEmotionSnapshot( + + {}} /> + test + , + )) + + test('renders correctly active', () => + shouldMatchEmotionSnapshot( + + test + , + )) + + test('renders correctly large', () => + shouldMatchEmotionSnapshot( + + test + , + )) + + test('renders correctly disabled', () => + shouldMatchEmotionSnapshot( + + test + , + )) + + test('renders correctly active disabled', () => + shouldMatchEmotionSnapshot( + + test + , + )) + + test('throw error when using Chip.Icon outside of Chip', () => { + expect(() => renderWithTheme()).toThrowError( + 'Chip.Icon can only be used inside a Chip component', + ) + }) + + test('renders correctly onClick', async () => { + const mockOnClick1 = vi.fn() + const mockOnClick2 = vi.fn() + const mockOnClickIcon1 = vi.fn() + const mockOnClickIcon2 = vi.fn() + + renderWithTheme( + <> + + test + + + + test Disabled + + + , + ) + + const chip = screen.getByTestId('test') + await userEvent.click(chip) + expect(mockOnClick1).toHaveBeenCalledOnce() + + const chipDisabled = screen.getByTestId('test-disabled') + await userEvent.click(chipDisabled) + expect(mockOnClick2).toHaveBeenCalledTimes(0) + + const chipIcon = screen.getByTestId('test-icon') + await userEvent.click(chipIcon) + expect(mockOnClickIcon1).toHaveBeenCalledOnce() + + const chipIconDisabled = screen.getByTestId('test-icon-disabled') + await userEvent.click(chipIconDisabled) + expect(mockOnClickIcon2).toHaveBeenCalledTimes(0) + }) +}) diff --git a/packages/ui/src/components/Chip/index.tsx b/packages/ui/src/components/Chip/index.tsx new file mode 100644 index 0000000000..a523eedeaa --- /dev/null +++ b/packages/ui/src/components/Chip/index.tsx @@ -0,0 +1,152 @@ +import styled from '@emotion/styled' +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react' +import { Stack } from '../Stack' +import { Text } from '../Text' +import { ChipContext } from './ChipContext' +import { ChipIcon } from './ChipIcon' + +const StyledContainer = styled(Stack)` + padding: ${({ theme }) => `${theme.space['0.5']} ${theme.space['2']} `}; + display: flex; + border-radius: ${({ theme }) => theme.radii.xlarge}; + width: fit-content; + background-color: ${({ theme }) => theme.colors.neutral.background}; + cursor: pointer; + border: 1px solid ${({ theme }) => theme.colors.neutral.borderWeak}; + text-align: center; + color: ${({ theme }) => theme.colors.neutral.text}; + user-select: none; + + + &[data-disabled="false"]:hover { + background-color: ${({ theme }) => theme.colors.neutral.backgroundHover}; + border-color: ${({ theme }) => theme.colors.neutral.borderStrongHover}; + color: ${({ theme }) => theme.colors.neutral.textHover}; + + } + + &[data-disabled="true"] { + background-color: ${({ theme }) => theme.colors.neutral.backgroundDisabled}; + border-color: ${({ theme }) => theme.colors.neutral.borderWeakDisabled}; + color: ${({ theme }) => theme.colors.neutral.textDisabled}; + cursor: not-allowed; + } + + &[data-active="true"]{ + background-color: ${({ theme }) => theme.colors.primary.backgroundStrong}; + border-color: ${({ theme }) => theme.colors.primary.backgroundStrong}; + color: ${({ theme }) => theme.colors.neutral.textStronger}; + + &[data-disabled="false"]:hover{ + background-color: ${({ theme }) => theme.colors.primary.backgroundStrongHover}; + border-color: ${({ theme }) => theme.colors.primary.backgroundStrongHover}; + color: ${({ theme }) => theme.colors.neutral.textStrongerHover}; + + + } + + &[data-disabled="true"] { + background-color: ${({ theme }) => theme.colors.primary.backgroundStrongDisabled}; + border: none; + } + } + + &[data-size='medium']{ + ${({ theme }) => ` + height: ${theme.space[3]}; + padding: ${theme.space['0.5']} ${theme.space['1.5']};`} + } + + &[data-size='large']{ + ${({ theme }) => ` + height: ${theme.space[4]}; + padding: ${theme.space['0.5']} ${theme.space['2']};`} + } + + &[data-trailing-icon="true"] { + padding-right: ${({ theme }) => theme.space[1]} + } + ` +type ChipType = { + children: ReactNode + size?: 'medium' | 'large' + disabled?: boolean + active?: boolean + className?: string + 'data-testid'?: string + onClick?: (active: boolean) => void +} + +/** + * Chip component is used to display a clickable status or a label in a small container + */ +export const Chip = ({ + children, + size = 'medium', + disabled = false, + active = false, + className, + 'data-testid': dataTestId, + onClick, +}: ChipType) => { + const [isActive, setIsActive] = useState(active) + const [hasTrailingIcon, setTrailingIcon] = useState(false) + const chipRef = useRef(null) // ref to the parent container + const iconRef = useRef(null) + const prominence = useMemo(() => { + if (isActive) return 'stronger' + if (disabled) return 'weak' + + return 'default' + }, [isActive, disabled]) + const value = useMemo( + () => ({ isActive, disabled, iconRef }), + [isActive, disabled, iconRef], + ) + useEffect(() => { + setIsActive(active) + }, [active]) + + useEffect(() => { + if (chipRef.current && iconRef.current) { + const lastChildNode = chipRef.current.lastChild + + // Compare the last child element with iconRef.current to check if the last element is an Icon + // This will mean that there is a trailing icon + if (lastChildNode === iconRef.current) { + setTrailingIcon(true) + } else setTrailingIcon(false) + } + }, [children, iconRef]) + + return ( + + + { + if (!disabled) { + setIsActive(!isActive) + onClick?.(!isActive) + } + }} + className={className} + data-active={isActive} + data-testid={dataTestId} + alignItems="center" + justifyContent="center" + data-disabled={disabled} + data-prominence={prominence} + direction="row" + gap={1} + ref={chipRef} + data-trailing-icon={hasTrailingIcon} + > + {children} + + + + ) +} + +Chip.Icon = ChipIcon diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 396ac18e61..78dd7355b1 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -13,6 +13,7 @@ export { Card } from './Card' export { Carousel } from './Carousel' export { Checkbox } from './Checkbox' export { CheckboxGroup, CheckboxGroupCheckbox } from './CheckboxGroup' +export { Chip } from './Chip' export { CopyButton } from './CopyButton' export { DateInput } from './DateInput' export { Dialog } from './Dialog'