Skip to content

Commit a8182e9

Browse files
mirkaaduthjameskosterciampo
authored
UI: Add InputLayout primitive (#74313)
* UI: Add `InputLayout` primitive * Replace with gap tokens * Add changelog * Clean up JSDoc params * Use `React.Children` to detect unrenderable children * Fix Storybook import * Temporary revert to dimension base tokens * Use font size tokens * Use border tokens * Fix incorrect color * Simplify className adding when `default` Co-authored-by: mirka <0mirka00@git.wordpress.org> Co-authored-by: aduth <aduth@git.wordpress.org> Co-authored-by: jameskoster <jameskoster@git.wordpress.org> Co-authored-by: ciampo <mciampini@git.wordpress.org>
1 parent 7592ba8 commit a8182e9

File tree

10 files changed

+348
-0
lines changed

10 files changed

+348
-0
lines changed

packages/ui/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@
1414
- Add `Fieldset` primitives ([#74296](https://github.com/WordPress/gutenberg/pull/74296)).
1515
- Add `Icon` component ([#74311](https://github.com/WordPress/gutenberg/pull/74311)).
1616
- 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)).
17+
- Add `InputLayout` primitive ([#74313](https://github.com/WordPress/gutenberg/pull/74313)).
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * as Field from './field';
22
export * as Fieldset from './fieldset';
3+
export { InputLayout } from './input-layout';
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Children, createContext, useContext } from '@wordpress/element';
2+
import type { InputLayoutSlotType } from './types';
3+
4+
/**
5+
* Context for providing slot type information to child components.
6+
*/
7+
export const InputLayoutSlotTypeContext =
8+
createContext< InputLayoutSlotType | null >( null );
9+
10+
/**
11+
* Hook to access the current slot context.
12+
*/
13+
export function useInputLayoutSlotContext() {
14+
return useContext( InputLayoutSlotTypeContext );
15+
}
16+
17+
/**
18+
* Wrapper component that provides slot type context for prefix and suffix slots.
19+
*/
20+
export function SlotContextProvider( {
21+
type,
22+
children,
23+
}: {
24+
type: InputLayoutSlotType;
25+
children: React.ReactNode;
26+
} ) {
27+
if ( Children.count( children ) === 0 ) {
28+
return null;
29+
}
30+
31+
return (
32+
<InputLayoutSlotTypeContext.Provider value={ type }>
33+
{ children }
34+
</InputLayoutSlotTypeContext.Provider>
35+
);
36+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { InputLayout as _InputLayout } from './input-layout';
2+
import { InputLayoutSlot } from './slot';
3+
4+
export const InputLayout = Object.assign( _InputLayout, {
5+
Slot: InputLayoutSlot,
6+
} );
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import clsx from 'clsx';
2+
import { forwardRef } from '@wordpress/element';
3+
import resetStyles from '../../../utils/css/resets.module.css';
4+
import styles from './style.module.css';
5+
import type { InputLayoutProps } from './types';
6+
import { SlotContextProvider } from './context';
7+
8+
/**
9+
* A low-level component that handles the visual layout of an input-like field,
10+
* including disabled states and standard prefix/suffix slots.
11+
*/
12+
export const InputLayout = forwardRef< HTMLDivElement, InputLayoutProps >(
13+
function InputLayout(
14+
{
15+
className,
16+
children,
17+
visuallyDisabled,
18+
size = 'default',
19+
isBorderless,
20+
prefix,
21+
suffix,
22+
...restProps
23+
},
24+
ref
25+
) {
26+
return (
27+
<div
28+
ref={ ref }
29+
className={ clsx(
30+
resetStyles[ 'box-sizing' ],
31+
styles[ 'input-layout' ],
32+
styles[ `is-size-${ size }` ],
33+
visuallyDisabled && styles[ 'is-disabled' ],
34+
isBorderless && styles[ 'is-borderless' ],
35+
className
36+
) }
37+
{ ...restProps }
38+
>
39+
<SlotContextProvider type="prefix">
40+
{ prefix }
41+
</SlotContextProvider>
42+
{ children }
43+
<SlotContextProvider type="suffix">
44+
{ suffix }
45+
</SlotContextProvider>
46+
</div>
47+
);
48+
}
49+
);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import clsx from 'clsx';
2+
import { forwardRef } from '@wordpress/element';
3+
import styles from './style.module.css';
4+
import type { InputLayoutSlotProps } from './types';
5+
import { useInputLayoutSlotContext } from './context';
6+
7+
/**
8+
* A layout helper to add paddings in a prefix or suffix.
9+
*/
10+
export const InputLayoutSlot = forwardRef<
11+
HTMLDivElement,
12+
InputLayoutSlotProps
13+
>( function InputLayoutSlot(
14+
{ type: typeProp, padding = 'default', ...restProps },
15+
ref
16+
) {
17+
const typeContext = useInputLayoutSlotContext();
18+
const type = typeProp ?? typeContext;
19+
20+
if ( ! type ) {
21+
throw new Error(
22+
'InputLayoutSlot requires a `type` prop or must be used within an InputLayout prefix/suffix slot.'
23+
);
24+
}
25+
26+
return (
27+
<div
28+
ref={ ref }
29+
className={ clsx(
30+
styles[ 'input-layout-slot' ],
31+
styles[ `is-${ type }` ],
32+
styles[ `is-padding-${ padding }` ]
33+
) }
34+
{ ...restProps }
35+
/>
36+
);
37+
} );
38+
39+
InputLayoutSlot.displayName = 'InputLayout.Slot';
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { Meta, StoryObj } from '@storybook/react-webpack5';
2+
import { InputLayout } from '../../../..';
3+
4+
const meta: Meta< typeof InputLayout > = {
5+
title: 'Design System/Components/Form/Primitives/InputLayout',
6+
component: InputLayout,
7+
subcomponents: {
8+
Slot: InputLayout.Slot,
9+
},
10+
};
11+
export default meta;
12+
13+
type Story = StoryObj< typeof InputLayout >;
14+
15+
export const Default: Story = {
16+
args: {},
17+
};
18+
19+
/**
20+
* By default, the `prefix` and `suffix` slots are rendered with no padding.
21+
*/
22+
export const WithPrefix: Story = {
23+
args: {
24+
prefix: (
25+
<div
26+
style={ {
27+
display: 'flex',
28+
alignItems: 'center',
29+
justifyContent: 'center',
30+
height: '100%',
31+
aspectRatio: '1 / 1',
32+
background: '#eee',
33+
} }
34+
>
35+
$
36+
</div>
37+
),
38+
},
39+
};
40+
41+
/**
42+
* The `InputLayout.Slot` component can be used to add standard padding in
43+
* the `prefix` or `suffix` slot.
44+
*
45+
* The `padding="minimal"` setting will work best when the slot content is a button or icon.
46+
*/
47+
export const WithPaddedPrefix: Story = {
48+
args: {
49+
prefix: <InputLayout.Slot>https://</InputLayout.Slot>,
50+
},
51+
};
52+
53+
export const Compact: Story = {
54+
args: {
55+
size: 'compact',
56+
},
57+
};
58+
59+
/**
60+
* The `small` size is intended only for rare cases like the trigger
61+
* button of a low-profile `select` element.
62+
*/
63+
export const Small: Story = {
64+
args: {
65+
size: 'small',
66+
},
67+
};
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides;
2+
3+
@layer wp-ui-components {
4+
.input-layout {
5+
/* TODO: Use padding tokens */
6+
--wp-ui-input-layout-padding-inline: calc(var(--wpds-dimension-base) * 3);
7+
8+
display: flex;
9+
height: 40px;
10+
background-color: var(--wpds-color-bg-interactive-neutral-weak);
11+
border-width: var(--wpds-border-width-surface-xs);
12+
border-style: solid;
13+
border-color: var(--wpds-color-stroke-interactive-neutral);
14+
border-radius: var(--wpds-border-radius-surface-sm);
15+
font-family: var(--wpds-font-family-body);
16+
font-size: max(var(--wpds-font-size-md), 16px); /* avoid mobile zoom */
17+
line-height: 1;
18+
color: var(--wpds-color-fg-interactive-neutral);
19+
20+
@media (min-width: 600px) {
21+
font-size: var(--wpds-font-size-md);
22+
}
23+
24+
&.is-size-compact {
25+
/* TODO: Use padding tokens */
26+
--wp-ui-input-layout-padding-inline: calc(var(--wpds-dimension-base) * 2);
27+
height: 32px;
28+
}
29+
30+
&.is-size-small {
31+
/* TODO: Use padding tokens */
32+
--wp-ui-input-layout-padding-inline: calc(var(--wpds-dimension-base) * 2);
33+
height: 24px;
34+
}
35+
36+
&.is-disabled {
37+
background-color: var(--wpds-color-bg-interactive-neutral-weak-disabled);
38+
border-color: var(--wpds-color-stroke-interactive-neutral-disabled);
39+
color: var(--wpds-color-fg-interactive-neutral-disabled);
40+
41+
@media (forced-colors: active) {
42+
border-color: GrayText;
43+
color: GrayText;
44+
}
45+
}
46+
47+
&.is-borderless {
48+
border-color: transparent;
49+
}
50+
51+
/* Don't show focus ring when the focus is in the prefix or suffix slots */
52+
&:has(.input-layout-slot:focus-within) {
53+
outline: none;
54+
}
55+
56+
&:hover:not(.is-disabled, .is-borderless) {
57+
border-color: var(--wpds-color-stroke-interactive-neutral-active);
58+
}
59+
}
60+
61+
.input-layout-slot {
62+
display: flex;
63+
align-items: center;
64+
65+
&.is-padding-minimal {
66+
--wp-ui-input-layout-prefix-padding-start:
67+
calc(var(--wp-ui-input-layout-padding-inline) -
68+
var(--wpds-dimension-base));
69+
--wp-ui-input-layout-suffix-padding-end:
70+
calc(var(--wp-ui-input-layout-padding-inline) -
71+
var(--wpds-dimension-base));
72+
}
73+
74+
&.is-prefix {
75+
padding-inline-start: var(--wp-ui-input-layout-prefix-padding-start, var(--wp-ui-input-layout-padding-inline));
76+
}
77+
78+
&.is-suffix {
79+
padding-inline-end: var(--wp-ui-input-layout-suffix-padding-end, var(--wp-ui-input-layout-padding-inline));
80+
}
81+
}
82+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { render } from '@testing-library/react';
2+
import { createRef } from '@wordpress/element';
3+
import { InputLayout } from '../index';
4+
import { InputLayoutSlot } from '../slot';
5+
6+
describe( 'InputLayout', () => {
7+
it( 'forwards ref', () => {
8+
const layoutRef = createRef< HTMLDivElement >();
9+
const slotRef = createRef< HTMLDivElement >();
10+
11+
render(
12+
<InputLayout
13+
ref={ layoutRef }
14+
prefix={
15+
<InputLayoutSlot ref={ slotRef }>Prefix</InputLayoutSlot>
16+
}
17+
/>
18+
);
19+
20+
expect( layoutRef.current ).toBeInstanceOf( HTMLDivElement );
21+
expect( slotRef.current ).toBeInstanceOf( HTMLDivElement );
22+
} );
23+
} );
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export interface InputLayoutProps
2+
extends Omit< React.HTMLAttributes< HTMLDivElement >, 'prefix' > {
3+
/**
4+
* Whether the field should be visually styled as disabled.
5+
*/
6+
visuallyDisabled?: boolean;
7+
/**
8+
* The size of the field.
9+
*
10+
* @default 'default'
11+
*/
12+
size?: 'default' | 'compact' | 'small';
13+
/**
14+
* Whether the field should hide the border.
15+
*/
16+
isBorderless?: boolean;
17+
/**
18+
* Element to render before the input.
19+
*/
20+
prefix?: React.ReactNode;
21+
/**
22+
* Element to render after the input.
23+
*/
24+
suffix?: React.ReactNode;
25+
}
26+
27+
export type InputLayoutSlotType = 'prefix' | 'suffix';
28+
29+
export interface InputLayoutSlotProps
30+
extends Omit< React.HTMLAttributes< HTMLDivElement >, 'type' > {
31+
/**
32+
* The type of the slot.
33+
*
34+
* When not provided, the type will be automatically inferred from the
35+
* `InputLayout` context if the slot is used within a `prefix` or `suffix`.
36+
*/
37+
type?: InputLayoutSlotType;
38+
/**
39+
* The padding of the slot.
40+
*
41+
* `minimal` will work best when the slot content is a button or icon.
42+
*/
43+
padding?: 'default' | 'minimal';
44+
}

0 commit comments

Comments
 (0)