Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/inline-field-variant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
"@gouvfr-lasuite/ui-components": minor
---

Add an `inline` field variant to Input, TextArea and Select

The label — and its new optional `labelDescription` — sits in a left column while the
field takes the right one, on a single row. The label column hugs its content by default;
use the new `labelWidth` prop to pin it and align several fields of the same form.
`labelDescription` is associated with the control through `aria-describedby`.

As a prerequisite, the `Select` label is now rendered as a sibling of `.c__select` instead
of one of its children, so that every field component exposes the same `.c__field`
structure. Styling that targets `.c__select__label` is unaffected; only stylesheets
relying on the descendant selector `.c__select .c__select__label` need updating.

Disabled labels also change colour across **every** variant: the
`forms-labelledbox--label-color--{small,big}--disabled` tokens resolved to
`content.semantic.neutral.secondary`, a regular text colour, and now resolve to
`content.semantic.disabled.primary` as the design requires.
19 changes: 19 additions & 0 deletions packages/ui-components/src/components/form/input/input.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,25 @@ export const IconRight = {
},
};

export const Inline = {
args: {
label: "Label",
labelDescription: "Text info",
variant: "inline",
placeholder: "Text input",
},
};

export const InlineDisabled = {
args: {
label: "Label",
labelDescription: "Text info",
variant: "inline",
placeholder: "Text input",
disabled: true,
},
};

export const FullWidth = {
args: {
defaultValue: "Hello world",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ describe("<Checkbox/>", () => {
rightText: "my right text",
indeterminate: true,
disabled: false,
labelWidth: "10rem",
};

render(<Checkbox {...propsInput} />);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const Checkbox = ({
const {
compact,
fullWidth,
labelWidth,
rightText,
state,
text,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@ import classNames from "classnames";

export interface ClassicLabelProps {
label?: string;
/** Secondary text rendered under the label. */
description?: string;
/** Id given to the description, so the control can reference it via aria-describedby. */
descriptionId?: string;
/**
* Wraps the label (and its description) in a `.c__field__label-block` container so the
* pair behaves as a single item. Required by the "inline" variant, whose grid places
* the whole block in the label column.
*/
withContainer?: boolean;
hideLabel?: boolean;
disabled?: boolean;
className?: string;
Expand All @@ -13,13 +23,19 @@ export interface ClassicLabelProps {
}

/**
* Renders a label for the "classic" field variant.
* Renders a label for the "classic" and "inline" field variants.
* - When hideLabel is false: renders a visible label with the given className.
* - When hideLabel is true: renders an offscreen label for accessibility.
* - When label is falsy: renders nothing.
*
* The label block is only introduced when a description or a container is asked for, so
* variants that need neither keep their historical markup.
*/
export const ClassicLabel = ({
label,
description,
descriptionId,
withContainer,
hideLabel,
disabled,
className,
Expand All @@ -34,13 +50,20 @@ export const ClassicLabel = ({

if (hideLabel) {
return (
<label className="c__offscreen" htmlFor={htmlFor} onClick={onClick}>
{label}
</label>
<>
<label className="c__offscreen" htmlFor={htmlFor} onClick={onClick}>
{label}
</label>
{description && (
<span id={descriptionId} className="c__offscreen">
{description}
</span>
)}
</>
);
}

return (
const labelElement = (
<label
className={classNames(className, {
[disabledClassName ?? ""]: disabled && disabledClassName,
Expand All @@ -52,4 +75,28 @@ export const ClassicLabel = ({
{label}
</label>
);

if (!withContainer && !description) {
return labelElement;
}

return (
<div
className={classNames("c__field__label-block", {
"c__field__label-block--disabled": disabled,
})}
>
{labelElement}
{description && (
<span
id={descriptionId}
className={classNames("c__field__label-description", {
"c__field__label-description--disabled": disabled,
})}
>
{description}
</span>
)}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { createCalendar, DateValue } from "@internationalized/date";
import classNames from "classnames";
import { LabelledBox, Props } from ":/components/forms/labelled-box";
import type { FieldVariant } from ":/components/forms/types";
import type { StackedFieldVariant } from ":/components/forms/types";

interface DateSegmentProps {
currentSegment: DateSegment;
Expand Down Expand Up @@ -70,7 +70,7 @@ const DateField = (props: AriaDatePickerProps<DateValue>) => {
interface DateFieldBoxProps
extends Props,
Omit<AriaDatePickerProps<DateValue>, "label"> {
variant?: FieldVariant;
variant?: StackedFieldVariant;
}

const DateFieldBox = ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ import { ClassicLabel } from ":/components/forms/classic-label";
import { useCunningham } from ":/components/provider";
import { Calendar, CalendarRange } from ":/components/calendar";
import { convertDateValueToString } from ":/components/forms/date-picker/utils";
import type { FieldVariant } from ":/components/forms/types";
import type { StackedFieldVariant } from ":/components/forms/types";

export type DatePickerAuxSubProps = FieldProps & {
label?: string;
variant?: FieldVariant;
variant?: StackedFieldVariant;
hideLabel?: boolean;
minValue?: string;
maxValue?: string;
Expand Down
79 changes: 79 additions & 0 deletions packages/ui-components/src/components/forms/field/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,83 @@
align-items: flex-start;
width: auto;
}

/**
* Label block: pairs a label with its description. Introduced by ClassicLabel as soon
* as a description is given, and always in the "inline" variant so that the grid below
* has a single item to place in the label column.
*/
&__label-block {
display: flex;
flex-direction: column;
gap: var(--c--components--forms-field--label-description-gap);
min-width: 0;
}

&__label-description {
font-family: var(--c--globals--font--families--base);
font-size: var(--c--components--forms-field--label-description-font-size);
font-weight: var(
--c--components--forms-field--label-description-font-weight
);
line-height: var(
--c--components--forms-field--label-description-line-height
);
color: var(--c--components--forms-field--label-description-color);

&--disabled {
color: var(
--c--components--forms-field--label-description-color--disabled
);
}
}

/* Same token as every other disabled label, so all variants stay in sync. */
&__label-block--disabled label {
color: var(
--c--components--forms-labelledbox--label-color--small--disabled
);
}

&--inline {
display: grid;
grid-template-columns:
var(--c--components--forms-field--inline-label-width)
minmax(0, var(--c--components--forms-field--width));
align-items: center;
column-gap: var(--c--components--forms-field--inline-column-gap);
row-gap: var(--c--components--forms-field--inline-row-gap);
width: auto;

/* The control, the footer and anything else belong to the field column… */
> * {
grid-column: 2;
}

/* …only the label sits in the label column, on the control's row. */
> .c__field__label-block,
> label {
grid-column: 1;
grid-row: 1;
}

/* The label is beside the control, not above it: drop the classic spacing. */
.c__field__label-block label,
> label {
margin-bottom: 0;
}

/* The footer is already aligned with the control by the grid. */
.c__field__footer {
padding-left: 0;
padding-right: 0;
}

&.c__field--full-width {
width: 100%;
grid-template-columns:
var(--c--components--forms-field--inline-label-width)
minmax(0, 1fr);
}
}
}
9 changes: 9 additions & 0 deletions packages/ui-components/src/components/forms/field/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ available design token to do so.
| width | Default width of inputs ( default is 292px ) |
| font-size | Font size of texts below inputs |
| color | Font color of texts below inputs |
| inline-label-width | Width of the label column of the "inline" variant ( default is `max-content` ). Override it per field with the `labelWidth` prop |
| inline-column-gap | Space between the label column and the field of the "inline" variant |
| inline-row-gap | Space between the field and its footer in the "inline" variant |
| label-description-gap | Space between the label and its description |
| label-description-font-size | Font size of the label description |
| label-description-font-weight | Font weight of the label description |
| label-description-line-height | Line height of the label description |
| label-description-color | Font color of the label description |
| label-description-color--disabled | Font color of the label description when disabled |

## Props

Expand Down
17 changes: 16 additions & 1 deletion packages/ui-components/src/components/forms/field/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { PropsWithChildren } from "react";
import React, { CSSProperties, PropsWithChildren } from "react";
import classNames from "classnames";

export type FieldState = "success" | "error" | "default";
Expand All @@ -12,6 +12,13 @@ export type FieldProps = {
compact?: boolean | undefined;
className?: string | undefined;
disabled?: boolean | undefined;
/**
* Width of the label column of the "inline" variant. Accepts any CSS length or sizing
* keyword ("12rem", "30%", "max-content"). Defaults to the forms-field
* `inline-label-width` token. Set the same value on several fields of a form to align
* their controls.
*/
labelWidth?: string | undefined;
};

type Props = FieldProps & PropsWithChildren;
Expand All @@ -26,6 +33,7 @@ export const Field = ({
compact,
className,
disabled,
labelWidth,
}: Props) => {
return (
<div
Expand All @@ -34,6 +42,13 @@ export const Field = ({
"c__field--compact": compact && !fullWidth,
"c__field--disabled": disabled,
})}
style={
labelWidth
? ({
"--c--components--forms-field--inline-label-width": labelWidth,
} as CSSProperties)
: undefined
}
>
{children}
{(text || rightText || textItems) && (
Expand Down
15 changes: 15 additions & 0 deletions packages/ui-components/src/components/forms/field/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,19 @@ export const tokens = (defaults: DefaultTokens) => ({
"color--error": defaults.contextuals.content.semantic.error.secondary,
"color--success": defaults.contextuals.content.semantic.success.secondary,
"color--disabled": defaults.contextuals.content.semantic.disabled.primary,

// Inline variant tokens
"inline-label-width": "max-content",
"inline-column-gap": defaults.globals.spacings.xs,
"inline-row-gap": defaults.globals.spacings["3xs"],

// Label block tokens, shared by the "classic" and "inline" variants
"label-description-gap": defaults.globals.spacings["4xs"],
"label-description-font-size": defaults.globals.font.sizes.s,
"label-description-font-weight": defaults.globals.font.weights.regular,
"label-description-line-height": "1rem",
"label-description-color":
defaults.contextuals.content.semantic.neutral.secondary,
"label-description-color--disabled":
defaults.contextuals.content.semantic.disabled.primary,
});
29 changes: 29 additions & 0 deletions packages/ui-components/src/components/forms/input/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,35 @@
align-items: center;
height: 2.75rem;
}

&--inline {
align-items: center;
height: auto;
min-height: var(--c--components--forms-input--inline-min-height);
border-radius: var(--c--components--forms-input--inline-border-radius);
padding: var(--c--components--forms-input--inline-padding-block)
var(--c--components--forms-input--inline-padding-inline);

&:hover:not(.c__input__wrapper--disabled),
&:focus-within {
border-radius: var(--c--components--forms-input--inline-border-radius);
}

&.c__input__wrapper--disabled {
background-color: var(
--c--components--forms-input--inline-background-color--disabled
);
border-color: var(
--c--components--forms-input--inline-border-color--disabled
);

&:hover {
border-color: var(
--c--components--forms-input--inline-border-color--disabled
);
}
}
}
}

.c__input--password {
Expand Down
Loading
Loading