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
5 changes: 5 additions & 0 deletions .changeset/field-empty-label.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/kumo": patch
---

`Field` no longer renders a `<label>` when it would be empty: when `label` and `labelTooltip` are each `undefined`, `null`, a boolean or `""`, and there is no "(optional)" indicator. This fixes `Input`, `InputArea` and `TagInput` without a `label` but with a description or error: they no longer show an empty row above the control, and their `aria-label` is no longer replaced by an empty accessible name.
53 changes: 53 additions & 0 deletions packages/kumo/src/components/field/field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,57 @@ describe("Field", () => {
screen.getByRole("button", { name: "Mais informações" }),
).toBeTruthy();
});

it.each([
["undefined", undefined],
["null", null],
["true", true],
["false", false],
["an empty string", ""],
])("renders no label element when label is %s", (_, label) => {
const { container } = render(
<Field label={label} description="Help text">
<input aria-label="Name" />
</Field>,
);

expect(container.querySelector("label")).toBeNull();
expect(screen.getByText("Help text")).toBeTruthy();
expect(screen.getByRole("textbox", { name: "Name" })).toBeTruthy();
});

it.each([
["text", "Name", {}],
["the number 0", 0, {}],
["only an optional indicator", undefined, { required: false }],
["only a tooltip", undefined, { labelTooltip: "More" }],
])("keeps the label element for %s", (_, label, props) => {
const { container } = render(
<Field label={label} {...props}>
<input />
</Field>,
);

expect(container.querySelector("label")).not.toBeNull();
});

it("renders no label element for an empty tooltip", () => {
const { container } = render(
<Field label={undefined} labelTooltip="">
<input />
</Field>,
);

expect(container.querySelector("label")).toBeNull();
});

it("still hides the label element with hideLabel", () => {
const { container } = render(
<Field label="Name" hideLabel>
<input />
</Field>,
);

expect(container.querySelector("label")).toBeNull();
});
});
11 changes: 10 additions & 1 deletion packages/kumo/src/components/field/field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ export interface FieldProps extends KumoFieldVariantsProps {
hideLabel?: boolean;
}

/** Values React renders as nothing. */
function rendersNothing(node: ReactNode) {
return node == null || typeof node === "boolean" || node === "";
}

/**
* Form field wrapper that provides a label, optional description, and error display
* around any form control. Built on Base UI Field primitives.
Expand All @@ -143,10 +148,14 @@ export function Field({
}: FieldProps) {
// Show "(optional)" when required is explicitly false
const showOptional = required === false;
// An empty <label> still takes a grid row and, through aria-labelledby,
// overrides the control's own aria-label with an empty name.
const hasLabelContent =
!rendersNothing(label) || showOptional || !rendersNothing(labelTooltip);

return (
<FieldBase.Root className={fieldVariants({ controlFirst })}>
{!hideLabel && (
{!hideLabel && hasLabelContent && (
<FieldBase.Label className="m-0 text-base font-medium text-kumo-default select-none">
<Label showOptional={showOptional} tooltip={labelTooltip} asContent>
{label}
Expand Down
19 changes: 19 additions & 0 deletions packages/kumo/src/components/input/input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -400,4 +400,23 @@ describe("InputArea", () => {
render(<InputArea ref={ref} aria-label="Notes" autoResize />);
expect(ref.current).toBeInstanceOf(HTMLTextAreaElement);
});

it.each([
["description", { description: "Help text" }],
["error", { error: "Required" }],
])("keeps its aria-label as the name with only a %s", (_, props) => {
const { container } = render(<Input aria-label="Name" {...props} />);

expect(container.querySelector("label")).toBeNull();
expect(screen.getByRole("textbox", { name: "Name" })).toBeTruthy();
});

it("keeps the InputArea aria-label as the name with only a description", () => {
const { container } = render(
<InputArea aria-label="Notes" description="Help text" />,
);

expect(container.querySelector("label")).toBeNull();
expect(screen.getByRole("textbox", { name: "Notes" })).toBeTruthy();
});
});
13 changes: 13 additions & 0 deletions packages/kumo/src/components/tag-input/tag-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,19 @@ describe("TagInput", () => {
expect(screen.getByText("Tags").closest("label")?.htmlFor).toBe(input.id);
});

it("does not render an empty label when only feedback is shown", () => {
const { container } = render(
<TagInput aria-label="Tags" validateValue={() => false} />,
);
fireEvent.change(screen.getByLabelText("Tags"), {
target: { value: "bad" },
});
fireEvent.keyDown(screen.getByLabelText("Tags"), { key: "Enter" });
expect(screen.getByText('"bad" is not valid.')).toBeTruthy();
expect(container.querySelector("label")).toBeNull();
expect(screen.getByRole("textbox", { name: "Tags" })).toBeTruthy();
});

it("forwards its ref to the input", () => {
const ref = createRef<HTMLInputElement>();
render(<TagInput ref={ref} aria-label="Tags" />);
Expand Down
2 changes: 1 addition & 1 deletion packages/kumo/src/components/tag-input/tag-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ export const TagInput = forwardRef<HTMLInputElement, TagInputProps>(
if (hasField) {
return (
<Field
label={label ?? ""}
label={label}
labelTooltip={labelTooltip}
description={description}
error={fieldError}
Expand Down
Loading