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
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import {
} from "../../ui/autocomplete/index";
import { Field } from "../../ui/field/field";
import { FieldDescription } from "../../ui/field/field-description";
import { FieldHelperText } from "../../ui/field/field-helper-text";
import { FieldLabel } from "../../ui/field/field-label";
import type { FieldMagnitude } from "../../ui/field/variants";
import { FieldHelperText } from "./field-helper-text";

export type AutocompleteFieldProps = Omit<AutocompleteProps<string>, "children" | "items"> & {
/** Supporting text shown below the input. */
Expand Down
2 changes: 1 addition & 1 deletion packages/propel/src/components/field/checkbox-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import {
type CheckboxFieldControlProps,
} from "../../ui/field/checkbox-field-control";
import { Field } from "../../ui/field/field";
import { FieldHelperText } from "../../ui/field/field-helper-text";
import { FieldItem } from "../../ui/field/field-item";
import { FieldItemContent } from "../../ui/field/field-item-content";
import type { FieldMagnitude } from "../../ui/field/variants";
import { FieldHelperText } from "./field-helper-text";

export type CheckboxFieldProps = Omit<
CheckboxFieldControlProps,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import type * as React from "react";
import { CheckboxGroup, type CheckboxGroupProps } from "../../ui/checkbox-group/index";
import { Field } from "../../ui/field/field";
import { FieldDescription } from "../../ui/field/field-description";
import { FieldHelperText } from "../../ui/field/field-helper-text";
import { FieldOptionMagnitudeProvider } from "../../ui/field/field-option-magnitude-provider";
import type { FieldMagnitude } from "../../ui/field/variants";
import { Fieldset, FieldsetLegend } from "../../ui/fieldset/index";
import { FieldHelperText } from "./field-helper-text";

export type CheckboxGroupFieldProps = Omit<CheckboxGroupProps, "children" | "density" | "name"> & {
/** Checkbox option rows, usually `CheckboxGroupFieldOption`. */
Expand Down
2 changes: 1 addition & 1 deletion packages/propel/src/components/field/combobox-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ import {
} from "../../ui/combobox/index";
import { Field } from "../../ui/field/field";
import { FieldDescription } from "../../ui/field/field-description";
import { FieldHelperText } from "../../ui/field/field-helper-text";
import { FieldLabel } from "../../ui/field/field-label";
import type { FieldMagnitude } from "../../ui/field/variants";
import { FieldHelperText } from "./field-helper-text";

export type ComboboxFieldProps = Omit<ComboboxProps<string>, "children" | "items"> & {
/** Supporting text shown below the input. */
Expand Down
31 changes: 31 additions & 0 deletions packages/propel/src/components/field/field-helper-text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type * as React from "react";

import { FieldDescription } from "../../ui/field/field-description";
import { FieldError } from "../../ui/field/field-error";
import type { InputMagnitude } from "../../ui/field/variants";

/**
* The error-or-hint line beneath a field: shows the `FieldError` when there is an error, otherwise
* the `FieldDescription` hint. A components-tier composition of the two ui parts (each a single
* element); shared by the ready-made field types so the error-XOR-hint rule lives in one place.
*/
export function FieldHelperText({
magnitude,
hint,
error,
}: {
magnitude: InputMagnitude;
hint?: React.ReactNode;
error?: React.ReactNode;
}) {
return (
<>
<FieldError magnitude={magnitude} match={error != null}>
{error}
</FieldError>
{error == null && hint != null ? (
<FieldDescription magnitude={magnitude}>{hint}</FieldDescription>
) : null}
</>
);
}
2 changes: 1 addition & 1 deletion packages/propel/src/components/field/input-field.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type * as React from "react";

import { FieldHelperText } from "../../ui/field/field-helper-text";
import { FieldLabelGroup } from "../../ui/field/field-label-group";
import { InputFieldBox } from "../../ui/field/input-field-box";
import { InputFieldContent } from "../../ui/field/input-field-content";
Expand All @@ -9,6 +8,7 @@ import { InputFieldIconSlot } from "../../ui/field/input-field-icon-slot";
import { InputFieldRoot } from "../../ui/field/input-field-root";
import type { InputMagnitude, InputTone } from "../../ui/field/variants";
import type { InputProps } from "../../ui/input/index";
import { FieldHelperText } from "./field-helper-text";

export type { InputMagnitude, InputTone } from "../../ui/field/variants";

Expand Down
2 changes: 1 addition & 1 deletion packages/propel/src/components/field/radio-group-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import type * as React from "react";

import { Field } from "../../ui/field/field";
import { FieldDescription } from "../../ui/field/field-description";
import { FieldHelperText } from "../../ui/field/field-helper-text";
import { FieldOptionMagnitudeProvider } from "../../ui/field/field-option-magnitude-provider";
import type { FieldMagnitude } from "../../ui/field/variants";
import { Fieldset, FieldsetLegend } from "../../ui/fieldset/index";
import { RadioGroup, type RadioGroupProps } from "../../ui/radio/index";
import { FieldHelperText } from "./field-helper-text";

export type RadioGroupFieldProps<Value = string> = Omit<
RadioGroupProps<Value>,
Expand Down
2 changes: 1 addition & 1 deletion packages/propel/src/components/field/select-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type * as React from "react";

import { Field } from "../../ui/field/field";
import { FieldDescription } from "../../ui/field/field-description";
import { FieldHelperText } from "../../ui/field/field-helper-text";
import type { FieldMagnitude } from "../../ui/field/variants";
import {
Select,
Expand All @@ -19,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from "../../ui/select/index";
import { FieldHelperText } from "./field-helper-text";

export type SelectFieldOption = {
label: React.ReactNode;
Expand Down
2 changes: 1 addition & 1 deletion packages/propel/src/components/field/switch-field.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type * as React from "react";

import { Field } from "../../ui/field/field";
import { FieldHelperText } from "../../ui/field/field-helper-text";
import { FieldItem } from "../../ui/field/field-item";
import { FieldItemContent } from "../../ui/field/field-item-content";
import type { FieldMagnitude } from "../../ui/field/variants";
import { FieldHelperText } from "./field-helper-text";
import {
SwitchFieldControl,
type SwitchFieldControlMagnitude,
Expand Down
7 changes: 4 additions & 3 deletions packages/propel/src/components/field/text-area-field.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type * as React from "react";

import { Field } from "../../ui/field/field";
import { FieldHelperText } from "../../ui/field/field-helper-text";
import { FieldLabelGroup } from "../../ui/field/field-label-group";
import { InputFieldContent } from "../../ui/field/input-field-content";
import { TextAreaFieldBox } from "../../ui/field/text-area-field-box";
import {
TextAreaFieldControl,
type TextAreaFieldControlProps,
} from "../../ui/field/text-area-field-control";
import type { InputMagnitude, InputTone } from "../../ui/field/variants";
import { FieldHelperText } from "./field-helper-text";

export type TextAreaFieldProps = Omit<TextAreaFieldControlProps, "magnitude" | "surface"> & {
/** Magnitude scale. `md` | `lg` | `xl`. */
Expand Down Expand Up @@ -52,7 +53,7 @@ export function TextAreaField({
description={description}
orientation="vertical"
/>
<div className="flex w-full flex-col gap-1.5">
<InputFieldContent orientation="vertical">
<TextAreaFieldBox tone={tone}>
<TextAreaFieldControl
required={required}
Expand All @@ -62,7 +63,7 @@ export function TextAreaField({
/>
</TextAreaFieldBox>
<FieldHelperText magnitude={magnitude} hint={hint} error={error} />
</div>
</InputFieldContent>
</Field>
);
}
26 changes: 0 additions & 26 deletions packages/propel/src/ui/field/field-helper-text.tsx

This file was deleted.

4 changes: 2 additions & 2 deletions packages/propel/src/ui/field/field-item-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type * as React from "react";

import { FieldDescription } from "./field-description";
import { FieldLabel } from "./field-label";
import type { InputMagnitude } from "./variants";
import { fieldItemContentVariants, type InputMagnitude } from "./variants";

export function FieldItemContent({
children,
Expand All @@ -14,7 +14,7 @@ export function FieldItemContent({
magnitude: InputMagnitude;
}) {
return (
<div className="flex min-w-0 flex-col gap-1">
<div className={fieldItemContentVariants()}>
<FieldLabel magnitude={magnitude}>{children}</FieldLabel>
{description != null ? (
<FieldDescription magnitude={magnitude}>{description}</FieldDescription>
Expand Down
6 changes: 3 additions & 3 deletions packages/propel/src/ui/field/field-label-group.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type * as React from "react";

import { FieldDescription } from "./field-description";
import { FieldLabel } from "./field-label";
import { type InputMagnitude, labelGroupVariants } from "./variants";
import { type InputMagnitude, fieldLabelGroupVariants } from "./variants";

export function FieldLabelGroup({
magnitude,
Expand All @@ -16,15 +16,15 @@ export function FieldLabelGroup({
required?: boolean;
label?: React.ReactNode;
description?: React.ReactNode;
orientation: NonNullable<VariantProps<typeof labelGroupVariants>["orientation"]>;
orientation: NonNullable<VariantProps<typeof fieldLabelGroupVariants>["orientation"]>;
}) {
if (label == null && description == null) {
return null;
}

const inset = orientation === "horizontal" && description == null;
return (
<div className={labelGroupVariants({ orientation })}>
<div className={fieldLabelGroupVariants({ orientation })}>
{label != null ? (
<FieldLabel magnitude={magnitude} required={required} inset={inset}>
{label}
Expand Down
21 changes: 21 additions & 0 deletions packages/propel/src/ui/field/field-label-required-marker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type * as React from "react";

import { fieldLabelRequiredMarkerVariants } from "./variants";

export type FieldLabelRequiredMarkerProps = Omit<
React.ComponentPropsWithoutRef<"span">,
"className" | "style"
>;

/**
* The required marker slot shown after a `FieldLabel`'s text. Decorative (the control's `required`
* attribute carries the semantics), so it is `aria-hidden`. Bakes no glyph: pass the marker (e.g.
* an asterisk) as `children`.
*/
export function FieldLabelRequiredMarker({ children, ...props }: FieldLabelRequiredMarkerProps) {
return (
<span aria-hidden className={fieldLabelRequiredMarkerVariants()} {...props}>
{children}
</span>
);
}
9 changes: 3 additions & 6 deletions packages/propel/src/ui/field/field-label.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Field as BaseField } from "@base-ui/react/field";
import type * as React from "react";

import { FieldLabelRequiredMarker } from "./field-label-required-marker";
import { fieldLabelVariants, type InputMagnitude } from "./variants";

export type FieldLabelProps = {
Expand All @@ -10,16 +11,12 @@ export type FieldLabelProps = {
inset?: boolean;
};

/** The label row: the label text and the required `*` asterisk in danger. */
/** The label row: the label text, plus a `FieldLabelRequiredMarker` when `required`. */
export function FieldLabel({ children, magnitude, required, inset }: FieldLabelProps) {
return (
<BaseField.Label className={fieldLabelVariants({ magnitude, inset })}>
{children}
{required ? (
<span aria-hidden className="text-danger-primary">
*
</span>
) : null}
{required ? <FieldLabelRequiredMarker>*</FieldLabelRequiredMarker> : null}
</BaseField.Label>
);
}
38 changes: 35 additions & 3 deletions packages/propel/src/ui/field/field.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect } from "storybook/test";

import { Field, FieldDescription, FieldError, FieldLabel, InputFieldControl } from "./index";
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
FieldLabelRequiredMarker,
InputFieldControl,
} from "./index";

// UI-tier story: composes the ATOMIC field parts. `Field` (Base UI `Field.Root`) is the
// labeling/validation shell; `FieldLabel` names the control, `InputFieldControl` is the
Expand All @@ -11,7 +18,13 @@ import { Field, FieldDescription, FieldError, FieldLabel, InputFieldControl } fr
const meta = {
title: "UI/Field",
component: Field,
subcomponents: { FieldLabel, InputFieldControl, FieldDescription, FieldError },
subcomponents: {
FieldLabel,
FieldLabelRequiredMarker,
InputFieldControl,
FieldDescription,
FieldError,
},
decorators: [
(Story) => (
<div className="w-80">
Expand All @@ -24,7 +37,10 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;

/** A labeled input with helper text, assembled from the atomic parts. */
/**
* A labeled input with helper text, assembled from the atomic parts. `FieldLabel required` appends
* the `FieldLabelRequiredMarker` slot, passing the asterisk glyph in as its child.
*/
export const Default: Story = {
render: () => (
<Field name="displayName">
Expand All @@ -42,6 +58,22 @@ export const Default: Story = {
},
};

/**
* The required marker is a bare slot that bakes no glyph: the consumer passes the marker (here, the
* conventional asterisk) as `children`.
*/
export const RequiredMarker: Story = {
render: () => (
<Field name="displayName">
<FieldLabel magnitude="md">
Display name
<FieldLabelRequiredMarker>*</FieldLabelRequiredMarker>
</FieldLabel>
<InputFieldControl magnitude="md" required placeholder="Ada Lovelace" />
</Field>
),
};

/** An invalid field: the control exposes `aria-invalid` and announces the `FieldError` text. */
export const Invalid: Story = {
render: () => (
Expand Down
4 changes: 3 additions & 1 deletion packages/propel/src/ui/field/field.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Field as BaseField } from "@base-ui/react/field";

import { fieldVariants } from "./variants";

export type FieldProps = Omit<BaseField.Root.Props, "className" | "style">;

/**
* The shared field chrome for custom controls. Compose it with `FieldLabel`, `InputFieldControl` or
* `TextAreaFieldControl`, `FieldDescription`, and `FieldError`.
*/
export function Field(props: FieldProps) {
return <BaseField.Root className="flex flex-col gap-1.5" {...props} />;
return <BaseField.Root className={fieldVariants()} {...props} />;
}
4 changes: 4 additions & 0 deletions packages/propel/src/ui/field/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ export { FieldDescription, type FieldDescriptionProps } from "./field-descriptio
export { FieldError, type FieldErrorProps } from "./field-error";
export { FieldItem, type FieldItemProps } from "./field-item";
export { FieldLabel, type FieldLabelProps } from "./field-label";
export {
FieldLabelRequiredMarker,
type FieldLabelRequiredMarkerProps,
} from "./field-label-required-marker";
export { InputFieldBox, type InputFieldBoxProps } from "./input-field-box";
export { InputFieldContent, type InputFieldContentProps } from "./input-field-content";
export { InputFieldControl, type InputFieldControlProps } from "./input-field-control";
Expand Down
4 changes: 2 additions & 2 deletions packages/propel/src/ui/field/input-field-icon-slot.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type * as React from "react";

import { iconSlotClass } from "./variants";
import { inputFieldIconSlotVariants } from "./variants";

export type InputFieldIconSlotProps = Omit<React.ComponentProps<"span">, "className" | "style">;

/** A 16px decorative slot rendered at the inline start/end of the `InputField` control. */
export function InputFieldIconSlot(props: InputFieldIconSlotProps) {
return <span aria-hidden className={iconSlotClass} {...props} />;
return <span aria-hidden className={inputFieldIconSlotVariants()} {...props} />;
}
Loading