diff --git a/e2e/components/FluidTextInput/FluidTextInput-test.e2e.js b/e2e/components/FluidTextInput/FluidTextInput-test.e2e.js new file mode 100644 index 000000000000..b0d752edd574 --- /dev/null +++ b/e2e/components/FluidTextInput/FluidTextInput-test.e2e.js @@ -0,0 +1,37 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const { expect, test } = require('@playwright/test'); +const { themes } = require('../../test-utils/env'); +const { snapshotStory, visitStory } = require('../../test-utils/storybook'); + +test.describe('FluidTextInput', () => { + themes.forEach((theme) => { + test.describe(theme, () => { + test('fluid text input @vrt', async ({ page }) => { + await snapshotStory(page, { + component: 'FluidTextInput', + id: 'experimental-unstable-fluidtextinput--default', + theme, + }); + }); + }); + }); + + test('accessibility-checker @avt', async ({ page }) => { + await visitStory(page, { + component: 'FluidTextInput', + id: 'experimental-unstable-fluidtextinput--default', + globals: { + theme: 'white', + }, + }); + await expect(page).toHaveNoACViolations('FluidTextInput'); + }); +}); diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index df56e8b322ee..13c08311100b 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -9165,6 +9165,71 @@ Map { }, }, }, + "unstable__FluidTextInput" => Object { + "propTypes": Object { + "className": Object { + "type": "string", + }, + "defaultValue": Object { + "args": Array [ + Array [ + Object { + "type": "string", + }, + Object { + "type": "number", + }, + ], + ], + "type": "oneOfType", + }, + "disabled": Object { + "type": "bool", + }, + "id": Object { + "isRequired": true, + "type": "string", + }, + "invalid": Object { + "type": "bool", + }, + "invalidText": Object { + "type": "node", + }, + "labelText": Object { + "isRequired": true, + "type": "node", + }, + "onChange": Object { + "type": "func", + }, + "onClick": Object { + "type": "func", + }, + "placeholder": Object { + "type": "string", + }, + "value": Object { + "args": Array [ + Array [ + Object { + "type": "string", + }, + Object { + "type": "number", + }, + ], + ], + "type": "oneOfType", + }, + "warn": Object { + "type": "bool", + }, + "warnText": Object { + "type": "node", + }, + }, + }, "unstable_useContextMenu" => Object {}, "unstable_useFeatureFlag" => Object {}, "unstable_useFeatureFlags" => Object {}, diff --git a/packages/react/src/__tests__/index-test.js b/packages/react/src/__tests__/index-test.js index c90af09ec018..62077eab9c9a 100644 --- a/packages/react/src/__tests__/index-test.js +++ b/packages/react/src/__tests__/index-test.js @@ -229,6 +229,7 @@ describe('Carbon Components React', () => { "unstable_Pagination", "unstable_Text", "unstable_TextDirection", + "unstable__FluidTextInput", "unstable_useContextMenu", "unstable_useFeatureFlag", "unstable_useFeatureFlags", diff --git a/packages/react/src/components/FluidTextInput/FluidTextInput.js b/packages/react/src/components/FluidTextInput/FluidTextInput.js new file mode 100644 index 000000000000..5fa57ed60f87 --- /dev/null +++ b/packages/react/src/components/FluidTextInput/FluidTextInput.js @@ -0,0 +1,96 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import PropTypes from 'prop-types'; +import React from 'react'; +import classnames from 'classnames'; +import TextInput from '../TextInput'; +import { usePrefix } from '../../internal/usePrefix'; +import { FormContext } from '../FluidForm/FormContext'; + +function FluidTextInput({ className, ...other }) { + const prefix = usePrefix(); + const classNames = classnames(`${prefix}--text-input--fluid`, className); + + return ( + + + + ); +} + +FluidTextInput.propTypes = { + /** + * Specify an optional className to be applied to the outer FluidForm wrapper + */ + className: PropTypes.string, + + /** + * Optionally provide the default value of the `` + */ + defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + + /** + * Specify whether the `` should be disabled + */ + disabled: PropTypes.bool, + + /** + * Specify a custom `id` for the `` + */ + id: PropTypes.string.isRequired, + + /** + * Specify whether the control is currently invalid + */ + invalid: PropTypes.bool, + + /** + * Provide the text that is displayed when the control is in an invalid state + */ + invalidText: PropTypes.node, + + /** + * Provide the text that will be read by a screen reader when visiting this + * control + */ + labelText: PropTypes.node.isRequired, + + /** + * Optionally provide an `onChange` handler that is called whenever `` + * is updated + */ + onChange: PropTypes.func, + + /** + * Optionally provide an `onClick` handler that is called whenever the + * `` is clicked + */ + onClick: PropTypes.func, + + /** + * Specify the placeholder attribute for the `` + */ + placeholder: PropTypes.string, + + /** + * Specify the value of the `` + */ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + + /** + * Specify whether the control is currently in warning state + */ + warn: PropTypes.bool, + + /** + * Provide the text that is displayed when the control is in warning state + */ + warnText: PropTypes.node, +}; + +export default FluidTextInput; diff --git a/packages/react/src/components/FluidTextInput/FluidTextInput.stories.js b/packages/react/src/components/FluidTextInput/FluidTextInput.stories.js new file mode 100644 index 000000000000..93d3beef886c --- /dev/null +++ b/packages/react/src/components/FluidTextInput/FluidTextInput.stories.js @@ -0,0 +1,117 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import FluidTextInput from '../FluidTextInput'; +import { + ToggletipLabel, + Toggletip, + ToggletipButton, + ToggletipContent, +} from '../Toggletip'; +import { Information } from '@carbon/icons-react'; +import './test.scss'; + +export default { + title: 'Experimental/unstable__FluidTextInput', + component: FluidTextInput, +}; + +export const Default = () => ( + +); + +const ToggleTip = ( + <> + Label + + + + + +

Additional field information here.

+
+
+ +); + +export const DefaultWithTooltip = () => ( + +); + +export const Playground = (args) => ( +
+ +
+); + +Playground.argTypes = { + playgroundWidth: { + control: { type: 'range', min: 300, max: 800, step: 50 }, + defaultValue: 300, + }, + className: { + control: { + type: 'text', + }, + defaultValue: 'test-class', + }, + defaultValue: { + control: { + type: 'text', + }, + }, + placeholder: { + control: { + type: 'text', + }, + defaultValue: 'Placeholder text', + }, + invalid: { + control: { + type: 'boolean', + }, + defaultValue: false, + }, + invalidText: { + control: { + type: 'text', + }, + defaultValue: + 'Error message that is really long can wrap to more lines but should not be excessively long.', + }, + disabled: { + control: { + type: 'boolean', + }, + defaultValue: false, + }, + labelText: { + control: { + type: 'text', + }, + defaultValue: 'Label', + }, + warn: { + control: { + type: 'boolean', + }, + defaultValue: false, + }, + warnText: { + control: { + type: 'text', + }, + defaultValue: + 'Warning message that is really long can wrap to more lines but should not be excessively long.', + }, + value: { + control: { + type: 'text', + }, + }, +}; diff --git a/packages/react/src/components/FluidTextInput/__tests__/FluidTextInput-test.js b/packages/react/src/components/FluidTextInput/__tests__/FluidTextInput-test.js new file mode 100644 index 000000000000..7162ffa631d3 --- /dev/null +++ b/packages/react/src/components/FluidTextInput/__tests__/FluidTextInput-test.js @@ -0,0 +1,292 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import FluidTextInput from '../FluidTextInput'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import { FeatureFlags } from '../../FeatureFlags'; + +const prefix = 'cds'; + +describe('FluidTextInput', () => { + describe('renders as expected - Component API', () => { + it('should spread extra props onto the input element', () => { + render( + + ); + + expect(screen.getByRole('textbox')).toHaveAttribute( + 'data-testid', + 'test-id' + ); + }); + + it('should support a custom `className` prop on the outermost element', () => { + const { container } = render( + + + + ); + + expect(container.firstChild).toHaveClass('custom-class'); + }); + + it('should support a custom `className` prop on the input element (V10)', () => { + render( + + ); + + expect(screen.getByRole('textbox')).toHaveClass('custom-class'); + }); + + it('should respect defaultValue prop', () => { + render( + + ); + + expect(screen.getByRole('textbox')).toHaveAttribute( + 'value', + 'This is default text' + ); + }); + + it('should respect disabled prop', () => { + render( + + ); + + expect(screen.getByRole('textbox')).toBeDisabled(); + }); + + it('should respect id prop', () => { + render(); + + expect(screen.getByRole('textbox')).toHaveAttribute('id', 'input-1'); + }); + + it('should respect invalid prop', () => { + const { container } = render( + + ); + + const invalidIcon = container.querySelector( + `svg.${prefix}--text-input__invalid-icon` + ); + + expect(screen.getByRole('textbox')).toHaveAttribute('data-invalid'); + expect(screen.getByRole('textbox')).toHaveClass( + `${prefix}--text-input--invalid` + ); + expect(invalidIcon).toBeInTheDocument(); + }); + + it('should respect invalidText prop', () => { + render( + + ); + + expect(screen.getByText('This is invalid text')).toBeInTheDocument(); + expect(screen.getByText('This is invalid text')).toHaveClass( + `${prefix}--form-requirement` + ); + }); + + it('should respect labelText prop', () => { + render(); + + expect(screen.getByText('FluidTextInput label')).toBeInTheDocument(); + expect(screen.getByText('FluidTextInput label')).toHaveClass( + `${prefix}--label` + ); + }); + + it('should respect placeholder prop', () => { + render( + + ); + + expect( + screen.getByPlaceholderText('Placeholder text') + ).toBeInTheDocument(); + }); + + it('should respect type prop', () => { + render( + + ); + + expect(screen.getByRole('textbox')).toHaveAttribute(`type`, 'text'); + }); + + it('should respect value prop', () => { + render( + + ); + + expect(screen.getByRole('textbox')).toHaveAttribute( + 'value', + 'This is a test value' + ); + }); + + it('should respect warn prop', () => { + const { container } = render( + + ); + + const warnIcon = container.querySelector( + `svg.${prefix}--text-input__invalid-icon--warning` + ); + + expect(screen.getByRole('textbox')).toHaveClass( + `${prefix}--text-input--warning` + ); + expect(warnIcon).toBeInTheDocument(); + }); + + it('should respect warnText prop', () => { + render( + + ); + + expect(screen.getByText('This is warning text')).toBeInTheDocument(); + expect(screen.getByText('This is warning text')).toHaveClass( + `${prefix}--form-requirement` + ); + }); + }); + + describe('behaves as expected - Component API', () => { + it('should respect onChange prop', () => { + const onChange = jest.fn(); + render( + + ); + + userEvent.type(screen.getByRole('textbox'), 'x'); + expect(screen.getByRole('textbox')).toHaveValue('x'); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.any(Object), + }) + ); + }); + + it('should respect onClick prop', () => { + const onClick = jest.fn(); + render( + + ); + + userEvent.click(screen.getByRole('textbox')); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.any(Object), + }) + ); + }); + + it('should not call `onClick` when the `` is clicked but disabled', () => { + const onClick = jest.fn(); + render( + + ); + + userEvent.click(screen.getByRole('textbox')); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('should respect readOnly prop', () => { + const onChange = jest.fn(); + const onClick = jest.fn(); + const { container } = render( + + ); + + // Click events should fire + userEvent.click(screen.getByRole('textbox')); + expect(onClick).toHaveBeenCalledTimes(1); + + // Change events should *not* fire + userEvent.type(screen.getByRole('textbox'), 'x'); + expect(screen.getByRole('textbox')).not.toHaveValue('x'); + expect(onChange).toHaveBeenCalledTimes(0); + + // Should display the "read-only" icon + const icon = container.querySelector( + `svg.${prefix}--text-input__readonly-icon` + ); + expect(icon).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/react/src/components/FluidTextInput/index.js b/packages/react/src/components/FluidTextInput/index.js new file mode 100644 index 000000000000..ad8946d3e7f9 --- /dev/null +++ b/packages/react/src/components/FluidTextInput/index.js @@ -0,0 +1,9 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default from './FluidTextInput'; +export FluidTextInput from './FluidTextInput'; diff --git a/packages/react/src/components/FluidTextInput/test.scss b/packages/react/src/components/FluidTextInput/test.scss new file mode 100644 index 000000000000..39dd297f889b --- /dev/null +++ b/packages/react/src/components/FluidTextInput/test.scss @@ -0,0 +1,11 @@ +.fluid-input-wrapper { + display: flex; + width: 100%; + justify-content: space-between; + margin-bottom: 2rem; +} + +.fluid-input-wrapper > * { + width: 33%; + margin: 0 1rem; +} diff --git a/packages/react/src/index.js b/packages/react/src/index.js index 56affb142734..9c0006811ab5 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -213,6 +213,7 @@ export { useFeatureFlag as unstable_useFeatureFlag, useFeatureFlags as unstable_useFeatureFlags, } from './components/FeatureFlags'; +export { FluidTextInput as unstable__FluidTextInput } from './components/FluidTextInput'; export { Heading, Section } from './components/Heading'; export { IconButton } from './components/IconButton'; export { Layer, useLayer } from './components/Layer'; diff --git a/packages/styles/scss/components/_index.scss b/packages/styles/scss/components/_index.scss index 532d1dc0a40b..715314a80f19 100644 --- a/packages/styles/scss/components/_index.scss +++ b/packages/styles/scss/components/_index.scss @@ -22,6 +22,7 @@ @use 'date-picker'; @use 'dropdown'; @use 'file-uploader'; +@use 'fluid-text-input'; @use 'form'; @use 'inline-loading'; @use 'link'; diff --git a/packages/styles/scss/components/fluid-text-input/_fluid-text-input.scss b/packages/styles/scss/components/fluid-text-input/_fluid-text-input.scss new file mode 100644 index 000000000000..fd916cf0c8a3 --- /dev/null +++ b/packages/styles/scss/components/fluid-text-input/_fluid-text-input.scss @@ -0,0 +1,125 @@ +// +// Copyright IBM Corp. 2018, 2018 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +//----------------------------- +// Fluid Text Input +//----------------------------- +@use '../../config' as *; +@use '../../motion' as *; +@use '../../spacing' as *; +@use '../../theme' as *; +@use '../../utilities/convert' as *; +@use '../../utilities/focus-outline' as *; +@use '../text-input'; + +@mixin fluid-text-input { + .#{$prefix}--text-input--fluid.#{$prefix}--text-input-wrapper { + position: relative; + height: 100%; + background: $field; + transition: background-color $duration-fast-01 motion(standard, productive), + outline $duration-fast-01 motion(standard, productive); + } + + .#{$prefix}--text-input--fluid .#{$prefix}--label { + position: absolute; + z-index: 1; + top: rem(13px); + left: $spacing-05; + display: flex; + height: rem(16px); + align-items: center; + margin: 0; + } + + .#{$prefix}--text-input--fluid .#{$prefix}--form__helper-text { + display: none; + } + + .#{$prefix}--text-input--fluid .#{$prefix}--text-input { + min-height: rem(64px); + padding: rem(32px) $spacing-05 rem(13px); + } + + .#{$prefix}--text-input__divider, + .#{$prefix}--text-input--fluid .#{$prefix}--text-input__divider { + display: none; + } + + .#{$prefix}--text-input--fluid + .#{$prefix}--text-input__field-wrapper[data-invalid], + .#{$prefix}--text-input--fluid + .#{$prefix}--text-input__field-wrapper--warning { + display: block; + } + + .#{$prefix}--text-input--fluid input[data-invalid] { + outline: none; + } + + .#{$prefix}--text-input--fluid .#{$prefix}--form-requirement { + padding: $spacing-03 rem(40px) $spacing-03 $spacing-05; + margin: 0; + } + + .#{$prefix}--text-input--fluid .#{$prefix}--text-input--invalid, + .#{$prefix}--text-input--fluid .#{$prefix}--text-input--warning { + border-bottom: none; + } + + .#{$prefix}--text-input--fluid + .#{$prefix}--text-input--invalid + + .#{$prefix}--text-input__divider, + .#{$prefix}--text-input--fluid + .#{$prefix}--text-input--warning + + .#{$prefix}--text-input__divider { + display: block; + border-style: solid; + border-color: $border-subtle; + border-bottom: none; + margin: 0 1rem; + } + + .#{$prefix}--text-input--fluid .#{$prefix}--text-input__invalid-icon { + top: rem(80px); + } + + .#{$prefix}--text-input--fluid + .#{$prefix}--text-input__field-wrapper[data-invalid] + > .#{$prefix}--text-input--invalid, + .#{$prefix}--text-input--fluid + .#{$prefix}--text-input__field-wrapper--warning + > .#{$prefix}--text-input--warning { + outline: none; + } + + .#{$prefix}--text-input--fluid + .#{$prefix}--text-input__field-wrapper--warning { + border-bottom: 1px solid $border-strong; + } + + .#{$prefix}--text-input--fluid + .#{$prefix}--text-input__field-wrapper[data-invalid]:not(:focus) { + @include focus-outline('invalid'); + } + + .#{$prefix}--text-input--fluid + .#{$prefix}--text-input__field-wrapper[data-invalid]:focus-within, + .#{$prefix}--text-input--fluid + .#{$prefix}--text-input__field-wrapper--warning:focus-within { + @include focus-outline('outline'); + } + + .#{$prefix}--text-input--fluid + .#{$prefix}--text-input__field-wrapper[data-invalid] + > .#{$prefix}--text-input--invalid:focus, + .#{$prefix}--text-input--fluid + .#{$prefix}--text-input__field-wrapper--warning + > .#{$prefix}--text-input--warning:focus { + outline: none; + } +} diff --git a/packages/styles/scss/components/fluid-text-input/_index.scss b/packages/styles/scss/components/fluid-text-input/_index.scss new file mode 100644 index 000000000000..a0619895a7bf --- /dev/null +++ b/packages/styles/scss/components/fluid-text-input/_index.scss @@ -0,0 +1,11 @@ +// +// Copyright IBM Corp. 2018, 2018 +// +// This source code is licensed under the Apache-2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +@forward 'fluid-text-input'; +@use 'fluid-text-input'; + +@include fluid-text-input.fluid-text-input; diff --git a/packages/styles/scss/components/text-input/_text-input.scss b/packages/styles/scss/components/text-input/_text-input.scss index 04606f73cef2..7457b6228efc 100644 --- a/packages/styles/scss/components/text-input/_text-input.scss +++ b/packages/styles/scss/components/text-input/_text-input.scss @@ -248,6 +248,8 @@ @include skeleton; } + // Deprecated -- Styles have been moved to `fluid-text-input.scss` + // V12 - Remove this block //----------------------------- // Fluid Text Input //----------------------------- @@ -263,6 +265,9 @@ z-index: 1; top: rem(13px); left: $spacing-05; + display: flex; + height: rem(16px); + align-items: center; margin: 0; } @@ -281,7 +286,7 @@ } .#{$prefix}--form--fluid .#{$prefix}--text-input--invalid, - .#{$prefix}--form--fluid .#{$prefix}--text-input--warn { + .#{$prefix}--form--fluid .#{$prefix}--text-input--warning { border-bottom: none; } @@ -289,7 +294,7 @@ .#{$prefix}--text-input--invalid + .#{$prefix}--text-input__divider, .#{$prefix}--form--fluid - .#{$prefix}--text-input--warn + .#{$prefix}--text-input--warning + .#{$prefix}--text-input__divider { display: block; border-style: solid; @@ -302,15 +307,17 @@ top: rem(80px); } - // V11: Possibly deprecate - .#{$prefix}--form--fluid .#{$prefix}--text-input-wrapper--light { - background: $field-02; - } - .#{$prefix}--form--fluid .#{$prefix}--text-input__field-wrapper[data-invalid] - > .#{$prefix}--text-input--invalid { - @include focus-outline('reset'); + > .#{$prefix}--text-input--invalid, + .#{$prefix}--form--fluid + .#{$prefix}--text-input__field-wrapper--warning + > .#{$prefix}--text-input--warning { + outline: none; + } + + .#{$prefix}--form--fluid .#{$prefix}--text-input__field-wrapper--warning { + border-bottom: 1px solid $border-strong; } .#{$prefix}--form--fluid @@ -319,11 +326,20 @@ } .#{$prefix}--form--fluid - .#{$prefix}--text-input__field-wrapper[data-invalid] - > .#{$prefix}--text-input--invalid:focus { + .#{$prefix}--text-input__field-wrapper[data-invalid]:focus-within, + .#{$prefix}--form--fluid + .#{$prefix}--text-input__field-wrapper--warning:focus-within { @include focus-outline('outline'); } + .#{$prefix}--form--fluid + .#{$prefix}--text-input__field-wrapper[data-invalid] + > .#{$prefix}--text-input--invalid:focus, + .#{$prefix}--form--fluid + .#{$prefix}--text-input__field-wrapper--warning + > .#{$prefix}--text-input--warning:focus { + outline: none; + } //----------------------------- // Inline Text Input //-----------------------------