diff --git a/src/components/TextInputFloating/TextInputFloating.Overview.stories.mdx b/src/components/TextInputFloating/TextInputFloating.Overview.stories.mdx new file mode 100644 index 000000000..f80d60ce1 --- /dev/null +++ b/src/components/TextInputFloating/TextInputFloating.Overview.stories.mdx @@ -0,0 +1,295 @@ +import { useState } from 'react'; +import { action } from '@storybook/addon-actions'; +import { Meta, Story, Canvas, ArgsTable } from '@storybook/addon-docs'; +import { TextInputFloating } from './TextInputFloating'; +import { Box } from '../Box/Box'; +import { Icon } from '../Icon/Icon'; + + + +# TextInputFloating + +Use `TextInputFloating` to create a floating label input field. It is a controlled component, which means that you need to provide a value and an `onChange` handler to update the value. + + + + {() => { + const [value, setValue] = useState(''); + const [valuePw, setValuePw] = useState(''); + return ( + + setValue(event.target.value)} + /> + setValuePw(event.target.value)} + helpText="Password must be at least 8 characters long" + /> + + ); + }} + + + +## Props + + + +### Required + +Use the `isRequired` prop to set the `required` and `aria-required` on the underlying input element. + + + + {() => { + const [value, setValue] = useState(''); + return ( + setValue(event.target.value)} + isRequired + /> + ); + }} + + + +Customize the required indicator with the `requiredIndicator` prop. + + + + {() => { + const [value, setValue] = useState(''); + return ( + setValue(event.target.value)} + isRequired + requiredIndicator=" (required)" + /> + ); + }} + + + +### Help Text + +Use `helpText` to provide additional information about the input field. + + + + {() => { + const [value, setValue] = useState(''); + return ( + setValue(event.target.value)} + helpText="Must be from the approved vendor list" + /> + ); + }} + + + +### Validation Error + +Use the `error` prop to mark the input as invalid. `error` accepts a `boolean`, `string`, or `node`. + + + + {() => { + const [value, setValue] = useState(''); + const [value2, setValue2] = useState('Invalid Value'); + return ( + + setValue(event.target.value)} + isRequired + /> + setValue2(event.target.value)} + /> + + ); + }} + + + +### Disabled + +Add `isDisabled`to give it a grayed out appearance, remove pointer events, and prevent focusing. + + + + {() => { + const [value, setValue] = useState(''); + const [value2, setValue2] = useState('Value'); + return ( + + setValue(event.target.value)} + isDisabled + /> + setValue2(event.target.value)} + isDisabled + /> + + ); + }} + + + +### Clearable + +Use the `onClear` prop to display a clear icon (x) when the input has a value. `onClear` will fire +a callback function when the clear icon is clicked, which can then be handled to clear the value. + + + + {() => { + const [value, setValue] = useState('clear me'); + return ( + setValue(event.target.value)} + onClear={event => setValue('')} + isRequired + /> + ); + }} + + + +### Prefix and Suffix + +All that is required to render a basic version of the TextInput is a unique `id`, `label`, `value`, and an onchange event handler passed to the `onChange` prop. + + + + {() => { + const [prefixValue0, setPrefixValue0] = useState(''); + const [prefixValue1, setPrefixValue1] = useState('palmettosolar'); + const [prefixValue2, setPrefixValue2] = useState('2.51'); + const [prefixValue3, setPrefixValue3] = useState(''); + const [prefixValue4, setPrefixValue4] = useState('Pre-populated Value'); + return ( + + setPrefixValue0(event.target.value)} + prefix="https://" + placeholder="Enter your website address" + /> + setPrefixValue1(event.target.value)} + prefix="@" + /> + setPrefixValue2(event.target.value)} + prefix="$" + suffix="/watt" + /> + setPrefixValue3(event.target.value)} + suffix={} + /> + setPrefixValue4(event.target.value)} + onClear={event => setPrefixValue4('')} + suffix={} + /> + + ); + }} + + + +### Sizes + +There are only two sizes for TextInputFloating, `md` and `lg`, with `md` being the default. + + + + {() => { + const [value1, setValue1] = useState(''); + const [value2, setValue2] = useState(''); + return ( + + setValue1(event.target.value)} + /> + setValue2(event.target.value)} + /> + + ); + }} + + + +## Component Design Tokens + +This component shares component design tokens with all form controls. For a complete list of tokens, see the [Theming Form Controls documentation](/docs/theming-form-controls--custom-theme-form). diff --git a/src/components/TextInputFloating/TextInputFloating.Playground.stories.tsx b/src/components/TextInputFloating/TextInputFloating.Playground.stories.tsx new file mode 100644 index 000000000..c24ea0a22 --- /dev/null +++ b/src/components/TextInputFloating/TextInputFloating.Playground.stories.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { Meta, Story } from '@storybook/react/types-6-0'; +import { TextInputFloating, TextInputFloatingProps } from './TextInputFloating'; + +export default { + title: 'Components/TextInputFloating/Playground', + component: TextInputFloating, + argTypes: { + autoComplete: { + control: 'boolean', + }, + id: { + control: 'text', + }, + label: { + control: 'text', + }, + name: { + control: 'text', + }, + value: { + control: 'text', + }, + autoFocus: { + control: 'boolean', + }, + error: { + control: 'text', + }, + helpText: { + control: 'text', + }, + hideLabel: { + control: 'boolean', + }, + isClearable: { + control: 'boolean', + }, + isDisabled: { + control: 'boolean', + }, + isRequired: { + control: 'boolean', + }, + className: { + control: 'text', + }, + placeholder: { + control: 'text', + }, + prefix: { + control: 'text', + }, + suffix: { + control: 'text', + }, + maxLength: { + control: 'number', + }, + size: { + control: { + type: 'radio', + options: ['md', 'lg'], + }, + }, + requiredIndicator: { + control: 'text', + }, + type: { + control: { + type: 'select', + options: ['text', 'password', 'email', 'tel', 'url', 'search'], + }, + }, + }, +} as Meta; + +const Template: Story = ({ ...args }) => ( + +); + +export const Playground = Template.bind({}); +Playground.args = { + id: 'playgroundTextInputFloating ', + label: 'Playground TextInputFloating ', + helpText: 'Helpful text', + name: 'playgroundTextInputFloating ', +}; diff --git a/src/components/TextInputFloating/TextInputFloating.VisualTest.stories.tsx b/src/components/TextInputFloating/TextInputFloating.VisualTest.stories.tsx new file mode 100644 index 000000000..96c4af67a --- /dev/null +++ b/src/components/TextInputFloating/TextInputFloating.VisualTest.stories.tsx @@ -0,0 +1,140 @@ +import React, { ReactElement, useState } from 'react'; +import { Meta, Story } from '@storybook/react/types-6-0'; +import { within } from '@storybook/testing-library'; +import { TextInputFloating, TextInputFloatingProps } from './TextInputFloating'; +import { Icon } from '../Icon/Icon'; +import { Box } from '../Box/Box'; +import { RESPONSIVE_STORY } from '../../docs/constants'; + +export default { + title: 'Components/TextInputFloating/Visual Regression Tests', + component: TextInputFloating, +} as Meta; + +const Template: Story = args => ( + {}} // eslint-disable-line + /> +); + +export const DefaultFocus = Template.bind({}); + +DefaultFocus.args = { + label: 'Default Focus', +}; + +DefaultFocus.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + canvas.getByLabelText('Default Focus').focus(); +}; + +export const ErrorFocus = Template.bind({}); + +ErrorFocus.args = { + label: 'Error Focus', + error: 'validation message', +}; + +ErrorFocus.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + canvas.getByLabelText('Error Focus').focus(); +}; + +export const ResponsiveSize = Template.bind({}); +ResponsiveSize.args = { + size: { + base: 'md', + tablet: 'lg', + desktop: 'md', + hd: 'lg', + }, + label: 'label', + value: 'responsive', + suffix: '00', + prefix: '$', +}; +ResponsiveSize.parameters = RESPONSIVE_STORY; + +export const PrefixSuffixSizes: React.FC = (): ReactElement => { + const [prefixValue, setPrefixValue] = useState('palmettosolar'); + const [prefixValue2, setPrefixValue2] = useState(''); + const [prefixValue3, setPrefixValue3] = useState(''); + const [prefixValue4, setPrefixValue4] = useState('Pre-populated Value'); + return ( + + + ) => setPrefixValue(event.target.value)} + prefix="@" + /> + ) => setPrefixValue2(event.target.value)} + prefix="$" + suffix=".99" + /> + ) => setPrefixValue3(event.target.value)} + suffix={} + /> + ) => setPrefixValue4(event.target.value)} + onClear={() => setPrefixValue4('')} + suffix={} + /> + + + ) => setPrefixValue(event.target.value)} + prefix="@" + size="lg" + /> + ) => setPrefixValue2(event.target.value)} + prefix="$" + suffix=".99" + size="lg" + /> + ) => setPrefixValue3(event.target.value)} + suffix={} + size="lg" + /> + ) => setPrefixValue4(event.target.value)} + onClear={() => setPrefixValue4('')} + suffix={} + size="lg" + /> + + + ); +}; diff --git a/src/components/TextInputFloating/TextInputFloating.module.scss b/src/components/TextInputFloating/TextInputFloating.module.scss new file mode 100644 index 000000000..9f9a5d5c8 --- /dev/null +++ b/src/components/TextInputFloating/TextInputFloating.module.scss @@ -0,0 +1,440 @@ +@import '~@palmetto/palmetto-design-tokens/build/scss/variables-size'; + +@mixin floating-label-minimize-md { + opacity: 0.75; + transform: scale(0.6875) translateY(-0.3rem) translateX(0.15rem); +} + +@mixin floating-label-minimize-lg { + opacity: 0.75; + transform: scale(0.75) translateY(-0.3rem) translateX(0.15rem); +} + +@mixin size-md { + border-radius: var( + --form-control-size-md-border-radius, + var(--INTERNAL_form-control-size-md-border-radius) + ); + font-size: var( + --form-control-size-sm-font-size, + var(--INTERNAL_form-control-size-sm-font-size) + ); + + .text-input-label { + padding: var( + --form-control-size-md-padding, + var(--INTERNAL_form-control-size-md-padding) + ); + } + + input { + padding: calc( + var( + --form-control-size-lg-padding, + var(--INTERNAL_form-control-size-lg-padding) + ) + 3px + ) + var( + --form-control-size-md-padding, + var(--INTERNAL_form-control-size-md-padding) + ) + calc( + var( + --form-control-size-md-padding, + var(--INTERNAL_form-control-size-md-padding) + ) - 5px + ) + var( + --form-control-size-md-padding, + var(--INTERNAL_form-control-size-md-padding) + ); + border-radius: var( + --form-control-size-md-border-radius, + var(--INTERNAL_form-control-size-md-border-radius) + ); + + &:focus { + + .text-input-label { + @include floating-label-minimize-md; + } + } + + &:not(:placeholder-shown) ~ label { + @include floating-label-minimize-md; + } + } + + .prefix { + padding: var( + --form-control-size-lg-padding, + var(--INTERNAL_form-control-size-md-padding) + ); + } + + .suffix { + padding: var( + --form-control-size-md-padding, + var(--INTERNAL_form-control-size-md-padding) + ); + } + + .clear-button { + padding: var( + --form-control-size-md-padding, + var(--INTERNAL_form-control-size-md-padding) + ); + } +} + +%size-md { + @include size-md; +} + +.size-md { + @extend %size-md; +} + +@mixin size-lg { + border-radius: var( + --form-control-size-lg-border-radius, + var(--INTERNAL_form-control-size-lg-border-radius) + ); + font-size: var( + --form-control-size-md-font-size, + var(--INTERNAL_form-control-size-md-font-size) + ); + + .text-input-label { + padding: var( + --form-control-size-lg-padding, + var(--INTERNAL_form-control-size-lg-padding) + ); + } + + input { + padding: calc( + var( + --form-control-size-lg-padding, + var(--INTERNAL_form-control-size-lg-padding) + ) + 10px + ) + var( + --form-control-size-lg-padding, + var(--INTERNAL_form-control-size-lg-padding) + ) + calc( + var( + --form-control-size-lg-padding, + var(--INTERNAL_form-control-size-lg-padding) + ) - 5px + ) + var( + --form-control-size-lg-padding, + var(--INTERNAL_form-control-size-lg-padding) + ); + border-radius: var( + --form-control-size-lg-border-radius, + var(--INTERNAL_form-control-size-lg-border-radius) + ); + + &:focus { + + .text-input-label { + @include floating-label-minimize-lg; + } + } + + &:not(:placeholder-shown) ~ label { + @include floating-label-minimize-lg; + } + } + + .prefix { + padding: var( + --form-control-size-lg-padding, + var(--INTERNAL_form-control-size-lg-padding) + ); + } + + .suffix { + padding: var( + --form-control-size-lg-padding, + var(--INTERNAL_form-control-size-lg-padding) + ); + } + + .clear-button { + padding: var( + --form-control-size-lg-padding, + var(--INTERNAL_form-control-size-lg-padding) + ) + var( + --form-control-size-lg-padding, + var(--INTERNAL_form-control-size-lg-padding) + ) + var( + --form-control-size-lg-padding, + var(--INTERNAL_form-control-size-lg-padding) + ) + 0; + } +} + +%size-lg { + @include size-lg; +} + +.size-lg { + @extend %size-lg; +} + +// https://stackoverflow.com/questions/262158/disabled-input-text-color-on-ios +%disabled-base { + background-color: var( + --form-control-background-color-disabled, + var(--INTERNAL_form-control-background-color-disabled) + ); + color: var( + --form-control-font-color-disabled, + var(--INTERNAL_form-control-font-color-disabled) + ); + -webkit-text-fill-color: var( + --form-control-font-color-disabled, + var(--INTERNAL_form-control-font-color-disabled) + ); + opacity: 1; + + &:hover { + cursor: not-allowed; + } +} + +.text-input-label { + position: absolute; + top: 0; + font-size: 1rem; + transform-origin: 0 0; + transition: opacity 0.1s ease-in-out, transform 0.1s ease-in-out; + font-weight: --form-control-label-font-weight, + var(--INTERNAL_form-control-label-font-weigh); + color: --form-control-label-font-color, + var(--INTERNAL_form-control-label-font-color); + cursor: text; + white-space: nowrap; +} + +.prefix { + border: 1px solid + var(--form-control-border-color, var(--INTERNAL_form-control-border-color)); + background-color: var(--color-brand-grey-50); + border-top-left-radius: var( + --form-control-size-md-border-radius, + var(--INTERNAL_form-control-size-md-border-radius) + ); + border-bottom-left-radius: var( + --form-control-size-md-border-radius, + var(--INTERNAL_form-control-size-md-border-radius) + ); +} + +.suffix { + border: 1px solid + var(--form-control-border-color, var(--INTERNAL_form-control-border-color)); + background-color: var(--color-brand-grey-50); + border-top-right-radius: var( + --form-control-size-md-border-radius, + var(--INTERNAL_form-control-size-md-border-radius) + ); + border-bottom-right-radius: var( + --form-control-size-md-border-radius, + var(--INTERNAL_form-control-size-md-border-radius) + ); +} + +.text-input-wrapper { + font-family: var( + --form-control-font-family, + var(--INTERNAL_form-control-font-family) + ); + position: relative; + box-shadow: var( + --form-control-box-shadow, + var(--INTERNAL_form-control-box-shadow) + ), + inset 0 0 0 1px + var( + --form-control-border-color, + var(--INTERNAL_form-control-border-color) + ); + background-color: var( + --form-control-background-color, + var(--INTERNAL_form-control-background-color) + ); + + &.size-md { + .label-input-wrapper { + @extend %size-md; + } + } + + &.size-lg { + .label-input-wrapper { + @extend %size-lg; + } + } + + @media (min-width: $size-breakpoint-tablet) { + &.size-md-tablet { + @include size-md; + } + + &.size-lg-tablet { + @include size-lg; + } + } + + @media (min-width: $size-breakpoint-desktop) { + &.size-md-desktop { + @include size-md; + } + + &.size-lg-desktop { + @include size-lg; + } + } + + @media (min-width: $size-breakpoint-hd) { + &.size-md-hd { + @include size-md; + } + + &.size-lg-hd { + @include size-lg; + } + } + + // border and background is set on the text-input-wrapper + input { + box-sizing: border-box; + display: flex; + transition-duration: 300ms; + transition-property: border, background-color; + transition-timing-function: cubic-bezier(0.2, 0.8, 0.4, 1); + border: none; + background-color: transparent; + width: 100%; + line-height: var( + --form-control-line-height, + var(--INTERNAL_form-control-line-height) + ); + color: var( + --form-control-font-color, + var(--INTERNAL_form-control-font-color) + ); + + &::placeholder { + color: transparent; + } + + &:focus { + outline: none; + border-color: var( + --form-control-border-color-focus, + var(--INTERNAL_form-control-border-color-focus) + ); + } + + &:disabled { + @extend %disabled-base; + + background-color: transparent; + + + .text-input-label { + cursor: not-allowed; + } + } + } + + &.error { + box-shadow: var( + --form-control-box-shadow, + var(--INTERNAL_form-control-box-shadow) + ), + inset 0 0 0 1px + var( + --form-control-border-color-error, + var(--INTERNAL_form-control-border-color-error) + ); + background-color: var( + --form-control-background-color-error, + var(--INTERNAL_form-control-background-color-error) + ); + + &:focus-within { + background-color: var( + --form-control-background-color, + var(--INTERNAL_form-control-background-color) + ); + } + + input:focus { + outline: none; + background-color: transparent; + } + } + + //Necessary so that inset shadow that we use for border does not get covered by child elements. + &:not(.error) { + > * { + &:focus, + &.disabled { + &:not(input) { + box-shadow: inset 0 1px 0 0 + var( + --form-control-box-shadow-focus, + var(--INTERNAL_form-control-box-shadow-focus) + ), + inset 0 -1px 0 0 var(--form-control-border-color-focus, var(--INTERNAL_form-control-border-color-focus)); + } + } + } + } + + &:focus-within { + outline: none; + box-shadow: var( + --form-control-box-shadow-focus, + var(--INTERNAL_form-control-box-shadow-focus) + ), + inset 0 0 0 1px + var( + --form-control-border-color-focus, + var(--INTERNAL_form-control-border-color-focus) + ); + } + + &.disabled { + @extend %disabled-base; + } + + // Reset default button styles + .clear-button { + transition-duration: 0.2s; + transition-property: color; + border: 0; + border-radius: 0; + background: none; + cursor: pointer; + color: var( + --form-control-icon-color, + var(--INTERNAL_form-control-icon-color) + ); + font-style: inherit; + + &:hover { + color: var( + --form-control-icon-hover-color, + var(--INTERNAL_form-control-icon-hover-color) + ); + } + } +} diff --git a/src/components/TextInputFloating/TextInputFloating.test.tsx b/src/components/TextInputFloating/TextInputFloating.test.tsx new file mode 100644 index 000000000..4986299c8 --- /dev/null +++ b/src/components/TextInputFloating/TextInputFloating.test.tsx @@ -0,0 +1,242 @@ +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { TextInputFloating } from './TextInputFloating'; + +const baseProps = { + name: 'firstName', + id: 'firstName', + label: 'first name', + value: 'hello', + onChange: () => null, + onClear: undefined, +}; + +describe('TextInput', () => { + describe('Callback Handling', () => { + describe('onChange', () => { + test('onChange event fires callback function', () => { + const mockedHandleChange = jest.fn(() => null); + + render( + , + ); + const inputElement = screen.getByDisplayValue(baseProps.value); + + fireEvent.change(inputElement, { target: { value: 'good bye' } }); + expect(mockedHandleChange).toHaveBeenCalledTimes(1); + }); + + test('Input value is updated properly when upper state changes', () => { + let value = 'hello'; + const mockedHandleChange = jest.fn(event => { + value = event.target.value; + }); + const { rerender } = render( + , + ); + + const inputElement = screen.getByDisplayValue( + 'hello', + ) as HTMLInputElement; + expect(inputElement.value).toBe('hello'); + + fireEvent.change(inputElement, { target: { value: 'good bye' } }); + expect(mockedHandleChange).toHaveBeenCalledTimes(1); + + rerender( + , + ); + expect(inputElement.value).toBe('good bye'); + }); + }); + + describe('onClear', () => { + test('onClear prop renders clear icon when input has value', () => { + render( null} />); + const clearButton = screen.getByTestId('text-input-clear-button'); + expect(clearButton).toBeInTheDocument(); + }); + + test('onClear event fires callback function', () => { + const mockedHandleClear = jest.fn(() => null); + + render( + , + ); + const clearButton = screen.getByTestId('text-input-clear-button'); + expect(clearButton).toBeInTheDocument(); + + fireEvent.click(clearButton); + expect(mockedHandleClear).toHaveBeenCalledTimes(1); + fireEvent.keyUp(clearButton, { keyCode: 13 }); + expect(mockedHandleClear).toHaveBeenCalledTimes(2); + fireEvent.keyUp(clearButton, { keyCode: 99 }); + expect(mockedHandleClear).toHaveBeenCalledTimes(2); + }); + }); + + describe('onFocus', () => { + test('Input fires onFocus callback', () => { + const mockedHandleFocus = jest.fn(); + render( + , + ); + const inputElement = screen.getByDisplayValue(baseProps.value); + fireEvent.focus(inputElement); + expect(mockedHandleFocus).toBeCalledTimes(1); + }); + }); + + describe('onBlur', () => { + test('Input fires onBlur callback', () => { + const mockedHandleBlur = jest.fn(); + render(); + const inputElement = screen.getByDisplayValue(baseProps.value); + fireEvent.focus(inputElement); + fireEvent.blur(inputElement); + expect(mockedHandleBlur).toBeCalledTimes(1); + }); + }); + }); + + describe('States', () => { + describe('Label', () => { + test('it renders a label', () => { + render(); + + const labelElement = screen.getByText(baseProps.label); + + expect(labelElement).toBeInTheDocument(); + }); + }); + + describe('Autofocused', () => { + test('Input autofocuses if "autoFocus" prop is set to true', () => { + render(); + const inputElement = screen.getByDisplayValue(baseProps.value); + expect(document.activeElement).toEqual(inputElement); + }); + }); + + describe('Autocomplete', () => { + test('Input correctly assigns autocomplete value of "on" when bool true is provided', () => { + render(); + const inputElement = screen.getByDisplayValue(baseProps.value); + expect(inputElement).toHaveAttribute('autocomplete', 'on'); + }); + + test('Input correctly assigns autocomplete value of "off" when bool false is provided', () => { + render(); + const inputElement = screen.getByDisplayValue('hello'); + expect(inputElement).toHaveAttribute('autocomplete', 'off'); + }); + + test('Input correctly assigns autocomplete specific value when provided', () => { + render(); + const inputElement = screen.getByDisplayValue('hello'); + expect(inputElement).toHaveAttribute('autocomplete', 'email'); + }); + }); + + describe('Required', () => { + test('it correctly assigns the "aria-required" attribute when "isRequired" prop is true', () => { + render(); + + const inputElement = screen.getByDisplayValue('hello'); + + expect(inputElement).toHaveAttribute('aria-required', 'true'); + }); + }); + + describe('Error', () => { + test('Input correctly displays error message if provided', () => { + render(); + + const validationMessageElement = screen.getByText('You silly goose'); + + expect(validationMessageElement).toBeInTheDocument(); + expect(validationMessageElement).toHaveTextContent('You silly goose'); + }); + }); + + describe('Help Text', () => { + test('Input renders help text', async () => { + const { getByText } = render( + , + ); + + expect(getByText('i am help text')).toBeDefined(); + }); + }); + + describe('Max Length', () => { + test('Input correctly passes maxlength property if prop is passed', async () => { + render(); + + const inputElement = screen.getByLabelText(baseProps.label); + expect(inputElement).toBeInTheDocument(); + expect(inputElement).toHaveAttribute('maxlength'); + expect(inputElement.getAttribute('maxlength')).toBe('3'); + }); + }); + + describe('Name', () => { + test('Input correctly passes name property if prop is passed', async () => { + render( + , + ); + + const inputElement = screen.getByLabelText(baseProps.label); + expect(inputElement).toBeInTheDocument(); + expect(inputElement).toHaveAttribute('name'); + expect(inputElement.getAttribute('name')).toBe('test floating label'); + }); + }); + + describe('Aria-labelledby', () => { + test('assigns the "aria-labelledby" attribute and renders label with correct id, when label is provided', () => { + render(); + const inputElement = screen.getByDisplayValue(baseProps.value); + expect(inputElement).toHaveAttribute( + 'aria-labelledby', + `${baseProps.id}Label`, + ); + expect(document.getElementById(baseProps.id)).toBeInTheDocument(); + }); + + test('does not assign "aria-labelledby" attribute when a label is hidden', () => { + render(); + const inputElement = screen.getByLabelText(baseProps.label); + expect(inputElement).not.toHaveAttribute('aria-labelledby'); + }); + }); + + describe('Prefix and Suffix', () => { + test('renders the prefix if specified', () => { + render(); + expect(screen.getByText('prefixValue')).toBeInTheDocument(); + }); + + test('renders the suffix if specified', () => { + render(); + expect(screen.getByText('suffixValue')).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/components/TextInputFloating/TextInputFloating.tsx b/src/components/TextInputFloating/TextInputFloating.tsx new file mode 100644 index 000000000..60c060511 --- /dev/null +++ b/src/components/TextInputFloating/TextInputFloating.tsx @@ -0,0 +1,269 @@ +import React, { + ChangeEvent, + forwardRef, + MouseEvent, + KeyboardEvent, + FocusEvent, + ForwardRefExoticComponent, + ReactNode, + HTMLProps, + InputHTMLAttributes, +} from 'react'; +import classNames from 'classnames'; +import { ChangeEvent as CleaveChangeEvent } from 'cleave.js/react/props'; +import { ResponsiveProp } from '../../types'; +import { generateResponsiveClasses } from '../../lib/generateResponsiveClasses'; + +import { Box, BoxProps } from '../Box/Box'; +import { Icon } from '../Icon/Icon'; +import { getAutoCompleteValue } from '../../lib/getAutoCompleteValue'; +import styles from './TextInputFloating.module.scss'; +import { InputValidationMessage } from '../InputValidationMessage/InputValidationMessage'; + +export type TextInputFloatingSize = 'md' | 'lg'; +export interface TextInputFloatingProps { + /** + * The input's id attribute. Used to programmatically tie the input with its label. + */ + id: string; + /** + * Custom content to be displayed above the input. If the label is hidden, will be used to set aria-label attribute. + */ + label: string; + /** + * Callback function to call on change event. + */ + onChange: ( + event: ChangeEvent | CleaveChangeEvent, + ) => void; + /** + * The text value of the input. Required since our Input is a controlled component. + */ + value: InputHTMLAttributes['value']; + /** + * Automatically focus the input when the page is loaded. + */ + autoFocus?: boolean; + /** + * Custom class to be added to standard input classes. + */ + className?: string; + /** + * Mark the input field as invalid and display a validation message. + * Pass a string or node to render a validation message below the input. + */ + error?: ReactNode; + /** + * Visually hide the label. + */ + hideLabel?: boolean; + /** + * Additional clarifying text to help describe the input + */ + helpText?: ReactNode; + /** + * Props passed directly to the input element of the component + */ + inputProps?: BoxProps & HTMLProps; + /** + * The input's disabled attribute + */ + isDisabled?: boolean; + /** + * The required and aria-required attributes on the input + */ + isRequired?: boolean; + /** + * The input's 'maxlength' attribute. + * NOTE: initializing the input with a value longer than the desired maxlength will not trim this value. + */ + maxLength?: number; + /** + * The input's 'name' attribute. + */ + name?: string; + /** + * Callback function to call on blur event. + */ + onBlur?: (event: FocusEvent) => void; + /** + * Callback function to call when input us cleared. When this is passed, + * the input will display an icon on the right side, for triggering this callback. + */ + onClear?: ( + event: MouseEvent | KeyboardEvent, + ) => void; + /** + * Callback function to call on focus event. + */ + onFocus?: (event: FocusEvent) => void; + /** + * The input placeholder attribute. + */ + placeholder?: string; + /** + * An input helper rendered before the input field value + */ + prefix?: ReactNode; + /** + * Visual indicator that the field is required, that gets appended to the label + */ + requiredIndicator?: ReactNode; + /** + * The size of the text input. + */ + size?: TextInputFloatingSize | ResponsiveProp; + /** + * An input helper rendered after the input field value + */ + suffix?: ReactNode; + /** + * The input 'type' value. Defaults to type 'text'. + */ + type?: InputHTMLAttributes['type']; + /** + * Additional props to be spread to rendered element + */ + [x: string]: any; // eslint-disable-line +} + +export const TextInputFloating: ForwardRefExoticComponent = forwardRef( + ( + { + id, + label, + onChange, + value, + autoComplete = false, + autoFocus = false, + error = false, + helpText, + hideLabel = false, + inputProps = {}, + isDisabled = false, + isRequired = false, + maxLength = undefined, + name = '', + onBlur = undefined, + onClear = undefined, + onFocus = undefined, + prefix = undefined, + placeholder = ' ', + requiredIndicator = ' *', + suffix = undefined, + size = 'md', + type = 'text', + }, + ref, + ) => { + const responsiveClasses = generateResponsiveClasses('size', size); + + const inputWrapperClasses = classNames( + 'palmetto-components__variables__form-control', + styles['text-input-wrapper'], + ...responsiveClasses.map(c => styles[c]), + { + [styles.error]: error, + [styles.disabled]: isDisabled, + [styles['is-clearable']]: onClear, + }, + ); + + const clearBtnClasses = classNames(styles['clear-button'], styles.md); + + const renderClearIcon = (): ReactNode => { + const handleKeyPress = ( + event: KeyboardEvent, + ): void => { + if (event.keyCode === 13 && onClear) onClear(event); + }; + + return ( + + ); + }; + + const computedInputProps: TextInputFloatingProps['inputProps'] = { + ...inputProps, // These are spread first so that we don't have top level props overwritten by the user. + 'aria-required': isRequired, + 'aria-invalid': !!error, + 'aria-label': label, + 'aria-labelledby': label && !hideLabel ? `${id}Label` : undefined, + autoComplete: getAutoCompleteValue(autoComplete), + autoFocus, + disabled: isDisabled, + id, + maxLength, + name, + onBlur, + onChange, + onFocus, + placeholder, + required: isRequired, + type, + value, + className: classNames(inputProps.className), + }; + + return ( +
+ + {prefix && ( + + {prefix} + + )} + + + {!!onClear && !!value && renderClearIcon()} + + + {suffix && ( + + {suffix} + + )} + + {helpText && ( + + {helpText} + + )} + {error && error !== true && ( + {error} + )} +
+ ); + }, +); diff --git a/src/components/index.ts b/src/components/index.ts index b82e88ec0..28a52945e 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -46,6 +46,7 @@ export * from './Tabs/Tabs'; export * from './TabsSlider/TabsSlider'; export * from './Table/Table'; export * from './TextInput/TextInput'; +export * from './TextInputFloating/TextInputFloating'; export * from './TextareaInput/TextareaInput'; export * from './TextLink/TextLink'; export * from './ThemeProvider/ThemeProvider'; diff --git a/src/docs/FormTheming.stories.mdx b/src/docs/FormTheming.stories.mdx index 53f9ee801..f39e7c00e 100644 --- a/src/docs/FormTheming.stories.mdx +++ b/src/docs/FormTheming.stories.mdx @@ -1,6 +1,7 @@ import { Meta, Story, Canvas } from '@storybook/addon-docs'; import { useState } from 'react'; import { TextInput } from '../components/TextInput/TextInput'; +import { TextInputFloating } from '../components/TextInputFloating/TextInputFloating'; import { CheckboxInput } from '../components/CheckboxInput/CheckboxInput'; import { SelectInput } from '../components/SelectInput/SelectInput'; import { SelectInputNative } from '../components/SelectInputNative/SelectInputNative'; @@ -57,6 +58,7 @@ Form controls share tokens since they are designed to have a consistent appearan selectValue: options[0].value, textValue: 'hello world', textareaValue: 'hello world', + emptyValue: '', }); const handleChange = (key, value) => { setThemeExampleValues(prevFields => ({ @@ -82,10 +84,9 @@ Form controls share tokens since they are designed to have a consistent appearan }; return ( setIsThemed(e.target.checked)} /> + handleChange('emptyValue', event.target.value)} + /> + handleChange('textValue', event.target.value)} + /> @@ -49,6 +50,32 @@ Sizes for form controls likes inputs and buttons, should match so that they can }; return ( + + + + + handleChange('textInputMd', event.target.value) + } + /> + + + + handleChange('textInputLg', event.target.value) + } + /> + + handleChange('selectInput', event.target.value) @@ -107,7 +134,7 @@ Sizes for form controls likes inputs and buttons, should match so that they can size="sm" /> handleChange('selectInput', event.target.value) @@ -118,7 +145,7 @@ Sizes for form controls likes inputs and buttons, should match so that they can size="md" /> handleChange('selectInput', event.target.value) @@ -253,11 +280,16 @@ Inline form sizes with labels. {() => { - const [value, setValue] = useState(''); - const [value1, setValue1] = useState(''); - const [value2, setValue2] = useState(''); - const [value3, setValue3] = useState(''); - const [valueLabel4, setValueLabel4] = useState(); + const [formValues, setFormValues] = useState({ + textInputSm: '', + textInputMd: '', + textInputLg: '', + selectInput: { + value: 'chocolate', + label: 'Chocolate', + }, + toggleValue: false, + }); const options = [ { value: 'chocolate', @@ -272,17 +304,26 @@ Inline form sizes with labels. label: 'Vanilla', }, ]; + const handleChange = (key, value) => { + setFormValues(prevFields => ({ + ...prevFields, + [key]: value, + })); + }; return (
+ setValue(event.target.value)} + onChange={event => + handleChange('textInputSm', event.target.value) + } /> @@ -294,9 +335,11 @@ Inline form sizes with labels. setValue3(event.target.value)} + onChange={event => + handleChange('selectInput', event.target.value) + } options={options} - value={value3} + value={formValues.selectInput} size="sm" /> @@ -304,20 +347,36 @@ Inline form sizes with labels. setValueLabel4(event.target.checked)} - isChecked={valueLabel4} + onChange={event => + handleChange('toggleValue', event.target.checked) + } + isChecked={formValues.toggleValue} size="sm" /> - + + + + handleChange('textInputMd', event.target.value) + } + /> + setValue1(event.target.value)} + onChange={event => + handleChange('textInputMd', event.target.value) + } /> @@ -327,9 +386,11 @@ Inline form sizes with labels. setValue3(event.target.value)} + onChange={event => + handleChange('selectInput', event.target.value) + } options={options} - value={value3} + value={formValues.selectInput} size="md" /> @@ -337,21 +398,37 @@ Inline form sizes with labels. setValueLabel4(event.target.checked)} - isChecked={valueLabel4} + onChange={event => + handleChange('toggleValue', event.target.checked) + } + isChecked={formValues.toggleValue} size="md" /> - + + + + handleChange('textInputLg', event.target.value) + } + /> + setValue2(event.target.value)} + onChange={event => + handleChange('textInputLg', event.target.value) + } /> @@ -363,9 +440,11 @@ Inline form sizes with labels. setValue3(event.target.value)} + onChange={event => + handleChange('selectInput', event.target.value) + } options={options} - value={value3} + value={formValues.selectInput} size="lg" /> @@ -373,8 +452,10 @@ Inline form sizes with labels. setValueLabel4(event.target.checked)} - isChecked={valueLabel4} + onChange={event => + handleChange('toggleValue', event.target.checked) + } + isChecked={formValues.toggleValue} size="lg" /> @@ -392,11 +473,16 @@ Inline form sizes with error states. {() => { - const [value, setValue] = useState(''); - const [value1, setValue1] = useState(''); - const [value2, setValue2] = useState(''); - const [value3, setValue3] = useState(); - const [valueError4, setValueError4] = useState(); + const [formValues, setFormValues] = useState({ + textInputSm: '', + textInputMd: '', + textInputLg: '', + selectInput: { + value: 'chocolate', + label: 'Chocolate', + }, + toggleValue: false, + }); const options = [ { value: 'chocolate', @@ -411,122 +497,170 @@ Inline form sizes with error states. label: 'Vanilla', }, ]; + const handleChange = (key, value) => { + setFormValues(prevFields => ({ + ...prevFields, + [key]: value, + })); + }; const errorMsg = 'Error message'; return (
+ + handleChange('textInputSm', event.target.value) + } error={errorMsg} - onChange={event => setValue(event.target.value)} /> - + setValue3(event.target.value)} + onChange={event => + handleChange('selectInput', event.target.value) + } options={options} - value={value3} - hideLabel + value={formValues.selectInput} size="sm" error={errorMsg} /> setValueError4(event.target.checked)} - isChecked={valueError4} + onChange={event => + handleChange('toggleValue', event.target.checked) + } + isChecked={formValues.toggleValue} size="sm" - hideLabel error={errorMsg} /> - + + + + handleChange('textInputMd', event.target.value) + } + error={errorMsg} + /> + + handleChange('textInputMd', event.target.value) + } error={errorMsg} - onChange={event => setValue1(event.target.value)} /> - + setValue3(event.target.value)} + onChange={event => + handleChange('selectInput', event.target.value) + } options={options} - value={value3} - hideLabel + value={formValues.selectInput} + size="md" error={errorMsg} /> setValueError4(event.target.checked)} - isChecked={valueError4} + id="mdToggleLabel" + label="medium toggle" + onChange={event => + handleChange('toggleValue', event.target.checked) + } + isChecked={formValues.toggleValue} size="md" - hideLabel error={errorMsg} /> - + + + + handleChange('textInputLg', event.target.value) + } + error={errorMsg} + /> + + handleChange('textInputLg', event.target.value) + } error={errorMsg} - onChange={event => setValue2(event.target.value)} /> - + setValue3(event.target.value)} + onChange={event => + handleChange('selectInput', event.target.value) + } options={options} - value={value3} - hideLabel + value={formValues.selectInput} size="lg" - isClearable error={errorMsg} /> setValueError4(event.target.checked)} - isChecked={valueError4} + onChange={event => + handleChange('toggleValue', event.target.checked) + } + isChecked={formValues.toggleValue} size="lg" - hideLabel error={errorMsg} />