Skip to content

Commit

Permalink
fix: move icon next to label in StudioIconTextfield (#14469)
Browse files Browse the repository at this point in the history
  • Loading branch information
standeren authored Jan 24, 2025
1 parent ea80461 commit 62e698b
Show file tree
Hide file tree
Showing 11 changed files with 193 additions and 61 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,35 @@ import * as StudioIconTextfieldStories from './StudioIconTextfield.stories';
StudioIconTextfield
</Heading>
<Paragraph>
StudioIconTextfield is textfield that supports icons on the left side as prefix.
StudioIconTextfield is a textfield that supports icons on the left side of the label as prefix.
</Paragraph>

<Canvas of={StudioIconTextfieldStories.Preview} />
<Heading level={2} size='xxsmall'>
StudioIconTextfield with icon
</Heading>
<Canvas of={StudioIconTextfieldStories.WithIcon} />

<Heading level={2} size='xxsmall'>
StudioIconTextfield without icon
</Heading>
<Canvas of={StudioIconTextfieldStories.WithoutIcon} />

<Heading level={2} size='xxsmall'>
StudioIconTextfield with error message
</Heading>
<Canvas of={StudioIconTextfieldStories.WithErrorMessage} />

<Heading level={2} size='xxsmall'>
StudioIconTextfield as read only
</Heading>
<Canvas of={StudioIconTextfieldStories.AsReadOnly} />

<Heading level={2} size='xxsmall'>
StudioIconTextfield as read only with icon
</Heading>
<Canvas of={StudioIconTextfieldStories.AsReadOnlyWithIcon} />

<Heading level={2} size='xxsmall'>
StudioIconTextfield as read only with icon and description
</Heading>
<Canvas of={StudioIconTextfieldStories.AsReadOnlyWithIconAndDescription} />
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
.container {
display: flex;
gap: var(--fds-spacing-2);
padding: var(--fds-spacing-3);
box-sizing: border-box;
flex-direction: column;
padding: var(--studio-property-button-vertical-spacing) var(--fds-spacing-5);
}

.prefixIcon {
color: var(--fds-semantic-text-neutral-default);
margin-top: var(--fds-spacing-7);
font-size: var(--fds-sizing-4);
align-content: center;
.iconLabel {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--fds-spacing-1);
font-weight: 500;
align-items: center;
}

.iconLabel:has(:nth-child(3)) {
grid-template-columns: auto 1fr auto;
}

.padLockIcon {
justify-self: flex-end;
}

.textfield {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,63 @@
import React from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { PencilIcon } from '@studio/icons';
import { KeyVerticalIcon } from '@studio/icons';
import { StudioIconTextfield } from './StudioIconTextfield';

type Story = StoryFn<typeof StudioIconTextfield>;

const meta: Meta = {
title: 'Components/StudioIconTextfield',
component: StudioIconTextfield,
argTypes: {
value: {
control: 'text',
parameters: {
docs: {
canvas: {
height: '100%',
},
},
},
};

export default meta;
export const Preview: Story = (args) => <StudioIconTextfield {...args}></StudioIconTextfield>;
export const WithIcon: Story = (args) => <StudioIconTextfield {...args} />;
WithIcon.args = {
icon: <KeyVerticalIcon />,
label: 'A label',
value: 'A value',
};

export const WithoutIcon: Story = (args) => <StudioIconTextfield {...args} />;
WithoutIcon.args = {
label: 'A label',
value: 'A value',
};

Preview.args = {
icon: <PencilIcon />,
value: 2.3,
export const WithErrorMessage: Story = (args) => <StudioIconTextfield {...args} />;
WithErrorMessage.args = {
label: 'A label',
value: 'A faulty value',
error: 'Your custom error message!',
};

export const AsReadOnly: Story = (args) => <StudioIconTextfield {...args} />;
AsReadOnly.args = {
label: 'A label',
value: 'A readonly value',
readOnly: true,
};

export const AsReadOnlyWithIcon: Story = (args) => <StudioIconTextfield {...args} />;
AsReadOnlyWithIcon.args = {
icon: <KeyVerticalIcon />,
label: 'A label',
value: 'A readonly value',
readOnly: true,
};

export const AsReadOnlyWithIconAndDescription: Story = (args) => <StudioIconTextfield {...args} />;
AsReadOnlyWithIconAndDescription.args = {
icon: <KeyVerticalIcon />,
description: 'A description',
label: 'A label',
value: 'A readonly value',
readOnly: true,
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import type { RenderResult } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import { StudioIconTextfield } from './StudioIconTextfield';
import type { StudioIconTextfieldProps } from './StudioIconTextfield';
Expand All @@ -7,43 +8,78 @@ import userEvent from '@testing-library/user-event';
import { testCustomAttributes } from '../../test-utils/testCustomAttributes';

describe('StudioIconTextfield', () => {
it('render the icon', async () => {
renderStudioIconTextfield({
icon: <KeyVerticalIcon title='my key icon title' />,
});
expect(screen.getByTitle('my key icon title')).toBeInTheDocument();
it('should render the textfield with label name', () => {
renderStudioIconTextfield();
expect(screen.getByRole('textbox', { name: label })).toBeInTheDocument();
});

it('should render the textfield with value', () => {
renderStudioIconTextfield();
expect(screen.getByRole('textbox', { name: label })).toHaveValue(value);
});

it('should render label', () => {
renderStudioIconTextfield({
icon: <div />,
label: 'id',
});
expect(screen.getByLabelText('id')).toBeInTheDocument();
renderStudioIconTextfield();
expect(screen.getByLabelText(label)).toBeInTheDocument();
});

it('should execute onChange callback when input value changes', async () => {
const user = userEvent.setup();
const onChangeMock = jest.fn();
it('should render textfield with label name when ID is set through props', () => {
const id = 'id';
renderStudioIconTextfield({ id });
expect(screen.getByRole('textbox', { name: label })).toBeInTheDocument();
});

it('should render the icon if provided', async () => {
renderStudioIconTextfield({
icon: <div />,
label: 'Your ID',
onChange: onChangeMock,
icon: <KeyVerticalIcon />,
});
expect(screen.getByRole('img')).toBeInTheDocument();
});

it('should render as readonly if readOnly prop is set', async () => {
renderStudioIconTextfield({ readOnly: true });
expect(screen.getByRole('textbox', { name: label })).toBeDisabled();
});

it('should render icon if readOnly prop is set', async () => {
renderStudioIconTextfield({ readOnly: true });
expect(screen.getByRole('img')).toBeInTheDocument();
});

const input = screen.getByLabelText('Your ID');
it('icon should have padLockIcon class if readOnly prop is set', async () => {
renderStudioIconTextfield({ readOnly: true });
expect(screen.getByRole('img')).toHaveClass('padLockIcon');
});

const inputValue = 'my id is 123';
await user.type(input, inputValue);
expect(onChangeMock).toHaveBeenCalledTimes(inputValue.length);
it('should render with two icons if custom icon is provided and readOnly prop is set', async () => {
renderStudioIconTextfield({ icon: <KeyVerticalIcon />, readOnly: true });
const icons = screen.getAllByRole('img');
expect(icons).toHaveLength(2);
});

it('should execute onChange callback when input value changes', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
renderStudioIconTextfield({ onChange });
const textfield = screen.getByRole('textbox', { name: label });
const newInput = 'newInput';
await user.type(textfield, newInput);
expect(onChange).toHaveBeenCalledTimes(newInput.length);
});

it('should forward the rest of the props to the input', () => {
const getTextbox = (): HTMLInputElement => screen.getByRole('textbox') as HTMLInputElement;
testCustomAttributes<HTMLInputElement>(renderStudioIconTextfield, getTextbox);
});
});
const renderStudioIconTextfield = (props: StudioIconTextfieldProps) => {
return render(<StudioIconTextfield {...props} />);

const label = 'label';
const value = 'value';
const defaultProps: StudioIconTextfieldProps = {
label,
value,
};

const renderStudioIconTextfield = (props: Partial<StudioIconTextfieldProps> = {}): RenderResult => {
return render(<StudioIconTextfield {...defaultProps} {...props} />);
};
Original file line number Diff line number Diff line change
@@ -1,28 +1,59 @@
import React, { forwardRef } from 'react';
import React, { forwardRef, useId } from 'react';
import { StudioTextfield, type StudioTextfieldProps } from '../StudioTextfield';
import cn from 'classnames';

import classes from './StudioIconTextfield.module.css';
import type { Override } from '../../types/Override';
import { Label } from '@digdir/designsystemet-react';
import { PadlockLockedFillIcon } from '@studio/icons';

export type StudioIconTextfieldProps = {
icon: React.ReactNode;
} & StudioTextfieldProps;
export type StudioIconTextfieldProps = Override<
{
icon?: React.ReactNode;
label: string;
},
StudioTextfieldProps
>;

export const StudioIconTextfield = forwardRef<HTMLDivElement, StudioIconTextfieldProps>(
(
{ icon, className: givenClassName, ...rest }: StudioIconTextfieldProps,
{ icon, id, label, className: givenClassName, readOnly, ...rest }: StudioIconTextfieldProps,
ref,
): React.ReactElement => {
const generatedId = useId();
const textFieldId = id ?? generatedId;
const className = cn(givenClassName, classes.container);
return (
<div className={className} ref={ref}>
<div aria-hidden className={classes.prefixIcon}>
{icon}
</div>
<StudioTextfield {...rest} className={classes.textfield} />
<IconLabel htmlFor={textFieldId} icon={icon} label={label} readonly={readOnly} />
<StudioTextfield
disabled={readOnly}
id={textFieldId}
size='sm'
className={classes.textfield}
{...rest}
/>
</div>
);
},
);

type IconLabelProps = {
htmlFor: string;
icon?: React.ReactNode;
label: string;
readonly?: boolean;
};

const IconLabel = ({ htmlFor, icon, label, readonly }: IconLabelProps): React.ReactElement => {
return (
<div className={classes.iconLabel}>
{icon}
<Label size='sm' htmlFor={htmlFor}>
{label}
</Label>
{readonly && <PadlockLockedFillIcon className={classes.padLockIcon} />}
</div>
);
};

StudioIconTextfield.displayName = 'StudioIconTextfield';
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ describe('StudioToggleableTextfield', () => {
const renderStudioTextField = (props: Partial<StudioToggleableTextfieldProps>) => {
const defaultProps: StudioToggleableTextfieldProps = {
inputProps: {
label: 'label',
value: 'value',
icon: <div />,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const defaultProps: StudioToggleableTextfieldSchemaProps = {
onChange: () => {},
},
inputProps: {
label: '',
value: '',
onChange: () => {},
icon: <div />,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,3 @@
gap: var(--fds-spacing-2);
padding-inline: var(--custom-receipt-spacing);
}

.textfield {
padding-inline: var(--custom-receipt-spacing);
padding-block: var(--fds-spacing-1);
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ export const CustomReceipt = (): React.ReactElement => {
validateLayoutSetName(newLayoutSetName, layoutSets, existingCustomReceiptLayoutSetId)
}
inputProps={{
className: classes.textfield,
icon: <KeyVerticalIcon />,
label: t('process_editor.configuration_panel_custom_receipt_textfield_label'),
value: existingCustomReceiptLayoutSetId,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
.changePageId {
background-color: var(--fds-semantic-surface-neutral-default);
}

.idInput {
padding: var(--fds-spacing-5);
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ export const EditPageId = ({ layoutName }: EditPageIdProps) => {
onBlur: (event) => handleSaveNewName(event.target.value),
label: t('ux_editor.modal_properties_textResourceBindings_page_id'),
size: 'small',
className: classes.idInput,
}}
customValidation={(value: string) => {
const validationResult = getPageNameErrorKey(value, layoutName, layoutOrder);
Expand Down

0 comments on commit 62e698b

Please sign in to comment.