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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ Versioning follows the policy in [CONTRIBUTING.md](CONTRIBUTING.md#versioning).

## [Unreleased]

### Added

- **`Form.Item` / `FormItemVisual` take `labelExtra`** for actions that belong
to a field's label row, such as a filter or sort toggle. The slot
(`.uic-form-item__label-extra`) renders outside the `<label>`, so its
buttons stay out of the control's accessible name and never trigger label
activation. Vertical layout puts it at the far end of the row, and the label
column stops clipping so its focus ring stays whole. Horizontal and inline
layout put it right after the label, and the colon moves to the end of the
slot. Ignored without a `label`.

## [0.2.0-alpha.18]

### Added
Expand Down
2 changes: 1 addition & 1 deletion astryx/components/Form.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export default {
],
usage: {
description:
"Data-entry screens whose state, validation and submit need more than a few controlled fields. Style hooks: .uic-form-item and its __label, __control, __explain-error, __extra elements, and --form-item-margin-bottom, --form-item-gap and --form-item-line-height. Help, extra and the tooltip glyph take the theme's --color-text-description when it declares one (default --color-text-secondary). A control reaches its item's status through Form.Item.useStatus() or FormItemInputContext.",
"Data-entry screens whose state, validation and submit need more than a few controlled fields. Form.Item's labelExtra puts actions (a filter or sort toggle) in the label row, outside the <label> so they stay out of the control's accessible name: at the far end in vertical layout, right after the label otherwise. Style hooks: .uic-form-item and its __label, __label-extra, __control, __explain-error, __extra elements, and --form-item-margin-bottom, --form-item-gap and --form-item-line-height. Help, extra and the tooltip glyph take the theme's --color-text-description when it declares one (default --color-text-secondary). A control reaches its item's status through Form.Item.useStatus() or FormItemInputContext.",
},
examples: [
{
Expand Down
33 changes: 33 additions & 0 deletions src/components/Form/Form.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,35 @@ function MembersForm(props: FormProps) {
);
}

/** `labelExtra`: an action in the label row, outside the `<label>`. */
function LabelExtraForm(props: FormProps) {
const [ascending, setAscending] = useState(true);
const options = ascending ? REGIONS : [...REGIONS].reverse();
return (
<Form layout="vertical" {...props} style={{ maxWidth: 480 }}>
<FormItem
name="region"
label="Region"
labelExtra={
<Button
variant="ghost"
size="sm"
label={ascending ? "Sort Z–A" : "Sort A–Z"}
onClick={() => setAscending((value) => !value)}
/>
}
>
<Selector
label="Region"
isLabelHidden
options={options}
placeholder="Select a region"
/>
</FormItem>
</Form>
);
}

const meta = {
title: "Components/Form",
// `Form` itself: its static members (`Form.Item`, ...) are not part of the props.
Expand Down Expand Up @@ -253,3 +282,7 @@ export const ItemVisual: Story = {
</VStack>
),
};

export const LabelExtra: Story = {
render: (args) => <LabelExtraForm {...args} />,
};
7 changes: 7 additions & 0 deletions src/components/Form/FormItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ export interface FormItemProps<Values = any> extends Omit<
* overlay and are ignored.
*/
tooltip?: React.ReactNode | FormItemTooltipConfig;
/**
* Actions for the label row: far end in vertical layout, right after the
* label in horizontal / inline layout. Needs a `label`.
*/
labelExtra?: React.ReactNode;
extra?: React.ReactNode;
/** `false` also suppresses meta bubbling to an ancestor item. */
help?: React.ReactNode;
Expand Down Expand Up @@ -154,6 +159,7 @@ const FormItem = <Values,>(props: FormItemProps<Values>) => {
name,
label,
tooltip,
labelExtra,
extra,
help,
required,
Expand Down Expand Up @@ -333,6 +339,7 @@ const FormItem = <Values,>(props: FormItemProps<Values>) => {
labelTitle={typeof label === "string" ? label : undefined}
tooltip={normalizeTooltip(tooltip)}
tooltipIcon={tooltipIcon(tooltip)}
labelExtra={labelExtra}
extra={extra}
help={help}
required={mergedRequired}
Expand Down
54 changes: 54 additions & 0 deletions src/components/Form/FormItemVisual.css
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,60 @@
visibility: hidden;
}

/* `labelExtra`: the label column becomes a flex row and the colon moves from
the label to the end of the slot. Vertical pushes the slot to the far end. */
.uic-form-item__label-col[data-has-label-extra] {
display: flex;
align-items: center;
justify-content: flex-end;
}

.uic-form-item__label-col[data-has-label-extra][data-align="left"] {
justify-content: flex-start;
}

/* `overflow: visible` keeps the flush-right button's focus ring unclipped. */
.uic-form-item[data-layout="vertical"]
.uic-form-item__label-col[data-has-label-extra] {
justify-content: space-between;
overflow: visible;
}

.uic-form-item__label-col[data-has-label-extra] > .uic-form-item__label {
min-width: 0;
}

/* A too-narrow column clips the label text instead of painting it over the slot. */
.uic-form-item__label-col[data-has-label-extra]:not([data-wrap])
> .uic-form-item__label {
overflow: hidden;
}

.uic-form-item__label-col[data-has-label-extra] > .uic-form-item__label::after {
content: none;
}

.uic-form-item__label-extra {
display: inline-flex;
flex: none;
align-items: center;
margin-inline-start: var(--spacing-1);
}

.uic-form-item__label-extra::after {
content: ":";
margin-inline-start: var(--spacing-0-5);
margin-inline-end: var(--spacing-2);
}

.uic-form-item__label-extra[data-no-colon]::after {
content: "\a0";
}

.uic-form-item[data-layout="vertical"] .uic-form-item__label-extra::after {
content: none;
}

.uic-form-item__optional {
display: inline-block;
margin-inline-start: var(--spacing-1);
Expand Down
125 changes: 125 additions & 0 deletions src/components/Form/FormItemVisual.labelExtra.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* `labelExtra` renders in the label row but outside the `<label>`, so its
* actions stay out of the control's accessible name and never trigger label
* activation.
*/
import FormItemVisual from "./FormItemVisual";
import { Form } from "./index";
import { render, screen } from "@testing-library/react";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";

describe("FormItemVisual — labelExtra", () => {
it("renders the slot in the label row, outside the <label>", () => {
render(
<FormItemVisual
label="Environments"
labelExtra={<button type="button">Filter</button>}
>
<input aria-label="environment" />
</FormItemVisual>,
);
const button = screen.getByRole("button", { name: "Filter" });
const slot = button.closest(".uic-form-item__label-extra");
expect(slot).not.toBeNull();
expect(button.closest("label")).toBeNull();
const labelCol = slot?.closest(".uic-form-item__label-col");
expect(labelCol?.hasAttribute("data-has-label-extra")).toBe(true);
expect(labelCol?.querySelector("label")?.textContent).toBe("Environments");
});

it("keeps the slot out of the control's accessible name", () => {
render(
<Form>
<Form.Item
name="environment"
label="Environments"
labelExtra={<button type="button">Filter</button>}
>
<input />
</Form.Item>
</Form>,
);
expect(screen.getByRole("textbox", { name: "Environments" })).not.toBeNull();
});

it("is forwarded by Form.Item", () => {
render(
<Form>
<Form.Item
name="environment"
label="Environments"
labelExtra={<button type="button">Filter</button>}
>
<input aria-label="environment" />
</Form.Item>
</Form>,
);
const button = screen.getByRole("button", { name: "Filter" });
expect(button.closest(".uic-form-item__label-extra")).not.toBeNull();
});

it("keeps the colon on the slot by default", () => {
render(
<FormItemVisual
layout="horizontal"
label="Environments"
labelExtra={<button type="button">Filter</button>}
>
<input aria-label="environment" />
</FormItemVisual>,
);
const slot = document.querySelector(".uic-form-item__label-extra");
expect(slot?.hasAttribute("data-no-colon")).toBe(false);
});

it("marks the slot as colon-less when the item has no colon", () => {
render(
<FormItemVisual
layout="horizontal"
colon={false}
label="Environments"
labelExtra={<button type="button">Filter</button>}
>
<input aria-label="environment" />
</FormItemVisual>,
);
const slot = document.querySelector(".uic-form-item__label-extra");
expect(slot?.hasAttribute("data-no-colon")).toBe(true);
});

it("renders no slot without a label", () => {
render(
<FormItemVisual labelExtra={<button type="button">Filter</button>}>
<input aria-label="environment" />
</FormItemVisual>,
);
expect(screen.queryByRole("button", { name: "Filter" })).toBeNull();
});

it.each([undefined, null, false])(
"renders no slot for labelExtra=%s",
(labelExtra) => {
render(
<FormItemVisual label="Environments" labelExtra={labelExtra}>
<input aria-label="environment" />
</FormItemVisual>,
);
expect(document.querySelector(".uic-form-item__label-extra")).toBeNull();
expect(
document
.querySelector(".uic-form-item__label-col")
?.hasAttribute("data-has-label-extra"),
).toBe(false);
},
);

// jsdom drops `@layer`, so the layout contract is read from the source.
it("spreads the row and unclips the focus ring in vertical layout", () => {
const css = readFileSync(join(__dirname, "FormItemVisual.css"), "utf8");
expect(css).toMatch(
/\[data-layout="vertical"\]\s+\.uic-form-item__label-col\[data-has-label-extra\]\s*\{[^}]*justify-content:\s*space-between;[^}]*overflow:\s*visible;/,
);
});
});
22 changes: 22 additions & 0 deletions src/components/Form/FormItemVisual.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* .uic-form-item__row
* .uic-form-item__label-col
* label.uic-form-item__label (+ .uic-form-item__tooltip)
* .uic-form-item__label-extra
* .uic-form-item__control
* .uic-form-item__control-input
* .uic-form-item__control-input-content
Expand Down Expand Up @@ -50,6 +51,13 @@ export interface FormItemVisualProps {
tooltip?: React.ReactNode;
/** The tooltip trigger glyph. Defaults to a question mark in a circle. */
tooltipIcon?: React.ReactNode;
/**
* Actions that belong to the label row (e.g. a filter button). Rendered
* outside the `<label>`, so they stay out of the control's accessible name:
* at the far end of the row in vertical layout, right after the label
* otherwise. Needs a `label`.
*/
labelExtra?: React.ReactNode;
extra?: React.ReactNode;
help?: React.ReactNode;
/** Renders the required marker. Independent of the `required` rule. */
Expand Down Expand Up @@ -116,6 +124,7 @@ export const FormItemVisual: React.FC<FormItemVisualProps> = ({
labelTitle,
tooltip,
tooltipIcon,
labelExtra,
extra,
help,
required,
Expand Down Expand Up @@ -168,12 +177,16 @@ export const FormItemVisual: React.FC<FormItemVisualProps> = ({
setExtraHeight(extra && extraRef.current ? extraRef.current.clientHeight : 0);
}, [extra]);

const hasLabelExtra =
labelExtra !== undefined && labelExtra !== null && labelExtra !== false;

const labelNode =
label === undefined || label === null ? null : (
<div
className={cx("uic-form-item__label-col", labelCol?.className)}
data-align={labelAlign === "left" ? "left" : undefined}
data-wrap={labelWrap ? "" : undefined}
data-has-label-extra={hasLabelExtra ? "" : undefined}
style={colStyle(labelCol, "label")}
>
<label
Expand Down Expand Up @@ -205,6 +218,15 @@ export const FormItemVisual: React.FC<FormItemVisualProps> = ({
</Tooltip>
) : null}
</label>
{hasLabelExtra ? (
// The colon moves here so it still ends the row in horizontal layout.
<span
className="uic-form-item__label-extra"
data-no-colon={colon === false ? "" : undefined}
>
{labelExtra}
</span>
) : null}
</div>
);

Expand Down
Loading