@cerebruminc/cerebellum 17.3.6 → 17.3.7-beta.dangerous.eb38f7c

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,12 @@
1
1
  # react-component-lib-boilerplate
2
2
 
3
+ ## [17.3.7](https://github.com/cerebruminc/cerebellum/compare/v17.3.6...v17.3.7) (2026-06-23)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **upload-dropzone:** increase contrast in UploadDropzone ([ca12fb3](https://github.com/cerebruminc/cerebellum/commit/ca12fb3e092138e49580e00d901dd3dc8a29bd58))
9
+
3
10
  ## [17.3.6](https://github.com/cerebruminc/cerebellum/compare/v17.3.5...v17.3.6) (2026-06-22)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerebruminc/cerebellum",
3
- "version": "17.3.6",
3
+ "version": "17.3.7-beta.dangerous.eb38f7c",
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", () => {
@@ -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 () => {
@@ -1,9 +1,11 @@
1
1
  import { fireEvent, render, screen } from "@testing-library/react";
2
2
  import React from "react";
3
3
  import { act } from "react-dom/test-utils";
4
+ import { contrast } from "../../helpers/accessibility";
4
5
  import { noop } from "../../helpers/utils";
5
- import { FileTypeEnum } from "../../sharedTypes/enums";
6
6
  import { withTheme } from "../../hocs/withTheme";
7
+ import { FileTypeEnum } from "../../sharedTypes/enums";
8
+ import { cerebellumTheme } from "../../theme";
7
9
  import { UploadDropzone } from "./UploadDropzone";
8
10
 
9
11
  const ThemedUploadDropzone = withTheme(UploadDropzone);
@@ -70,3 +72,40 @@ describe("UploadDropzone", () => {
70
72
  expect(await screen.findByText("ping.json - Invalid file type")).toBeInTheDocument();
71
73
  });
72
74
  });
75
+
76
+ describe("UploadDropzone color contrast (WCAG 2.1 AA)", () => {
77
+ const WCAG_AA_TEXT = 4.5;
78
+
79
+ // Source directly from the theme so these tests catch regressions in
80
+ // both the token values and the theme wiring (e.g. textColor, secondaryText).
81
+ const textColor = cerebellumTheme.uploadDropzone.textColor;
82
+
83
+ const colorFamilyPairs = [
84
+ {
85
+ family: "Purple",
86
+ accent: cerebellumTheme.colorFamilies.PURPLE.secondaryText!,
87
+ background: cerebellumTheme.colorFamilies.PURPLE.light,
88
+ },
89
+ {
90
+ family: "Blue",
91
+ accent: cerebellumTheme.colorFamilies.BLUE.secondaryText!,
92
+ background: cerebellumTheme.colorFamilies.BLUE.light,
93
+ },
94
+ {
95
+ family: "Red",
96
+ accent: cerebellumTheme.colorFamilies.RED.secondaryText!,
97
+ background: cerebellumTheme.colorFamilies.RED.light,
98
+ },
99
+ ];
100
+
101
+ test.each(colorFamilyPairs)("$family: body text (COOL_GREY_70) achieves >= 4.5:1 contrast against light background", ({ background }) => {
102
+ expect(contrast(textColor, background)).toBeGreaterThanOrEqual(WCAG_AA_TEXT);
103
+ });
104
+
105
+ test.each(colorFamilyPairs)(
106
+ "$family: accent color (secondaryText) achieves >= 4.5:1 contrast against light background",
107
+ ({ accent, background }) => {
108
+ expect(contrast(accent, background)).toBeGreaterThanOrEqual(WCAG_AA_TEXT);
109
+ }
110
+ );
111
+ });
@@ -1,9 +1,9 @@
1
1
  import React, { FC, useCallback, useEffect, useRef, useState } from "react";
2
2
  import { useTheme } from "styled-components";
3
3
 
4
+ import { colors } from "../../const/colors";
4
5
  import { CloseCircle, File, FileComplete, FileError as FileErrorIcon, UploadDocument } from "../Icons";
5
6
  import { TextLink } from "../TextLink";
6
- import { colors } from "../../const/colors";
7
7
  import {
8
8
  DropZoneContainer,
9
9
  DropzoneBox,
@@ -22,13 +22,13 @@ import {
22
22
  import { DropzoneColorFamilyEnum, FileError, UploadDropzoneType } from "./types";
23
23
 
24
24
  import { FileRejection, useDropzone } from "react-dropzone";
25
- import { Truncate } from "../Truncate";
26
25
  import { formatBytesToString } from "../../helpers/numbers";
27
26
  import useMeasure from "../../hooks/useMeasure";
28
27
  import { showLabel } from "../../sharedHelpers/showLabel";
29
28
  import { Asterisk, LabelBox, ValidationText } from "../../sharedStyle/Inputs";
30
29
  import { TextStyleEnum } from "../../sharedTypes/enums";
31
30
  import { ASTERISK_OFFSET_SPACE } from "../Input/helpers";
31
+ import { Truncate } from "../Truncate";
32
32
  import { TextVariantEnum, Typography } from "../Typography";
33
33
  import { createAcceptObject, defaultDescripion } from "./helpers";
34
34
 
@@ -57,6 +57,7 @@ export const UploadDropzone: FC<UploadDropzoneType> = ({
57
57
  const theme = useTheme();
58
58
  const { colorFamilies } = theme;
59
59
  const colorGroup = colorFamilyOverride || colorFamilies[dropzoneColorFamily || DropzoneColorFamilyEnum.Purple];
60
+ const accentColor = colorGroup.secondaryText || colorGroup.main;
60
61
  const acceptObject = createAcceptObject(acceptedFileTypes);
61
62
 
62
63
  const [errorSummary, setErrorSummary] = useState<Array<FileError>>([]);
@@ -186,7 +187,7 @@ export const UploadDropzone: FC<UploadDropzoneType> = ({
186
187
  {displayFileInline && !multipleFileUploads && errorSummary?.[0] ? (
187
188
  <InlineFileItemBox>
188
189
  <InlineIconBox>
189
- {errorSummary.length > 0 ? <FileErrorIcon fill={theme.input.failColor} /> : <FileComplete fill={colorGroup.hover} />}
190
+ {errorSummary.length > 0 ? <FileErrorIcon fill={theme.input.failColor} /> : <FileComplete fill={accentColor} />}
190
191
  </InlineIconBox>
191
192
  <InlineFileTextBox>
192
193
  <Truncate>{errorSummary?.[0]?.name}</Truncate>
@@ -210,12 +211,11 @@ export const UploadDropzone: FC<UploadDropzoneType> = ({
210
211
  <UploadIconBox>
211
212
  <UploadDocument
212
213
  boxColor={disabled ? theme?.uploadDropzone?.disabledColorLight : colorGroup.medium}
213
- fill={disabled ? theme.uploadDropzone.disabledColorDark : colorGroup.hover}
214
+ fill={disabled ? theme.uploadDropzone.disabledColorDark : accentColor}
214
215
  />
215
216
  </UploadIconBox>
216
217
  <TextSection $disabled={disabled} $isDragging={isDragActive} $themeOverride={themeOverride}>
217
- {description ||
218
- defaultDescripion(disabled ? theme.uploadDropzone.disabledColorText : colorGroup.hover, acceptObject, disabled)}
218
+ {description || defaultDescripion(disabled ? theme.uploadDropzone.disabledColorText : accentColor, acceptObject, disabled)}
219
219
  </TextSection>
220
220
  </>
221
221
  )}
@@ -237,7 +237,7 @@ export const UploadDropzone: FC<UploadDropzoneType> = ({
237
237
  {fileList.map((item, idx) => (
238
238
  <FileItemBox key={`fileitem_${item?.name}_${idx}`}>
239
239
  <ItemIconBox>
240
- <File fill={colorGroup.hover} />
240
+ <File fill={accentColor} />
241
241
  </ItemIconBox>
242
242
  <Truncate>{item.name}</Truncate>
243
243
  <RightAligned>
package/src/theme.ts CHANGED
@@ -806,6 +806,7 @@ export const cerebellumTheme: DefaultTheme = {
806
806
  main: colors.PURPLE_55,
807
807
  hover: colors.PURPLE_70,
808
808
  text: colors.WHITE,
809
+ secondaryText: colors.PURPLE_75,
809
810
  },
810
811
  RED: {
811
812
  light: colors.RED_5,
@@ -1332,7 +1333,7 @@ export const cerebellumTheme: DefaultTheme = {
1332
1333
  listDeleteColor: colors.BLUE_100,
1333
1334
  minHeight: 180,
1334
1335
  paddingString: "37px 26px 32px 26px",
1335
- textColor: "#767689",
1336
+ textColor: colors.COOL_GREY_70,
1336
1337
  },
1337
1338
  userCard: {
1338
1339
  avatarBackgroundColor: colors.BLUE_55,