Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: input component #221

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
8 changes: 8 additions & 0 deletions .changeset/input-component.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@cypress-design/react-button": patch
"@cypress-design/react-input": patch
"@cypress-design/vue-input": patch
"@cypress-design/constants-input": patch
---

feat: input component
1 change: 0 additions & 1 deletion components/Button/assertions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ export default function assertions(
'text-decoration-line',
'underline'
)
cy
})

it('renders variants', () => {
Expand Down
3 changes: 2 additions & 1 deletion components/Button/react/ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,11 @@ export default () => {
backgroundColor: variant === 'outline-dark' ? '#1a202c' : 'white',
color: variant === 'outline-dark' ? 'white' : 'black',
}}
key={variant}
>
{variant}
{Object.keys(SizeClassesTable).map((size) => (
<div key={size} className="flex gap-[8px] items-center">
<div className="flex gap-[8px] items-center" key={size}>
{size}
<Button variant={variant} size={size}>
Button
Expand Down
13 changes: 13 additions & 0 deletions components/Input/ReadMe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts" setup>
import Input from '@cypress-design/vue-input'
</script>

# Input

<DemoWrapper>
<Input/>
</DemoWrapper>

Describe your component here.

[figma::Input (to be updated)](https://www.figma.com/file/1WJ3GVQyMV5e7xVxPg3yID/Design-System%2C-v1.x---%40latest?type=design&t=vlt0Jvqi1bwqH4P0-0)
62 changes: 62 additions & 0 deletions components/Input/assertions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/// <reference types="cypress" />

export default function assertions(mountStory: (options?: any) => void): void {
it('renders disabled', () => {
mountStory({ disabled: true })
jaimefps marked this conversation as resolved.
Show resolved Hide resolved
cy.get('input').first().as('firstInput')
cy.log('disabled state should not allow text changes')
cy.get('@firstInput').should('have.value', '')
cy.get('@firstInput').type('my search')
cy.get('@firstInput').should('have.value', '')
cy.percySnapshot()
})

it('can type text', () => {
mountStory()
cy.get('input').first().as('firstInput')
cy.log('enabled state should allow text changes')
cy.get('@firstInput').should('have.value', '')
cy.get('@firstInput').type('my search')
cy.get('@firstInput').should('have.value', 'my search')
cy.percySnapshot()
})

it('calls onChange', () => {
const onChangeSpy = cy.spy().as('onChangeSpy')
mountStory({ onChange: onChangeSpy })
cy.get('input').first().as('firstInput')
cy.get('@firstInput').should('have.value', '')
cy.get('@firstInput')
.type('my search')
.get('@onChangeSpy')
.should('have.been.called')
})

it('calls onReset', () => {
const onResetSpy = cy.spy().as('onResetSpy')
mountStory({ onChange: onResetSpy })
cy.get('@onResetSpy').click().should('have.been.calledOnce')
})

it('shows search icon', () => {
mountStory({ isSearch: true })
cy.get('[data-cy="text-input--search-icon"]').should('exist')
cy.percySnapshot()
})

it('shows search results', () => {
mountStory({
searchResults: {
match: 7,
total: 124,
entity: 'specs',
},
})
cy.get('[data-cy="text-input--search-icon"]')
.should('exist')
.within(() => {
cy.should('have.text', '7 of 124 specs')
})
cy.percySnapshot()
})
}
23 changes: 23 additions & 0 deletions components/Input/constants/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@cypress-design/constants-input",
"private": true,
"version": "0.3.0",
"files": [
"*"
],
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc --project ./tsconfig.json"
},
"devDependencies": {
"@cypress-design/icon-registry": "*"
},
"license": "MIT"
}
109 changes: 109 additions & 0 deletions components/Input/constants/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as React from 'react'
import type { WindiColor } from '@cypress-design/icon-registry'

export const SharedSettings = {}

const enabledShadow =
'hover:shadow-ring-hover focus:shadow-ring-focus active:shadow-ring-focus'

export const VariantClassesTable = {
default: `text-gray-800 border-gray-100 hocus:shadow-indigo-300/[.35] hocus:border-indigo-300 ${enabledShadow}`,
active: `text-gray-800 border-indigo-300 hocus:shadow-indigo-300/[.35] hocus:border-indigo-300 ${enabledShadow}`,
valid: `text-jade-500 border-jade-300 hocus:shadow-jade-300/[.35] hocus:border-jade-300 ${enabledShadow}`,
invalid: `text-red-500 border-red-300 hocus:shadow-red-300/[.35] hocus:border-red-300 ${enabledShadow}`,
warning: `text-gray-800 border-orange-300 hocus:shadow-orange-300/[.35] hocus:border-orange-300 ${enabledShadow}`,
disabled: `text-gray-600 bg-gray-50 border-gray-100 disabled:hocus:shadow-none`,
} as const
export type InputVariants = keyof typeof VariantClassesTable

type InputClasses = {
icon: WindiColor
}

export const inputClasses: Record<InputVariants, InputClasses> = {
default: {
// <wind-keep strokeColor="gray-800" focusWithinStrokeColor="gray-800" interactiveColorsOnGroup/>
icon: 'gray-800',
},
active: {
// <wind-keep strokeColor="indigo-500" focusWithinStrokeColor="indigo-500" interactiveColorsOnGroup/>
icon: 'indigo-500',
},
valid: {
// <wind-keep strokeColor="jade-500" focusWithinStrokeColor="jade-500" interactiveColorsOnGroup/>
icon: 'jade-500',
},
invalid: {
// <wind-keep strokeColor="red-500" focusWithinStrokeColor="red-500" interactiveColorsOnGroup/>
icon: 'red-500',
},
warning: {
// <wind-keep strokeColor="gray-800" focusWithinStrokeColor="gray-800" interactiveColorsOnGroup/>
icon: 'gray-800',
},
disabled: {
// <wind-keep strokeColor="gray-600" focusWithinStrokeColor="gray-600" interactiveColorsOnGroup/>
icon: 'gray-600',
},
} as const

export const SizeClassesTable = {
'32': 'h-32px px-[12px] py-[6px] text-[14px]',
'40': 'h-40px px-[16px] py-[8px] text-[16px]',
'48': 'h-48px px-[16px] py-[12px] text-[16px]',
} as const
export type InputSizes = keyof typeof SizeClassesTable

export const StaticClasses =
'border border-solid rounded rounded-[4px] flex items-center font-medium transition duration-150 '
export const ResultStaticClass =
'whitespace-nowrap border-l-1 border-gray-100 ml-[16px] pl-[16px] '
export const StaticInputClasses = 'clear-none w-[100%] '
export const IconStaticClasses = 'mr-[8px] min-w-[16px] '
export const ResetStaticClasses = 'ml-[8px] '

export const DefaultVariant: keyof typeof VariantClassesTable = 'default'
export const DefaultSize: keyof typeof SizeClassesTable = '40'

export interface InputProps {
/**package
* Visual variant to display the button;
* It will pick colors for font, background and border.
*/
variant?: keyof typeof VariantClassesTable
/**
* Size (height) of the button (in pixels)
*/
size?: InputSizes
/**
* Is the button clickable and active?
* Note that `variant="disabled"` will also set this
*/
disabled?: boolean
/**
* Replace the default left icon
*/
customIcon?: React.FC<React.SVGProps<SVGSVGElement>>
/**
* Change handler for the text input field.
*/
onChange?: React.ChangeEventHandler<HTMLInputElement>
/**
* Click handler for the reset button.
* Button is only displayed when this is defined.
*/
onReset?: React.MouseEventHandler<HTMLButtonElement>
/**
* Whether the input is meant for search
* and to auto-include the MagnifyingGlass icon.
*/
isSearch?: boolean
/**
* Optional details for search results.
*/
searchResults?: {
entity: string
match: number
total: number
}
}
12 changes: 12 additions & 0 deletions components/Input/constants/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.json",
"include": ["src/*.ts"],
"compilerOptions": {
"rootDir": "./src",
"noEmit": false,
"declaration": true,
"declarationMap": true,
"outDir": "dist",
"types": []
}
}
1 change: 1 addition & 0 deletions components/Input/react/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @cypress-design/react-input
7 changes: 7 additions & 0 deletions components/Input/react/Input.rootstory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as React from 'react'
import Input from './Input'

export default (options: { id?: string }) => {
const { id = 'foo', ...rest } = options
return <Input id={id} {...rest} />
}
125 changes: 125 additions & 0 deletions components/Input/react/Input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import * as React from 'react'
import clsx from 'clsx'
import {
InputProps,
DefaultSize,
DefaultVariant,
SizeClassesTable,
VariantClassesTable,
IconStaticClasses,
ResetStaticClasses,
ResultStaticClass,
StaticClasses,
inputClasses,
StaticInputClasses,
} from '@cypress-design/constants-input'
import {
IconObjectMagnifyingGlass,
IconActionDeleteLarge,
} from '@cypress-design/react-icon'

export interface InputPropsJsx extends InputProps {
id: string
elevatebart marked this conversation as resolved.
Show resolved Hide resolved
value?: string
className?: string
}

type ReactInputProps = InputPropsJsx & React.HTMLProps<HTMLInputElement>

// tbd: what is a better way of allowing the <input/>
// to propagate its focus state to siblings?
const usePropagateFocusProps = () => {
const [hasFocus, setHasFocus] = React.useState(false)
const enableFocusStyle = React.useCallback(() => setHasFocus(true), [])
const disableFocusStyle = React.useCallback(() => setHasFocus(false), [])
const propagateFocusProps = React.useMemo(() => {
return {
onFocus: enableFocusStyle,
onMouseEnter: enableFocusStyle,
onMouseOver: enableFocusStyle,
onBlur: disableFocusStyle,
onMouseLeave: disableFocusStyle,
onMouseOut: disableFocusStyle,
}
}, [enableFocusStyle, disableFocusStyle])
return {
propagateFocusProps,
hasFocus,
}
}
Comment on lines +31 to +49
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changing the style on focus is usually easier to build using tools like focus-within.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an example of it in a react component at this time, it seems you've only done it in vue atm?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is actually pure css... I'll modify this with an example.


export const Input: React.FC<ReactInputProps> = ({
id,
className,
customIcon,
isSearch,
searchResults,
size = DefaultSize,
variant = DefaultVariant,
onReset,
onChange,
placeholder,
disabled,
value,
...rest
}) => {
const { propagateFocusProps, hasFocus } = usePropagateFocusProps()

const finalIsDisabled = disabled || variant === 'disabled'
const finalVariant = finalIsDisabled ? 'disabled' : variant

const variantClasses = inputClasses[finalVariant] ?? {}

const iconStrokeColor =
finalVariant !== 'default'
? variantClasses.icon
: hasFocus
? inputClasses.active.icon
: inputClasses.default.icon

const Icon = customIcon ?? isSearch ? IconObjectMagnifyingGlass : null

return (
<div
{...rest}
id={id}
className={clsx(
StaticClasses,
VariantClassesTable[finalVariant],
SizeClassesTable[size],
className
)}
>
{Icon && (
<Icon
className={IconStaticClasses}
strokeColor={iconStrokeColor}
data-cy="text-input--search-icon"
/>
)}
<input
// unclear how to prevent the native [X] from showing
// with tailwind, so only using "text" for now:
type="text" // tbd: support "search"
value={value}
disabled={finalIsDisabled}
onChange={onChange}
placeholder={placeholder}
className={StaticInputClasses}
{...propagateFocusProps}
/>
{onReset && (
<button type="button" className={ResetStaticClasses} onClick={onReset}>
<IconActionDeleteLarge strokeColor={iconStrokeColor} />
</button>
)}
{searchResults && (
<p data-cy="text-input--search-results" className={ResultStaticClass}>
{searchResults.match} of {searchResults.total} {searchResults.entity}
</p>
)}
</div>
)
}

export default Input
Loading