Skip to content

Commit 0d32cbd

Browse files
fix(field): define footer behavior and styling contract (#310)
* fix(field): define footer behavior and styling contract * test(field): address review feedback
1 parent 1da87f8 commit 0d32cbd

File tree

7 files changed

+98
-10
lines changed

7 files changed

+98
-10
lines changed

docs/widgets/catalog.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@
297297
"name": "field",
298298
"requiredProps": ["label", "children"],
299299
"commonProps": ["required", "error", "hint"],
300-
"example": "ui.field({ label: 'Email', children: ui.input('email', '') })"
300+
"example": "ui.field({ label: 'Email', children: ui.input({ id: 'email', value: '' }) })"
301301
},
302302
{
303303
"name": "select",

docs/widgets/field.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,14 @@ form.field("name", { label: "Name", required: true, hint: "Your display name" })
3333
| `required` | `boolean` | `false` | Show required indicator |
3434
| `error` | `string` | - | Error message |
3535
| `hint` | `string` | - | Helper text |
36+
| `key` | `string` | - | Reconciliation key |
3637

3738
## Notes
3839

3940
- The wrapped child remains the focusable element.
41+
- `Field` renders at most one footer line. A non-empty `error` takes precedence; otherwise `hint` is shown.
42+
- An empty-string `error` is treated as absent, so `hint` still renders when provided.
43+
- Footer colors come from the active theme. The exported field style helpers are structural presets, not fixed color tokens.
4044
- Use `Field` to keep label, hint, and error layout consistent across forms.
4145

4246
## Related

packages/core/etc/core.api.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2188,7 +2188,7 @@ export type FgTokens = Readonly<{
21882188

21892189
// @public
21902190
export const FIELD_ERROR_STYLE: Readonly<{
2191-
fg: number;
2191+
bold: true;
21922192
}>;
21932193

21942194
// @public
@@ -2674,6 +2674,9 @@ export function getDefaultFocusConfig(kind: string): FocusConfig;
26742674
// @public
26752675
export function getExpandIndicator(hasChildren: boolean, isExpanded: boolean, isLoading: boolean): string;
26762676

2677+
// @public
2678+
export function getFieldFooterText(error: string | undefined, hint: string | undefined): string | undefined;
2679+
26772680
// @public
26782681
export function getFilteredItems(sources: readonly CommandSource[], query: string): Promise<readonly CommandItem[]>;
26792682

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,7 @@ export {
10551055
FIELD_HINT_STYLE,
10561056
FIELD_LABEL_STYLE,
10571057
REQUIRED_INDICATOR,
1058+
getFieldFooterText,
10581059
shouldShowError,
10591060
} from "./widgets/field.js";
10601061

packages/core/src/renderer/renderToDrawlist/widgets/renderFormWidgets.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ import {
1414
sliderRecipe,
1515
} from "../../../ui/recipes.js";
1616
import { DEFAULT_PLACEHOLDER, getSelectDisplayText } from "../../../widgets/select.js";
17+
import {
18+
FIELD_ERROR_STYLE,
19+
FIELD_HINT_STYLE,
20+
FIELD_LABEL_STYLE,
21+
buildFieldLabel,
22+
getFieldFooterText,
23+
shouldShowError,
24+
} from "../../../widgets/field.js";
1725
import {
1826
DEFAULT_SLIDER_TRACK_WIDTH,
1927
formatSliderValue,
@@ -822,16 +830,22 @@ export function renderFormWidgets(
822830
const error = typeof props.error === "string" ? props.error : undefined;
823831
const hint = typeof props.hint === "string" ? props.hint : undefined;
824832

825-
const labelText = required ? `${label} *` : label;
833+
const labelText = buildFieldLabel(label, required);
834+
const showError = shouldShowError(error);
835+
const footer = getFieldFooterText(error, hint);
836+
const labelStyle = mergeTextStyle(parentStyle, FIELD_LABEL_STYLE);
826837
builder.pushClip(rect.x, rect.y, rect.w, rect.h);
827-
builder.drawText(rect.x, rect.y, truncateToWidth(labelText, rect.w), parentStyle);
838+
builder.drawText(rect.x, rect.y, truncateToWidth(labelText, rect.w), labelStyle);
828839
if (rect.h >= 2) {
829840
const footerY = rect.y + rect.h - 1;
830-
const footer = error ?? hint;
831841
if (footer) {
842+
const footerBaseStyle = showError ? FIELD_ERROR_STYLE : FIELD_HINT_STYLE;
843+
const footerColorStyle = showError
844+
? { fg: theme.colors.danger }
845+
: { fg: theme.colors.muted };
832846
const footerStyle = mergeTextStyle(
833-
parentStyle,
834-
error ? { fg: theme.colors.danger } : { fg: theme.colors.muted },
847+
mergeTextStyle(parentStyle, footerBaseStyle),
848+
footerColorStyle,
835849
);
836850
builder.drawText(rect.x, footerY, truncateToWidth(footer, rect.w), footerStyle);
837851
}

packages/core/src/widgets/__tests__/formWidgets.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
*/
66

77
import { assert, describe, test } from "@rezi-ui/testkit";
8+
import { createTestRenderer } from "../../testing/renderer.js";
9+
import { darkTheme } from "../../theme/presets.js";
810
import {
911
CHECKBOX_CHECKED,
1012
CHECKBOX_DISABLED_CHECKED,
@@ -14,7 +16,15 @@ import {
1416
getCheckboxIndicator,
1517
toggleCheckbox,
1618
} from "../checkbox.js";
17-
import { REQUIRED_INDICATOR, buildFieldLabel, shouldShowError } from "../field.js";
19+
import {
20+
FIELD_ERROR_STYLE,
21+
FIELD_HINT_STYLE,
22+
FIELD_LABEL_STYLE,
23+
REQUIRED_INDICATOR,
24+
buildFieldLabel,
25+
getFieldFooterText,
26+
shouldShowError,
27+
} from "../field.js";
1828
import {
1929
RADIO_SELECTED,
2030
RADIO_UNSELECTED,
@@ -57,6 +67,52 @@ describe("field widget utilities", () => {
5767
test("shouldShowError returns true for non-empty string", () => {
5868
assert.equal(shouldShowError("Required"), true);
5969
});
70+
71+
test("getFieldFooterText prefers non-empty error over hint", () => {
72+
assert.equal(getFieldFooterText("Required", "Helpful hint"), "Required");
73+
});
74+
75+
test("getFieldFooterText falls back to hint when error is empty", () => {
76+
assert.equal(getFieldFooterText("", "Helpful hint"), "Helpful hint");
77+
});
78+
79+
test("field helper styles stay theme-agnostic", () => {
80+
assert.deepEqual(FIELD_LABEL_STYLE, { bold: true });
81+
assert.deepEqual(FIELD_ERROR_STYLE, { bold: true });
82+
assert.deepEqual(FIELD_HINT_STYLE, { dim: true });
83+
});
84+
85+
test("field renderer shows error footer before hint and falls back from empty error to hint", () => {
86+
const renderer = createTestRenderer({
87+
viewport: { cols: 40, rows: 6 },
88+
theme: darkTheme,
89+
});
90+
91+
const withError = renderer.render(
92+
ui.field({
93+
label: "Name",
94+
error: "Required",
95+
hint: "Enter your name",
96+
children: ui.text("abcdefghijklmnopqrst"),
97+
}),
98+
);
99+
const withEmptyError = renderer.render(
100+
ui.field({
101+
label: "Name",
102+
error: "",
103+
hint: "Enter your name",
104+
children: ui.text("abcdefghijklmnopqrst"),
105+
}),
106+
);
107+
108+
const withErrorText = withError.toText();
109+
const withEmptyErrorText = withEmptyError.toText();
110+
111+
assert.ok(withErrorText.includes("Required"));
112+
assert.ok(!withErrorText.includes("Enter your name"));
113+
assert.ok(withEmptyErrorText.includes("Enter your name"));
114+
assert.ok(!withEmptyErrorText.includes("Required"));
115+
});
60116
});
61117

62118
describe("select widget utilities", () => {

packages/core/src/widgets/field.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
* @see docs/widgets/field.md (GitHub issue #119)
99
*/
1010

11-
import { rgb } from "./style.js";
1211
import type { FieldProps, VNode } from "./types.js";
1312

1413
/** Character used to indicate required fields. */
@@ -21,7 +20,7 @@ export const FIELD_LABEL_STYLE = Object.freeze({
2120

2221
/** Default styles for field error. */
2322
export const FIELD_ERROR_STYLE = Object.freeze({
24-
fg: rgb(255, 0, 0),
23+
bold: true,
2524
});
2625

2726
/** Default styles for field hint. */
@@ -53,6 +52,17 @@ export function shouldShowError(error: string | undefined): boolean {
5352
return error !== undefined && error !== "";
5453
}
5554

55+
/**
56+
* Resolve the footer text shown below a field.
57+
* Non-empty errors take precedence; otherwise the hint is shown.
58+
*/
59+
export function getFieldFooterText(
60+
error: string | undefined,
61+
hint: string | undefined,
62+
): string | undefined {
63+
return shouldShowError(error) ? error : hint;
64+
}
65+
5666
/**
5767
* Create a VNode for a field wrapper.
5868
* This creates the structure: column(label, children, error?, hint?)

0 commit comments

Comments
 (0)