diff --git a/packages/harmony/src/components/input/TextInput/TextInput.module.css b/packages/harmony/src/components/input/TextInput/TextInput.module.css new file mode 100644 index 00000000000..6116108f571 --- /dev/null +++ b/packages/harmony/src/components/input/TextInput/TextInput.module.css @@ -0,0 +1,131 @@ +/* The absolute root of the entire component, including assistive text */ +.root { + /* This isolates z-index to only affect this component */ + isolation: isolate; +} + +/* The contentContainer is the root container of all things that aren't assistive text */ +.contentContainer { + display: flex; + align-items: center; + /* Dont need Y padding since the flex centering takes care of it */ + padding: 0 var(--harmony-spacing-l); + border: 1px solid var(--harmony-border-default); + border-radius: var(--harmony-border-radius-s); + background-color: var(--harmony-bg-surface-1); + transition: border ease-in-out 0.1s; + box-sizing: border-box; + & svg path { + fill: var(--harmony-text-subdued); + } +} + +/* hover border color */ +.contentContainer:hover { + border-color: var(--harmony-border-strong); +} + +/* small input size */ +.contentContainer.small { + /* gap between icons & content */ + gap: var(--harmony-spacing-s); + font-size: var(--harmony-font-s); + height: 34px; +} + +/* default input size */ +.contentContainer.contentContainer.default { + /* gap between icons & content */ + gap: var(--harmony-spacing-m); + font-size: var(--harmony-font-m); + height: 64px; +} + +/* focused border color */ +.contentContainer.focused, +.contentContainer.focused:hover { + border-color: var(--harmony-secondary); +} + +/* warning border color */ +.contentContainer.warning, +.contentContainer.warning.focused, +.contentContainer.warning:hover { + border-color: var(--harmony-orange); +} + +/* error border color */ +.contentContainer.error, +.contentContainer.error.focused, +.contentContainer.error:hover { + border-color: var(--harmony-red); +} + +/* disabled styles */ +.contentContainer.disabled { + cursor: not-allowed; + & * { + cursor: not-allowed; + } +} + +/* Actual input element */ +.input { + padding: 0; + width: 100%; + height: 100%; + outline: 0; + border: 0; + background: none; + font-weight: var(--harmony-font-medium); + color: var(--harmony-text-default); + text-overflow: ellipsis; +} + +.input.focused { + caret-color: var(--harmony-secondary); +} + +.input.disabled, +.input.disabled::placeholder { + color: var(--harmony-text-subdued); +} + +/* Change font size of the input based on size */ +.input.small { + font-size: var(--harmony-font-s); +} +.input.default { + font-size: var(--harmony-font-l); +} + +/* Placeholder and label text styles (they are the same for the most part) */ +.input::placeholder, +.label { + color: var(--harmony-text-default); + font-weight: var(--harmony-font-medium); + font-family: var(--harmony-font-family); +} + +/** Position the label in the center when unfocussed **/ +.label { + color: var(--harmony-text-subdued); + /* text overflow should just break off into the ellipses */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + z-index: 2; + transition: all var(--harmony-expressive); + /* When not focussed, the label centers itself visually */ + transform: translate( + 0px, + 13px + ); /* 13px is a ✨magic✨ number that makes it look centered with the input */ +} + +/** Shrink the size of the label **/ +.label.focused, +.hasValue { + transform: translate(0, 0); + font-size: var(--harmony-font-s); +} diff --git a/packages/harmony/src/components/input/TextInput/TextInput.stories.tsx b/packages/harmony/src/components/input/TextInput/TextInput.stories.tsx new file mode 100644 index 00000000000..c0439a457ad --- /dev/null +++ b/packages/harmony/src/components/input/TextInput/TextInput.stories.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react' + +import type { Meta, StoryObj } from '@storybook/react' + +import { IconSearch, IconVisibilityHidden } from '../../typography' + +import { TextInput } from './TextInput' +import { TextInputProps, TextInputSize } from './types' + +const StoryRender = (props: TextInputProps) => { + const [value, setValue] = useState(props.value) + return ( + { + setValue(e.target.value) + }} + /> + ) +} + +const meta: Meta = { + title: 'Components/Input/TextInput', + component: TextInput, + parameters: { + docs: { + controls: { + exclude: ['inputRef', 'inputRootClassName', 'inputClassName'] + } + } + }, + render: StoryRender +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + label: 'Input Label', + helperText: 'This is assistive text.', + placeholder: 'Placeholder' + } +} + +export const Small: Story = { + args: { + label: 'Input Label', + size: TextInputSize.SMALL, + helperText: 'This is assistive text.', + placeholder: 'Placeholder', + startIcon: IconSearch + } +} + +export const TextAdornments: Story = { + args: { + label: 'Input Label', + helperText: 'This is assistive text.', + placeholder: 'Enter an amount', + startAdornmentText: '@', + endAdornmentText: '$AUDIO' + } +} + +export const Icons: Story = { + args: { + label: 'Input Label', + helperText: 'This is assistive text.', + placeholder: 'Your handle', + startIcon: IconSearch, + endIcon: IconVisibilityHidden + } +} + +export const MaxCharacters: Story = { + args: { + label: 'Input Label', + helperText: 'This is assistive text.', + placeholder: 'Your handle', + maxLength: 60, + value: 'Flying a little close to the character limit there bud 🦅' + } +} + +export const Disabled: Story = { + args: { + label: 'Input Label', + helperText: 'This is assistive text.', + disabled: true, + value: "You couldn't change me if you tried" + } +} diff --git a/packages/harmony/src/components/input/TextInput/TextInput.tsx b/packages/harmony/src/components/input/TextInput/TextInput.tsx new file mode 100644 index 00000000000..6302556f9b5 --- /dev/null +++ b/packages/harmony/src/components/input/TextInput/TextInput.tsx @@ -0,0 +1,185 @@ +import { forwardRef, useId } from 'react' + +import cn from 'classnames' + +import { Text, TextSize } from 'components/typography' + +import { Flex } from '../../layout' +import { useFocusState } from '../useFocusState' + +import styles from './TextInput.module.css' +import { TextInputSize, type TextInputProps } from './types' + +export const TextInput = forwardRef( + (props, ref) => { + const { + required, + className, + inputRootClassName, + maxLength, + showMaxLengthThreshold = 0.7, + maxLengthWarningThreshold = 0.9, + size = TextInputSize.DEFAULT, + hideLabel, + label: labelProp, + value, + id: idProp, + warning: warningProp, + error, + className: inputClassName, + disabled, + onFocus: onFocusProp, + onBlur: onBlurProp, + placeholder, + helperText, + startAdornmentText, + endAdornmentText, + startIcon: StartIcon, + endIcon: EndIcon, + ...other + } = props + + /** + * Since Firefox doesn't support the :has() pseudo selector, + * manually track the focused state and use classes for focus, required, and disabled + */ + const [isFocused, handleFocus, handleBlur] = useFocusState( + onFocusProp, + onBlurProp + ) + + // For focus behavior and accessiblity,