Skip to content

Commit bdc9f17

Browse files
committed
feat(hooks): adds useAutoId hook
1 parent ceb9093 commit bdc9f17

File tree

5 files changed

+169
-0
lines changed

5 files changed

+169
-0
lines changed

cypress/test/hooks/use-auto-id.cy.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Please refer to the terms of the license agreement.
3+
*
4+
* (c) 2024 Feedzai, Rights Reserved.
5+
*/
6+
import { useAutoId } from "src/hooks";
7+
8+
function DemoComponent({ value = null, prefix }: { value?: string | null; prefix?: string }) {
9+
const firstId = useAutoId(value, prefix);
10+
const secondId = useAutoId();
11+
return (
12+
<div>
13+
<p id={firstId}>A paragraph</p>
14+
<span id={secondId}>An inline span element</span>
15+
</div>
16+
);
17+
}
18+
19+
function FallbackDemo({
20+
value = "feedzai-fallback-id",
21+
prefix,
22+
}: {
23+
value?: string | null;
24+
prefix?: string;
25+
}) {
26+
const id = useAutoId(value, prefix);
27+
return <h1 id={id}>Feedzai</h1>;
28+
}
29+
30+
describe("useAutoId", () => {
31+
it("should generate a unique ID value", () => {
32+
cy.mount(<DemoComponent />);
33+
34+
cy.findByText("A paragraph")
35+
.invoke("attr", "id")
36+
.then((idOne) => {
37+
cy.findByText("An inline span element").invoke("attr", "id").should("not.equal", idOne);
38+
});
39+
});
40+
41+
it("should generate a prefixed unique ID value", () => {
42+
const expected = "feedzai-a-prefix";
43+
cy.mount(<DemoComponent value={undefined} prefix={expected} />);
44+
45+
cy.findByText("A paragraph").invoke("attr", "id").should("contain", expected);
46+
});
47+
48+
it("uses a fallback ID", () => {
49+
cy.mount(<FallbackDemo />);
50+
51+
cy.findByText("Feedzai").should("have.id", "feedzai-fallback-id");
52+
});
53+
54+
it("should return a prefixed fallback ID", () => {
55+
cy.mount(<FallbackDemo prefix="js-prefix" value="423696e5" />);
56+
57+
cy.findByText("Feedzai").should("have.id", "js-prefix--423696e5");
58+
});
59+
});

src/functions/string/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from "./camel-case";
77
export * from "./capitalize";
88
export * from "./escape-reg-exp";
99
export * from "./kebab-case";
10+
export * from "./make-id";
1011
export * from "./pascal-case";
1112
export * from "./readable-string-list";
1213
export * from "./strip-unit";

src/functions/string/make-id.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Please refer to the terms of the license
3+
* agreement.
4+
*
5+
* (c) 2024 Feedzai, Rights Reserved.
6+
*/
7+
8+
/**
9+
* Joins strings to format IDs for compound components.
10+
*
11+
* @example
12+
*
13+
* // Custom generated id by using the `useAutoId` hook and the `makeId` function
14+
* // to join the auto-generated id with a custom string
15+
* const autoId = useAutoId(id);
16+
* const { current: generatedId } = useRef(makeId("fdz-js-tabbable-button-", autoId));
17+
*
18+
* @param args
19+
* @returns {string}
20+
*/
21+
export function makeId(...args: (string | number | null | undefined)[]) {
22+
return args.filter((val) => val !== null).join("--");
23+
}

src/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*
44
* (c) 2024 Feedzai
55
*/
6+
export * from "./use-auto-id";
67
export * from "./use-click-outside";
78
export * from "./use-constant";
89
export * from "./use-container-query";

src/hooks/use-auto-id.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Please refer to the terms of the license
3+
* agreement.
4+
*
5+
* (c) 2024 Feedzai, Rights Reserved.
6+
*/
7+
import { useEffect, useMemo, useRef, useState } from "react";
8+
import { useSafeLayoutEffect } from ".";
9+
import { makeId } from "..";
10+
11+
let hasHydrated = false;
12+
let id = 0;
13+
14+
/**
15+
* Returns an incremented id number
16+
*
17+
* @returns {number}
18+
*/
19+
const generateIncrementalId = () => ++id;
20+
21+
/**
22+
* Generate automatic IDs to facilitate WAI-ARIA
23+
*
24+
* The returned ID will initially be `null` and will update after a
25+
* component mounts. Users may need to supply their own ID if they need
26+
* consistent values for SSR.
27+
*
28+
* @example
29+
*
30+
* // Generating an id (no pre-defined id and no prefix)
31+
* const id1 = useAutoId(); // will return, for example, "0"
32+
*
33+
* // Using a pre-defined id (no prefix)
34+
* const id2 = useAutoId("8e88aa2e-e6a8") // will return "8e88aa2e-e6a8"
35+
*
36+
* // Using a prefix with an auto-generated id (no pre-defined id)
37+
* const id3 = useAutoId(undefined, "fdz-prefix") // will return, for example, "fdz-prefix--10"
38+
*
39+
* // Using a prefix with a pre-defined id
40+
* const id4 = useAutoId("6949d175", "fdz-js-checkbox") // will return "fdz-js-checkbox--6949d175"
41+
*
42+
* @param {string | null | undefined} customId - You can pass an previously defined value
43+
* and that value will be used as the value of the returned id.
44+
* @param {string | undefined} prefix - If necessary, you can prepend a generated id with a prefix.
45+
* @returns {string | undefined} an auto/self-generated id with a possible prefix
46+
*/
47+
function useAutoId(customId?: string | null, prefix?: string): string | undefined {
48+
const { current: idPrefix } = useRef(prefix);
49+
const initialId = customId || (hasHydrated ? generateIncrementalId() : null);
50+
const [id, setId] = useState(initialId);
51+
52+
/*
53+
* Patch the ID after render to avoid any rendering flicker.
54+
*/
55+
useSafeLayoutEffect(() => {
56+
if (id === null) {
57+
setId(generateIncrementalId());
58+
}
59+
}, []);
60+
61+
/*
62+
* Flag all future uses of `useAutoId` to skip the updating cycle.
63+
* We use `useEffect` because it happens after `useLayoutEffect`.
64+
* This way we make sure that we complete the patch process until the end.
65+
*/
66+
useEffect(() => {
67+
if (hasHydrated === false) {
68+
hasHydrated = true;
69+
}
70+
}, []);
71+
72+
const finalId = useMemo(() => {
73+
if (!id) {
74+
return undefined;
75+
}
76+
77+
const finalId = String(id);
78+
79+
return idPrefix ? makeId(idPrefix, finalId) : finalId;
80+
}, [id, idPrefix]);
81+
82+
return finalId;
83+
}
84+
85+
export { useAutoId };

0 commit comments

Comments
 (0)