Skip to content
Merged
1 change: 1 addition & 0 deletions packages/ui/src/form/primitives/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * as Field from './field';
export { InputLayout } from './input-layout';
39 changes: 39 additions & 0 deletions packages/ui/src/form/primitives/input-layout/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { createContext, useContext } from '@wordpress/element';
import type { InputLayoutSlotType } from './types';

/**
* Context for providing slot type information to child components.
*/
export const InputLayoutSlotTypeContext =
createContext< InputLayoutSlotType | null >( null );

/**
* Hook to access the current slot context.
*/
export function useInputLayoutSlotContext() {
return useContext( InputLayoutSlotTypeContext );
}

/**
* Wrapper component that provides slot type context for prefix and suffix slots.
* @param root0
* @param root0.type
* @param root0.children
*/
export function SlotContextProvider( {
type,
children,
}: {
type: InputLayoutSlotType;
children: React.ReactNode;
} ) {
if ( children === null || children === undefined ) {
return null;
}

return (
<InputLayoutSlotTypeContext.Provider value={ type }>
{ children }
</InputLayoutSlotTypeContext.Provider>
);
}
6 changes: 6 additions & 0 deletions packages/ui/src/form/primitives/input-layout/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { InputLayout as _InputLayout } from './input-layout';
import { InputLayoutSlot } from './slot';

export const InputLayout = Object.assign( _InputLayout, {
Slot: InputLayoutSlot,
} );
49 changes: 49 additions & 0 deletions packages/ui/src/form/primitives/input-layout/input-layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import clsx from 'clsx';
import { forwardRef } from '@wordpress/element';
import resetStyles from '../../../utils/css/resets.module.css';
import styles from './style.module.css';
import type { InputLayoutProps } from './types';
import { SlotContextProvider } from './context';

/**
* A low-level component that handles the visual layout of an input-like field,
* including disabled states and standard prefix/suffix slots.
*/
export const InputLayout = forwardRef< HTMLDivElement, InputLayoutProps >(
function InputLayout(
{
className,
children,
visuallyDisabled,
size = 'default',
isBorderless,
prefix,
suffix,
...restProps
},
ref
) {
return (
<div
ref={ ref }
className={ clsx(
resetStyles[ 'box-sizing' ],
styles[ 'input-layout' ],
styles[ `is-size-${ size }` ],
visuallyDisabled && styles[ 'is-disabled' ],
isBorderless && styles[ 'is-borderless' ],
className
) }
{ ...restProps }
>
<SlotContextProvider type="prefix">
{ prefix }
</SlotContextProvider>
{ children }
<SlotContextProvider type="suffix">
{ suffix }
</SlotContextProvider>
</div>
);
}
);
39 changes: 39 additions & 0 deletions packages/ui/src/form/primitives/input-layout/slot.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import clsx from 'clsx';
import { forwardRef } from '@wordpress/element';
import styles from './style.module.css';
import type { InputLayoutSlotProps } from './types';
import { useInputLayoutSlotContext } from './context';

/**
* A layout helper to add paddings in a prefix or suffix.
*/
export const InputLayoutSlot = forwardRef<
HTMLDivElement,
InputLayoutSlotProps
>( function InputLayoutSlot(
{ type: typeProp, padding = 'default', ...restProps },
ref
) {
const typeContext = useInputLayoutSlotContext();
const type = typeProp ?? typeContext;

if ( ! type ) {
throw new Error(
'InputLayoutSlot requires a `type` prop or must be used within an InputLayout prefix/suffix slot.'
);
}

return (
<div
ref={ ref }
className={ clsx(
styles[ 'input-layout-slot' ],
styles[ `is-${ type }` ],
padding !== 'default' && styles[ `is-padding-${ padding }` ]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More of a general comment on "default" values, but this feels like either:

  1. If we don't want the default to effect some state, then it should be undefined by default.
  2. Otherwise, we should remove the condition and let the class be applied.

My preference here would be toward the first. But I think we'll want to adopt a consistent and documented approach across our components.

Suggested change
padding !== 'default' && styles[ `is-padding-${ padding }` ]
padding && styles[ `is-padding-${ padding }` ]

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API-wise, apart from early components like Button which were before our time, we've tried to be consistent about having an explicit "default" value for size and variant props. Although it can feel verbose in code, I think this is the best for consumer clarity — there's no room for confusion about what the default is, and it's clearer to talk about because it matches human language ("the default variant" vs "the undefined variant").

Assuming we'll continue this pattern, do you still prefer a class to be applied? I feel like classes are just an implementation detail that may or may not be necessary for any specific style implementation, especially with CSS modules where they are going to be mangled in the final output anyway. We are actually free to do styles[ 'is-padding-${ padding }' ] with no condition at all, just that we'd be adding a technically unnecessary class in the default case. So, no strong opinion there, but wanted to understand your reasoning.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, reflecting on my comment, I think I was considering this in the context of consistency in how we consider the default value. If it's valuable to be explicit with a default 'default' value, then we should treat it the same as any other valid state for that value, i.e. apply a 'is-padding-default' class. Or at least not go out of our way to add logic to handle it distinctly.

But while I think it's good to get on the same page with how we consider defaults (should probably document), the specific point of whether or not to apply the class is pretty negligible and I don't have strong feelings.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gotcha. I'm fine with removing the logic, if only for simplicity 👍 2e17b3b

) }
{ ...restProps }
/>
);
} );

InputLayoutSlot.displayName = 'InputLayout.Slot';
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Meta, StoryObj } from '@storybook/react';
import { InputLayout } from '../../../..';

const meta: Meta< typeof InputLayout > = {
title: 'Design System/Components/Form/Primitives/InputLayout',
component: InputLayout,
subcomponents: {
Slot: InputLayout.Slot,
},
};
export default meta;

type Story = StoryObj< typeof InputLayout >;

export const Default: Story = {
args: {},
};

/**
* By default, the `prefix` and `suffix` slots are rendered with no padding.
*/
export const WithPrefix: Story = {
args: {
prefix: (
<div
style={ {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
aspectRatio: '1 / 1',
background: '#eee',
} }
>
$
</div>
),
},
};

/**
* The `InputLayout.Slot` component can be used to add standard padding in
* the `prefix` or `suffix` slot.
*
* The `padding="minimal"` setting will work best when the slot content is a button or icon.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add a proper story for this "minimal" padding case after we add an IconButton component.

*/
export const WithPaddedPrefix: Story = {
args: {
prefix: <InputLayout.Slot>https://</InputLayout.Slot>,
},
};

export const Compact: Story = {
args: {
size: 'compact',
},
};

/**
* The `small` size is intended only for rare cases like the trigger
* button of a low-profile `select` element.
*/
export const Small: Story = {
args: {
size: 'small',
},
};
79 changes: 79 additions & 0 deletions packages/ui/src/form/primitives/input-layout/style.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;

@layer wp-ui-components {
.input-layout {
--wp-ui-input-layout-padding-inline: var(--wpds-dimension-gap-sm);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels to me like it should use a "padding" token. We probably want to create a separate set of padding tokens for interactive / form elements. It might end up being the same value as --wpds-dimension-gap-sm, but I think conceptually this represents something different.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, this is padding and not a gap.

Any reason we should have a separate set of padding tokens for interactive/form elements, rather than expand the existing set to cover it?

@WordPress/gutenberg-design The Input and Select components use an inline padding value of 12px, but this value is not included in the dimension-padding-surface tokens. How do you prefer we add it?

(I'll revert this PR to use a multiple of dimension-base, and we can deal with the tokens separately.)

Copy link
Member

@aduth aduth Jan 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My reason for suggesting a separate set was that the existing set is tailored for "surface" targets, where with other token values (color) we've been categorizing input aspects under "interactive".

There might be an argument that we don't need these targets at all for paddings and can be flattened to a uniform set of padding tokens (like what was decided for gaps), though I'd defer to design folks whether that's something we want to be able to differentiate.

A bit of past discussion in #72984 (comment)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some general thoughts about dimension tokens and density here


display: flex;
height: 40px;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to what was discussed in #73875 (specifically #73875 (comment)), do we need an explicit height, or could we achieve this through a combination of padding, line-height, font-size, etc. Or at least, if we needed an explicit height, would it be better to express in this in terms of what we're trying to do with it (i.e. align to some 4px grid, based on --wpds-dimension-base).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid questions. Let's figure this out as part of #74556. Part of it is understanding what exactly is needed from size/density theming, from a product standpoint. Do form components really need to scale fluidly? That kind of thing.

background-color: var(--wpds-color-bg-interactive-neutral);
border-width: 1px;
border-style: solid;
border-color: var(--wpds-color-stroke-interactive-neutral);
border-radius: 2px;
font-family: var(--wpds-font-family-body);
font-size: 16px; /* for mobile */
line-height: 1;
color: var(--wpds-color-fg-content-neutral);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be interactive.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, wait, does this apply to the input or just the prefix/suffix? If it's the latter ignore me :)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With that said, I think it might be beneficial to use --wpds-color-fg-content-neutral-weak for the prefix/suffix to better distinguish it from the input value.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks fc024b9

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With that said, I think it might be beneficial to use --wpds-color-fg-content-neutral-weak for the prefix/suffix to better distinguish it from the input value.

Interesting. I do see some potential drawbacks there, e.g. looking placeholder-like (and thus clickable) or disabled. Maybe best to leave alone for now. And consumers can always change the color if desired.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the (light gray) background helps as it generally indicates non-interactive/disabled. If we wanted to further separate (in a more accessible way) we could introduce a stroke between prefix and value:

Screenshot 2026-01-09 at 14 21 39

Fine to defer this detail if you prefer.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'll try this in a follow-up PR (there are additional things I need to test) 👍


@media (min-width: 600px) {
font-size: 13px;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use font tokens? Also noting with the breakpoint this is something we'll want to consider as part of #73361. Regarding the need for mobile minimum font size, maybe we could make it at least somewhat token-compliant with max as a way of communicating "we want to make sure it's at least 16px due to mobile behaviors" ?

Suggested change
font-size: 16px; /* for mobile */
line-height: 1;
color: var(--wpds-color-fg-content-neutral);
@media (min-width: 600px) {
font-size: 13px;
font-size: max(var(--wpds-font-size-md), 16px); /* avoid mobile zoom */
line-height: 1;
color: var(--wpds-color-fg-content-neutral);
@media (min-width: 600px) {
font-size: var(--wpds-font-size-md);

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love the max idea. Great way to make absolute values work with tokens.

c6b84af

}

&.is-size-compact {
--wp-ui-input-layout-padding-inline: var(--wpds-dimension-gap-xs);
height: 32px;
}

&.is-size-small {
--wp-ui-input-layout-padding-inline: var(--wpds-dimension-gap-xs);
height: 24px;
}

&.is-disabled {
background-color: var(--wpds-color-bg-interactive-neutral-disabled);
border-color: var(--wpds-color-stroke-interactive-neutral-disabled);
color: var(--wpds-color-fg-interactive-neutral-disabled);

@media (forced-colors: active) {
border-color: GrayText;
color: GrayText;
}
}

&.is-borderless {
border-color: transparent;
}

/* Don't show focus ring when the focus is in the prefix or suffix slots */
&:has(.input-layout-slot:focus-within) {
outline: none;
}

&:hover:not(.is-disabled, .is-borderless) {
border-color: var(--wpds-color-stroke-interactive-neutral-active);
}
}

.input-layout-slot {
display: flex;
align-items: center;

&.is-padding-minimal {
--wp-ui-input-layout-prefix-padding-start:
calc(var(--wp-ui-input-layout-padding-inline) -
var(--wpds-dimension-gap-2xs));
--wp-ui-input-layout-suffix-padding-end:
calc(var(--wp-ui-input-layout-padding-inline) -
var(--wpds-dimension-gap-2xs));
}

&.is-prefix {
padding-inline-start: var(--wp-ui-input-layout-prefix-padding-start, var(--wp-ui-input-layout-padding-inline));
}

&.is-suffix {
padding-inline-end: var(--wp-ui-input-layout-suffix-padding-end, var(--wp-ui-input-layout-padding-inline));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { render } from '@testing-library/react';
import { createRef } from '@wordpress/element';
import { InputLayout } from '../index';
import { InputLayoutSlot } from '../slot';

describe( 'InputLayout', () => {
it( 'forwards ref', () => {
const layoutRef = createRef< HTMLDivElement >();
const slotRef = createRef< HTMLDivElement >();

render(
<InputLayout
ref={ layoutRef }
prefix={
<InputLayoutSlot ref={ slotRef }>Prefix</InputLayoutSlot>
}
/>
);

expect( layoutRef.current ).toBeInstanceOf( HTMLDivElement );
expect( slotRef.current ).toBeInstanceOf( HTMLDivElement );
} );
} );
44 changes: 44 additions & 0 deletions packages/ui/src/form/primitives/input-layout/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export interface InputLayoutProps
extends Omit< React.HTMLAttributes< HTMLDivElement >, 'prefix' > {
/**
* Whether the field should be visually styled as disabled.
*/
visuallyDisabled?: boolean;
/**
* The size of the field.
*
* @default 'default'
*/
size?: 'default' | 'compact' | 'small';
/**
* Whether the field should hide the border.
*/
isBorderless?: boolean;
/**
* Element to render before the input.
*/
prefix?: React.ReactNode;
/**
* Element to render after the input.
*/
suffix?: React.ReactNode;
}

export type InputLayoutSlotType = 'prefix' | 'suffix';

export interface InputLayoutSlotProps
extends Omit< React.HTMLAttributes< HTMLDivElement >, 'type' > {
/**
* The type of the slot.
*
* When not provided, the type will be automatically inferred from the
* `InputLayout` context if the slot is used within a `prefix` or `suffix`.
*/
type?: InputLayoutSlotType;
/**
* The padding of the slot.
*
* `minimal` will work best when the slot content is a button or icon.
*/
padding?: 'default' | 'minimal';
}
Loading