Fix Visuals of The Selector Component (#11628)

This PR fixes a bug when Selector component has wrong visuals when selected

Also, this PR fixes visual inconsistencies and bugs in this component.
This commit is contained in:
Sergei Garin 2024-11-22 17:03:41 +03:00 committed by GitHub
parent 7c5e69219c
commit 8891051475
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 244 additions and 24 deletions

View File

@ -21,12 +21,30 @@ const sharedConfig: Partial<ReactStorybookConfig> = {
env: { FRAMEWORK: framework },
previewHead: (head) => {
return `
return /*html*/ `
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=M+PLUS+1:wght@300;400;500;600;700&display=swap"
rel="preload"
as="style"
crossorigin
/>
<script>
window.global = window;
// Pass environment variables to the storybook
window.ENV = {
// The framework used to render the story
// Used by the preview to determine which framework to use
FRAMEWORK: '${framework}',
}
// Allow React DevTools to work in Storybook
if (window.parent !== window) {
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = window.parent.__REACT_DEVTOOLS_GLOBAL_HOOK__
}
</script>
${head}
`

View File

@ -9,6 +9,8 @@ import { useLayoutEffect, useState } from 'react'
import invariant from 'tiny-invariant'
import UIProviders from '../src/dashboard/components/UIProviders'
import { QueryClientProvider } from '@tanstack/react-query'
import { createQueryClient } from 'enso-common/src/queryClient'
import { MotionGlobalConfig } from 'framer-motion'
import z from 'zod'
import '../src/dashboard/tailwind.css'
@ -68,6 +70,11 @@ const reactPreview: ReactPreview = {
<div id="enso-portal-root" className="enso-portal-root" />
</>
),
(Story, context) => {
const [queryClient] = useState(() => createQueryClient())
return <QueryClientProvider client={queryClient}>{Story(context)}</QueryClientProvider>
},
],
}

View File

@ -114,7 +114,7 @@ AnimatedBackground.Item = memo(function AnimatedBackgroundItem(props: AnimatedBa
const isActive = isSelected ?? activeValue === value
return (
<div className={twJoin('relative', className)}>
<div className={twJoin('relative *:isolate', className)}>
<AnimatedBackgroundItemUnderlay
isActive={isActive}
underlayElement={underlayElement}
@ -122,7 +122,7 @@ AnimatedBackground.Item = memo(function AnimatedBackgroundItem(props: AnimatedBa
transition={transition}
/>
<div className="isolate contents">{children}</div>
<div className="isolate contents *:isolate">{children}</div>
</div>
)
})

View File

@ -0,0 +1,37 @@
import type { Meta, StoryObj } from '@storybook/react'
import { userEvent, within } from '@storybook/test'
import { z } from 'zod'
import { Form } from '../../Form'
import type { MultiSelectorProps } from './MultiSelector.tsx'
import { MultiSelector } from './MultiSelector.tsx'
type Props = MultiSelectorProps<typeof schema, 'value'>
type Story = StoryObj<Props>
const schema = z.object({ value: z.array(z.enum(['one', 'two', 'three'])) })
export default {
title: 'Components/AriaComponents/Inputs/MultiSelector',
component: MultiSelector,
render: (args) => <MultiSelector {...args} />,
tags: ['autodocs'],
decorators: [(Story, context) => <Form schema={schema}>{Story(context)}</Form>],
args: { name: 'value', items: ['one', 'two', 'three'] },
} as Meta<Props>
export const Default: Story = {}
export const TwoColumns: Story = { args: { columns: 2 } }
export const SelectedItems: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByText('one'))
await userEvent.click(canvas.getByText('two'))
await userEvent.click(canvas.getByText('three'))
await userEvent.click(canvas.getByText('one'))
},
}

View File

@ -0,0 +1,84 @@
import type { Meta, StoryObj } from '@storybook/react'
import { z } from 'zod'
import { Form } from '#/components/AriaComponents'
import { userEvent, within } from '@storybook/test'
import type { SelectorProps } from './Selector'
import { Selector } from './Selector'
// Schema for our form
const schema = z.object({
plan: z.enum(['basic', 'pro', 'enterprise']),
})
type Props = SelectorProps<typeof schema, 'plan'>
export default {
title: 'Components/AriaComponents/Inputs/Selector',
component: Selector,
parameters: {
layout: 'centered',
},
render: (args) => <Selector {...args} />,
args: {
name: 'plan',
items: ['basic', 'pro', 'enterprise'],
},
decorators: [
(Story, context) => (
<Form schema={schema} className="w-96" defaultValues={{ plan: 'basic' }}>
{Story(context)}
</Form>
),
],
} as Meta<Props>
type Story = StoryObj<Props>
// Basic usage
export const Default: Story = {}
// Different rounded variants
export const VisualVariants: Story = {
render: (args) => {
return (
<div className="w-full space-y-12">
<div className="w-full space-y-2">
{(['outline'] as const).map((variant) => (
<Selector {...args} key={variant} label={variant} variant={variant} />
))}
</div>
<div className="w-full space-y-2">
{(['medium', 'small'] as const).map((size) => (
<Selector {...args} key={size} label={size} size={size} />
))}
</div>
<div className="w-full space-y-2">
{(
['medium', 'xxxlarge', 'none', 'small', 'large', 'xlarge', 'xxlarge', 'full'] as const
).map((rounded) => (
<Selector {...args} key={rounded} label={rounded} rounded={rounded} />
))}
</div>
<div className="w-full space-y-2">
<Selector {...args} label="Invalid" isInvalid />
<Selector {...args} label="Required" isRequired />
<Selector {...args} label="Disabled" isDisabled />
<Selector {...args} label="Readonly" isReadOnly />
<Selector {...args} label="Invalid & Disabled" isInvalid isDisabled />
</div>
</div>
)
},
}
export const Interactions: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement)
await userEvent.click(canvas.getByText('enterprise'))
},
}

View File

@ -49,6 +49,7 @@ export const SELECTOR_STYLES = tv({
readOnly: { true: 'cursor-default' },
size: {
medium: { base: '' },
small: { base: '' },
},
rounded: {
none: 'rounded-none',
@ -92,6 +93,7 @@ export const Selector = forwardRef(function Selector<
label,
size,
rounded,
variant,
isRequired = false,
isInvalid = false,
fieldVariants,
@ -108,6 +110,7 @@ export const Selector = forwardRef(function Selector<
rounded,
readOnly: inputProps.readOnly,
disabled: isDisabled || formInstance.formState.isSubmitting,
variant,
})
return (
@ -157,7 +160,14 @@ export const Selector = forwardRef(function Selector<
>
<AnimatedBackground value={String(items.indexOf(value))}>
{items.map((item, i) => (
<SelectorOption key={i} value={String(i)} label={children(item)} />
<SelectorOption
key={i}
value={String(i)}
label={children(item)}
rounded={rounded}
size={size}
variant={variant}
/>
))}
</AnimatedBackground>
</RadioGroup>

View File

@ -16,7 +16,7 @@ export interface SelectorOptionProps
}
export const SELECTOR_OPTION_STYLES = tv({
base: 'flex flex-1 w-full min-h-8 cursor-pointer',
base: 'flex flex-1 w-full cursor-pointer',
variants: {
rounded: {
// specified in compoundSlots
@ -30,69 +30,126 @@ export const SELECTOR_OPTION_STYLES = tv({
full: '',
},
size: {
medium: { radio: 'px-[9px] py-[3.5px]' },
small: { radio: 'px-[7px] py-[1.5px]' },
medium: { base: 'min-h-[31px]', radio: 'px-[9px] py-[3.5px]' },
small: { base: 'min-h-6', radio: 'px-[7px] py-[1.5px]' },
},
variant: {
primary: {
isHovered: {
true: { radio: '' },
false: { radio: '' },
},
isSelected: {
// specified in compoundVariants
true: { radio: '' },
false: { radio: '' },
},
isFocusVisible: {
// specified in compoundVariants
true: {
radio:
'overflow-clip outline outline-2 outline-transparent outline-offset-[-2px] [&:not(:selected)]:bg-primary/5 selected:text-white pressed:bg-primary/10 focus-visible:outline-primary focus-visible:outline-offset-0',
'outline outline-2 outline-transparent outline-offset-[-6px] focus-visible:outline-primary focus-visible:outline-offset-[2px] transition-[outline-offset] duration-200',
},
false: { radio: '' },
},
isPressed: {
// specified in compoundVariants
true: { radio: '' },
false: { radio: '' },
},
variant: {
// specified in compoundVariants
outline: {
base: '',
},
},
},
slots: {
animation: 'bg-primary',
radio: TEXT_STYLE({
className: 'flex flex-1 w-full items-center justify-center transition-colors duration-200',
className:
'relative flex flex-1 w-full items-center justify-center transition-colors duration-200',
variant: 'body',
}),
hover: 'absolute inset-x-0 inset-y-0 transition-colors duration-200',
},
compoundSlots: [
{
slots: ['radio', 'animation', 'base'],
slots: ['radio', 'animation', 'base', 'hover'],
rounded: 'none',
class: 'rounded-none',
},
{
slots: ['radio', 'animation', 'base'],
slots: ['radio', 'animation', 'base', 'hover'],
rounded: 'small',
class: 'rounded-sm',
},
{
slots: ['radio', 'animation', 'base'],
slots: ['radio', 'animation', 'base', 'hover'],
rounded: 'medium',
class: 'rounded-md',
},
{
slots: ['radio', 'animation', 'base'],
slots: ['radio', 'animation', 'base', 'hover'],
rounded: 'large',
class: 'rounded-lg',
},
{
slots: ['radio', 'animation', 'base'],
slots: ['radio', 'animation', 'base', 'hover'],
rounded: 'xlarge',
class: 'rounded-xl',
},
{
slots: ['radio', 'animation', 'base'],
slots: ['radio', 'animation', 'base', 'hover'],
rounded: 'xxlarge',
class: 'rounded-2xl',
},
{
slots: ['radio', 'animation', 'base'],
slots: ['radio', 'animation', 'base', 'hover'],
rounded: 'xxxlarge',
class: 'rounded-3xl',
},
{
slots: ['radio', 'animation', 'base'],
slots: ['radio', 'animation', 'base', 'hover'],
rounded: 'full',
class: 'rounded-full',
},
],
compoundVariants: [
{
variant: 'outline',
isSelected: true,
class: { radio: TEXT_STYLE({ variant: 'body', color: 'invert' }) },
},
{
variant: 'outline',
isHovered: true,
isSelected: false,
class: { hover: 'bg-primary/5' },
},
{
variant: 'outline',
isPressed: true,
class: { hover: 'bg-primary/10' },
},
{
variant: 'outline',
isSelected: false,
class: { radio: TEXT_STYLE({ variant: 'body', color: 'primary' }) },
},
{
size: 'small',
class: { hover: 'inset-[2px]' },
},
{
size: 'medium',
class: { hover: 'inset-[3px]' },
},
],
defaultVariants: {
size: 'medium',
rounded: 'xxxlarge',
variant: 'primary',
variant: 'outline',
},
})
@ -124,13 +181,19 @@ export const SelectorOption = memo(
ref={ref}
{...radioProps}
value={value}
className={(renderProps) =>
styles.radio({
className={(renderProps) => {
return styles.radio({
className: typeof className === 'function' ? className(renderProps) : className,
...renderProps,
})
}
}}
>
{label}
{({ isHovered, isSelected, isPressed }) => (
<>
{label}
<div className={styles.hover({ isHovered, isSelected, isPressed })} />
</>
)}
</Radio>
</AnimatedBackground.Item>
)

View File

@ -565,6 +565,7 @@ export default [
'jsdoc/require-file-overview': 'off',
'@typescript-eslint/no-magic-numbers': 'off',
'@typescript-eslint/unbound-method': 'off',
'@typescript-eslint/naming-convention': 'off',
},
},
]