-
Notifications
You must be signed in to change notification settings - Fork 4.7k
UI: Add InputLayout primitive
#74313
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
e992073
a8d3f42
be8c7b9
8e30f1a
5a59467
37cab90
8bf60ae
252b710
136f438
c6b84af
0e12971
fc024b9
2e17b3b
3462aec
f04da79
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,2 @@ | ||
| export * as Field from './field'; | ||
| export { InputLayout } from './input-layout'; |
| 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 ) { | ||
aduth marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return null; | ||
| } | ||
|
|
||
| return ( | ||
| <InputLayoutSlotTypeContext.Provider value={ type }> | ||
| { children } | ||
| </InputLayoutSlotTypeContext.Provider> | ||
| ); | ||
| } | ||
| 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, | ||
| } ); |
| 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 ( | ||
aduth marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| <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> | ||
| ); | ||
| } | ||
| ); | ||
| 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 }` ] | ||||||
|
||||||
| padding !== 'default' && styles[ `is-padding-${ padding }` ] | |
| padding && styles[ `is-padding-${ padding }` ] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| 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. | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| */ | ||
| 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', | ||
| }, | ||
| }; | ||
| 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); | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| display: flex; | ||||||||||||||||||||||||||
| height: 40px; | ||||||||||||||||||||||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||||||||||||||||||||||||||
mirka marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||||||||||||||||||||||
| font-family: var(--wpds-font-family-body); | ||||||||||||||||||||||||||
| font-size: 16px; /* for mobile */ | ||||||||||||||||||||||||||
| line-height: 1; | ||||||||||||||||||||||||||
| color: var(--wpds-color-fg-content-neutral); | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| @media (min-width: 600px) { | ||||||||||||||||||||||||||
| font-size: 13px; | ||||||||||||||||||||||||||
|
||||||||||||||||||||||||||
| 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); |
There was a problem hiding this comment.
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.
| 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 ); | ||
| } ); | ||
| } ); |
| 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'; | ||
| } |

Uh oh!
There was an error while loading. Please reload this page.