@cerebruminc/cerebellum 17.3.7 → 17.3.8

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # react-component-lib-boilerplate
2
2
 
3
+ ## [17.3.8](https://github.com/cerebruminc/cerebellum/compare/v17.3.7...v17.3.8) (2026-06-25)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **input:** add id's to input erro messages ([6e131e3](https://github.com/cerebruminc/cerebellum/commit/6e131e3dc2c8f713ce3ba3f2156562d15af91a40))
9
+ * **toggle:** support explicit labelling for interactive switches ([6a18319](https://github.com/cerebruminc/cerebellum/commit/6a18319cacabd10148e663d0c8db97f76f6dc587))
10
+
3
11
  ## [17.3.7](https://github.com/cerebruminc/cerebellum/compare/v17.3.6...v17.3.7) (2026-06-23)
4
12
 
5
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerebruminc/cerebellum",
3
- "version": "17.3.7",
3
+ "version": "17.3.8",
4
4
  "description": "Cerebrum's React Component Library",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -60,8 +60,9 @@ describe("DescriptiveSwitchMenu", () => {
60
60
 
61
61
  test("Toggle enum displays toggles", () => {
62
62
  render(<ThemedDescriptiveSwitchMenu options={options} switchType={SwitchTypeEnum.Toggle} isClickable />);
63
- const toggles = screen.getAllByRole("switch");
63
+ const toggles = screen.getAllByRole("checkbox");
64
64
  expect(toggles.length).toEqual(options.length);
65
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument();
65
66
  });
66
67
 
67
68
  test("active option is checked", () => {
@@ -66,6 +66,7 @@ export const InlineInput: FC<InlineInputType> = ({
66
66
  trailingText,
67
67
  trailingTextColor,
68
68
  type = "text",
69
+ validationMessageId,
69
70
  value = "",
70
71
  }) => {
71
72
  const theme = useTheme();
@@ -191,6 +192,7 @@ export const InlineInput: FC<InlineInputType> = ({
191
192
  $showValidationMessage={showValidationMessage}
192
193
  $sidePadding={sidePadding}
193
194
  step={type === "number" ? step : undefined}
195
+ aria-describedby={showValidationMessage && validationMessageId ? validationMessageId : undefined}
194
196
  aria-invalid={showValidationMessage ? "true" : undefined}
195
197
  aria-required={required ? "true" : undefined}
196
198
  required={required}
@@ -5,6 +5,8 @@ import { InputTheme } from "../../sharedTypes/enums";
5
5
  export interface InlineInputType {
6
6
  /** Sets HTML attribute `autofocus`, which will focus the input on load */
7
7
  autofocus?: boolean;
8
+ /** An id pointing to the validation message element. Used internally by Input to set `aria-describedby` on the input when validation is shown. */
9
+ validationMessageId?: string;
8
10
  /** Maximum number of characters the input will accept */
9
11
  characterLimit?: number;
10
12
  /** Enables the clear button feature, which displays an X in the right side to easily clear the input */
@@ -71,6 +71,24 @@ describe("Input", () => {
71
71
  expect(input).not.toHaveAttribute("aria-invalid");
72
72
  });
73
73
 
74
+ test("input has aria-describedby pointing to validation message when showValidationMessage is true", () => {
75
+ const label = "Test input";
76
+ const validationMessage = "This field is required";
77
+ render(<ThemedInput value="" inputLabel={label} showValidationMessage validationMessage={validationMessage} />);
78
+ const input = screen.getByLabelText(label);
79
+ const describedById = input.getAttribute("aria-describedby");
80
+ expect(describedById).toBeTruthy();
81
+ const validationElement = document.getElementById(describedById!);
82
+ expect(validationElement).toHaveTextContent(validationMessage);
83
+ });
84
+
85
+ test("input does not have aria-describedby when showValidationMessage is false", () => {
86
+ const label = "Test input";
87
+ render(<ThemedInput value="" inputLabel={label} validationMessage="Error" />);
88
+ const input = screen.getByLabelText(label);
89
+ expect(input).not.toHaveAttribute("aria-describedby");
90
+ });
91
+
74
92
  // ------ The rest of these tests are redundant with InlinInput's tests, but there's not much point deleting them
75
93
  test("renders Input with the text present", () => {
76
94
  const inputValue = "foo";
@@ -1,4 +1,4 @@
1
- import React, { FC, useEffect, useRef, useState } from "react";
1
+ import React, { FC, useEffect, useId, useRef, useState } from "react";
2
2
  import { InlineInput } from "../InlineInput";
3
3
  import useMeasure from "../../hooks/useMeasure";
4
4
 
@@ -24,9 +24,11 @@ export const Input: FC<InputType> = (props: InputType) => {
24
24
  validationMessage,
25
25
  } = props;
26
26
 
27
+ const reactId = useId();
28
+ const validationId = `${reactId}-validation`;
27
29
  const labelBoxRef = useRef<HTMLDivElement>(null);
28
30
  const [inputGroupRef, inputGroupMeasure] = useMeasure();
29
- const validationRef = useRef<HTMLDivElement>(null);
31
+ const validationRef = useRef<HTMLParagraphElement>(null);
30
32
  const [designSwitch, setDesignSwitch] = useState(false);
31
33
  const helperTextVisible = !showValidationMessage || !designSwitch;
32
34
 
@@ -47,8 +49,9 @@ export const Input: FC<InputType> = (props: InputType) => {
47
49
  {showLabel({ inputLabel, disabled, disabledColor, labelColor, htmlFor: id, removePaddingRight: required })}
48
50
  {required && <Asterisk $asteriskColor={asteriskColor}>*</Asterisk>}
49
51
  </LabelBox>
50
- <InlineInput {...props} trailingTextColor={helperTextColor} />
52
+ <InlineInput {...props} trailingTextColor={helperTextColor} validationMessageId={validationId} />
51
53
  <ValidationText
54
+ id={validationId}
52
55
  ref={validationRef}
53
56
  $showValidationMessage={showValidationMessage}
54
57
  $disabled={disabled}
@@ -32,11 +32,13 @@ export const InlineTextarea: FC<InlineTextareaType> = ({
32
32
  textareaRef,
33
33
  textFontSize,
34
34
  // theme = InputTheme.Default,
35
+ validationMessageId,
35
36
  value,
36
37
  }) => {
37
38
  return (
38
39
  <PositionWrapper $width={inputWidth}>
39
40
  <TextareaElement
41
+ aria-describedby={showValidationMessage && validationMessageId ? validationMessageId : undefined}
40
42
  aria-invalid={showValidationMessage ? "true" : undefined}
41
43
  id={htmlId}
42
44
  ref={textareaRef}
@@ -103,6 +103,22 @@ describe("Textarea", () => {
103
103
  expect(textarea).not.toHaveAttribute("aria-invalid");
104
104
  });
105
105
 
106
+ test("textarea has aria-describedby pointing to validation message when showValidationMessage is true", () => {
107
+ const validationMessage = "This field is required";
108
+ render(<ThemedTextarea showValidationMessage validationMessage={validationMessage} value={"Foo"} />);
109
+ const textarea = screen.getByRole("textbox");
110
+ const describedById = textarea.getAttribute("aria-describedby");
111
+ expect(describedById).toBeTruthy();
112
+ const validationElement = document.getElementById(describedById!);
113
+ expect(validationElement).toHaveTextContent(validationMessage);
114
+ });
115
+
116
+ test("textarea does not have aria-describedby when showValidationMessage is false", () => {
117
+ render(<ThemedTextarea validationMessage="Error" value={"Foo"} />);
118
+ const textarea = screen.getByRole("textbox");
119
+ expect(textarea).not.toHaveAttribute("aria-describedby");
120
+ });
121
+
106
122
  test("helper text is visible when received as a prop", () => {
107
123
  const helperText = "This is a helper text";
108
124
  render(<ThemedTextarea helperText={helperText} value={"Foo"} />);
@@ -1,4 +1,4 @@
1
- import React, { FC, useEffect, useRef, useState } from "react";
1
+ import React, { FC, useEffect, useId, useRef, useState } from "react";
2
2
 
3
3
  import useMeasure from "../../hooks/useMeasure";
4
4
  import { showLabel } from "../../sharedHelpers/showLabel";
@@ -25,6 +25,8 @@ export const Textarea: FC<TextareaType> = (props) => {
25
25
  validationMessage,
26
26
  } = props;
27
27
 
28
+ const reactId = useId();
29
+ const validationId = `${reactId}-validation`;
28
30
  const textareaRef = useRef<HTMLTextAreaElement>(null);
29
31
  const labelBoxRef = useRef<HTMLDivElement>(null);
30
32
  const [inputGroupRef, inputGroupMeasure] = useMeasure();
@@ -62,13 +64,14 @@ export const Textarea: FC<TextareaType> = (props) => {
62
64
  {showLabel({ inputLabel, disabled, disabledColor, labelColor, htmlFor: htmlId, removePaddingRight: required })}
63
65
  {required && <Asterisk $asteriskColor={asteriskColor}>*</Asterisk>}
64
66
  </LabelBox>
65
- <InlineTextarea htmlId={htmlId} {...props} textareaRef={textareaRef} />
67
+ <InlineTextarea htmlId={htmlId} {...props} textareaRef={textareaRef} validationMessageId={validationId} />
66
68
  {helperTextVisible && (
67
69
  <HelperText $disabled={disabled} $helperTextColor={helperTextColor} $disabledColor={disabledColor} data-sentry-unmask>
68
70
  {helperText}
69
71
  </HelperText>
70
72
  )}
71
73
  <ValidationText
74
+ id={validationId}
72
75
  ref={validationRef}
73
76
  $disabled={disabled}
74
77
  $showValidationMessage={showValidationMessage}
@@ -4,6 +4,8 @@ import { InputTheme } from "../../sharedTypes/enums";
4
4
  export interface InlineTextareaType {
5
5
  /** Restricts Textarea to this number of characters. No limit applied if undefined. */
6
6
  characterLimit?: number;
7
+ /** An id pointing to the validation message element. Used internally by Textarea to set `aria-describedby` on the textarea when validation is shown. */
8
+ validationMessageId?: string;
7
9
  /** Puts Textarea into a disabled state. */
8
10
  disabled?: boolean;
9
11
  /** An id for the textarea html element - needed in case there are multiple textareas in a page (for screenreaders) */
@@ -10,12 +10,13 @@ import * as ToggleStories from "./Toggle.stories";
10
10
  ## Toggle Usage
11
11
 
12
12
  ```jsx
13
- import React from "react";
13
+ import React, { useState } from "react";
14
14
 
15
15
  import { Toggle } from "@cerebruminc/cerebellum";
16
16
 
17
- const Example = ({ active }) => {
18
- return <Toggle active={active} />;
17
+ const Example = () => {
18
+ const [active, setActive] = useState(false);
19
+ return <Toggle active={active} onToggle={() => setActive((prev) => !prev)} aria-label="Dark mode" />;
19
20
  };
20
21
  ```
21
22
 
@@ -1,12 +1,14 @@
1
1
  import { Meta, StoryObj } from "@storybook/react";
2
2
  import React from "react";
3
3
  import { colors } from "../../const/colors";
4
- import { BodyMEmphasis } from "../../const/text";
4
+ import { BodyMEmphasis, BodySPrimary } from "../../const/text";
5
5
  import { Toggle } from "./Toggle";
6
6
 
7
7
  /**
8
8
  * A simple toggle switch. Please note that this is not an `<input>` element, and will not work with some form generators. `ToggleItem` and `ToggleGroup` both use `<input>` elements.
9
9
 
10
+ When `Toggle` is interactive, provide either `aria-label` or `aria-labelledby`. A wrapping `<label>` does not name a custom `role="switch"` element.
11
+
10
12
  **Note:** You must handle the active state externally.
11
13
  */
12
14
  const meta: Meta<typeof Toggle> = { component: Toggle, title: "components/Toggles/Toggle" };
@@ -35,12 +37,30 @@ export const WithChildren: Story = {
35
37
  },
36
38
  };
37
39
 
40
+ /**
41
+ * Use `aria-labelledby` when the visible label is rendered separately from the custom switch element.
42
+ */
43
+ export const WithSeparateLabel: Story = {
44
+ render: (args) => (
45
+ <div style={{ alignItems: "center", display: "flex", gap: "12px" }}>
46
+ <BodySPrimary id="toggle-separate-label">High contrast theme</BodySPrimary>
47
+ <Toggle {...args} aria-labelledby="toggle-separate-label" />
48
+ </div>
49
+ ),
50
+ args: {
51
+ active: true,
52
+ disabled: false,
53
+ onToggle: () => console.log("ToggleClick"),
54
+ },
55
+ };
56
+
38
57
  /**
39
58
  * You can override the theme if you need to. Make sure you know what you're doing, or something awful could happnen:
40
59
  */
41
60
  export const OverrideTheTheme: Story = {
42
61
  args: {
43
62
  active: true,
63
+ "aria-label": "Toggle switch",
44
64
  disabled: false,
45
65
  onToggle: () => console.log("ToggleClick"),
46
66
  knobColor: colors.RED_10,
@@ -38,6 +38,17 @@ describe("Toggle", () => {
38
38
  expect(toggle).toBeInTheDocument();
39
39
  });
40
40
 
41
+ test("uses aria-labelledby when provided", () => {
42
+ render(
43
+ <>
44
+ <span id="dark-mode-label">Dark mode</span>
45
+ <ThemedToggle active onToggle={jest.fn()} aria-labelledby="dark-mode-label" />
46
+ </>
47
+ );
48
+ const toggle = screen.getByRole("switch", { name: "Dark mode" });
49
+ expect(toggle).toBeInTheDocument();
50
+ });
51
+
41
52
  test("toggles on Space key press", async () => {
42
53
  const mockOnToggle = jest.fn();
43
54
  render(<ThemedToggle active onToggle={mockOnToggle} />);
@@ -63,4 +74,10 @@ describe("Toggle", () => {
63
74
  await expect(() => userEvent.click(toggle)).rejects.toThrow();
64
75
  expect(mockOnClick).toHaveBeenCalledTimes(0);
65
76
  });
77
+
78
+ test("renders as decorative when onToggle is not provided", () => {
79
+ render(<ThemedToggle active />);
80
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument();
81
+ expect(screen.getByTestId("toggle-component")).toHaveAttribute("aria-hidden", "true");
82
+ });
66
83
  });
@@ -8,6 +8,7 @@ import { ToggleType } from "./types";
8
8
  export const Toggle: FC<ToggleType> = ({
9
9
  active,
10
10
  "aria-label": ariaLabel,
11
+ "aria-labelledby": ariaLabelledBy,
11
12
  clickPadding = 15,
12
13
  children,
13
14
  disabled,
@@ -24,6 +25,8 @@ export const Toggle: FC<ToggleType> = ({
24
25
  trackHeight,
25
26
  width,
26
27
  }) => {
28
+ const isInteractive = !!onToggle;
29
+
27
30
  return (
28
31
  <ClickPadding
29
32
  data-testid="toggle-component"
@@ -41,11 +44,13 @@ export const Toggle: FC<ToggleType> = ({
41
44
  }
42
45
  }}
43
46
  $disabled={disabled}
44
- role="switch"
45
- aria-checked={active}
46
- aria-disabled={disabled}
47
- aria-label={ariaLabel}
48
- tabIndex={!onToggle || disabled ? -1 : 0}
47
+ role={isInteractive ? "switch" : undefined}
48
+ aria-checked={isInteractive ? active : undefined}
49
+ aria-disabled={isInteractive ? disabled : undefined}
50
+ aria-hidden={isInteractive ? undefined : true}
51
+ aria-label={isInteractive ? ariaLabel : undefined}
52
+ aria-labelledby={isInteractive ? ariaLabelledBy : undefined}
53
+ tabIndex={!isInteractive || disabled ? -1 : 0}
49
54
  >
50
55
  <Base disabled={disabled} $knobSize={knobSize} $trackHeight={trackHeight} $width={width}>
51
56
  <Knob $active={active} $knobSize={knobSize} $width={width}>
@@ -3,8 +3,10 @@ import { KeyboardEvent, MouseEvent } from "react";
3
3
  export interface ToggleType {
4
4
  /** The current state of the Toggle */
5
5
  active: boolean;
6
- /** Accessible label for the toggle switch. Required when Toggle is used without a visible label or wrapping <label> element. */
6
+ /** Accessible label for an interactive toggle switch. Required when Toggle is used without a separate visible label element. */
7
7
  "aria-label"?: string;
8
+ /** Id of the visible label for an interactive toggle switch. Prefer this when the label text is already rendered elsewhere. */
9
+ "aria-labelledby"?: string;
8
10
  children?: React.ReactNode;
9
11
  /** Clickable space added around Toggle, in pixels. Pass `0` to disable */
10
12
  clickPadding?: number | string;
@@ -11,8 +11,9 @@ describe("ToggleItem", () => {
11
11
  const text = "Unique ToggleItem text";
12
12
  render(<ThemedToggleItem active={true} value={text} onToggle={jest.fn()} />);
13
13
 
14
- const toggle = screen.getByText(text);
14
+ const toggle = screen.getByRole("checkbox", { name: text });
15
15
  expect(toggle).toBeInTheDocument();
16
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument();
16
17
  });
17
18
 
18
19
  test("disabled checkbox is not clickable", async () => {