Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions src/shared/composites/Topbar/Topbar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Topbar } from './Topbar'
import { AvatarMenu } from '@/shared/composites/AvatarMenu/AvatarMenu'

import { Button } from '@/shared/primitives/Button'
import { Input } from '@/shared/primitives/Input'

const meta = {
title: 'Shared/Composites/Topbar',
Expand Down Expand Up @@ -41,14 +42,14 @@ export const WithSearch: Story = {
title: 'Moderation Queue',

searchSlot: (
<div className="border-border-secondary bg-bg-secondary flex items-center gap-2 rounded-md border px-3 py-2">
<MagnifyingGlassIcon className="text-text-tertiary size-4" />

<input
placeholder="Search reports..."
className="border-border-secondary text-text-secondary w-full border-2 bg-transparent text-sm outline-none"
/>
</div>
<Input
type="search"
value=""
onChange={() => {}}
placeholder={'Search...'}
prefixIcon={<MagnifyingGlassIcon className="text-text-tertiary size-4" />}
className="py-2"
/>
),

actionsSlot: <AvatarMenu name="Admin Mod" />,
Expand Down
21 changes: 12 additions & 9 deletions src/shared/composites/Topbar/Topbar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from 'react'
import React, { useState } from 'react'

import { MagnifyingGlassIcon } from '@heroicons/react/24/solid'
import { Input } from '@/shared/primitives/Input'

export interface TopbarProps {
title: string
Expand Down Expand Up @@ -29,6 +30,7 @@ export function Topbar({
searchPlaceholder,
className = '',
}: TopbarProps) {
const [searchQuery, overwriteSearchQuery] = useState('')
return (
<header
className={`border-border-secondary bg-bg-secondary sticky top-0 z-20 flex h-16 items-center justify-between border-b px-4 md:px-6 ${className} `}
Expand All @@ -51,14 +53,15 @@ export function Topbar({
<div className="hidden flex-1 items-center px-6 md:flex md:justify-center">
<div className="w-full max-w-xl">
{searchSlot || (
<div className="border-border-secondary bg-bg-secondary flex items-center gap-2 rounded-md border px-3 py-2">
<MagnifyingGlassIcon className="text-text-tertiary size-4 shrink-0" />

<input
placeholder={searchPlaceholder || 'Search...'}
className="w-full bg-transparent text-sm outline-none"
/>
</div>
<Input
type="search"
value={searchQuery}
onChange={(e) => {
overwriteSearchQuery(e)
}}
placeholder={searchPlaceholder || 'Search...'}
prefixIcon={<MagnifyingGlassIcon className="text-text-tertiary size-4" />}
/>
)}
</div>
</div>
Expand Down
114 changes: 114 additions & 0 deletions src/shared/primitives/Input/Input.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { Meta, StoryObj } from '@storybook/react-vite'

import { useState } from 'react'

import { MagnifyingGlassIcon, EyeIcon } from '@heroicons/react/24/outline'

import { Input, type InputProps } from './Input'

const meta: Meta<typeof Input> = {
title: 'Shared/Primitives/Input',

component: Input,

tags: ['autodocs'],

decorators: [
(Story) => (
<div className="px-3 py-2">
<Story />
</div>
),
],
}

export default meta

type Story = StoryObj<typeof meta>

function StatefulInput(args: InputProps) {
const [value, setValue] = useState('')

return (
<div className="w-full max-w-md p-6">
<Input {...args} value={value} onChange={setValue} />
</div>
)
}

export const Default: Story = {
render: (args) => <StatefulInput {...args} />,

args: {
label: 'Username',
placeholder: 'Enter username',
},
}

export const Error: Story = {
render: (args) => <StatefulInput {...args} />,

args: {
label: 'Email',

placeholder: 'Enter email',

error: 'Invalid email address',
},
}

export const Disabled: Story = {
render: (args) => <StatefulInput {...args} />,

args: {
label: 'Disabled',

value: 'Readonly',

isDisabled: true,
},
}

export const WithIcons: Story = {
render: (args) => <StatefulInput {...args} />,

args: {
label: 'Password',

type: 'password',

placeholder: 'Enter password',

prefixIcon: <MagnifyingGlassIcon className="size-4" />,

suffixIcon: <EyeIcon className="size-4" />,
},
}

export const Search: Story = {
render: (args) => <StatefulInput {...args} />,

args: {
type: 'search',

placeholder: 'Search reports...',

prefixIcon: <MagnifyingGlassIcon className="size-4" />,
},
}

export const Focus: Story = {
render: (args) => <StatefulInput {...args} />,

args: {
label: 'Focused Input',

placeholder: 'Click to focus',
},

play: async ({ canvasElement }) => {
const input = canvasElement.querySelector('input')

input?.focus()
},
}
188 changes: 188 additions & 0 deletions src/shared/primitives/Input/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import React, { useId } from 'react'

import { cva } from 'class-variance-authority'

export interface InputProps {
label?: string

placeholder?: string

value: string

onChange: (value: string) => void

type?: 'text' | 'email' | 'password' | 'search' | 'number'

error?: string | null

helperText?: string

prefixIcon?: React.ReactNode

suffixIcon?: React.ReactNode

isDisabled?: boolean

isReadOnly?: boolean

autoComplete?: string

testId?: string

className?: string
}

const inputStyles = cva(
`
w-full
rounded-md
border
bg-bg-secondary
py-2
text-sm
text-text-primary
transition-colors
outline-none
placeholder:text-text-tertiary
`,
{
variants: {
hasError: {
true: `
border-border-danger
focus:border-border-danger
`,

false: `
border-border-secondary
focus:border-border-primary
focus:ring-0
focus:outline-none
`,
},

disabled: {
true: `
cursor-not-allowed
opacity-50
disabled:pointer-events-none
`,

false: '',
},

hasPrefix: {
true: 'pl-10',

false: 'pl-3',
},

hasSuffix: {
true: 'pr-10',

false: 'pr-3',
},
},

defaultVariants: {
hasError: false,
disabled: false,
hasPrefix: false,
hasSuffix: false,
},
}
)

export function Input({
label,

placeholder,

value,

onChange,

type = 'text',

error = null,

helperText,

prefixIcon,

suffixIcon,

isDisabled = false,

isReadOnly = false,

autoComplete,

testId,

className = '',
}: InputProps) {
const id = useId()

return (
<div className="flex w-full flex-col gap-1.5">
{label && (
<label htmlFor={id} className="text-text-primary text-sm font-medium">
{label}
</label>
)}

<div className="relative w-full">
{prefixIcon && (
<div
className={`text-text-tertiary pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 transition-opacity ${
isDisabled ? 'opacity-50' : ''
} `}
>
{prefixIcon}
</div>
)}

<input
id={id}
type={type}
value={value}
placeholder={placeholder}
disabled={isDisabled}
readOnly={isReadOnly}
autoComplete={autoComplete}
data-testid={testId}
aria-invalid={Boolean(error)}
onChange={(e) => {
onChange(e.target.value)
}}
className={inputStyles({
hasError: Boolean(error),
disabled: isDisabled,
hasPrefix: Boolean(prefixIcon),
hasSuffix: Boolean(suffixIcon),
className,
})}
/>

{suffixIcon && (
<div
className={`text-text-tertiary absolute top-1/2 right-3 -translate-y-1/2 transition-opacity ${
isDisabled ? 'opacity-50' : ''
} `}
>
{suffixIcon}
</div>
)}
</div>

{error ? (
<p className="text-text-danger text-xs">{error}</p>
) : helperText ? (
<p className="text-text-secondary text-xs">{helperText}</p>
) : null}
</div>
)
}

export default Input
3 changes: 3 additions & 0 deletions src/shared/primitives/Input/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { Input } from './Input'

export type { InputProps } from './Input'
Loading