@simplybusiness/mobius 6.6.0 → 6.7.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.
@@ -3,6 +3,10 @@ import type { ForwardedRefComponent } from "../../types/components";
3
3
  import type { DOMProps } from "../../types/dom";
4
4
  import type { HTMLElementEvent } from "../../types/events";
5
5
  export type RadioElementType = HTMLInputElement;
6
+ export type RadioOverflowInfo = {
7
+ vertical: boolean;
8
+ horizontal: boolean;
9
+ };
6
10
  export type AriaRadioProps = {
7
11
  /**
8
12
  * Defines a string value that labels the current element.
@@ -31,20 +35,14 @@ export interface RadioProps extends DOMProps, AriaRadioProps, RefAttributes<Radi
31
35
  onChange?: (event: HTMLElementEvent<RadioElementType>) => void;
32
36
  defaultChecked?: boolean;
33
37
  /**
34
- * **Internal:** Do not use
38
+ * Callback fired when label overflow state changes.
39
+ * Only invoked when the Radio is in horizontal orientation to prevent infinite loops with autoStack.
40
+ * Provides information about vertical and horizontal overflow.
35
41
  */
42
+ onOverflow?: (overflow: RadioOverflowInfo) => void;
36
43
  groupDisabled?: boolean;
37
- /**
38
- * **Internal:** Do not use
39
- */
40
44
  name?: string;
41
- /**
42
- * **Internal:** Do not use
43
- */
44
45
  selected?: string;
45
- /**
46
- * **Internal:** Do not use
47
- */
48
46
  setSelected?: React.Dispatch<React.SetStateAction<string>>;
49
47
  isRequired?: boolean;
50
48
  }
@@ -10,4 +10,5 @@ export declare const Invalid: StoryType;
10
10
  export declare const WithIconViaChildren: StoryType;
11
11
  export declare const ComplexLabel: StoryType;
12
12
  export declare const HorizontalLayout: StoryType;
13
+ export declare const AutoStack: StoryType;
13
14
  export default meta;
@@ -10,6 +10,11 @@ export interface RadioGroupProps extends DOMProps, Validation, RefAttributes<Rad
10
10
  orientation?: "horizontal" | "vertical";
11
11
  errorMessage?: string;
12
12
  onChange?: (event: HTMLElementEvent<HTMLInputElement>) => void;
13
+ /**
14
+ * Automatically change orientation from horizontal to vertical when any Radio label overflows.
15
+ * Only applies when orientation is set to "horizontal".
16
+ */
17
+ autoStack?: boolean;
13
18
  "aria-label"?: string;
14
19
  "aria-labelledby"?: string;
15
20
  "aria-errormessage"?: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@simplybusiness/mobius",
3
3
  "license": "UNLICENSED",
4
- "version": "6.6.0",
4
+ "version": "6.7.0",
5
5
  "description": "Core library of Mobius react components",
6
6
  "repository": {
7
7
  "type": "git",
@@ -93,6 +93,7 @@
93
93
  position: relative;
94
94
  cursor: pointer;
95
95
  background-color: var(--radio-label-background);
96
+ word-break: break-all;
96
97
 
97
98
  /* Disabled */
98
99
  &.--is-disabled {
@@ -1,13 +1,13 @@
1
+ import { rocketLaunch, screwdriverWrench, truck } from "@simplybusiness/icons";
1
2
  import type { Meta, StoryObj } from "@storybook/react-webpack5";
2
3
  import { excludeControls } from "../../utils";
3
4
  import { StoryContainer } from "../../utils/StoryContainer";
4
5
  import { Divider } from "../Divider";
5
6
  import { Flex } from "../Flex";
7
+ import { Icon } from "../Icon";
6
8
  import { Radio } from "./Radio";
7
9
  import type { RadioGroupProps } from "./RadioGroup";
8
10
  import { RadioGroup } from "./RadioGroup";
9
- import { rocketLaunch, screwdriverWrench, truck } from "@simplybusiness/icons";
10
- import { Icon } from "../Icon";
11
11
 
12
12
  type StoryType = StoryObj<typeof RadioGroup>;
13
13
 
@@ -222,4 +222,49 @@ export const HorizontalLayout: StoryType = {
222
222
  },
223
223
  };
224
224
 
225
+ export const AutoStack: StoryType = {
226
+ render: () => (
227
+ <div>
228
+ <p style={{ marginBottom: "20px" }}>
229
+ With <code>autoStack</code> enabled, RadioGroup automatically changes
230
+ from horizontal to vertical orientation when any radio label overflows.
231
+ Resize the container to see it in action.
232
+ </p>
233
+ <div
234
+ style={{
235
+ maxWidth: "400px",
236
+ border: "1px dashed #ccc",
237
+ padding: "10px",
238
+ }}
239
+ >
240
+ <RadioGroup
241
+ label="Payment Options (with autoStack)"
242
+ orientation="horizontal"
243
+ autoStack
244
+ >
245
+ <Radio value="credit" label="Credit Card" />
246
+ <Radio value="debit" label="Debit Card" />
247
+ </RadioGroup>
248
+ </div>
249
+ <div
250
+ style={{
251
+ marginTop: "30px",
252
+ maxWidth: "200px",
253
+ border: "1px dashed #ccc",
254
+ padding: "10px",
255
+ }}
256
+ >
257
+ <RadioGroup
258
+ label="Same options, narrower container"
259
+ orientation="horizontal"
260
+ autoStack
261
+ >
262
+ <Radio value="credit" label="Credit Card" />
263
+ <Radio value="debit" label="Debit Card" />
264
+ </RadioGroup>
265
+ </div>
266
+ </div>
267
+ ),
268
+ };
269
+
225
270
  export default meta;
@@ -10,6 +10,41 @@ const LABEL_CLASS = "mobius-label";
10
10
  const RADIO_WRAPPER_CLASS = "mobius-radio__wrapper";
11
11
  const RADIO_INPUT_CLASS = "mobius-radio__input";
12
12
 
13
+ const mockElementDimensions = (
14
+ element: Element,
15
+ dimensions: {
16
+ scrollHeight?: number;
17
+ clientHeight?: number;
18
+ scrollWidth?: number;
19
+ clientWidth?: number;
20
+ },
21
+ ) => {
22
+ Object.entries(dimensions).forEach(([key, value]) => {
23
+ Object.defineProperty(element, key, {
24
+ configurable: true,
25
+ value,
26
+ });
27
+ });
28
+ };
29
+
30
+ const mockOverflow = (element: Element) => {
31
+ mockElementDimensions(element, {
32
+ scrollHeight: 100,
33
+ clientHeight: 50,
34
+ scrollWidth: 100,
35
+ clientWidth: 100,
36
+ });
37
+ };
38
+
39
+ const mockNoOverflow = (element: Element) => {
40
+ mockElementDimensions(element, {
41
+ scrollHeight: 50,
42
+ clientHeight: 50,
43
+ scrollWidth: 100,
44
+ clientWidth: 100,
45
+ });
46
+ };
47
+
13
48
  describe("Radio", () => {
14
49
  it("should render without error", () => {
15
50
  const view = render(
@@ -837,3 +872,257 @@ describe.skip("focus behavior", () => {
837
872
  expect(selectedOption).toHaveFocus();
838
873
  });
839
874
  });
875
+
876
+ describe("overflow detection", () => {
877
+ describe("Radio onOverflow callback", () => {
878
+ it("should call onOverflow with vertical overflow when content height exceeds container", () => {
879
+ const onOverflow = jest.fn();
880
+
881
+ const { container, rerender } = render(
882
+ <Radio value="test" label="Test Label" onOverflow={onOverflow} />,
883
+ );
884
+
885
+ const contentElement = container.querySelector(
886
+ ".mobius-radio__content",
887
+ ) as HTMLElement;
888
+
889
+ // Mock overflow: scrollHeight > clientHeight
890
+ mockOverflow(contentElement);
891
+
892
+ // Trigger useLayoutEffect by re-rendering
893
+ rerender(
894
+ <Radio
895
+ value="test"
896
+ label="Test Label Updated"
897
+ onOverflow={onOverflow}
898
+ />,
899
+ );
900
+
901
+ expect(onOverflow).toHaveBeenCalledWith({
902
+ vertical: true,
903
+ horizontal: false,
904
+ });
905
+ });
906
+
907
+ it("should call onOverflow with horizontal overflow when content width exceeds container", () => {
908
+ const onOverflow = jest.fn();
909
+
910
+ const { container, rerender } = render(
911
+ <Radio value="test" label="Test Label" onOverflow={onOverflow} />,
912
+ );
913
+
914
+ const contentElement = container.querySelector(
915
+ ".mobius-radio__content",
916
+ ) as HTMLElement;
917
+
918
+ // Mock overflow: scrollWidth > clientWidth
919
+ mockElementDimensions(contentElement, {
920
+ scrollHeight: 50,
921
+ clientHeight: 50,
922
+ scrollWidth: 200,
923
+ clientWidth: 100,
924
+ });
925
+
926
+ // Trigger useLayoutEffect by re-rendering
927
+ rerender(
928
+ <Radio
929
+ value="test"
930
+ label="Test Label Updated"
931
+ onOverflow={onOverflow}
932
+ />,
933
+ );
934
+
935
+ expect(onOverflow).toHaveBeenCalledWith({
936
+ vertical: false,
937
+ horizontal: true,
938
+ });
939
+ });
940
+
941
+ it("should call onOverflow with both overflow types when content exceeds in both dimensions", () => {
942
+ const onOverflow = jest.fn();
943
+
944
+ const { container, rerender } = render(
945
+ <Radio value="test" label="Test Label" onOverflow={onOverflow} />,
946
+ );
947
+
948
+ const contentElement = container.querySelector(
949
+ ".mobius-radio__content",
950
+ ) as HTMLElement;
951
+
952
+ // Mock overflow in both dimensions
953
+ mockElementDimensions(contentElement, {
954
+ scrollHeight: 100,
955
+ clientHeight: 50,
956
+ scrollWidth: 200,
957
+ clientWidth: 100,
958
+ });
959
+
960
+ // Trigger useLayoutEffect by re-rendering
961
+ rerender(
962
+ <Radio
963
+ value="test"
964
+ label="Test Label Updated"
965
+ onOverflow={onOverflow}
966
+ />,
967
+ );
968
+
969
+ expect(onOverflow).toHaveBeenCalledWith({
970
+ vertical: true,
971
+ horizontal: true,
972
+ });
973
+ });
974
+
975
+ it("should not call onOverflow when content fits within container and state has not changed", () => {
976
+ const onOverflow = jest.fn();
977
+
978
+ const { container, rerender } = render(
979
+ <Radio value="test" label="Test Label" onOverflow={onOverflow} />,
980
+ );
981
+
982
+ const contentElement = container.querySelector(
983
+ ".mobius-radio__content",
984
+ ) as HTMLElement;
985
+
986
+ mockNoOverflow(contentElement);
987
+
988
+ // Trigger useLayoutEffect by re-rendering
989
+ rerender(
990
+ <Radio
991
+ value="test"
992
+ label="Test Label Updated"
993
+ onOverflow={onOverflow}
994
+ />,
995
+ );
996
+
997
+ // Should not be called because overflow state didn't change (was false, still false)
998
+ expect(onOverflow).not.toHaveBeenCalled();
999
+ });
1000
+
1001
+ it("should not call onOverflow when callback is not provided", () => {
1002
+ const { container } = render(<Radio value="test" label="Test Label" />);
1003
+
1004
+ const contentElement = container.querySelector(
1005
+ ".mobius-radio__content",
1006
+ ) as HTMLElement;
1007
+
1008
+ // Mock overflow
1009
+ Object.defineProperty(contentElement, "scrollHeight", {
1010
+ configurable: true,
1011
+ value: 100,
1012
+ });
1013
+ Object.defineProperty(contentElement, "clientHeight", {
1014
+ configurable: true,
1015
+ value: 50,
1016
+ });
1017
+
1018
+ // Should not throw
1019
+ expect(() => {
1020
+ render(<Radio value="test" label="Test Label Updated" />);
1021
+ }).not.toThrow();
1022
+ });
1023
+ });
1024
+
1025
+ describe("RadioGroup autoStack", () => {
1026
+ it("should change orientation from horizontal to vertical when overflow detected and autoStack is true", () => {
1027
+ const { container, rerender } = render(
1028
+ <RadioGroup label="Color" orientation="horizontal" autoStack>
1029
+ <Radio value="red">Red</Radio>
1030
+ <Radio value="blue">Blue</Radio>
1031
+ </RadioGroup>,
1032
+ );
1033
+
1034
+ // Initially should be horizontal
1035
+ expect(container.firstChild).toHaveClass("--is-horizontal");
1036
+ expect(container.firstChild).toHaveAttribute(
1037
+ "aria-orientation",
1038
+ "horizontal",
1039
+ );
1040
+
1041
+ const radioContents = container.querySelectorAll(
1042
+ ".mobius-radio__content",
1043
+ );
1044
+
1045
+ mockOverflow(radioContents[0]);
1046
+ mockNoOverflow(radioContents[1]);
1047
+
1048
+ // Trigger overflow detection by re-rendering
1049
+ rerender(
1050
+ <RadioGroup label="Color Updated" orientation="horizontal" autoStack>
1051
+ <Radio value="red">Red</Radio>
1052
+ <Radio value="blue">Blue</Radio>
1053
+ </RadioGroup>,
1054
+ );
1055
+
1056
+ // Should now be vertical
1057
+ expect(container.firstChild).toHaveClass("--is-vertical");
1058
+ expect(container.firstChild).toHaveAttribute(
1059
+ "aria-orientation",
1060
+ "vertical",
1061
+ );
1062
+ });
1063
+
1064
+ it("should not change orientation when autoStack is false", () => {
1065
+ const { container, rerender } = render(
1066
+ <RadioGroup label="Color" orientation="horizontal" autoStack={false}>
1067
+ <Radio value="red">Red</Radio>
1068
+ <Radio value="blue">Blue</Radio>
1069
+ </RadioGroup>,
1070
+ );
1071
+
1072
+ const radioContents = container.querySelectorAll(
1073
+ ".mobius-radio__content",
1074
+ );
1075
+
1076
+ mockOverflow(radioContents[0]);
1077
+
1078
+ // Trigger overflow detection
1079
+ rerender(
1080
+ <RadioGroup
1081
+ label="Color Updated"
1082
+ orientation="horizontal"
1083
+ autoStack={false}
1084
+ >
1085
+ <Radio value="red">Red</Radio>
1086
+ <Radio value="blue">Blue</Radio>
1087
+ </RadioGroup>,
1088
+ );
1089
+
1090
+ // Should still be horizontal
1091
+ expect(container.firstChild).toHaveClass("--is-horizontal");
1092
+ expect(container.firstChild).toHaveAttribute(
1093
+ "aria-orientation",
1094
+ "horizontal",
1095
+ );
1096
+ });
1097
+
1098
+ it("should not change orientation when initial orientation is vertical", () => {
1099
+ const { container, rerender } = render(
1100
+ <RadioGroup label="Color" orientation="vertical" autoStack>
1101
+ <Radio value="red">Red</Radio>
1102
+ <Radio value="blue">Blue</Radio>
1103
+ </RadioGroup>,
1104
+ );
1105
+
1106
+ const radioContents = container.querySelectorAll(
1107
+ ".mobius-radio__content",
1108
+ );
1109
+
1110
+ mockOverflow(radioContents[0]);
1111
+
1112
+ // Trigger overflow detection
1113
+ rerender(
1114
+ <RadioGroup label="Color Updated" orientation="vertical" autoStack>
1115
+ <Radio value="red">Red</Radio>
1116
+ <Radio value="blue">Blue</Radio>
1117
+ </RadioGroup>,
1118
+ );
1119
+
1120
+ // Should remain vertical (no change)
1121
+ expect(container.firstChild).toHaveClass("--is-vertical");
1122
+ expect(container.firstChild).toHaveAttribute(
1123
+ "aria-orientation",
1124
+ "vertical",
1125
+ );
1126
+ });
1127
+ });
1128
+ });
@@ -2,7 +2,14 @@
2
2
 
3
3
  import classNames from "classnames/dedupe";
4
4
  import type { ReactNode, Ref, RefAttributes } from "react";
5
- import { Children, forwardRef, isValidElement, useMemo } from "react";
5
+ import {
6
+ Children,
7
+ forwardRef,
8
+ isValidElement,
9
+ useLayoutEffect,
10
+ useMemo,
11
+ useRef,
12
+ } from "react";
6
13
  import type { ForwardedRefComponent } from "../../types/components";
7
14
  import type { DOMProps } from "../../types/dom";
8
15
  import type { HTMLElementEvent } from "../../types/events";
@@ -11,6 +18,11 @@ import { Label } from "../Label";
11
18
 
12
19
  export type RadioElementType = HTMLInputElement;
13
20
 
21
+ export type RadioOverflowInfo = {
22
+ vertical: boolean;
23
+ horizontal: boolean;
24
+ };
25
+
14
26
  export type AriaRadioProps = {
15
27
  /**
16
28
  * Defines a string value that labels the current element.
@@ -43,20 +55,18 @@ export interface RadioProps
43
55
  onChange?: (event: HTMLElementEvent<RadioElementType>) => void;
44
56
  defaultChecked?: boolean;
45
57
  /**
46
- * **Internal:** Do not use
58
+ * Callback fired when label overflow state changes.
59
+ * Only invoked when the Radio is in horizontal orientation to prevent infinite loops with autoStack.
60
+ * Provides information about vertical and horizontal overflow.
47
61
  */
62
+ onOverflow?: (overflow: RadioOverflowInfo) => void;
63
+ // Internal:** Do not use
48
64
  groupDisabled?: boolean;
49
- /**
50
- * **Internal:** Do not use
51
- */
65
+ // Internal:** Do not use
52
66
  name?: string;
53
- /**
54
- * **Internal:** Do not use
55
- */
67
+ // Internal:** Do not use
56
68
  selected?: string;
57
- /**
58
- * **Internal:** Do not use
59
- */
69
+ // Internal:** Do not use
60
70
  setSelected?: React.Dispatch<React.SetStateAction<string>>;
61
71
  isRequired?: boolean;
62
72
  }
@@ -79,6 +89,7 @@ const Radio: ForwardedRefComponent<RadioProps, RadioElementType> = forwardRef(
79
89
  selected,
80
90
  setSelected,
81
91
  isRequired,
92
+ onOverflow,
82
93
  ...otherProps
83
94
  } = props;
84
95
  const realDisabled = groupDisabled || isDisabled;
@@ -86,6 +97,19 @@ const Radio: ForwardedRefComponent<RadioProps, RadioElementType> = forwardRef(
86
97
  const isControlled = selected !== undefined;
87
98
  const isChecked = isControlled ? selected === value : defaultChecked;
88
99
 
100
+ const contentRef = useRef<HTMLDivElement>(null);
101
+ const prevOverflowRef = useRef<RadioOverflowInfo>({
102
+ vertical: false,
103
+ horizontal: false,
104
+ });
105
+
106
+ // Extract orientation from otherProps for overflow detection
107
+ // @ts-expect-error - orientation is passed via cloneElement
108
+ const currentOrientation = otherProps.orientation as
109
+ | "horizontal"
110
+ | "vertical"
111
+ | undefined;
112
+
89
113
  const hasIconFirst = useMemo(() => {
90
114
  if (!children || Children.count(children) === 0) return false;
91
115
 
@@ -96,6 +120,52 @@ const Radio: ForwardedRefComponent<RadioProps, RadioElementType> = forwardRef(
96
120
  return "icon" in props && props.icon !== undefined;
97
121
  }, [children]);
98
122
 
123
+ // Detect overflow and call callback
124
+ useLayoutEffect(() => {
125
+ if (!contentRef.current || !onOverflow) return;
126
+
127
+ // Only detect overflow when in horizontal orientation
128
+ // This prevents infinite loops when autoStack switches to vertical
129
+ if (currentOrientation === "vertical") {
130
+ return;
131
+ }
132
+
133
+ const element = contentRef.current;
134
+
135
+ // Check for content being cut off (true overflow)
136
+ const scrollOverflowVertical =
137
+ element.scrollHeight > element.clientHeight;
138
+ const scrollOverflowHorizontal =
139
+ element.scrollWidth > element.clientWidth;
140
+
141
+ // Check for multi-line text wrapping by comparing height to single line height
142
+ const styles = window.getComputedStyle(element);
143
+ const lineHeight = parseFloat(styles.lineHeight);
144
+ const fontSize = parseFloat(styles.fontSize);
145
+ const singleLineHeight = isNaN(lineHeight) ? fontSize * 1.2 : lineHeight;
146
+
147
+ // Tolerance multiplier to account for sub-pixel rendering and line-height variations.
148
+ // If element height is greater than single line, text has wrapped.
149
+ const WRAP_DETECTION_TOLERANCE = 1.1;
150
+ const hasWrapped =
151
+ element.clientHeight > singleLineHeight * WRAP_DETECTION_TOLERANCE;
152
+
153
+ const vertical = scrollOverflowVertical || hasWrapped;
154
+ const horizontal = scrollOverflowHorizontal;
155
+
156
+ const newOverflowState = { vertical, horizontal };
157
+ const prevOverflow = prevOverflowRef.current;
158
+
159
+ // Only call callback if state actually changed
160
+ if (
161
+ newOverflowState.vertical !== prevOverflow.vertical ||
162
+ newOverflowState.horizontal !== prevOverflow.horizontal
163
+ ) {
164
+ prevOverflowRef.current = newOverflowState;
165
+ onOverflow(newOverflowState);
166
+ }
167
+ }, [label, children, onOverflow, currentOrientation]);
168
+
99
169
  const radioClasses = {
100
170
  "--is-disabled": realDisabled,
101
171
  "--is-selected": selected === value,
@@ -157,12 +227,14 @@ const Radio: ForwardedRefComponent<RadioProps, RadioElementType> = forwardRef(
157
227
  {...rest}
158
228
  />
159
229
  {isMultiline ? (
160
- <div className="mobius-radio__content--multiline">
230
+ <div ref={contentRef} className="mobius-radio__content--multiline">
161
231
  <div className="mobius-radio__content-first-line">{label}</div>
162
232
  <div className="mobius-radio__extra-content">{children}</div>
163
233
  </div>
164
234
  ) : (
165
- <div className="mobius-radio__content">{label || children}</div>
235
+ <div ref={contentRef} className="mobius-radio__content">
236
+ {label || children}
237
+ </div>
166
238
  )}
167
239
  </Label>
168
240
  {errorMessage && <ErrorMessage errorMessage={errorMessage} />}