@simplybusiness/mobius 5.3.1 → 5.4.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/cjs/components/Checkbox/Checkbox.js +5 -20
  3. package/dist/cjs/components/Checkbox/Checkbox.js.map +1 -1
  4. package/dist/cjs/components/Checkbox/CheckboxGroup.js +29 -11
  5. package/dist/cjs/components/Checkbox/CheckboxGroup.js.map +1 -1
  6. package/dist/cjs/hooks/index.js +1 -0
  7. package/dist/cjs/hooks/index.js.map +1 -1
  8. package/dist/cjs/hooks/useRenderCount/index.js +20 -0
  9. package/dist/cjs/hooks/useRenderCount/index.js.map +1 -0
  10. package/dist/cjs/hooks/useRenderCount/useRenderCount.js +20 -0
  11. package/dist/cjs/hooks/useRenderCount/useRenderCount.js.map +1 -0
  12. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  13. package/dist/esm/components/Checkbox/Checkbox.js +6 -21
  14. package/dist/esm/components/Checkbox/Checkbox.js.map +1 -1
  15. package/dist/esm/components/Checkbox/CheckboxGroup.js +29 -11
  16. package/dist/esm/components/Checkbox/CheckboxGroup.js.map +1 -1
  17. package/dist/esm/components/Checkbox/types.js.map +1 -1
  18. package/dist/esm/hooks/index.js +1 -0
  19. package/dist/esm/hooks/index.js.map +1 -1
  20. package/dist/esm/hooks/useRenderCount/index.js +3 -0
  21. package/dist/esm/hooks/useRenderCount/index.js.map +1 -0
  22. package/dist/esm/hooks/useRenderCount/useRenderCount.js +10 -0
  23. package/dist/esm/hooks/useRenderCount/useRenderCount.js.map +1 -0
  24. package/dist/types/components/Checkbox/CheckboxGroup.stories.d.ts +1 -0
  25. package/dist/types/components/Checkbox/types.d.ts +10 -6
  26. package/dist/types/hooks/index.d.ts +1 -0
  27. package/dist/types/hooks/useRenderCount/index.d.ts +1 -0
  28. package/dist/types/hooks/useRenderCount/useRenderCount.d.ts +1 -0
  29. package/dist/types/hooks/useRenderCount/useRenderCount.test.d.ts +1 -0
  30. package/package.json +17 -17
  31. package/src/components/Checkbox/Checkbox.test.tsx +2 -1
  32. package/src/components/Checkbox/Checkbox.tsx +7 -31
  33. package/src/components/Checkbox/CheckboxGroup.stories.tsx +15 -0
  34. package/src/components/Checkbox/CheckboxGroup.test.tsx +107 -1
  35. package/src/components/Checkbox/CheckboxGroup.tsx +45 -15
  36. package/src/components/Checkbox/types.ts +13 -6
  37. package/src/hooks/index.tsx +1 -0
  38. package/src/hooks/useRenderCount/index.ts +1 -0
  39. package/src/hooks/useRenderCount/useRenderCount.test.ts +26 -0
  40. package/src/hooks/useRenderCount/useRenderCount.ts +9 -0
@@ -48,6 +48,18 @@ describe("CheckboxGroup", () => {
48
48
  expect(errorMessage).toBeInTheDocument();
49
49
  });
50
50
 
51
+ it("does not call onChange on initial render", () => {
52
+ const callback = jest.fn();
53
+
54
+ render(
55
+ <CheckboxGroup onChange={callback}>
56
+ <Checkbox label="Checkbox" value="value" />
57
+ </CheckboxGroup>,
58
+ );
59
+
60
+ expect(callback).not.toHaveBeenCalled();
61
+ });
62
+
51
63
  it("sets aria-describedby for all checkboxes when errorMessage is set", () => {
52
64
  const errorMessageText = "Error message";
53
65
 
@@ -393,7 +405,7 @@ describe("CheckboxGroup", () => {
393
405
 
394
406
  const { getByLabelText } = render(
395
407
  <CheckboxGroup isInvalid errorMessage="There is a problem">
396
- <Checkbox label={optionText} value="value" />,
408
+ <Checkbox label={optionText} value="value" />
397
409
  </CheckboxGroup>,
398
410
  );
399
411
 
@@ -503,4 +515,98 @@ describe("CheckboxGroup", () => {
503
515
  expect(option).toHaveAttribute("readonly");
504
516
  });
505
517
  });
518
+
519
+ describe("last item disables", () => {
520
+ it("should disable all checkboxes except the last one when the last item is selected", async () => {
521
+ const option1Text = "Checkbox 1";
522
+ const option2Text = "Checkbox 2";
523
+ const option3Text = "Checkbox 3";
524
+ const option1Value = "value1";
525
+ const option2Value = "value2";
526
+ const option3Value = "value3";
527
+
528
+ render(
529
+ <CheckboxGroup lastItemDisables>
530
+ <Checkbox label={option1Text} value={option1Value} />
531
+ <Checkbox label={option2Text} value={option2Value} />
532
+ <Checkbox label={option3Text} value={option3Value} />
533
+ </CheckboxGroup>,
534
+ );
535
+
536
+ const option1 = screen.getByLabelText(option1Text);
537
+ const option2 = screen.getByLabelText(option2Text);
538
+ const option3 = screen.getByLabelText(option3Text);
539
+
540
+ await userEvent.click(option3);
541
+
542
+ expect(option1).toBeDisabled();
543
+ expect(option2).toBeDisabled();
544
+ expect(option3).not.toBeDisabled();
545
+ });
546
+
547
+ it("should unchecked all checkboxes except the last one when the last item is selected", async () => {
548
+ const option1Text = "Checkbox 1";
549
+ const option2Text = "Checkbox 2";
550
+ const option3Text = "Checkbox 3";
551
+ const option1Value = "value1";
552
+ const option2Value = "value2";
553
+ const option3Value = "value3";
554
+
555
+ render(
556
+ <CheckboxGroup lastItemDisables>
557
+ <Checkbox label={option1Text} value={option1Value} />
558
+ <Checkbox label={option2Text} value={option2Value} />
559
+ <Checkbox label={option3Text} value={option3Value} />
560
+ </CheckboxGroup>,
561
+ );
562
+
563
+ const option1 = screen.getByLabelText(option1Text);
564
+ const option2 = screen.getByLabelText(option2Text);
565
+ const option3 = screen.getByLabelText(option3Text);
566
+
567
+ await userEvent.click(option1);
568
+ await userEvent.click(option3);
569
+
570
+ expect(option1).not.toBeChecked();
571
+ expect(option2).not.toBeChecked();
572
+ expect(option3).toBeChecked();
573
+ });
574
+
575
+ it("should overwrite group value when last item is selected", async () => {
576
+ const callback = jest.fn();
577
+
578
+ const option1Text = "Checkbox 1";
579
+ const option2Text = "Checkbox 2";
580
+ const option3Text = "Checkbox 3";
581
+ const option1Value = "value1";
582
+ const option2Value = "value2";
583
+ const option3Value = "value3";
584
+
585
+ render(
586
+ <CheckboxGroup lastItemDisables onChange={callback}>
587
+ <Checkbox label={option1Text} value={option1Value} />
588
+ <Checkbox label={option2Text} value={option2Value} />
589
+ <Checkbox label={option3Text} value={option3Value} />
590
+ </CheckboxGroup>,
591
+ );
592
+
593
+ const option1 = screen.getByLabelText(option1Text);
594
+ const option2 = screen.getByLabelText(option2Text);
595
+ const option3 = screen.getByLabelText(option3Text);
596
+
597
+ await userEvent.click(option1);
598
+ await userEvent.click(option2);
599
+
600
+ expect(callback).toHaveBeenCalledWith([option1Value, option2Value]);
601
+
602
+ await userEvent.click(option3);
603
+
604
+ expect(callback).toHaveBeenCalledWith([option3Value]);
605
+
606
+ // Resets to empty array when last item unchecked again
607
+ await userEvent.click(option3);
608
+
609
+ expect(callback).toHaveBeenCalledWith([]);
610
+ });
611
+ });
506
612
  });
@@ -1,28 +1,29 @@
1
1
  "use client";
2
2
 
3
+ import classNames from "classnames/dedupe";
3
4
  import {
5
+ type ChangeEvent,
6
+ type ReactElement,
4
7
  Children,
5
- forwardRef,
6
- ReactElement,
7
8
  cloneElement,
9
+ forwardRef,
8
10
  isValidElement,
11
+ useEffect,
9
12
  useId,
10
- ChangeEvent,
11
13
  useState,
12
- useEffect,
13
14
  } from "react";
14
- import classNames from "classnames/dedupe";
15
+ import { useRenderCount, useValidationClasses } from "../../hooks";
15
16
  import { ForwardedRefComponent } from "../../types/components";
17
+ import { spaceDelimitedList } from "../../utils/spaceDelimitedList";
18
+ import { ErrorMessage } from "../ErrorMessage";
19
+ import { Label } from "../Label";
20
+ import { Checkbox } from "./Checkbox";
16
21
  import {
17
22
  CheckboxElementType,
18
23
  CheckboxGroupElementType,
19
24
  CheckboxGroupProps,
20
25
  CheckboxGroupRef,
21
26
  } from "./types";
22
- import { Label } from "../Label";
23
- import { ErrorMessage } from "../ErrorMessage";
24
- import { spaceDelimitedList } from "../../utils/spaceDelimitedList";
25
- import { useValidationClasses } from "../../hooks";
26
27
 
27
28
  export const CheckboxGroup: ForwardedRefComponent<
28
29
  CheckboxGroupProps,
@@ -42,6 +43,7 @@ export const CheckboxGroup: ForwardedRefComponent<
42
43
  defaultValue = [],
43
44
  isReadOnly,
44
45
  itemsPerRow,
46
+ lastItemDisables = false,
45
47
  ...rest
46
48
  } = props;
47
49
  const [selected, setSelected] = useState<string[]>(defaultValue);
@@ -74,7 +76,10 @@ export const CheckboxGroup: ForwardedRefComponent<
74
76
  ]);
75
77
  const labelId = useId();
76
78
 
77
- const handleChange = (event: ChangeEvent<CheckboxElementType>) => {
79
+ const handleChange = (
80
+ event: ChangeEvent<CheckboxElementType>,
81
+ isLastItem = false,
82
+ ) => {
78
83
  const {
79
84
  target: { value, checked },
80
85
  } = event;
@@ -83,16 +88,34 @@ export const CheckboxGroup: ForwardedRefComponent<
83
88
  setSelected(selected.filter(item => item !== value));
84
89
  }
85
90
 
91
+ if (checked && lastItemDisables && isLastItem) {
92
+ setSelected([value]);
93
+ return;
94
+ }
95
+
86
96
  if (checked) {
87
97
  setSelected([...selected, value]);
88
98
  }
89
99
  };
90
100
 
101
+ // HACK: This is a workaround to ensure that the onChange event is not
102
+ // fired on the initial render.
103
+ const renderCount = useRenderCount();
91
104
  useEffect(() => {
92
- if (onChange) {
105
+ if (onChange && renderCount > 1) {
93
106
  onChange(selected);
94
107
  }
95
- }, [selected, onChange]);
108
+ }, [selected, onChange, renderCount]);
109
+
110
+ const childrenArray = Children.toArray(children);
111
+ const lastCheckbox = childrenArray
112
+ .filter(
113
+ child =>
114
+ isValidElement(child) && (child as ReactElement).type === Checkbox,
115
+ )
116
+ .pop() as ReactElement<CheckboxElementType> | undefined;
117
+ const lastCheckboxIsChecked =
118
+ lastCheckbox && selected.includes(lastCheckbox.props.value);
96
119
 
97
120
  return (
98
121
  <div
@@ -113,14 +136,21 @@ export const CheckboxGroup: ForwardedRefComponent<
113
136
  </Label>
114
137
  )}
115
138
  <div className="mobius-checkbox-group__wrapper">
116
- {Children.map(children, child => {
139
+ {childrenArray.map(child => {
117
140
  if (isValidElement(child)) {
141
+ // lastItemDisables support
142
+ const isLastItem = child === lastCheckbox;
143
+ const isChildDisabled =
144
+ isDisabled ||
145
+ (lastItemDisables && lastCheckboxIsChecked && !isLastItem);
146
+
118
147
  return cloneElement(child as ReactElement, {
119
- isDisabled,
148
+ isDisabled: isChildDisabled,
120
149
  isRequired,
121
150
  isReadOnly,
122
151
  isInvalid,
123
- defaultSelected: selected.includes(child.props.value),
152
+ isLastItem,
153
+ selected: selected.includes(child.props.value),
124
154
  onChange: handleChange,
125
155
  "aria-describedby": describedBy,
126
156
  });
@@ -19,7 +19,10 @@ export interface CheckboxProps
19
19
  value?: string;
20
20
  // Whether the input is disabled.
21
21
  isDisabled?: boolean;
22
- onChange?: (event: ChangeEvent<CheckboxElementType>) => void;
22
+ onChange?: (
23
+ event: ChangeEvent<CheckboxElementType>,
24
+ isLastItem?: boolean,
25
+ ) => void;
23
26
  // The default value (uncontrolled).
24
27
  defaultSelected?: boolean;
25
28
  // Whether the input can be selected but not changed by the user.
@@ -33,17 +36,17 @@ export interface CheckboxProps
33
36
  */
34
37
  "aria-describedby"?: string;
35
38
  /**
36
- * **Internal:** Do not use
39
+ * Whether the checkbox is the last item of a group.
37
40
  */
38
- groupDisabled?: boolean;
41
+ isLastItem?: boolean;
39
42
  /**
40
43
  * **Internal:** Do not use
41
44
  */
42
- selected?: string;
45
+ groupDisabled?: boolean;
43
46
  /**
44
- * **Internal:** Do not use
47
+ * Whether the checkbox is selected.
45
48
  */
46
- setSelected?: React.Dispatch<React.SetStateAction<string>>;
49
+ selected?: boolean;
47
50
  }
48
51
 
49
52
  export type CheckboxGroupElementType = HTMLDivElement;
@@ -81,6 +84,10 @@ interface CheckboxGroupPropsInternal
81
84
  * See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/radio#Value).
82
85
  */
83
86
  value?: string;
87
+ /**
88
+ * This determines if the last item in the group should disable the other items when selected.
89
+ */
90
+ lastItemDisables?: boolean;
84
91
  }
85
92
 
86
93
  interface HorizontalCheckboxGroupProps extends CheckboxGroupPropsInternal {
@@ -7,6 +7,7 @@ export * from "./useDialogPolyfill";
7
7
  export * from "./useLabel";
8
8
  export * from "./useOnClickOutside";
9
9
  export * from "./usePrefersReducedMotion";
10
+ export * from "./useRenderCount";
10
11
  export * from "./useTextField";
11
12
  export * from "./useValidationClasses";
12
13
  export * from "./useWindowEvent";
@@ -0,0 +1 @@
1
+ export * from "./useRenderCount";
@@ -0,0 +1,26 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { useRenderCount } from "./useRenderCount";
3
+
4
+ describe("useRenderCount", () => {
5
+ it("should return 1 on initial render", () => {
6
+ const { result } = renderHook(() => useRenderCount());
7
+ expect(result.current).toBe(1);
8
+ });
9
+
10
+ it("should increment the count on re-render", () => {
11
+ const { result, rerender } = renderHook(() => useRenderCount());
12
+ expect(result.current).toBe(1);
13
+ rerender();
14
+ expect(result.current).toBe(2);
15
+ rerender();
16
+ expect(result.current).toBe(3);
17
+ });
18
+
19
+ it("should maintain the count across multiple renders", () => {
20
+ const { result, rerender } = renderHook(() => useRenderCount());
21
+ rerender();
22
+ rerender();
23
+ rerender();
24
+ expect(result.current).toBe(4);
25
+ });
26
+ });
@@ -0,0 +1,9 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ export function useRenderCount() {
4
+ const count = useRef(1);
5
+ useEffect(() => {
6
+ count.current += 1;
7
+ });
8
+ return count.current;
9
+ }