diff --git a/packages/ui/src/SegmentedControl/index.test.tsx b/packages/ui/src/SegmentedControl/index.test.tsx
index 78c00b91b8..138db897be 100644
--- a/packages/ui/src/SegmentedControl/index.test.tsx
+++ b/packages/ui/src/SegmentedControl/index.test.tsx
@@ -35,4 +35,46 @@ describe('', () => {
expect(onChange).toHaveBeenCalledWith('two');
});
+
+ describe('when the options have non-string values', () => {
+ const valueOne = { toString: () => 'one' };
+ const valueTwo = { toString: () => 'two' };
+ const valueThree = { toString: () => 'three' };
+
+ const options = [
+ { value: valueOne, label: 'One' },
+ { value: valueTwo, label: 'Two' },
+ { value: valueThree, label: 'Three' },
+ ];
+
+ it('calls the `onClick` handler with the value of the clicked option', () => {
+ const { getByText } = render(
+ ,
+ { wrapper: PenumbraUIProvider },
+ );
+ fireEvent.click(getByText('Two', { selector: ':not([aria-hidden])' }));
+
+ expect(onChange).toHaveBeenCalledWith(valueTwo);
+ });
+
+ describe("when the options' `.toString()` methods return non-unique values", () => {
+ const valueOne = { toString: () => 'one' };
+ const valueTwo = { toString: () => 'two' };
+ const valueTwoAgain = { toString: () => 'two' };
+
+ const options = [
+ { value: valueOne, label: 'One' },
+ { value: valueTwo, label: 'Two' },
+ { value: valueTwoAgain, label: 'Two again' },
+ ];
+
+ it('throws', () => {
+ expect(() =>
+ render(, {
+ wrapper: PenumbraUIProvider,
+ }),
+ ).toThrow('The value options passed to `` are not unique.');
+ });
+ });
+ });
});
diff --git a/packages/ui/src/SegmentedControl/index.tsx b/packages/ui/src/SegmentedControl/index.tsx
index 4b524b2803..da80c7fdd6 100644
--- a/packages/ui/src/SegmentedControl/index.tsx
+++ b/packages/ui/src/SegmentedControl/index.tsx
@@ -5,6 +5,8 @@ import { Density } from '../types/Density';
import { useDensity } from '../hooks/useDensity';
import * as RadixRadioGroup from '@radix-ui/react-radio-group';
import { useDisabled } from '../hooks/useDisabled';
+import { ToStringable } from '../utils/ToStringable';
+import { useEffect } from 'react';
const Root = styled.div`
display: flex;
@@ -34,17 +36,44 @@ const Segment = styled.button<{
padding-right: ${props => props.theme.spacing(props.$density === 'sparse' ? 4 : 2)};
`;
-export interface Option {
- value: string;
+/**
+ * Radix's `` component only accepts strings for its values, but
+ * we don't want to enforce that in ``. Instead, we allow
+ * options to be passed whose values extend `ToStringable` (i.e., they have a
+ * `.toString()` method). Then, when a specific option is selected and passed to
+ * `onChange()`, we need to map from the string value back to the original value
+ * passed in the options array.
+ *
+ * To make sure this works as expected, we need to assert that each option
+ * value's `.toString()` method returns a unique value. That way, we can avoid a
+ * situation where, e.g., all the options' values return `[object Object]`, and
+ * the wrong object is passed to `onChange`.
+ */
+const assertUniqueOptions = (options: Option[]) => {
+ const existingOptions = new Set();
+
+ options.forEach(option => {
+ if (existingOptions.has(option.value.toString())) {
+ throw new Error(
+ 'The value options passed to `` are not unique. Please check that the result of calling `.toString()` on each of the options passed to `` is unique.',
+ );
+ }
+
+ existingOptions.add(option.value.toString());
+ });
+};
+
+export interface Option {
+ value: ValueType;
label: string;
/** Whether this individual option should be disabled. */
disabled?: boolean;
}
-export interface SegmentedControlProps {
- value: string;
- onChange: (value: string) => void;
- options: Option[];
+export interface SegmentedControlProps {
+ value: ValueType;
+ onChange: (value: ValueType) => void;
+ options: Option[];
/**
* Whether this entire control should be disabled. Note that single options
* can be disabled individually by setting the `disabled` property for that
@@ -74,15 +103,31 @@ export interface SegmentedControlProps {
* />
* ```
*/
-export const SegmentedControl = ({ value, onChange, options, disabled }: SegmentedControlProps) => {
+export const SegmentedControl = ({
+ value,
+ onChange,
+ options,
+ disabled,
+}: SegmentedControlProps) => {
const density = useDensity();
disabled = useDisabled(disabled);
+ useEffect(() => assertUniqueOptions(options), [options]);
+
+ const handleChange = (value: string) => {
+ const matchingOption = options.find(option => option.value.toString() === value)!;
+ onChange(matchingOption.value);
+ };
+
return (
-
+
{options.map(option => (
-
+
onChange(option.value)}
$getBorderRadius={theme => theme.borderRadius.full}
diff --git a/packages/ui/src/utils/ToStringable.ts b/packages/ui/src/utils/ToStringable.ts
new file mode 100644
index 0000000000..e95788492c
--- /dev/null
+++ b/packages/ui/src/utils/ToStringable.ts
@@ -0,0 +1,9 @@
+/**
+ * Utility interface to represent types that can be cast to string. Useful for
+ * e.g., accepting an array of `.toString()`-able items will be mapped over, so
+ * that the items can have `.toString()` called on them for the React `key`
+ * prop.
+ */
+export interface ToStringable {
+ toString: () => string;
+}