@grapu-design/react-fieldset 0.1.0

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/index.js ADDED
@@ -0,0 +1,12 @@
1
+ import { F as FieldsetDescription, a as FieldsetErrorMessage, b as FieldsetLabel, c as FieldsetRoot } from './Fieldset-12s-CW3j613b.js';
2
+ export { d as FieldsetProvider, u as useFieldset, e as useFieldsetContext } from './Fieldset-12s-CW3j613b.js';
3
+
4
+ var Fieldset_namespace = {
5
+ __proto__: null,
6
+ Description: FieldsetDescription,
7
+ ErrorMessage: FieldsetErrorMessage,
8
+ Label: FieldsetLabel,
9
+ Root: FieldsetRoot
10
+ };
11
+
12
+ export { Fieldset_namespace as Fieldset, FieldsetDescription, FieldsetErrorMessage, FieldsetLabel, FieldsetRoot };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@grapu-design/react-fieldset",
3
+ "version": "0.1.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/grapu-design/designsystem.git",
7
+ "directory": "packages/react-headless/fieldset"
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
+ "@grapu-design/dom-utils": "^0.1.0",
32
+ "@grapu-design/react-primitive": "^0.1.0"
33
+ },
34
+ "devDependencies": {
35
+ "@types/react": "^19.1.6",
36
+ "react": "^19.1.0",
37
+ "react-dom": "^19.1.0"
38
+ },
39
+ "peerDependencies": {
40
+ "react": ">=18.0.0",
41
+ "react-dom": ">=18.0.0"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
@@ -0,0 +1,10 @@
1
+ export {
2
+ FieldsetRoot as Root,
3
+ FieldsetLabel as Label,
4
+ FieldsetDescription as Description,
5
+ FieldsetErrorMessage as ErrorMessage,
6
+ type FieldsetRootProps as RootProps,
7
+ type FieldsetLabelProps as LabelProps,
8
+ type FieldsetDescriptionProps as DescriptionProps,
9
+ type FieldsetErrorMessageProps as ErrorMessageProps,
10
+ } from "./Fieldset";
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ import { composeRefs } from "@radix-ui/react-compose-refs";
4
+ import { mergeProps } from "@grapu-design/dom-utils";
5
+ import { Primitive, type PrimitiveProps } from "@grapu-design/react-primitive";
6
+ import type * as React from "react";
7
+ import { forwardRef } from "react";
8
+ import { useFieldset } from "./useFieldset";
9
+ import { FieldsetProvider, useFieldsetContext } from "./useFieldsetContext";
10
+
11
+ export interface FieldsetRootProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
12
+
13
+ export const FieldsetRoot = forwardRef<HTMLDivElement, FieldsetRootProps>((props, ref) => {
14
+ const api = useFieldset();
15
+ const mergedProps = mergeProps(api.rootProps, props);
16
+
17
+ return (
18
+ <FieldsetProvider value={api}>
19
+ <Primitive.div ref={ref} {...mergedProps} />
20
+ </FieldsetProvider>
21
+ );
22
+ });
23
+ FieldsetRoot.displayName = "FieldsetRoot";
24
+
25
+ export interface FieldsetLabelProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
26
+
27
+ export const FieldsetLabel = forwardRef<HTMLDivElement, FieldsetLabelProps>((props, ref) => {
28
+ const { refs, labelProps } = useFieldsetContext();
29
+ const mergedProps = mergeProps(labelProps, props);
30
+
31
+ return <Primitive.div ref={composeRefs(refs.label, ref)} {...mergedProps} />;
32
+ });
33
+ FieldsetLabel.displayName = "FieldsetLabel";
34
+
35
+ export interface FieldsetDescriptionProps
36
+ extends PrimitiveProps,
37
+ React.HTMLAttributes<HTMLSpanElement> {}
38
+
39
+ export const FieldsetDescription = forwardRef<HTMLSpanElement, FieldsetDescriptionProps>(
40
+ (props, ref) => {
41
+ const { refs, descriptionProps } = useFieldsetContext();
42
+ const mergedProps = mergeProps(descriptionProps, props);
43
+
44
+ return <Primitive.span ref={composeRefs(refs.description, ref)} {...mergedProps} />;
45
+ },
46
+ );
47
+ FieldsetDescription.displayName = "FieldsetDescription";
48
+
49
+ export interface FieldsetErrorMessageProps
50
+ extends PrimitiveProps,
51
+ React.HTMLAttributes<HTMLDivElement> {}
52
+
53
+ export const FieldsetErrorMessage = forwardRef<HTMLDivElement, FieldsetErrorMessageProps>(
54
+ (props, ref) => {
55
+ const { refs, errorMessageProps } = useFieldsetContext();
56
+ const mergedProps = mergeProps(errorMessageProps, props);
57
+
58
+ return <Primitive.div ref={composeRefs(refs.errorMessage, ref)} {...mergedProps} />;
59
+ },
60
+ );
61
+ FieldsetErrorMessage.displayName = "FieldsetErrorMessage";
package/src/dom.ts ADDED
@@ -0,0 +1,3 @@
1
+ export const getLabelId = (id: string) => `fieldset:${id}:label`;
2
+ export const getDescriptionId = (id: string) => `fieldset:${id}:description`;
3
+ export const getErrorMessageId = (id: string) => `fieldset:${id}:error-message`;
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { useFieldset, type UseFieldsetReturn } from "./useFieldset";
2
+
3
+ export {
4
+ FieldsetRoot,
5
+ FieldsetLabel,
6
+ FieldsetDescription,
7
+ FieldsetErrorMessage,
8
+ type FieldsetRootProps,
9
+ type FieldsetLabelProps,
10
+ type FieldsetDescriptionProps,
11
+ type FieldsetErrorMessageProps,
12
+ } from "./Fieldset";
13
+
14
+ export {
15
+ FieldsetProvider,
16
+ useFieldsetContext,
17
+ type UseFieldsetContext,
18
+ } from "./useFieldsetContext";
19
+
20
+ export * as Fieldset from "./Fieldset.namespace";
@@ -0,0 +1,161 @@
1
+ import "@testing-library/jest-dom/vitest";
2
+ import { cleanup, render } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { afterEach, describe, expect, it } from "vitest";
5
+
6
+ import { forwardRef, type ReactElement, type ReactNode } from "react";
7
+
8
+ import {
9
+ FieldsetDescription,
10
+ FieldsetErrorMessage,
11
+ FieldsetLabel,
12
+ FieldsetRoot,
13
+ type FieldsetRootProps,
14
+ } from "./Fieldset";
15
+ import { getDescriptionId, getErrorMessageId, getLabelId } from "./dom";
16
+
17
+ afterEach(cleanup);
18
+
19
+ function setUp(jsx: ReactElement) {
20
+ return {
21
+ user: userEvent.setup(),
22
+ ...render(jsx),
23
+ };
24
+ }
25
+
26
+ interface TestFieldsetProps extends FieldsetRootProps {
27
+ label?: ReactNode;
28
+ description?: ReactNode;
29
+ errorMessage?: ReactNode;
30
+ }
31
+
32
+ const Fieldset = forwardRef<HTMLDivElement, TestFieldsetProps>(
33
+ ({ label, description, errorMessage, ...rootProps }, ref) => {
34
+ return (
35
+ <FieldsetRoot data-testid="fieldset-root" {...rootProps} ref={ref}>
36
+ {label && <FieldsetLabel data-testid="fieldset-label">{label}</FieldsetLabel>}
37
+ <div data-testid="fieldset-content">Content</div>
38
+ {description && (
39
+ <FieldsetDescription data-testid="fieldset-description">
40
+ {description}
41
+ </FieldsetDescription>
42
+ )}
43
+ {errorMessage && (
44
+ <FieldsetErrorMessage data-testid="fieldset-error-message">
45
+ {errorMessage}
46
+ </FieldsetErrorMessage>
47
+ )}
48
+ </FieldsetRoot>
49
+ );
50
+ },
51
+ );
52
+ Fieldset.displayName = "Fieldset";
53
+
54
+ describe("Fieldset components", () => {
55
+ describe("basic rendering", () => {
56
+ it("should render without crashing", () => {
57
+ const { getByTestId } = setUp(<Fieldset />);
58
+ expect(getByTestId("fieldset-root")).toBeInTheDocument();
59
+ });
60
+
61
+ it("should render label, description, and error message", () => {
62
+ const { getByTestId } = setUp(
63
+ <Fieldset label="Label" description="Description" errorMessage="Error" />,
64
+ );
65
+
66
+ expect(getByTestId("fieldset-label")).toHaveTextContent("Label");
67
+ expect(getByTestId("fieldset-description")).toHaveTextContent("Description");
68
+ expect(getByTestId("fieldset-error-message")).toHaveTextContent("Error");
69
+ });
70
+ });
71
+
72
+ describe("ID generation", () => {
73
+ it("should generate unique id for label", () => {
74
+ const { getByTestId } = setUp(<Fieldset label="Label" />);
75
+
76
+ const label = getByTestId("fieldset-label");
77
+ expect(label.id).toMatch(/^fieldset:.+:label$/);
78
+ });
79
+ });
80
+
81
+ describe("aria attributes", () => {
82
+ it("should have role=group on root", () => {
83
+ const { getByTestId } = setUp(<Fieldset />);
84
+
85
+ const root = getByTestId("fieldset-root");
86
+ expect(root).toHaveAttribute("role", "group");
87
+ });
88
+
89
+ it("should set aria-labelledby when label is rendered", () => {
90
+ const { getByTestId } = setUp(<Fieldset label="Label" />);
91
+
92
+ const root = getByTestId("fieldset-root");
93
+ const label = getByTestId("fieldset-label");
94
+
95
+ expect(root).toHaveAttribute("aria-labelledby", label.id);
96
+ });
97
+
98
+ it("should not set aria-labelledby when label is not rendered", () => {
99
+ const { getByTestId } = setUp(<Fieldset />);
100
+
101
+ const root = getByTestId("fieldset-root");
102
+ expect(root).not.toHaveAttribute("aria-labelledby");
103
+ });
104
+
105
+ it("should set aria-describedby with description id when description is rendered", () => {
106
+ const { getByTestId } = setUp(<Fieldset description="Description" />);
107
+
108
+ const root = getByTestId("fieldset-root");
109
+ const description = getByTestId("fieldset-description");
110
+
111
+ expect(root).toHaveAttribute("aria-describedby", description.id);
112
+ });
113
+
114
+ it("should set aria-describedby with error message id when error is rendered", () => {
115
+ const { getByTestId } = setUp(<Fieldset errorMessage="Error" />);
116
+
117
+ const root = getByTestId("fieldset-root");
118
+ const errorMessage = getByTestId("fieldset-error-message");
119
+
120
+ expect(root).toHaveAttribute("aria-describedby", errorMessage.id);
121
+ });
122
+
123
+ it("should combine description and error in aria-describedby", () => {
124
+ const { getByTestId } = setUp(<Fieldset description="Description" errorMessage="Error" />);
125
+
126
+ const root = getByTestId("fieldset-root");
127
+ const description = getByTestId("fieldset-description");
128
+ const errorMessage = getByTestId("fieldset-error-message");
129
+
130
+ expect(root).toHaveAttribute("aria-describedby", `${description.id} ${errorMessage.id}`);
131
+ });
132
+
133
+ it("should not set aria-describedby when neither description nor error is rendered", () => {
134
+ const { getByTestId } = setUp(<Fieldset label="Label" />);
135
+
136
+ const root = getByTestId("fieldset-root");
137
+ expect(root).not.toHaveAttribute("aria-describedby");
138
+ });
139
+
140
+ it("should set aria-live on error message", () => {
141
+ const { getByTestId } = setUp(<Fieldset errorMessage="Error" />);
142
+
143
+ const errorMessage = getByTestId("fieldset-error-message");
144
+ expect(errorMessage).toHaveAttribute("aria-live", "polite");
145
+ });
146
+ });
147
+ });
148
+
149
+ describe("dom utilities", () => {
150
+ it("getLabelId should return correct format", () => {
151
+ expect(getLabelId("test")).toBe("fieldset:test:label");
152
+ });
153
+
154
+ it("getDescriptionId should return correct format", () => {
155
+ expect(getDescriptionId("test")).toBe("fieldset:test:description");
156
+ });
157
+
158
+ it("getErrorMessageId should return correct format", () => {
159
+ expect(getErrorMessageId("test")).toBe("fieldset:test:error-message");
160
+ });
161
+ });
@@ -0,0 +1,73 @@
1
+ import { useCallback, useId, useState } from "react";
2
+
3
+ import { elementProps } from "@grapu-design/dom-utils";
4
+ import { getDescriptionId, getErrorMessageId, getLabelId } from "./dom";
5
+
6
+ export type UseFieldsetReturn = ReturnType<typeof useFieldset>;
7
+
8
+ export function useFieldset() {
9
+ const id = useId();
10
+
11
+ const [isLabelRendered, setIsLabelRendered] = useState(false);
12
+ const labelRef = useCallback((node: HTMLElement | null) => {
13
+ setIsLabelRendered(!!node);
14
+ }, []);
15
+
16
+ const [isDescriptionRendered, setIsDescriptionRendered] = useState(false);
17
+ const descriptionRef = useCallback((node: HTMLElement | null) => {
18
+ setIsDescriptionRendered(!!node);
19
+ }, []);
20
+
21
+ const [isErrorMessageRendered, setIsErrorMessageRendered] = useState(false);
22
+ const errorMessageRef = useCallback((node: HTMLElement | null) => {
23
+ setIsErrorMessageRendered(!!node);
24
+ }, []);
25
+
26
+ const ariaDescribedBy =
27
+ [
28
+ isDescriptionRendered ? getDescriptionId(id) : false,
29
+ isErrorMessageRendered ? getErrorMessageId(id) : false,
30
+ ]
31
+ .filter(Boolean)
32
+ .join(" ") || undefined;
33
+
34
+ return {
35
+ id,
36
+
37
+ refs: {
38
+ label: labelRef,
39
+ description: descriptionRef,
40
+ errorMessage: errorMessageRef,
41
+ },
42
+
43
+ renderedElements: {
44
+ label: isLabelRendered,
45
+ description: isDescriptionRendered,
46
+ errorMessage: isErrorMessageRendered,
47
+ },
48
+
49
+ rootProps: elementProps({
50
+ // see: https://w3c.github.io/aria/#group
51
+ role: "group",
52
+
53
+ // note: aria-disabled is supported but useFieldset doesn't know about the disabled state of its children
54
+ // note: aria-required and aria-invalid should not be set here
55
+
56
+ ...(isLabelRendered && { "aria-labelledby": getLabelId(id) }),
57
+ "aria-describedby": ariaDescribedBy,
58
+ }),
59
+
60
+ labelProps: elementProps({
61
+ id: getLabelId(id),
62
+ }),
63
+
64
+ descriptionProps: elementProps({
65
+ id: getDescriptionId(id),
66
+ }),
67
+
68
+ errorMessageProps: elementProps({
69
+ id: getErrorMessageId(id),
70
+ "aria-live": "polite",
71
+ }),
72
+ };
73
+ }
@@ -0,0 +1,21 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { UseFieldsetReturn } from "./useFieldset";
3
+
4
+ export interface UseFieldsetContext extends UseFieldsetReturn {}
5
+
6
+ const FieldsetContext = createContext<UseFieldsetContext | null>(null);
7
+
8
+ export const FieldsetProvider = FieldsetContext.Provider;
9
+
10
+ export function useFieldsetContext<T extends boolean | undefined = true>({
11
+ strict = true,
12
+ }: {
13
+ strict?: T;
14
+ } = {}): T extends false ? UseFieldsetContext | null : UseFieldsetContext {
15
+ const context = useContext(FieldsetContext);
16
+ if (!context && strict) {
17
+ throw new Error("useFieldsetContext must be used within a Fieldset");
18
+ }
19
+
20
+ return context as UseFieldsetContext;
21
+ }