diff --git a/packages/examples/src/examples/adornments.ts b/packages/examples/src/examples/adornments.ts new file mode 100644 index 000000000..337486deb --- /dev/null +++ b/packages/examples/src/examples/adornments.ts @@ -0,0 +1,136 @@ +/* + The MIT License + + Copyright (c) 2017-2026 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import { registerExamples } from '../register'; + +export const schema = { + type: 'object', + properties: { + price: { + type: 'number', + }, + weight: { + type: 'number', + }, + quantity: { + type: 'integer', + }, + website: { + type: 'string', + }, + username: { + type: 'string', + }, + voltage: { + type: 'string', + enum: ['110', '230', '400'], + }, + frequency: { + type: 'string', + oneOf: [ + { const: '50', title: '50' }, + { const: '60', title: '60' }, + ], + }, + }, +}; + +export const uischema = { + type: 'VerticalLayout', + elements: [ + { + type: 'HorizontalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/price', + options: { startAdornment: '€' }, + }, + { + type: 'Control', + scope: '#/properties/weight', + options: { endAdornment: 'kg' }, + }, + { + type: 'Control', + scope: '#/properties/quantity', + options: { endAdornment: 'pcs' }, + }, + ], + }, + { + type: 'HorizontalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/website', + options: { startAdornment: 'https://' }, + }, + { + type: 'Control', + scope: '#/properties/username', + options: { startAdornment: '@', endAdornment: '.example.com' }, + }, + ], + }, + { + type: 'HorizontalLayout', + elements: [ + { + type: 'Control', + scope: '#/properties/voltage', + label: 'Voltage (autocomplete)', + options: { endAdornment: 'V' }, + }, + { + type: 'Control', + scope: '#/properties/voltage', + label: 'Voltage (select)', + options: { autocomplete: false, endAdornment: 'V' }, + }, + { + type: 'Control', + scope: '#/properties/frequency', + options: { autocomplete: false, endAdornment: 'Hz' }, + }, + ], + }, + ], +}; + +export const data = { + weight: 12.5, + username: 'jane', + voltage: '230', +}; + +registerExamples([ + { + name: 'adornments', + label: 'Adornments', + data, + schema, + uischema, + }, +]); diff --git a/packages/examples/src/index.ts b/packages/examples/src/index.ts index cf75ae737..fe63f8fc8 100644 --- a/packages/examples/src/index.ts +++ b/packages/examples/src/index.ts @@ -26,6 +26,7 @@ import * as issue_1884 from './examples/1884'; import * as issue_1948 from './examples/1948'; import * as additionalErrors from './examples/additional-errors'; import * as additionalProperties from './examples/additional-properties'; +import * as adornments from './examples/adornments'; import * as allOf from './examples/allOf'; import * as anyOf from './examples/anyOf'; import * as anyOfOneOfAllOfResolve from './examples/anyOf-oneOf-allOf-resolve'; @@ -92,6 +93,7 @@ import * as jsonschema from './examples/jsonschema'; export { additionalErrors, additionalProperties, + adornments, allOf, anyOf, anyOfOneOfAllOfResolve, diff --git a/packages/material-renderers/src/controls/MaterialAnyOfStringOrEnumControl.tsx b/packages/material-renderers/src/controls/MaterialAnyOfStringOrEnumControl.tsx index 47090b64b..35c927043 100644 --- a/packages/material-renderers/src/controls/MaterialAnyOfStringOrEnumControl.tsx +++ b/packages/material-renderers/src/controls/MaterialAnyOfStringOrEnumControl.tsx @@ -40,6 +40,7 @@ import merge from 'lodash/merge'; import React, { useMemo } from 'react'; import { useDebouncedChange, + useInputAdornments, useInputComponent, WithInputProps, useFocus, @@ -78,6 +79,9 @@ const MuiAutocompleteInputText = ( () => merge({}, config, uischema.options), [config, uischema.options] ); + const { endAdornment, ...adornmentProps } = useInputAdornments( + appliedUiSchemaOptions + ); const inputProps: InputBaseComponentProps = useMemo(() => { let propMemo: InputBaseComponentProps = {}; if (appliedUiSchemaOptions.restrict) { @@ -122,7 +126,13 @@ const MuiAutocompleteInputText = ( fullWidth={!appliedUiSchemaOptions.trim || maxLength === undefined} inputProps={inputProps} error={!isValid} - endAdornment={dataList} + {...adornmentProps} + endAdornment={ + <> + {dataList} + {endAdornment} + + } /> ); }; diff --git a/packages/material-renderers/src/mui-controls/MuiAutocomplete.tsx b/packages/material-renderers/src/mui-controls/MuiAutocomplete.tsx index 0bd646614..89c0c824c 100644 --- a/packages/material-renderers/src/mui-controls/MuiAutocomplete.tsx +++ b/packages/material-renderers/src/mui-controls/MuiAutocomplete.tsx @@ -39,6 +39,7 @@ import FormHelperText from '@mui/material/FormHelperText'; import TextField from '@mui/material/TextField'; import merge from 'lodash/merge'; import { useFocus } from '../util/focus'; +import { useInputAdornments } from '../util/adornment'; export interface WithOptionLabel { getOptionLabel?(option: EnumOption): string; @@ -79,6 +80,9 @@ export const MuiAutocomplete = ( const appliedUiSchemaOptions = merge({}, config, uischema.options); const [inputValue, setInputValue] = React.useState(data ?? ''); const [focused, onFocus, onBlur] = useFocus(); + const { startAdornment, endAdornment } = useInputAdornments( + appliedUiSchemaOptions + ); const findOption = options.find((o) => o.value === data) ?? null; @@ -133,6 +137,17 @@ export const MuiAutocomplete = ( fullWidth={!appliedUiSchemaOptions.trim} slotProps={{ ...params.slotProps, + input: { + ...params.slotProps.input, + startAdornment: + startAdornment ?? params.slotProps.input.startAdornment, + endAdornment: ( + <> + {endAdornment} + {params.slotProps.input.endAdornment} + + ), + }, inputLabel: { ...params.slotProps.inputLabel, ...(data ? { shrink: true } : undefined), diff --git a/packages/material-renderers/src/mui-controls/MuiInputInteger.tsx b/packages/material-renderers/src/mui-controls/MuiInputInteger.tsx index 8d69b1e63..1c202101a 100644 --- a/packages/material-renderers/src/mui-controls/MuiInputInteger.tsx +++ b/packages/material-renderers/src/mui-controls/MuiInputInteger.tsx @@ -27,6 +27,7 @@ import { CellProps, WithClassname } from '@jsonforms/core'; import merge from 'lodash/merge'; import { useDebouncedChange, + useInputAdornments, useInputComponent, WithInputProps, useFocus, @@ -56,6 +57,7 @@ export const MuiInputInteger = React.memo(function MuiInputInteger( const inputProps = { step: '1' }; const appliedUiSchemaOptions = merge({}, config, uischema.options); + const adornmentProps = useInputAdornments(appliedUiSchemaOptions); const [inputValue, onChange] = useDebouncedChange( handleChange, @@ -83,6 +85,7 @@ export const MuiInputInteger = React.memo(function MuiInputInteger( inputProps={inputProps} fullWidth={true} error={!isValid} + {...adornmentProps} /> ); }); diff --git a/packages/material-renderers/src/mui-controls/MuiInputNumber.tsx b/packages/material-renderers/src/mui-controls/MuiInputNumber.tsx index f31e90f96..0697a4920 100644 --- a/packages/material-renderers/src/mui-controls/MuiInputNumber.tsx +++ b/packages/material-renderers/src/mui-controls/MuiInputNumber.tsx @@ -27,6 +27,7 @@ import { CellProps, WithClassname } from '@jsonforms/core'; import merge from 'lodash/merge'; import { useDebouncedChange, + useInputAdornments, useInputComponent, WithInputProps, useFocus, @@ -55,6 +56,7 @@ export const MuiInputNumber = React.memo(function MuiInputNumber( const inputProps = { step: '0.1' }; const appliedUiSchemaOptions = merge({}, config, uischema.options); + const adornmentProps = useInputAdornments(appliedUiSchemaOptions); const [inputValue, onChange] = useDebouncedChange( handleChange, '', @@ -81,6 +83,7 @@ export const MuiInputNumber = React.memo(function MuiInputNumber( inputProps={inputProps} fullWidth={true} error={!isValid} + {...adornmentProps} /> ); }); diff --git a/packages/material-renderers/src/mui-controls/MuiInputNumberFormat.tsx b/packages/material-renderers/src/mui-controls/MuiInputNumberFormat.tsx index e7ee02099..06a040220 100644 --- a/packages/material-renderers/src/mui-controls/MuiInputNumberFormat.tsx +++ b/packages/material-renderers/src/mui-controls/MuiInputNumberFormat.tsx @@ -27,6 +27,7 @@ import { CellProps, Formatted, WithClassname } from '@jsonforms/core'; import merge from 'lodash/merge'; import { useDebouncedChange, + useInputAdornments, useInputComponent, WithInputProps, useFocus, @@ -51,6 +52,7 @@ export const MuiInputNumberFormat = React.memo(function MuiInputNumberFormat( const InputComponent = useInputComponent(); const maxLength = schema.maxLength; const appliedUiSchemaOptions = merge({}, config, uischema.options); + const adornmentProps = useInputAdornments(appliedUiSchemaOptions); let inputProps; if (appliedUiSchemaOptions.restrict) { inputProps = { maxLength: maxLength }; @@ -90,6 +92,7 @@ export const MuiInputNumberFormat = React.memo(function MuiInputNumberFormat( fullWidth={!appliedUiSchemaOptions.trim || maxLength === undefined} inputProps={inputProps} error={!isValid} + {...adornmentProps} /> ); }); diff --git a/packages/material-renderers/src/mui-controls/MuiInputText.tsx b/packages/material-renderers/src/mui-controls/MuiInputText.tsx index ddc3b0d3a..2730bdbdf 100644 --- a/packages/material-renderers/src/mui-controls/MuiInputText.tsx +++ b/packages/material-renderers/src/mui-controls/MuiInputText.tsx @@ -35,6 +35,7 @@ import { JsonFormsTheme, WithInputProps, useDebouncedChange, + useInputAdornments, useInputComponent, useFocus, } from '../util'; @@ -97,6 +98,9 @@ export const MuiInputText = React.memo(function MuiInputText( const onPointerLeave = () => setShowAdornment(false); const theme: JsonFormsTheme = useTheme(); + const { endAdornment, ...adornmentProps } = useInputAdornments( + appliedUiSchemaOptions + ); const closeStyle = { background: @@ -105,6 +109,19 @@ export const MuiInputText = React.memo(function MuiInputText( borderRadius: '50%', }; + const isClearable = showAdornment && enabled && data !== undefined; + // With an end adornment, the clear button stays in the layout so it never covers the adornment + const clearButtonStyle: React.CSSProperties = endAdornment + ? { + display: enabled ? 'flex' : 'none', + visibility: isClearable ? 'visible' : 'hidden', + } + : { + display: isClearable ? 'flex' : 'none', + position: 'absolute', + right: 0, + }; + return ( - - - - + <> + + + + + + {endAdornment} + } inputComponent={inputComponent} /> diff --git a/packages/material-renderers/src/mui-controls/MuiSelect.tsx b/packages/material-renderers/src/mui-controls/MuiSelect.tsx index ee77f4513..bad582590 100644 --- a/packages/material-renderers/src/mui-controls/MuiSelect.tsx +++ b/packages/material-renderers/src/mui-controls/MuiSelect.tsx @@ -29,7 +29,16 @@ import MenuItem from '@mui/material/MenuItem'; import Select from '@mui/material/Select'; import merge from 'lodash/merge'; import { TranslateProps } from '@jsonforms/react'; -import { i18nDefaults, WithInputProps, WithSelectProps } from '../util'; +import { + i18nDefaults, + InputAdornmentSx, + useInputAdornments, + WithInputProps, + WithSelectProps, +} from '../util'; + +// MUI positions the dropdown arrow absolutely, so it would cover the end adornment otherwise +const adornmentSx: InputAdornmentSx = { endAdornmentSx: { mr: 4 } }; export const MuiSelect = React.memo(function MuiSelect( props: EnumCellProps & @@ -55,6 +64,10 @@ export const MuiSelect = React.memo(function MuiSelect( multiple, } = props; const appliedUiSchemaOptions = merge({}, config, uischema.options); + const adornmentProps = useInputAdornments( + appliedUiSchemaOptions, + adornmentSx + ); const noneOptionLabel = useMemo( () => t('enum.none', i18nDefaults['enum.none'], { schema, uischema, path }), [t, schema, uischema, path] @@ -72,6 +85,7 @@ export const MuiSelect = React.memo(function MuiSelect( fullWidth={true} multiple={multiple || false} error={!isValid} + {...adornmentProps} > {[ diff --git a/packages/material-renderers/src/util/adornment.tsx b/packages/material-renderers/src/util/adornment.tsx new file mode 100644 index 000000000..9747a2c5a --- /dev/null +++ b/packages/material-renderers/src/util/adornment.tsx @@ -0,0 +1,72 @@ +/* + The MIT License + + Copyright (c) 2017-2026 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import React, { ReactNode } from 'react'; +import InputAdornment from '@mui/material/InputAdornment'; +import { SxProps, Theme } from '@mui/material/styles'; + +export interface AdornmentOptions { + startAdornment?: unknown; + endAdornment?: unknown; +} + +export interface AdornedInputProps { + startAdornment?: ReactNode; + endAdornment?: ReactNode; +} + +export interface InputAdornmentSx { + startAdornmentSx?: SxProps; + endAdornmentSx?: SxProps; +} + +export const isAdornmentText = (value: unknown): value is string => + typeof value === 'string' && value !== ''; + +/** + * Creates the start and end adornments configured via the `startAdornment` + * and `endAdornment` UI schema options. + */ +export const useInputAdornments = ( + options: AdornmentOptions, + { startAdornmentSx, endAdornmentSx }: InputAdornmentSx = {} +): AdornedInputProps => { + const { startAdornment, endAdornment } = options; + const adornmentProps: AdornedInputProps = {}; + if (isAdornmentText(startAdornment)) { + adornmentProps.startAdornment = ( + + {startAdornment} + + ); + } + if (isAdornmentText(endAdornment)) { + adornmentProps.endAdornment = ( + + {endAdornment} + + ); + } + return adornmentProps; +}; diff --git a/packages/material-renderers/src/util/index.ts b/packages/material-renderers/src/util/index.ts index 8ac005b54..70943267a 100644 --- a/packages/material-renderers/src/util/index.ts +++ b/packages/material-renderers/src/util/index.ts @@ -28,3 +28,4 @@ export * from './theme'; export * from './debounce'; export * from './focus'; export * from './i18nDefaults'; +export * from './adornment'; diff --git a/packages/material-renderers/test/renderers/MaterialInputAdornments.test.tsx b/packages/material-renderers/test/renderers/MaterialInputAdornments.test.tsx new file mode 100644 index 000000000..e0e848d9d --- /dev/null +++ b/packages/material-renderers/test/renderers/MaterialInputAdornments.test.tsx @@ -0,0 +1,288 @@ +/* + The MIT License + + Copyright (c) 2017-2026 EclipseSource Munich + https://github.com/eclipsesource/jsonforms + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import Enzyme, { mount, ReactWrapper } from 'enzyme'; +import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; +import { ControlElement, JsonSchema7 } from '@jsonforms/core'; +import { JsonForms } from '@jsonforms/react'; +import InputAdornment from '@mui/material/InputAdornment'; +import OutlinedInput from '@mui/material/OutlinedInput'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; +import { + materialCells, + materialRenderers, + MuiInputNumberFormat, + useInputAdornments, +} from '../../src'; + +Enzyme.configure({ adapter: new Adapter() }); + +const adornments = { startAdornment: '€', endAdornment: 'kg' }; + +const renderControl = ( + property: JsonSchema7, + options: Record, + data?: unknown, + enabled = true +) => { + const uischema: ControlElement = { + type: 'Control', + scope: '#/properties/field', + options, + }; + return mount( + + ); +}; + +const adornmentTexts = (wrapper: ReactWrapper, position: 'Start' | 'End') => + wrapper + .find(`.MuiInputAdornment-position${position}`) + .hostNodes() + .map((node) => node.text()) + .filter((text) => text !== ''); + +const enumSchema: JsonSchema7 = { type: 'string', enum: ['110', '230'] }; +const oneOfEnumSchema: JsonSchema7 = { + oneOf: [ + { const: '110', title: '110' }, + { const: '230', title: '230' }, + ], +}; + +describe('Material input adornments', () => { + let wrapper: ReactWrapper; + + afterEach(() => { + wrapper.unmount(); + }); + + describe.each<[string, JsonSchema7, Record]>([ + ['text', { type: 'string' }, {}], + ['number', { type: 'number' }, {}], + ['integer', { type: 'integer' }, {}], + ['enum autocomplete', enumSchema, {}], + ['enum select', enumSchema, { autocomplete: false }], + ['oneOf enum autocomplete', oneOfEnumSchema, {}], + ['oneOf enum select', oneOfEnumSchema, { autocomplete: false }], + [ + 'anyOf string or enum', + { anyOf: [{ type: 'string' }, { enum: ['foo', 'bar'] }] }, + {}, + ], + ])('%s control', (_name, property, options) => { + it('renders the configured start and end adornment', () => { + wrapper = renderControl(property, { ...options, ...adornments }); + expect(adornmentTexts(wrapper, 'Start')).toEqual(['€']); + expect(adornmentTexts(wrapper, 'End')).toEqual(['kg']); + }); + + it('renders no adornment text without adornment options', () => { + wrapper = renderControl(property, options); + expect(adornmentTexts(wrapper, 'Start')).toEqual([]); + expect(adornmentTexts(wrapper, 'End')).toEqual([]); + }); + }); + + it('renders adornments in the number format input', () => { + wrapper = mount( + {}} + toFormatted={(value: number) => value.toLocaleString('en-US')} + fromFormatted={(value: string) => Number(value.replace(/,/g, ''))} + /> + ); + expect(adornmentTexts(wrapper, 'Start')).toEqual(['€']); + expect(adornmentTexts(wrapper, 'End')).toEqual(['kg']); + }); + + it('ignores adornment options that are not non-empty strings', () => { + wrapper = renderControl( + { type: 'string' }, + { startAdornment: 5, endAdornment: '' } + ); + expect(adornmentTexts(wrapper, 'Start')).toEqual([]); + expect(adornmentTexts(wrapper, 'End')).toEqual([]); + }); + + it('applies the given sx to the start and end adornment', () => { + const AdornedInput = () => { + const { startAdornment, endAdornment } = useInputAdornments(adornments, { + startAdornmentSx: { ml: 1 }, + endAdornmentSx: { mr: 2 }, + }); + return ( + + ); + }; + wrapper = mount(); + const sxByPosition = wrapper + .find(InputAdornment) + .map((adornment) => [adornment.props().position, adornment.props().sx]); + expect(sxByPosition).toEqual([ + ['start', { ml: 1 }], + ['end', { mr: 2 }], + ]); + }); + + describe('label', () => { + it.each(['standard', 'outlined', 'filled'] as const)( + 'shrinks the label for a rendered start adornment with the %s variant', + (variant) => { + wrapper = mount( + + + + ); + expect(wrapper.find('label').hasClass('MuiInputLabel-shrink')).toBe( + true + ); + if (variant === 'outlined') { + expect(wrapper.find('NotchedOutline').prop('notched')).toBe(true); + } + } + ); + + it('shrinks an empty field label when a start adornment is set', () => { + wrapper = renderControl({ type: 'string' }, adornments); + expect(wrapper.find('label').hasClass('MuiInputLabel-shrink')).toBe(true); + expect(wrapper.find('NotchedOutline').prop('notched')).toBe(true); + }); + + it('keeps an empty field label unshrunk without a start adornment', () => { + wrapper = renderControl({ type: 'string' }, { endAdornment: 'kg' }); + expect(wrapper.find('label').hasClass('MuiInputLabel-shrink')).toBe( + false + ); + }); + }); + + describe('built-in end adornments', () => { + it('keeps the autocomplete popup button next to the end adornment', () => { + wrapper = renderControl(enumSchema, adornments); + expect( + wrapper.find('.MuiAutocomplete-popupIndicator').hostNodes() + ).toHaveLength(1); + }); + + it('keeps the anyOf datalist next to the end adornment', () => { + wrapper = renderControl( + { anyOf: [{ type: 'string' }, { enum: ['foo', 'bar'] }] }, + adornments + ); + expect(wrapper.find('datalist').children()).toHaveLength(2); + }); + }); + + describe('text clear button with an end adornment', () => { + const clearButtonStyle = () => + wrapper + .find('button[aria-label="Clear input field"]') + .closest(InputAdornment) + .props().style; + + const hover = () => { + // call onPointerEnter prop manually as the tests seem to ignore 'pointerenter' events + act(() => { + wrapper.find(OutlinedInput).props().onPointerEnter?.call(this); + }); + wrapper.update(); + }; + + it.each([ + ['empty', undefined, true, false], + ['populated', 'foo', true, false], + ['hovered', 'foo', true, true], + ['disabled', 'foo', false, false], + ])( + 'keeps the end adornment after the clear button when %s', + (_state, data, enabled, hovered) => { + wrapper = renderControl({ type: 'string' }, adornments, data, enabled); + if (hovered) { + hover(); + } + const endAdornments = wrapper + .find('.MuiInputAdornment-positionEnd') + .hostNodes(); + expect( + endAdornments.at(0).find('button[aria-label="Clear input field"]') + ).toHaveLength(1); + expect(endAdornments.at(1).text()).toBe('kg'); + } + ); + + it('reserves space for the hidden clear button instead of overlaying', () => { + wrapper = renderControl({ type: 'string' }, adornments, 'foo'); + expect(clearButtonStyle()).not.toHaveProperty('position', 'absolute'); + expect(clearButtonStyle()).toHaveProperty('visibility', 'hidden'); + }); + + it('shows the clear button on hover', () => { + wrapper = renderControl({ type: 'string' }, adornments, 'foo'); + hover(); + expect(clearButtonStyle()).toHaveProperty('visibility', 'visible'); + }); + + it('reserves no space for the clear button when disabled', () => { + wrapper = renderControl({ type: 'string' }, adornments, 'foo', false); + expect(clearButtonStyle()).toHaveProperty('display', 'none'); + }); + }); +}); diff --git a/packages/material-renderers/test/renderers/MaterialInputControl.test.tsx b/packages/material-renderers/test/renderers/MaterialInputControl.test.tsx index 3906f0882..383e2fbe2 100644 --- a/packages/material-renderers/test/renderers/MaterialInputControl.test.tsx +++ b/packages/material-renderers/test/renderers/MaterialInputControl.test.tsx @@ -44,6 +44,7 @@ import { import { MaterialInputControl } from '../../src/controls/MaterialInputControl'; import MaterialHorizontalLayoutRenderer from '../../src/layouts/MaterialHorizontalLayout'; import { MuiInputText } from '../../src/mui-controls'; +import OutlinedInput from '@mui/material/OutlinedInput'; import { initCore } from './util'; Enzyme.configure({ adapter: new Adapter() }); @@ -100,6 +101,26 @@ describe('Material input control', () => { expect(validation.children()).toHaveLength(0); }); + it('does not shrink the label for a custom input that ignores adornment options', () => { + const control: ControlElement = { + ...uischema, + options: { startAdornment: '€' }, + }; + const CustomInput = () => ; + const CustomControl = withJsonFormsControlProps((props: ControlProps) => ( + + )); + wrapper = mount( + + + + ); + + expect(wrapper.find('label').hasClass('MuiInputLabel-shrink')).toBe(false); + }); + it('should render without label', () => { const control: ControlElement = { type: 'Control',