Skip to content
Merged
1 change: 1 addition & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@
- Add `Fieldset` primitives ([#74296](https://github.com/WordPress/gutenberg/pull/74296)).
- Add `Icon` component ([#74311](https://github.com/WordPress/gutenberg/pull/74311)).
- Add `Button` component ([#74415](https://github.com/WordPress/gutenberg/pull/74415), [#74416](https://github.com/WordPress/gutenberg/pull/74416), [#74470](https://github.com/WordPress/gutenberg/pull/74470)).
- Add `InputLayout` primitive ([#74313](https://github.com/WordPress/gutenberg/pull/74313)).
1 change: 1 addition & 0 deletions packages/ui/src/form/primitives/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * as Field from './field';
export * as Fieldset from './fieldset';
export { InputLayout } from './input-layout';
36 changes: 36 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,36 @@
import { Children, 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.
*/
export function SlotContextProvider( {
type,
children,
}: {
type: InputLayoutSlotType;
children: React.ReactNode;
} ) {
if ( Children.count( children ) === 0 ) {
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 }` ],
styles[ `is-padding-${ padding }` ]
) }
{ ...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-webpack5';
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',
},
};
82 changes: 82 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,82 @@
@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;

@layer wp-ui-components {
.input-layout {
/* TODO: Use padding tokens */
Copy link
Member

Choose a reason for hiding this comment

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

Can we create and link to a related issue? Or will this be a quick follow-on?

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 don't expect this to be a quick decision 😅 I'll link to the issue Marco posted for us #74556.

--wp-ui-input-layout-padding-inline: calc(var(--wpds-dimension-base) * 3);

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-weak);
border-width: var(--wpds-border-width-surface-xs);
border-style: solid;
border-color: var(--wpds-color-stroke-interactive-neutral);
border-radius: var(--wpds-border-radius-surface-sm);
font-family: var(--wpds-font-family-body);
font-size: max(var(--wpds-font-size-md), 16px); /* avoid mobile zoom */
line-height: 1;
color: var(--wpds-color-fg-interactive-neutral);

@media (min-width: 600px) {
font-size: var(--wpds-font-size-md);
}

&.is-size-compact {
/* TODO: Use padding tokens */
--wp-ui-input-layout-padding-inline: calc(var(--wpds-dimension-base) * 2);
height: 32px;
}

&.is-size-small {
/* TODO: Use padding tokens */
--wp-ui-input-layout-padding-inline: calc(var(--wpds-dimension-base) * 2);
height: 24px;
}

&.is-disabled {
background-color: var(--wpds-color-bg-interactive-neutral-weak-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-base));
--wp-ui-input-layout-suffix-padding-end:
calc(var(--wp-ui-input-layout-padding-inline) -
var(--wpds-dimension-base));
}

&.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