import React from 'react'; import {TertiaryButton} from '@workday/canvas-kit-react/button'; import { ExtractProps, createContainer, createElemPropsHook, createModelHook, createSubcomponent, dispatchInputEvent, useForkRef, } from '@workday/canvas-kit-react/common'; import {systemIconStencil} from '@workday/canvas-kit-react/icon'; import {Flex, mergeStyles} from '@workday/canvas-kit-react/layout'; import {createStencil, handleCsProp, wrapProperty} from '@workday/canvas-kit-styling'; import {CanvasSystemIcon, xSmallIcon} from '@workday/canvas-system-icons-web'; import {component, system} from '@workday/canvas-tokens-web'; import {TextInput} from './TextInput'; export const useInputGroupModel = createModelHook({})(() => { const inputRef = React.useRef(null); return { state: { inputRef, }, events: {}, }; }); export const inputGroupInnerStencil = createStencil({ vars: { /** * Offset of the inner item. Set by the `InputGroup` and depends on siblings. Do not change this * on your own. */ insetInlineStart: 'initial', /** * Offset of the inner item. Set by the `InputGroup` and depends on siblings. Do not change this * on your own. */ insetInlineEnd: 'initial', width: system.legacy.size.md, height: system.legacy.size.md, /** * Some inner input group elements are decoration only and should not have pointer events */ pointerEvents: '', }, base: ({width, height, insetInlineStart, insetInlineEnd}) => ({ display: 'flex', position: 'absolute', alignItems: 'center', justifyContent: 'center', width, height, insetInlineStart, insetInlineEnd, [systemIconStencil.vars.size]: component.systemIcon.size.xs, }), modifiers: { pointerEvents: { _: ({pointerEvents}) => ({pointerEvents}), }, }, }); const InputGroupInnerStart = createSubcomponent('div')({ modelHook: useInputGroupModel, })>( ({pointerEvents, insetInlineStart, insetInlineEnd, width, height, ...elemProps}, Element) => { return ( ); } ); const InputGroupInnerEnd = createSubcomponent('div')({ modelHook: useInputGroupModel, })>( ({pointerEvents, insetInlineStart, insetInlineEnd, width, height, ...elemProps}, Element) => { return ( ); } ); export const useInputGroupInput = createElemPropsHook(useInputGroupModel)((model, ref) => { const elementRef = useForkRef(ref, model.state.inputRef); return { ref: elementRef, placeholder: '', // Make sure a placeholder attribute always exists for `:placeholder-shown` }; }); export const inputGroupInputStencil = createStencil({ vars: { paddingInlineStart: '', paddingInlineEnd: '', }, base: { display: 'flex', width: '100%', }, modifiers: { paddingInlineStart: { _: ({paddingInlineStart}) => ({paddingInlineStart}), }, paddingInlineEnd: { _: ({paddingInlineEnd}) => ({paddingInlineEnd}), }, }, }); const InputGroupInput = createSubcomponent(TextInput)({ modelHook: useInputGroupModel, elemPropsHook: useInputGroupInput, })>( ({paddingInlineStart, paddingInlineEnd, ...elemProps}, Element) => { return ( ); } ); export const useClearButton = createElemPropsHook(useInputGroupModel)(model => { return { // This element does not need to be accessible via screen reader. The user can already clear // an input role: 'presentation', // A clear input button doesn't need focus. There's already keyboard keys to clear an input tabIndex: -1, icon: xSmallIcon as CanvasSystemIcon, // "small" is needed to render correctly within a `TextInput` size: 'small', // prevent a focus change to the button. Focus should stay in the input. onMouseDown(event: React.MouseEvent) { event.preventDefault(); }, onClick() { // This will clear the input's value dispatchInputEvent(model.state.inputRef.current, ''); }, } as const; }); const clearButtonStencil = createStencil({ base: { svg: { [systemIconStencil.vars.size]: component.systemIcon.size.xs, }, }, }); /** * A clear input button. This can be a component later. */ const ClearButton = createSubcomponent(TertiaryButton)({ modelHook: useInputGroupModel, elemPropsHook: useClearButton, })>((elemProps, Element) => { return ( ); }); // make sure we always use pixels if the input is a number - this is required for `calc` const toPx = (input: string | number | undefined): string | undefined => { return typeof input === 'number' ? `${input}px` : input; }; // wrap an array of widths into something the browser can understand, including `calc` for multiple // values const wrapInCalc = (values: (string | number)[]): string | number | undefined => { if (values.length === 0) { return undefined; } if (values.length === 1) { return values[0]; } return `calc(${values.map(toPx).join(' + ')})`; }; export const inputGroupStencil = createStencil({ base: { display: 'flex', position: 'relative', alignItems: 'center', // Clear Button '& :has([data-part="input-group-clear-button"])': { transition: 'opacity 300ms ease', }, // Clear Button when a placeholder is being shown (no value) '&:where(:has(input:placeholder-shown)) :has([data-part="input-group-clear-button"])': { opacity: 0, pointerEvents: 'none', }, }, }); /** * An `InputGroup` is a container around a {@link TextInput} with optional inner start and end * elements. The inner start and end elements are usually icons or icon buttons visually represented * inside the input. The `InputGroup` will add padding to the input so the icons/buttons display * correctly. This component uses `React.Children.map` and `React.cloneElement` from the * [React.Children](https://react.dev/reference/react/Children) API. This means all children must be * `InputGroup.*` components. Any other direct children will cause issues. You can add different * elements/components inside the {@link InputGroupInnerStart InputGroup.InnerStart} and * {@link InputGroupInnerEnd InputGroup.InnerEnd} subcomponents. * * ```tsx * * * * * * * * ``` */ export const InputGroup = createContainer('div')({ displayName: 'InputGroup', modelHook: useInputGroupModel, subComponents: { /** * A component to show inside and at the start of the input. The input's padding will be * adjusted by the `InputGroup` to not overlap with this element. Use `width` to adjust the * width offset. The width defaults to 40px which is the correct width for icons or icon * buttons. */ InnerStart: InputGroupInnerStart, /** * The input to render. By default, this is a {@link TextInput}. Use the `as` prop to change the * component to be rendered. */ Input: InputGroupInput, /** * A component to show inside and at the end of the input. The input's padding will be adjusted * by the `InputGroup` to not overlap with this element. Use `width` to adjust the width offset. * The width defaults to 40px which is the correct width for icons or icon buttons within the * input. */ InnerEnd: InputGroupInnerEnd, /** * A component that can be added to an input group that will clear the input. It will only render * when the input has a value and will fade when a value is entered. */ ClearButton: ClearButton, }, })>(({children, ...elemProps}, Element) => { const offsetsStart: (string | number)[] = []; const offsetsEnd: (string | number)[] = []; // Collect the widths of the `InnerStart` and `InnerEnd` components into `offsetStart` and // `offsetEnd` arrays React.Children.forEach(children, child => { if (React.isValidElement(child) && child.type === InputGroupInnerStart) { const width = wrapProperty(child.props.width || system.legacy.size.md); offsetsStart.push(width); } if (React.isValidElement(child) && child.type === InputGroupInnerEnd) { const width = wrapProperty(child.props.width || system.legacy.size.md); offsetsEnd.push(width); } }); // keep track of the index offsets to make sure we calculate the correct position offset let indexStart = 0; let indexEnd = 0; // Loop over all the children and set the correct padding and positions const mappedChildren = React.Children.map(children, child => { if (React.isValidElement(child)) { if (child.type === InputGroupInput) { return React.cloneElement(child, { paddingInlineStart: wrapInCalc(offsetsStart), paddingInlineEnd: wrapInCalc(offsetsEnd), }); } if (child.type === InputGroupInnerStart) { const offset = wrapInCalc(offsetsStart.slice(0, indexStart)) || '0px'; indexStart++; return React.cloneElement(child, { insetInlineStart: offset, }); } if (child.type === InputGroupInnerEnd) { const offset = wrapInCalc(offsetsEnd.slice(indexEnd + 1, offsetsEnd.length)) || '0px'; indexEnd++; return React.cloneElement(child, { insetInlineEnd: offset, }); } } return child; }); return {mappedChildren}; });