@seed-design/react-field-button 0.0.0-alpha-20260414104312
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/FieldButton-12s-BzczO8x6.js +225 -0
- package/lib/FieldButton-12s-CG6rqNcy.cjs +231 -0
- package/lib/index.cjs +20 -0
- package/lib/index.d.ts +2096 -0
- package/lib/index.js +14 -0
- package/package.json +48 -0
- package/src/FieldButton.namespace.ts +14 -0
- package/src/FieldButton.tsx +101 -0
- package/src/dom.ts +2 -0
- package/src/index.ts +19 -0
- package/src/useFieldButton.test.tsx +276 -0
- package/src/useFieldButton.ts +210 -0
- package/src/useFieldButtonContext.tsx +21 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { F as FieldButtonButton, a as FieldButtonClearButton, b as FieldButtonDescription, c as FieldButtonErrorMessage, d as FieldButtonHiddenInput, e as FieldButtonRoot } from './FieldButton-12s-BzczO8x6.js';
|
|
2
|
+
export { u as useFieldButtonContext } from './FieldButton-12s-BzczO8x6.js';
|
|
3
|
+
|
|
4
|
+
var FieldButton_namespace = {
|
|
5
|
+
__proto__: null,
|
|
6
|
+
Button: FieldButtonButton,
|
|
7
|
+
ClearButton: FieldButtonClearButton,
|
|
8
|
+
Description: FieldButtonDescription,
|
|
9
|
+
ErrorMessage: FieldButtonErrorMessage,
|
|
10
|
+
HiddenInput: FieldButtonHiddenInput,
|
|
11
|
+
Root: FieldButtonRoot
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export { FieldButton_namespace as FieldButton, FieldButtonButton, FieldButtonClearButton, FieldButtonDescription, FieldButtonErrorMessage, FieldButtonHiddenInput, FieldButtonRoot };
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@seed-design/react-field-button",
|
|
3
|
+
"version": "0.0.0-alpha-20260414104312",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/daangn/seed-design.git",
|
|
7
|
+
"directory": "packages/react-headless/field-button"
|
|
8
|
+
},
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./lib/index.d.ts",
|
|
14
|
+
"import": "./lib/index.js",
|
|
15
|
+
"require": "./lib/index.cjs"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"main": "./lib/index.cjs",
|
|
20
|
+
"files": [
|
|
21
|
+
"lib",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"clean": "rm -rf lib",
|
|
26
|
+
"build": "bunchee",
|
|
27
|
+
"lint:publish": "bun publint"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@radix-ui/react-compose-refs": "^1.1.2",
|
|
31
|
+
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
|
32
|
+
"@seed-design/dom-utils": "1.0.0",
|
|
33
|
+
"@seed-design/react-primitive": "0.0.0-alpha-20260414104312",
|
|
34
|
+
"@seed-design/react-supports": "0.0.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/react": "^19.1.6",
|
|
38
|
+
"react": "^19.1.0",
|
|
39
|
+
"react-dom": "^19.1.0"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": ">=18.0.0",
|
|
43
|
+
"react-dom": ">=18.0.0"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export {
|
|
2
|
+
FieldButtonButton as Button,
|
|
3
|
+
FieldButtonClearButton as ClearButton,
|
|
4
|
+
FieldButtonDescription as Description,
|
|
5
|
+
FieldButtonErrorMessage as ErrorMessage,
|
|
6
|
+
FieldButtonHiddenInput as HiddenInput,
|
|
7
|
+
FieldButtonRoot as Root,
|
|
8
|
+
type FieldButtonButtonProps as ButtonProps,
|
|
9
|
+
type FieldButtonClearButtonProps as ClearButtonProps,
|
|
10
|
+
type FieldButtonDescriptionProps as DescriptionProps,
|
|
11
|
+
type FieldButtonErrorMessageProps as ErrorMessageProps,
|
|
12
|
+
type FieldButtonHiddenInputProps as HiddenInputProps,
|
|
13
|
+
type FieldButtonRootProps as RootProps,
|
|
14
|
+
} from "./FieldButton";
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { composeRefs } from "@radix-ui/react-compose-refs";
|
|
4
|
+
import { mergeProps } from "@seed-design/dom-utils";
|
|
5
|
+
import { Primitive, type PrimitiveProps } from "@seed-design/react-primitive";
|
|
6
|
+
import React, { forwardRef, type ButtonHTMLAttributes, type HTMLAttributes } from "react";
|
|
7
|
+
import { useFieldButton, type UseFieldButtonProps } from "./useFieldButton";
|
|
8
|
+
import { FieldButtonProvider, useFieldButtonContext } from "./useFieldButtonContext";
|
|
9
|
+
|
|
10
|
+
export interface FieldButtonRootProps
|
|
11
|
+
extends UseFieldButtonProps,
|
|
12
|
+
PrimitiveProps,
|
|
13
|
+
HTMLAttributes<HTMLDivElement> {}
|
|
14
|
+
|
|
15
|
+
export const FieldButtonRoot = forwardRef<HTMLDivElement, FieldButtonRootProps>(
|
|
16
|
+
({ disabled, readOnly, invalid, name, values, onValuesChange, ...otherProps }, ref) => {
|
|
17
|
+
const api = useFieldButton({ disabled, readOnly, invalid, name, values, onValuesChange });
|
|
18
|
+
const mergedProps = mergeProps(api.rootProps, otherProps);
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<FieldButtonProvider value={api}>
|
|
22
|
+
<Primitive.div ref={ref} {...mergedProps} />
|
|
23
|
+
</FieldButtonProvider>
|
|
24
|
+
);
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
FieldButtonRoot.displayName = "FieldButtonRoot";
|
|
28
|
+
|
|
29
|
+
export interface FieldButtonButtonProps
|
|
30
|
+
extends PrimitiveProps,
|
|
31
|
+
ButtonHTMLAttributes<HTMLButtonElement> {}
|
|
32
|
+
|
|
33
|
+
export const FieldButtonButton = forwardRef<HTMLButtonElement, FieldButtonButtonProps>(
|
|
34
|
+
(props, ref) => {
|
|
35
|
+
const { buttonProps } = useFieldButtonContext();
|
|
36
|
+
const mergedProps = mergeProps(buttonProps, props);
|
|
37
|
+
|
|
38
|
+
return <Primitive.button ref={ref} {...mergedProps} />;
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
FieldButtonButton.displayName = "FieldButtonButton";
|
|
42
|
+
|
|
43
|
+
export interface FieldButtonDescriptionProps
|
|
44
|
+
extends PrimitiveProps,
|
|
45
|
+
HTMLAttributes<HTMLSpanElement> {}
|
|
46
|
+
|
|
47
|
+
export const FieldButtonDescription = forwardRef<HTMLSpanElement, FieldButtonDescriptionProps>(
|
|
48
|
+
(props, ref) => {
|
|
49
|
+
const { refs, descriptionProps } = useFieldButtonContext();
|
|
50
|
+
const mergedProps = mergeProps(descriptionProps, props);
|
|
51
|
+
|
|
52
|
+
return <Primitive.span ref={composeRefs(refs.description, ref)} {...mergedProps} />;
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
FieldButtonDescription.displayName = "FieldButtonDescription";
|
|
56
|
+
|
|
57
|
+
export interface FieldButtonErrorMessageProps
|
|
58
|
+
extends PrimitiveProps,
|
|
59
|
+
HTMLAttributes<HTMLDivElement> {}
|
|
60
|
+
|
|
61
|
+
export const FieldButtonErrorMessage = forwardRef<HTMLDivElement, FieldButtonErrorMessageProps>(
|
|
62
|
+
(props, ref) => {
|
|
63
|
+
const { refs, errorMessageProps } = useFieldButtonContext();
|
|
64
|
+
const mergedProps = mergeProps(errorMessageProps, props);
|
|
65
|
+
|
|
66
|
+
return <Primitive.div ref={composeRefs(refs.errorMessage, ref)} {...mergedProps} />;
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
FieldButtonErrorMessage.displayName = "FieldButtonErrorMessage";
|
|
70
|
+
|
|
71
|
+
export interface FieldButtonHiddenInputProps
|
|
72
|
+
extends PrimitiveProps,
|
|
73
|
+
React.InputHTMLAttributes<HTMLInputElement> {
|
|
74
|
+
valueIndex: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const FieldButtonHiddenInput = forwardRef<HTMLInputElement, FieldButtonHiddenInputProps>(
|
|
78
|
+
({ valueIndex, ...props }, ref) => {
|
|
79
|
+
const { getHiddenInputProps } = useFieldButtonContext();
|
|
80
|
+
const hiddenInputProps = getHiddenInputProps(valueIndex);
|
|
81
|
+
|
|
82
|
+
if (!hiddenInputProps) return null;
|
|
83
|
+
|
|
84
|
+
return <Primitive.input ref={ref} {...mergeProps(hiddenInputProps, props)} />;
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
FieldButtonHiddenInput.displayName = "FieldButtonHiddenInput";
|
|
88
|
+
|
|
89
|
+
export interface FieldButtonClearButtonProps
|
|
90
|
+
extends PrimitiveProps,
|
|
91
|
+
ButtonHTMLAttributes<HTMLButtonElement> {}
|
|
92
|
+
|
|
93
|
+
export const FieldButtonClearButton = forwardRef<HTMLButtonElement, FieldButtonClearButtonProps>(
|
|
94
|
+
(props, ref) => {
|
|
95
|
+
const { clearButtonProps } = useFieldButtonContext();
|
|
96
|
+
const mergedProps = mergeProps(clearButtonProps, props);
|
|
97
|
+
|
|
98
|
+
return <Primitive.button ref={ref} {...mergedProps} />;
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
FieldButtonClearButton.displayName = "FieldButtonClearButton";
|
package/src/dom.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export {
|
|
2
|
+
FieldButtonButton,
|
|
3
|
+
FieldButtonClearButton,
|
|
4
|
+
FieldButtonDescription,
|
|
5
|
+
FieldButtonErrorMessage,
|
|
6
|
+
FieldButtonHiddenInput,
|
|
7
|
+
FieldButtonRoot,
|
|
8
|
+
type FieldButtonButtonProps,
|
|
9
|
+
type FieldButtonClearButtonProps,
|
|
10
|
+
type FieldButtonDescriptionProps,
|
|
11
|
+
type FieldButtonErrorMessageProps,
|
|
12
|
+
type FieldButtonHiddenInputProps,
|
|
13
|
+
type FieldButtonRootProps,
|
|
14
|
+
} from "./FieldButton";
|
|
15
|
+
|
|
16
|
+
export { useFieldButtonContext, type UseFieldButtonContext } from "./useFieldButtonContext";
|
|
17
|
+
export type { UseFieldButtonProps } from "./useFieldButton";
|
|
18
|
+
|
|
19
|
+
export * as FieldButton from "./FieldButton.namespace";
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { fireEvent, render } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, mock } from "bun:test";
|
|
4
|
+
|
|
5
|
+
import type { ReactElement } from "react";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
FieldButtonButton,
|
|
9
|
+
FieldButtonClearButton,
|
|
10
|
+
FieldButtonDescription,
|
|
11
|
+
FieldButtonErrorMessage,
|
|
12
|
+
FieldButtonHiddenInput,
|
|
13
|
+
FieldButtonRoot,
|
|
14
|
+
type FieldButtonRootProps,
|
|
15
|
+
} from "./FieldButton";
|
|
16
|
+
|
|
17
|
+
function setUp(jsx: ReactElement) {
|
|
18
|
+
return {
|
|
19
|
+
user: userEvent.setup(),
|
|
20
|
+
...render(jsx),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function FieldButton(props: FieldButtonRootProps) {
|
|
25
|
+
return (
|
|
26
|
+
<FieldButtonRoot {...props}>
|
|
27
|
+
<FieldButtonButton>Click me</FieldButtonButton>
|
|
28
|
+
<FieldButtonClearButton>Clear</FieldButtonClearButton>
|
|
29
|
+
<FieldButtonDescription>Description text</FieldButtonDescription>
|
|
30
|
+
<FieldButtonErrorMessage>Error message</FieldButtonErrorMessage>
|
|
31
|
+
{props.values?.map((_, index) => (
|
|
32
|
+
<FieldButtonHiddenInput key={index} valueIndex={index} />
|
|
33
|
+
))}
|
|
34
|
+
</FieldButtonRoot>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("useFieldButton", () => {
|
|
39
|
+
it("should render the field button correctly", () => {
|
|
40
|
+
const { getByRole } = setUp(<FieldButton />);
|
|
41
|
+
const button = getByRole("button", { name: "Click me" });
|
|
42
|
+
|
|
43
|
+
expect(button).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should render with initial values", () => {
|
|
47
|
+
const { container } = setUp(<FieldButton values={["value1", "value2"]} />);
|
|
48
|
+
const hiddenInputs = container.querySelectorAll('input[type="hidden"]');
|
|
49
|
+
|
|
50
|
+
expect(hiddenInputs).toHaveLength(2);
|
|
51
|
+
expect(hiddenInputs[0]).toHaveValue("value1");
|
|
52
|
+
expect(hiddenInputs[1]).toHaveValue("value2");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("should have hover state when pointer moves over root", async () => {
|
|
56
|
+
const { container, user } = setUp(<FieldButton />);
|
|
57
|
+
const root = container.firstElementChild;
|
|
58
|
+
|
|
59
|
+
if (!root) throw new Error("root not found");
|
|
60
|
+
|
|
61
|
+
await user.hover(root);
|
|
62
|
+
expect(root).toHaveAttribute("data-hover");
|
|
63
|
+
|
|
64
|
+
await user.unhover(root);
|
|
65
|
+
expect(root).not.toHaveAttribute("data-hover");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("should have active state when button is pressed", () => {
|
|
69
|
+
const { getByRole } = setUp(<FieldButton />);
|
|
70
|
+
const button = getByRole("button", { name: "Click me" });
|
|
71
|
+
|
|
72
|
+
fireEvent.pointerDown(button);
|
|
73
|
+
expect(button).toHaveAttribute("data-active");
|
|
74
|
+
|
|
75
|
+
fireEvent.pointerUp(button);
|
|
76
|
+
expect(button).not.toHaveAttribute("data-active");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("should have focus state when button is focused", async () => {
|
|
80
|
+
const { getByRole, user } = setUp(<FieldButton />);
|
|
81
|
+
const button = getByRole("button", { name: "Click me" });
|
|
82
|
+
|
|
83
|
+
await user.tab();
|
|
84
|
+
expect(button).toHaveFocus();
|
|
85
|
+
expect(button).toHaveAttribute("data-focus");
|
|
86
|
+
expect(button).toHaveAttribute("data-focus-visible");
|
|
87
|
+
|
|
88
|
+
await user.click(document.body);
|
|
89
|
+
expect(button).not.toHaveFocus();
|
|
90
|
+
expect(button).not.toHaveAttribute("data-focus");
|
|
91
|
+
expect(button).not.toHaveAttribute("data-focus-visible");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should have disabled state when disabled prop is true", () => {
|
|
95
|
+
const { getByRole } = setUp(<FieldButton disabled />);
|
|
96
|
+
const button = getByRole("button", { name: "Click me" });
|
|
97
|
+
|
|
98
|
+
expect(button).toBeDisabled();
|
|
99
|
+
expect(button).toHaveAttribute("data-disabled");
|
|
100
|
+
expect(button).toHaveAttribute("aria-disabled", "true");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should have readOnly state when readOnly prop is true", () => {
|
|
104
|
+
const { getByRole } = setUp(<FieldButton readOnly />);
|
|
105
|
+
const button = getByRole("button", { name: "Click me" });
|
|
106
|
+
|
|
107
|
+
expect(button).toBeDisabled();
|
|
108
|
+
expect(button).toHaveAttribute("data-readonly");
|
|
109
|
+
expect(button).toHaveAttribute("aria-disabled", "true");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should have invalid state when invalid prop is true", () => {
|
|
113
|
+
const { getByRole } = setUp(<FieldButton invalid />);
|
|
114
|
+
const button = getByRole("button", { name: "Click me" });
|
|
115
|
+
|
|
116
|
+
expect(button).toHaveAttribute("data-invalid");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should have aria-describedby with description id", () => {
|
|
120
|
+
const { getByRole, getByText } = setUp(<FieldButton />);
|
|
121
|
+
const button = getByRole("button", { name: "Click me" });
|
|
122
|
+
const description = getByText("Description text");
|
|
123
|
+
|
|
124
|
+
expect(button).toHaveAttribute("aria-describedby");
|
|
125
|
+
const describedBy = button.getAttribute("aria-describedby");
|
|
126
|
+
expect(describedBy).toContain(description.id);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("should have aria-describedby with error message id ", () => {
|
|
130
|
+
const { getByRole, getByText } = setUp(<FieldButton />);
|
|
131
|
+
const button = getByRole("button", { name: "Click me" });
|
|
132
|
+
const errorMessage = getByText("Error message");
|
|
133
|
+
|
|
134
|
+
expect(button).toHaveAttribute("aria-describedby");
|
|
135
|
+
const describedBy = button.getAttribute("aria-describedby");
|
|
136
|
+
expect(describedBy).toContain(errorMessage.id);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("should have aria-live on error message", () => {
|
|
140
|
+
const { getByText } = setUp(<FieldButton />);
|
|
141
|
+
const errorMessage = getByText("Error message");
|
|
142
|
+
|
|
143
|
+
expect(errorMessage).toHaveAttribute("aria-live", "polite");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("should use custom name for hidden inputs", () => {
|
|
147
|
+
const { container } = setUp(<FieldButton name="custom-name" values={["value1"]} />);
|
|
148
|
+
const hiddenInput = container.querySelector('input[type="hidden"]');
|
|
149
|
+
|
|
150
|
+
expect(hiddenInput).toHaveAttribute("name", "custom-name");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("should clear active state when pointer leaves", () => {
|
|
154
|
+
const { getByRole, container } = setUp(<FieldButton />);
|
|
155
|
+
const button = getByRole("button", { name: "Click me" });
|
|
156
|
+
const root = container.firstElementChild;
|
|
157
|
+
|
|
158
|
+
if (!root) throw new Error("root not found");
|
|
159
|
+
|
|
160
|
+
fireEvent.pointerDown(button);
|
|
161
|
+
expect(button).toHaveAttribute("data-active");
|
|
162
|
+
|
|
163
|
+
fireEvent.pointerLeave(root);
|
|
164
|
+
expect(button).not.toHaveAttribute("data-active");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("should not disable hidden inputs when readOnly (unlike disabled)", () => {
|
|
168
|
+
const { container } = setUp(<FieldButton readOnly values={["value1", "value2"]} />);
|
|
169
|
+
const hiddenInputs = container.querySelectorAll('input[type="hidden"]');
|
|
170
|
+
|
|
171
|
+
expect(hiddenInputs).toHaveLength(2);
|
|
172
|
+
expect(hiddenInputs[0]).not.toBeDisabled();
|
|
173
|
+
expect(hiddenInputs[1]).not.toBeDisabled();
|
|
174
|
+
expect(hiddenInputs[0]).toHaveValue("value1");
|
|
175
|
+
expect(hiddenInputs[1]).toHaveValue("value2");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("should disable hidden inputs when disabled", () => {
|
|
179
|
+
const { container } = setUp(<FieldButton disabled values={["value1", "value2"]} />);
|
|
180
|
+
const hiddenInputs = container.querySelectorAll('input[type="hidden"]');
|
|
181
|
+
|
|
182
|
+
expect(hiddenInputs).toHaveLength(2);
|
|
183
|
+
expect(hiddenInputs[0]).toBeDisabled();
|
|
184
|
+
expect(hiddenInputs[1]).toBeDisabled();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should render without description and errorMessage", () => {
|
|
188
|
+
function MinimalFieldButton(props: FieldButtonRootProps) {
|
|
189
|
+
return (
|
|
190
|
+
<FieldButtonRoot {...props}>
|
|
191
|
+
<FieldButtonButton>Click me</FieldButtonButton>
|
|
192
|
+
{props.values?.map((_, index) => (
|
|
193
|
+
<FieldButtonHiddenInput key={index} valueIndex={index} />
|
|
194
|
+
))}
|
|
195
|
+
</FieldButtonRoot>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const { getByRole } = setUp(<MinimalFieldButton />);
|
|
200
|
+
const button = getByRole("button", { name: "Click me" });
|
|
201
|
+
|
|
202
|
+
expect(button).not.toHaveAttribute("aria-describedby");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("should only include rendered elements in aria-describedby", () => {
|
|
206
|
+
function FieldButtonWithDescription(props: FieldButtonRootProps) {
|
|
207
|
+
return (
|
|
208
|
+
<FieldButtonRoot {...props}>
|
|
209
|
+
<FieldButtonButton>Click me</FieldButtonButton>
|
|
210
|
+
<FieldButtonDescription>Description text</FieldButtonDescription>
|
|
211
|
+
{props.values?.map((_, index) => (
|
|
212
|
+
<FieldButtonHiddenInput key={index} valueIndex={index} />
|
|
213
|
+
))}
|
|
214
|
+
</FieldButtonRoot>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const { getByRole, getByText } = setUp(<FieldButtonWithDescription />);
|
|
219
|
+
const button = getByRole("button", { name: "Click me" });
|
|
220
|
+
const description = getByText("Description text");
|
|
221
|
+
|
|
222
|
+
const describedBy = button.getAttribute("aria-describedby");
|
|
223
|
+
expect(describedBy).toBe(description.id);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("clear button", () => {
|
|
227
|
+
it("should call onValuesChange when clear button is clicked", async () => {
|
|
228
|
+
const handleValuesChange = mock(() => {});
|
|
229
|
+
const { getByRole, user } = setUp(
|
|
230
|
+
<FieldButton values={["value1"]} onValuesChange={handleValuesChange} />,
|
|
231
|
+
);
|
|
232
|
+
const clearButton = getByRole("button", { name: "Clear" });
|
|
233
|
+
|
|
234
|
+
await user.click(clearButton);
|
|
235
|
+
expect(handleValuesChange).toHaveBeenCalledWith([]);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("should hide clear button when disabled", async () => {
|
|
239
|
+
const handleValuesChange = mock(() => {});
|
|
240
|
+
const { getByText, user } = setUp(
|
|
241
|
+
<FieldButton disabled values={["value1"]} onValuesChange={handleValuesChange} />,
|
|
242
|
+
);
|
|
243
|
+
const clearButton = getByText("Clear");
|
|
244
|
+
|
|
245
|
+
expect(clearButton).toHaveAttribute("hidden");
|
|
246
|
+
|
|
247
|
+
await user.click(clearButton);
|
|
248
|
+
expect(handleValuesChange).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("should hide clear button when readOnly", async () => {
|
|
252
|
+
const handleValuesChange = mock(() => {});
|
|
253
|
+
const { getByText, user } = setUp(
|
|
254
|
+
<FieldButton readOnly values={["value1"]} onValuesChange={handleValuesChange} />,
|
|
255
|
+
);
|
|
256
|
+
const clearButton = getByText("Clear");
|
|
257
|
+
|
|
258
|
+
expect(clearButton).toHaveAttribute("hidden");
|
|
259
|
+
|
|
260
|
+
await user.click(clearButton);
|
|
261
|
+
expect(handleValuesChange).not.toHaveBeenCalled();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("should not have active state when clear button is pressed", () => {
|
|
265
|
+
const { getByRole } = setUp(<FieldButton />);
|
|
266
|
+
const button = getByRole("button", { name: "Click me" });
|
|
267
|
+
const clearButton = getByRole("button", { name: "Clear" });
|
|
268
|
+
|
|
269
|
+
fireEvent.pointerDown(clearButton);
|
|
270
|
+
expect(button).not.toHaveAttribute("data-active");
|
|
271
|
+
|
|
272
|
+
fireEvent.pointerUp(clearButton);
|
|
273
|
+
expect(button).not.toHaveAttribute("data-active");
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { ariaAttr, buttonProps, dataAttr, elementProps, inputProps } from "@seed-design/dom-utils";
|
|
2
|
+
import { useCallback, useId, useState } from "react";
|
|
3
|
+
import { getDescriptionId, getErrorMessageId } from "./dom";
|
|
4
|
+
import { useSupports } from "@seed-design/react-supports";
|
|
5
|
+
|
|
6
|
+
interface UseFieldButtonStateProps {
|
|
7
|
+
values?: string[];
|
|
8
|
+
onValuesChange?: (values: string[]) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function useFieldButtonState({ values = [], onValuesChange = () => {} }: UseFieldButtonStateProps) {
|
|
12
|
+
const [isHovered, setIsHovered] = useState(false);
|
|
13
|
+
const [isActive, setIsActive] = useState(false);
|
|
14
|
+
const [isFocused, setIsFocused] = useState(false);
|
|
15
|
+
const [isFocusVisible, setIsFocusVisible] = useState(false);
|
|
16
|
+
|
|
17
|
+
const [isDescriptionRendered, setIsDescriptionRendered] = useState(false);
|
|
18
|
+
const descriptionRef = useCallback((node: HTMLElement | null) => {
|
|
19
|
+
setIsDescriptionRendered(!!node);
|
|
20
|
+
}, []);
|
|
21
|
+
const [isErrorMessageRendered, setIsErrorMessageRendered] = useState(false);
|
|
22
|
+
const errorMessageRef = useCallback((node: HTMLElement | null) => {
|
|
23
|
+
setIsErrorMessageRendered(!!node);
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
values,
|
|
28
|
+
isHovered,
|
|
29
|
+
isActive,
|
|
30
|
+
isFocused,
|
|
31
|
+
isFocusVisible,
|
|
32
|
+
|
|
33
|
+
refs: {
|
|
34
|
+
description: descriptionRef,
|
|
35
|
+
errorMessage: errorMessageRef,
|
|
36
|
+
},
|
|
37
|
+
renderedElements: {
|
|
38
|
+
description: isDescriptionRendered,
|
|
39
|
+
errorMessage: isErrorMessageRendered,
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
setValues: onValuesChange,
|
|
43
|
+
setIsHovered,
|
|
44
|
+
setIsActive,
|
|
45
|
+
setIsFocused,
|
|
46
|
+
setIsFocusVisible,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface UseFieldButtonProps extends UseFieldButtonStateProps {
|
|
51
|
+
/**
|
|
52
|
+
* @default false
|
|
53
|
+
*/
|
|
54
|
+
disabled?: boolean;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @default false
|
|
58
|
+
*/
|
|
59
|
+
invalid?: boolean;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @default false
|
|
63
|
+
*/
|
|
64
|
+
readOnly?: boolean;
|
|
65
|
+
|
|
66
|
+
name?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type UseFieldButtonReturn = ReturnType<typeof useFieldButton>;
|
|
70
|
+
|
|
71
|
+
export function useFieldButton({
|
|
72
|
+
values: propValues,
|
|
73
|
+
onValuesChange,
|
|
74
|
+
disabled = false,
|
|
75
|
+
invalid = false,
|
|
76
|
+
readOnly = false,
|
|
77
|
+
name,
|
|
78
|
+
}: UseFieldButtonProps) {
|
|
79
|
+
const id = useId();
|
|
80
|
+
const isFocusVisibleSupported = useSupports("selector(:focus-visible)");
|
|
81
|
+
|
|
82
|
+
const {
|
|
83
|
+
values: stateValues,
|
|
84
|
+
isHovered,
|
|
85
|
+
isActive,
|
|
86
|
+
isFocused,
|
|
87
|
+
isFocusVisible,
|
|
88
|
+
refs,
|
|
89
|
+
renderedElements,
|
|
90
|
+
setValues,
|
|
91
|
+
setIsHovered,
|
|
92
|
+
setIsActive,
|
|
93
|
+
setIsFocused,
|
|
94
|
+
setIsFocusVisible,
|
|
95
|
+
} = useFieldButtonState({ values: propValues, onValuesChange });
|
|
96
|
+
|
|
97
|
+
const ariaDescribedBy =
|
|
98
|
+
[
|
|
99
|
+
renderedElements.description ? getDescriptionId(id) : false,
|
|
100
|
+
renderedElements.errorMessage ? getErrorMessageId(id) : false,
|
|
101
|
+
]
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.join(" ") || undefined;
|
|
104
|
+
|
|
105
|
+
const stateProps = elementProps({
|
|
106
|
+
"data-hover": dataAttr(isHovered),
|
|
107
|
+
"data-active": dataAttr(isActive),
|
|
108
|
+
"data-focus": dataAttr(isFocused),
|
|
109
|
+
"data-focus-visible": dataAttr(isFocusVisible),
|
|
110
|
+
"data-disabled": dataAttr(disabled),
|
|
111
|
+
"data-invalid": dataAttr(invalid),
|
|
112
|
+
"data-readonly": dataAttr(readOnly),
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
values: stateValues,
|
|
117
|
+
active: isActive,
|
|
118
|
+
focused: isFocused,
|
|
119
|
+
invalid,
|
|
120
|
+
disabled,
|
|
121
|
+
readOnly,
|
|
122
|
+
|
|
123
|
+
setIsFocused,
|
|
124
|
+
setIsFocusVisible,
|
|
125
|
+
|
|
126
|
+
refs,
|
|
127
|
+
|
|
128
|
+
stateProps,
|
|
129
|
+
|
|
130
|
+
rootProps: elementProps({
|
|
131
|
+
...stateProps,
|
|
132
|
+
|
|
133
|
+
onPointerMove() {
|
|
134
|
+
setIsHovered(true);
|
|
135
|
+
},
|
|
136
|
+
onPointerLeave() {
|
|
137
|
+
setIsHovered(false);
|
|
138
|
+
setIsActive(false);
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
|
|
142
|
+
buttonProps: buttonProps({
|
|
143
|
+
...stateProps,
|
|
144
|
+
|
|
145
|
+
type: "button",
|
|
146
|
+
disabled: disabled || readOnly,
|
|
147
|
+
|
|
148
|
+
"aria-disabled": ariaAttr(disabled || readOnly),
|
|
149
|
+
"aria-describedby": ariaDescribedBy,
|
|
150
|
+
|
|
151
|
+
// note that pointerdown and pointerup are attached to the button, not the root
|
|
152
|
+
// this is for preventing setting isActive to true when the clear button is pressed
|
|
153
|
+
onPointerDown() {
|
|
154
|
+
setIsActive(true);
|
|
155
|
+
},
|
|
156
|
+
onPointerUp() {
|
|
157
|
+
setIsActive(false);
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
onBlur() {
|
|
161
|
+
setIsFocused(false);
|
|
162
|
+
if (isFocusVisibleSupported) {
|
|
163
|
+
setIsFocusVisible(false);
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
onFocus(event) {
|
|
167
|
+
setIsFocused(true);
|
|
168
|
+
if (isFocusVisibleSupported) {
|
|
169
|
+
setIsFocusVisible(event.target.matches(":focus-visible"));
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
}),
|
|
173
|
+
|
|
174
|
+
clearButtonProps: buttonProps({
|
|
175
|
+
type: "button",
|
|
176
|
+
disabled: disabled || readOnly,
|
|
177
|
+
|
|
178
|
+
onClick: useCallback(() => setValues([]), [setValues]),
|
|
179
|
+
|
|
180
|
+
hidden: disabled || readOnly,
|
|
181
|
+
}),
|
|
182
|
+
|
|
183
|
+
getHiddenInputProps: useCallback(
|
|
184
|
+
(index: number) => {
|
|
185
|
+
const value = stateValues[index];
|
|
186
|
+
|
|
187
|
+
if (value === undefined) return null;
|
|
188
|
+
|
|
189
|
+
return inputProps({
|
|
190
|
+
type: "hidden",
|
|
191
|
+
value,
|
|
192
|
+
disabled, // disabled field button should not submit values, while readonly field button should submit values
|
|
193
|
+
name: name || id,
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
[stateValues, name, id, disabled],
|
|
197
|
+
),
|
|
198
|
+
|
|
199
|
+
descriptionProps: elementProps({
|
|
200
|
+
...stateProps,
|
|
201
|
+
id: getDescriptionId(id),
|
|
202
|
+
}),
|
|
203
|
+
|
|
204
|
+
errorMessageProps: elementProps({
|
|
205
|
+
...stateProps,
|
|
206
|
+
id: getErrorMessageId(id),
|
|
207
|
+
"aria-live": "polite",
|
|
208
|
+
}),
|
|
209
|
+
};
|
|
210
|
+
}
|