Skip to content

Commit a111afc

Browse files
committed
feat(ui): clarify automatic filter helper
1 parent 669ced2 commit a111afc

4 files changed

Lines changed: 81 additions & 16 deletions

File tree

packages/ui/src/components/ui/form-controls.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,34 @@ describe("composed form controls", () => {
4040
expect(html).toContain('aria-invalid="true"');
4141
expect(html).toContain('aria-required="true"');
4242
expect(html).toContain('role="alert"');
43+
expect(html.indexOf("账号")).toBeLessThan(html.indexOf("<input"));
44+
expect(html.indexOf("<input")).toBeLessThan(html.indexOf("用于登录"));
45+
});
46+
47+
it("can place compact helper text between the label and control", () => {
48+
const html = renderToStaticMarkup(
49+
React.createElement(
50+
FormField,
51+
{
52+
id: "exclude-regex",
53+
label: "排除正则",
54+
description: "每行一条,匹配到的节点会被全局排除,关闭后恢复。",
55+
descriptionPlacement: "before-control",
56+
},
57+
React.createElement(Input)
58+
)
59+
);
60+
const labelIndex = html.indexOf("排除正则");
61+
const descriptionIndex = html.indexOf(
62+
"每行一条,匹配到的节点会被全局排除,关闭后恢复。"
63+
);
64+
const controlIndex = html.indexOf("<input");
65+
66+
expect(labelIndex).toBeGreaterThanOrEqual(0);
67+
expect(descriptionIndex).toBeGreaterThan(labelIndex);
68+
expect(controlIndex).toBeGreaterThan(descriptionIndex);
69+
expect(html).toContain('class="space-y-1"');
70+
expect(html).toContain('aria-describedby="exclude-regex-description"');
4371
});
4472

4573
it("renders a disabled, labelled switch with its description", () => {

packages/ui/src/components/ui/form-field.tsx

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface FormFieldProps {
1515
id?: string;
1616
label: React.ReactNode;
1717
description?: React.ReactNode;
18+
descriptionPlacement?: "before-control" | "after-control";
1819
error?: React.ReactNode;
1920
required?: boolean;
2021
className?: string;
@@ -29,6 +30,7 @@ function FormField({
2930
id,
3031
label,
3132
description,
33+
descriptionPlacement = "after-control",
3234
error,
3335
required = false,
3436
className,
@@ -51,19 +53,30 @@ function FormField({
5153
"aria-invalid": error ? true : controlChild.props["aria-invalid"],
5254
"aria-required": required || controlChild.props["aria-required"] || undefined,
5355
});
56+
const labelElement = (
57+
<Label htmlFor={controlChild.props.id ?? controlId}>
58+
{label}
59+
{required ? <span aria-hidden="true" className="ml-1 text-red-400">*</span> : null}
60+
</Label>
61+
);
62+
const descriptionElement = description ? (
63+
<p id={descriptionId} className="text-xs leading-relaxed text-white/45">
64+
{description}
65+
</p>
66+
) : null;
5467

5568
return (
5669
<div className={cn("space-y-2", className)}>
57-
<Label htmlFor={controlChild.props.id ?? controlId}>
58-
{label}
59-
{required ? <span aria-hidden="true" className="ml-1 text-red-400">*</span> : null}
60-
</Label>
70+
{descriptionPlacement === "before-control" && descriptionElement ? (
71+
<div className="space-y-1">
72+
{labelElement}
73+
{descriptionElement}
74+
</div>
75+
) : (
76+
labelElement
77+
)}
6178
{control}
62-
{description ? (
63-
<p id={descriptionId} className="text-xs leading-relaxed text-white/45">
64-
{description}
65-
</p>
66-
) : null}
79+
{descriptionPlacement === "after-control" ? descriptionElement : null}
6780
{error ? (
6881
<p id={errorId} className="text-xs leading-relaxed text-red-400" role="alert">
6982
{error}

packages/ui/src/product/converter/advanced-mode/sections/node-management/auto-processing-dialog.test.ts

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { ParsedNode } from "@subboost/core/types/node";
66
const mocks = vi.hoisted(() => ({
77
buttons: [] as Array<Record<string, any>>,
88
dialog: null as Record<string, any> | null,
9+
formField: null as Record<string, any> | null,
910
switchField: null as Record<string, any> | null,
1011
textarea: null as Record<string, any> | null,
1112
}));
@@ -28,15 +29,23 @@ vi.mock("@subboost/ui/components/ui/dialog", () => ({
2829
DialogTitle: (props: any) => React.createElement("h2", null, props.children),
2930
}));
3031
vi.mock("@subboost/ui/components/ui/form-field", () => ({
31-
FormField: (props: any) =>
32-
React.createElement(
32+
FormField: (props: any) => {
33+
mocks.formField = props;
34+
const label = React.createElement("label", null, props.label);
35+
const description = props.description
36+
? React.createElement("p", null, props.description)
37+
: null;
38+
return React.createElement(
3339
"div",
3440
null,
35-
React.createElement("label", null, props.label),
41+
props.descriptionPlacement === "before-control"
42+
? React.createElement("div", null, label, description)
43+
: label,
3644
props.children,
37-
props.description ? React.createElement("p", null, props.description) : null,
45+
props.descriptionPlacement === "before-control" ? null : description,
3846
props.error ? React.createElement("p", { role: "alert" }, props.error) : null
39-
),
47+
);
48+
},
4049
}));
4150
vi.mock("@subboost/ui/components/ui/switch-field", () => ({
4251
SwitchField: (props: any) => {
@@ -111,6 +120,7 @@ describe("NodeManagementAutoProcessingDialog", () => {
111120
beforeEach(() => {
112121
mocks.buttons = [];
113122
mocks.dialog = null;
123+
mocks.formField = null;
114124
mocks.switchField = null;
115125
mocks.textarea = null;
116126
});
@@ -121,12 +131,25 @@ describe("NodeManagementAutoProcessingDialog", () => {
121131
expect(html).toContain("自动处理");
122132
expect(html).toContain("启用");
123133
expect(html).toContain("排除正则");
124-
expect(html).toContain("每行一条,匹配导入名称。");
134+
expect(html).toContain("每行一条,匹配到的节点会被全局排除,关闭后恢复。");
125135
expect(html).toContain("剩余流量|套餐到期|注意事项");
136+
expect(html.indexOf("排除正则")).toBeLessThan(
137+
html.indexOf("每行一条,匹配到的节点会被全局排除,关闭后恢复。")
138+
);
139+
expect(
140+
html.indexOf("每行一条,匹配到的节点会被全局排除,关闭后恢复。")
141+
).toBeLessThan(html.indexOf("剩余流量|套餐到期|注意事项"));
126142
expect(html).toContain("导入 2 · 排除 1 · 保留 1");
127143
expect(html).toContain("[HK] Alpha");
128144
expect(html).toContain("原名:Alpha");
129145
expect(html).not.toContain("原名:Beta");
146+
expect(mocks.formField).toEqual(
147+
expect.objectContaining({
148+
description: "每行一条,匹配到的节点会被全局排除,关闭后恢复。",
149+
descriptionPlacement: "before-control",
150+
label: "排除正则",
151+
})
152+
);
130153
expect(mocks.switchField).toEqual(
131154
expect.objectContaining({ checked: true, label: "启用" })
132155
);

packages/ui/src/product/converter/advanced-mode/sections/node-management/auto-processing-dialog.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,8 @@ export function NodeManagementAutoProcessingDialog({
118118

119119
<FormField
120120
label="排除正则"
121-
description="每行一条,匹配导入名称。"
121+
description="每行一条,匹配到的节点会被全局排除,关闭后恢复。"
122+
descriptionPlacement="before-control"
122123
error={validationMessage}
123124
>
124125
<Textarea

0 commit comments

Comments
 (0)