diff --git a/packages/react/src/components/ComposedModal/next/ModalHeader-test.js b/packages/react/src/components/ComposedModal/next/ModalHeader-test.js index 42a480f1b293..2c63bd96b230 100644 --- a/packages/react/src/components/ComposedModal/next/ModalHeader-test.js +++ b/packages/react/src/components/ComposedModal/next/ModalHeader-test.js @@ -24,7 +24,3 @@ describe('ModalHeader', () => { expect(container.firstChild).toHaveTextContent('Carbon label'); }); }); - -// TODO: write tests for composed modal -// TODO: write tests for modal body -// TODO: write tests for modal footer diff --git a/packages/react/src/components/Search/index.js b/packages/react/src/components/Search/index.js index b617c32e6275..5b710f70130e 100644 --- a/packages/react/src/components/Search/index.js +++ b/packages/react/src/components/Search/index.js @@ -1,9 +1,17 @@ /** - * Copyright IBM Corp. 2016, 2018 + * Copyright IBM Corp. 2016, 2021 * * 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 * as FeatureFlags from '@carbon/feature-flags'; +import { default as SearchNext } from './next/Search'; +import { default as SearchClassic } from './Search'; + +const Search = FeatureFlags.enabled('enable-v11-release') + ? SearchNext + : SearchClassic; export * from './Search.Skeleton'; -export default from './Search'; + +export default Search; diff --git a/packages/react/src/components/Search/next/Search-test.js b/packages/react/src/components/Search/next/Search-test.js new file mode 100644 index 000000000000..68fd7b93136b --- /dev/null +++ b/packages/react/src/components/Search/next/Search-test.js @@ -0,0 +1,281 @@ +/** + * 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 { Search16, Close16 } from '@carbon/icons-react'; +import Search from './Search'; +import { mount, shallow } from 'enzyme'; +import { settings } from 'carbon-components'; + +const { prefix } = settings; + +describe('Search', () => { + describe('renders as expected', () => { + const wrapper = mount( + + ); + + const label = wrapper.find('label'); + const textInput = wrapper.find('input'); + const container = wrapper.find(`.${prefix}--search`); + + describe('container', () => { + it('should add extra classes that are passed via className', () => { + expect(container.hasClass('extra-class')).toEqual(true); + }); + }); + + describe('input', () => { + it('renders as expected', () => { + expect(textInput.length).toBe(1); + }); + + it('has the expected classes', () => { + expect(textInput.hasClass(`${prefix}--search-input`)).toEqual(true); + }); + + it('should set type as expected', () => { + expect(textInput.props().type).toEqual('text'); + wrapper.setProps({ type: 'email' }); + expect(wrapper.find('input').props().type).toEqual('email'); + }); + + it('should set value as expected', () => { + expect(textInput.props().defaultValue).toEqual(undefined); + wrapper.setProps({ defaultValue: 'test' }); + expect(wrapper.find('input').props().defaultValue).toEqual('test'); + expect(wrapper.find('input').props().value).toEqual(undefined); + }); + + it('should set placeholder as expected', () => { + expect(textInput.props().placeholder).toEqual(''); + wrapper.setProps({ placeholder: 'Enter text' }); + expect(wrapper.find('input').props().placeholder).toEqual('Enter text'); + }); + }); + + describe('label', () => { + it('renders a label', () => { + expect(label.length).toBe(1); + }); + + it('has the expected classes', () => { + expect(label.hasClass(`${prefix}--label`)).toEqual(true); + }); + + it('should set label as expected', () => { + expect(wrapper.props().label).toEqual('Search Field'); + wrapper.setProps({ label: 'Email Input' }); + expect(wrapper.props().label).toEqual('Email Input'); + }); + }); + + describe('Large Search', () => { + const large = mount( + + ); + + const largeContainer = large.find(`.${prefix}--search`); + + it('renders correct search icon', () => { + const icons = large.find(Search16); + expect(icons.length).toBe(1); + }); + + it('should have the expected large class', () => { + expect(largeContainer.hasClass(`${prefix}--search--lg`)).toEqual(true); + }); + + it('should only have 1 button (clear)', () => { + const btn = large.find('button'); + expect(btn.length).toEqual(1); + }); + + it('renders two Icons', () => { + const iconTypes = [Search16, Close16]; + const icons = large.findWhere((n) => iconTypes.includes(n.type())); + expect(icons.length).toEqual(2); + }); + + describe('buttons', () => { + const btns = wrapper.find('button'); + + it('should be one button', () => { + expect(btns.length).toBe(1); + }); + + it('should have type="button"', () => { + const type1 = btns.first().instance().getAttribute('type'); + const type2 = btns.last().instance().getAttribute('type'); + expect(type1).toEqual('button'); + expect(type2).toEqual('button'); + }); + }); + + describe('icons', () => { + it('renders "search" icon', () => { + const icons = wrapper.find(Search16); + expect(icons.length).toBe(1); + }); + + it('renders two Icons', () => { + wrapper.setProps({ size: undefined }); + const iconTypes = [Search16, Close16]; + const icons = wrapper.findWhere((n) => iconTypes.includes(n.type())); + expect(icons.length).toEqual(2); + }); + }); + }); + + describe('Small Search', () => { + const small = mount( + + ); + + const smallContainer = small.find(`.${prefix}--search`); + + it('renders correct search icon', () => { + const icons = small.find(Search16); + expect(icons.length).toBe(1); + }); + + it('should have the expected small class', () => { + expect(smallContainer.hasClass(`${prefix}--search--sm`)).toEqual(true); + }); + + it('should only have 1 button (clear)', () => { + const btn = small.find('button'); + expect(btn.length).toEqual(1); + }); + + it('renders two Icons', () => { + const iconTypes = [Search16, Close16]; + const icons = wrapper.findWhere((n) => iconTypes.includes(n.type())); + expect(icons.length).toEqual(2); + }); + }); + }); + + describe('events', () => { + describe('enabled textinput', () => { + const onClick = jest.fn(); + const onChange = jest.fn(); + const onClear = jest.fn(); + + const wrapper = shallow( + + ); + + const input = wrapper.find('input'); + const eventObject = { + target: { + defaultValue: 'test', + }, + }; + + it('should invoke onClick when input is clicked', () => { + input.simulate('click'); + expect(onClick).toHaveBeenCalled(); + }); + + it('should invoke onChange when input value is changed', () => { + input.simulate('change', eventObject); + expect(onChange).toHaveBeenCalledWith(eventObject); + }); + + it('should call onClear when input value is cleared', () => { + const node = document.createElement('div'); + document.body.appendChild(node); + + const wrapper = mount( + , + { + attachTo: node, + } + ); + + wrapper.find('button').simulate('click', { + target: { + value: 'test', + }, + }); + expect(onClear).toHaveBeenCalled(); + expect(wrapper.find('input').getDOMNode()).toHaveFocus(); + + document.body.removeChild(node); + }); + }); + }); +}); + +/** + * Find the element. + * @param {Enzymecontainer} wrapper + * @returns {Enzymecontainer} + */ +const getInput = (wrapper) => { + return wrapper.find(`.${prefix}--search-input`); +}; + +/** + * Find the value of the element + * @param {EnzymeWrapper} wrapper + * @returns {number} + */ +const getInputValue = (wrapper) => { + return getInput(wrapper).prop('value'); +}; + +describe('Detecting change in value from props', () => { + it('should have empty value', () => { + const search = shallow( + + ); + expect(getInputValue(search)).toBe(undefined); + }); + + it('should set value if value prop is added', () => { + const search = shallow( + + ); + + expect(getInputValue(search)).toBe('foo'); + }); +}); diff --git a/packages/react/src/components/Search/next/Search.js b/packages/react/src/components/Search/next/Search.js new file mode 100644 index 000000000000..d1d7b2b77c34 --- /dev/null +++ b/packages/react/src/components/Search/next/Search.js @@ -0,0 +1,261 @@ +/** + * 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 { Search16, Close16 } from '@carbon/icons-react'; +import cx from 'classnames'; +import PropTypes from 'prop-types'; +import React, { useRef, useState } from 'react'; +import { focus } from '../../../internal/focus'; +import { keys, match } from '../../../internal/keyboard'; +import { useId } from '../../../internal/useId'; +import { usePrefix } from '../../../internal/usePrefix'; +import deprecate from '../../../prop-types/deprecate'; +import { composeEventHandlers } from '../../../tools/events'; + +function Search({ + autoComplete = 'off', + className, + closeButtonLabelText = 'Clear search input', + defaultValue, + disabled, + id, + labelText, + light, + onChange = () => {}, + onClear = () => {}, + onKeyDown, + placeHolderText, + placeholder = '', + renderIcon, + role = 'searchbox', + size = !small ? 'lg' : 'sm', + small, + type = 'text', + value, + ...rest +}) { + const prefix = usePrefix(); + const input = useRef(null); + const magnifier = useRef(null); + const inputId = useId('search-input'); + const uniqueId = id || inputId; + const searchId = `${uniqueId}-search`; + const [hasContent, setHasContent] = useState(value || defaultValue || false); + const [prevValue, setPrevValue] = useState(value); + const searchClasses = cx({ + [`${prefix}--search`]: true, + [`${prefix}--search--sm`]: size === 'sm', + [`${prefix}--search--md`]: size === 'md', + [`${prefix}--search--lg`]: size === 'lg', + [`${prefix}--search--light`]: light, + [`${prefix}--search--disabled`]: disabled, + [className]: className, + }); + const clearClasses = cx({ + [`${prefix}--search-close`]: true, + [`${prefix}--search-close--hidden`]: !hasContent, + }); + + if (value !== prevValue) { + setHasContent(!!value); + setPrevValue(value); + } + + function clearInput(event) { + if (!value) { + input.current.value = ''; + onChange(event); + } else { + const clearedEvt = Object.assign({}, event.target, { + target: { + value: '', + }, + }); + onChange(clearedEvt); + } + + onClear(); + setHasContent(false); + focus(input); + } + + function handleChange(event) { + setHasContent(event.target.value !== ''); + } + + function handleKeyDown(event) { + if (match(event, keys.Escape)) { + event.stopPropagation(); + clearInput(event); + } + } + + return ( +
+
+ +
+ + + +
+ ); +} + +Search.propTypes = { + /** + * Specify an optional value for the `autocomplete` property on the underlying + * ``, defaults to "off" + */ + autoComplete: PropTypes.string, + + /** + * Specify an optional className to be applied to the container node + */ + className: PropTypes.string, + + /** + * Specify a label to be read by screen readers on the "close" button + */ + closeButtonLabelText: 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 input + */ + id: PropTypes.string, + + /** + * Provide the label text for the Search icon + */ + labelText: PropTypes.node.isRequired, + + /** + * Specify light version or default version of this control + */ + light: PropTypes.bool, + + /** + * Optional callback called when the search value changes. + */ + onChange: PropTypes.func, + + /** + * Optional callback called when the search value is cleared. + */ + onClear: PropTypes.func, + + /** + * Provide a handler that is invoked on the key down event for the input + */ + onKeyDown: PropTypes.func, + + /** + * Deprecated in favor of `placeholder` + */ + placeHolderText: deprecate( + PropTypes.string, + `\nThe prop \`placeHolderText\` for Search has been deprecated in favor of \`placeholder\`. Please use \`placeholder\` instead.` + ), + + /** + * Provide an optional placeholder text for the Search. + * Note: if the label and placeholder differ, + * VoiceOver on Mac will read both + */ + placeholder: PropTypes.string, + + /** + * Rendered icon for the Search. + * Can be a React component class + */ + renderIcon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), + + /** + * Specify the role for the underlying ``, defaults to `searchbox` + */ + role: PropTypes.string, + + /** + * Specify the search size + */ + size: PropTypes.oneOf(['sm', 'md', 'lg', 'xl']), + + /** + * Specify whether the Search should be a small variant + */ + + /** + * Specify whether the load was successful + */ + small: deprecate( + PropTypes.bool, + `\nThe prop \`small\` for Search has been deprecated in favor of \`size\`. Please use \`size="sm"\` instead.` + ), + + /** + * Optional prop to specify the type of the `` + */ + type: PropTypes.string, + + /** + * Specify the value of the `` + */ + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}; + +function SearchIcon({ icon }) { + const prefix = usePrefix(); + + if (icon) { + return React.cloneElement(icon, { + className: `${prefix}--search-magnifier-icon`, + }); + } + return ; +} + +SearchIcon.propTypes = { + /** + * Rendered icon for the Search. Can be a React component class + */ + icon: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), +}; + +export default Search;