Skip to content

Commit 635693b

Browse files
Introduce Segment Picker (#107)
* Introduce Segment Picker * new variables * Improvements * lint fix * Add forwardRef and css layers * add event object to onChange * Add segment picker item docs * Introduce shape validation
1 parent 0e5a91e commit 635693b

File tree

24 files changed

+677
-8
lines changed

24 files changed

+677
-8
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
:root {
2+
--ax-public-segment-picker-border-radius-extra-large: var(
3+
--ax-token-radius-seg-picker-xl
4+
);
5+
--ax-public-segment-picker-border-radius-large: var(
6+
--ax-token-radius-seg-picker-l
7+
);
8+
--ax-public-segment-picker-border-radius-medium: var(
9+
--ax-token-radius-seg-picker-m
10+
);
11+
--ax-public-segment-picker-border-radius-small: var(
12+
--ax-token-radius-seg-picker-s
13+
);
14+
--ax-public-segment-picker-border-radius-extra-small: var(
15+
--ax-token-radius-seg-picker-xs
16+
);
17+
--ax-public-segment-picker-border-radius-xx-small: var(
18+
--ax-token-radius-seg-picker-xxs
19+
);
20+
--ax-public-segment-picker-border-radius-xxx-small: var(
21+
--ax-token-radius-seg-picker-xxxs
22+
);
23+
}
24+
25+
@layer ui.component {
26+
.extra-large {
27+
border-radius: var(--ax-public-segment-picker-border-radius-extra-large);
28+
}
29+
30+
.large {
31+
border-radius: var(--ax-public-segment-picker-border-radius-large);
32+
}
33+
34+
.medium {
35+
border-radius: var(--ax-public-segment-picker-border-radius-medium);
36+
}
37+
38+
.small {
39+
border-radius: var(--ax-public-segment-picker-border-radius-small);
40+
}
41+
42+
.extra-small {
43+
border-radius: var(--ax-public-segment-picker-border-radius-extra-small);
44+
}
45+
46+
.xx-small {
47+
border-radius: var(--ax-public-segment-picker-border-radius-xx-small);
48+
}
49+
50+
.xxx-small {
51+
border-radius: var(--ax-public-segment-picker-border-radius-xxx-small);
52+
}
53+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
@layer ui.component {
2+
.item:not(.circle),
3+
div:has(.item:not(.circle)) {
4+
width: 100%;
5+
}
6+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import clsx from 'clsx';
2+
import itemShapeStyles from './segment-picker-item-shape.module.css';
3+
4+
import { NavLabelButtonProps } from '@ui/components/button/nav-button/nav-label-button/nav-label-button';
5+
import { NavIconButtonProps } from '@ui/components/button/nav-button/nav-icon-button/nav-icon-button';
6+
import { NavIconLabelButtonProps } from '@ui/components/button/nav-button/nav-icon-label-button/nav-icon-label-button';
7+
import {
8+
hasStringChildrenOnly,
9+
hasIconChildrenOnly,
10+
hasChildrenWithStringAndIcons,
11+
} from '@ui/components/button/guards';
12+
import { NavButton } from '@ui/components/button/nav-button/nav-button';
13+
import { MouseEventHandler, useContext } from 'react';
14+
import { BaseButtonProps } from '../../button/types';
15+
import { SegmentPickerContext } from '../utils/context';
16+
17+
export type SegmentPickerItemProps = BaseButtonProps & {
18+
value: string;
19+
} & (
20+
| Pick<NavLabelButtonProps, 'children'>
21+
| Pick<NavIconButtonProps, 'children'>
22+
| Pick<NavIconLabelButtonProps, 'children'>
23+
);
24+
25+
/**
26+
* A single item in the SegmentPicker, rendered as a NavButton under the hood.
27+
*
28+
* Automatically receives size and shape from SegmentPicker context.
29+
* Must be used only within a SegmentPicker component.
30+
*
31+
* Determines which NavButton variant to render based on its children
32+
* (label only, icon only, or icon + label).
33+
*/
34+
export function Item({
35+
children,
36+
value,
37+
...buttonProps
38+
}: SegmentPickerItemProps) {
39+
const context = useContext(SegmentPickerContext);
40+
41+
if (!context) {
42+
throw new Error('SegmentPicker.Item must be used within a SegmentPicker');
43+
}
44+
45+
const { selectedValue, onSelect, shape, ...other } = context;
46+
47+
const props = {
48+
className: clsx(itemShapeStyles['item'], itemShapeStyles[shape || '']),
49+
isSelected: selectedValue === value,
50+
onClick: (event: MouseEventHandler<HTMLButtonElement>) =>
51+
onSelect(event, value),
52+
shape,
53+
children,
54+
...other,
55+
...buttonProps,
56+
};
57+
58+
if (hasStringChildrenOnly<NavLabelButtonProps>(props)) {
59+
return <NavButton {...props} />;
60+
}
61+
62+
if (hasIconChildrenOnly<NavIconButtonProps>(props)) {
63+
return <NavButton {...props} />;
64+
}
65+
66+
if (hasChildrenWithStringAndIcons<NavIconLabelButtonProps>(props)) {
67+
return <NavButton {...props} />;
68+
}
69+
70+
return null;
71+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
:root {
2+
--ax-public-segment-picker-gap: var(--ax-token-spacing-seg-picker-gap);
3+
--ax-public-segment-picker-padding: var(--ax-token-spacing-seg-picker-pad);
4+
--ax-public-segment-picker-background-color: var(--ax-ui-bg-tertiary-default);
5+
--ax-public-segment-picker-circle-border-radius: var(
6+
--ax-token-radius-button-round
7+
);
8+
}
9+
10+
@layer ui.component {
11+
.container {
12+
display: flex;
13+
gap: var(--ax-public-segment-picker-gap);
14+
padding: var(--ax-public-segment-picker-padding);
15+
16+
background-color: var(--ax-public-segment-picker-background-color);
17+
18+
&.circle {
19+
border-radius: var(--ax-public-segment-picker-circle-border-radius);
20+
width: fit-content;
21+
}
22+
23+
&:not(.circle) {
24+
width: 100%;
25+
}
26+
}
27+
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import clsx from 'clsx';
2+
import styles from './segment-picker.module.css';
3+
import borderRadiusStyles from './border-radius-size.module.css';
4+
5+
import {
6+
useState,
7+
ReactElement,
8+
forwardRef,
9+
ForwardRefExoticComponent,
10+
MouseEventHandler,
11+
} from 'react';
12+
import { Size } from '@ui/shared/types/size';
13+
import { Shape } from '@ui/components/button/types';
14+
import { SegmentPickerItemProps, Item } from './item/segment-picker-item';
15+
import { getValidShape } from './utils/get-valid-shape';
16+
import { SegmentPickerContext } from './utils/context';
17+
18+
type SegmentPickerProps = {
19+
children: ReactElement<SegmentPickerItemProps, typeof Item>[];
20+
value: string;
21+
size?: Size;
22+
/**
23+
* Controls the shape of the SegmentPicker and its items.
24+
* (default) -Items stretch to fill the container equally.
25+
* 'circle' - Items fit tightly around their content to maintain a circular shape.
26+
* Only supported when items contain icons only.
27+
*/
28+
shape?: Shape;
29+
className?: string;
30+
onChange?: (
31+
event: MouseEventHandler<HTMLButtonElement>,
32+
value: string,
33+
) => void;
34+
};
35+
36+
type SegmentPickerComponent = ForwardRefExoticComponent<
37+
SegmentPickerProps & React.RefAttributes<HTMLDivElement>
38+
> & {
39+
Item: typeof Item;
40+
};
41+
42+
export const SegmentPicker = forwardRef<HTMLDivElement, SegmentPickerProps>(
43+
(
44+
{ children, value, size = 'medium', shape = '', className, onChange },
45+
ref,
46+
) => {
47+
const validShape = getValidShape(shape, children);
48+
const [selectedValue, setSelectedValue] = useState<string | null>(
49+
value || null,
50+
);
51+
52+
const handleSelect = (
53+
event: React.MouseEventHandler<HTMLButtonElement>,
54+
newValue: string,
55+
) => {
56+
setSelectedValue(newValue);
57+
onChange?.(event, newValue);
58+
};
59+
60+
return (
61+
<SegmentPickerContext.Provider
62+
value={{
63+
selectedValue,
64+
onSelect: handleSelect,
65+
size,
66+
shape: validShape,
67+
}}
68+
>
69+
<div
70+
ref={ref}
71+
className={clsx(
72+
styles['container'],
73+
styles[validShape],
74+
borderRadiusStyles[size],
75+
className,
76+
)}
77+
>
78+
{children}
79+
</div>
80+
</SegmentPickerContext.Provider>
81+
);
82+
},
83+
) as SegmentPickerComponent;
84+
85+
SegmentPicker.Item = Item;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { createContext, MouseEventHandler } from 'react';
2+
import { Shape } from '@ui/components/button/types';
3+
import { Size } from '@ui/shared/types/size';
4+
5+
type SegmentPickerContextType = {
6+
selectedValue: string | null;
7+
onSelect: (
8+
event: MouseEventHandler<HTMLButtonElement>,
9+
value: string,
10+
) => void;
11+
size?: Size;
12+
shape?: Shape;
13+
};
14+
15+
export const SegmentPickerContext =
16+
createContext<SegmentPickerContextType | null>(null);
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { hasIconChildrenOnly } from '../../button/guards';
2+
import { Shape } from '@ui/components/button/types';
3+
import { ReactElement } from 'react';
4+
import { Item, SegmentPickerItemProps } from '../item/segment-picker-item';
5+
6+
export function getValidShape(
7+
shape: Shape,
8+
items: ReactElement<SegmentPickerItemProps, typeof Item>[],
9+
): Shape {
10+
if (shape !== 'circle') {
11+
return shape;
12+
}
13+
14+
const everyItemHasOnlyIcon = items.every((item) =>
15+
hasIconChildrenOnly({ children: item.props.children }),
16+
);
17+
18+
if (!everyItemHasOnlyIcon) {
19+
console.warn(
20+
'[SegmentPicker] The "circle" shape can only be used when all SegmentPicker.Item components contain icon-only children.',
21+
);
22+
return '';
23+
}
24+
25+
return shape;
26+
}

packages/ui/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ export * from './components/separator/separator';
4949

5050
export * from './components/status/status';
5151

52+
export * from './components/segment-picker/segment-picker';
53+
5254
// Types
5355
export * from './components/button/regular-button/types';
5456
export * from './components/date-picker/types';

packages/ui/src/shared/types/size.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,5 @@ export const SIZES = [
66
'medium',
77
'large',
88
'extra-large',
9-
'xx-large',
109
] as const;
1110
export type Size = (typeof SIZES)[number];
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { ComponentPage } from '@site/src/components/component-utils/component-page/component-page';
2+
import exampleCode from '!!raw-loader!@site/docs/code-examples/segment-picker.example.tsx';
3+
import { ComponentProp, toPropMap } from '@site/docs/utils/to-prop-map';
4+
import { SIZES } from '@synergycodes/axiom';
5+
6+
export function SegmentPickerDocs() {
7+
const props: Record<string, ComponentProp> = {
8+
size: {
9+
defaultValue: 'medium',
10+
unionValues: SIZES,
11+
},
12+
children: {
13+
required: true,
14+
unionValues: ['ReactElement<SegmentPickerItemProps>[]'],
15+
},
16+
onChange: {
17+
unionValues: [
18+
'(event: MouseEventHandler<HTMLButtonElement>, value: string) => void',
19+
],
20+
},
21+
};
22+
23+
return (
24+
<ComponentPage
25+
cssPaths={[
26+
'components/segment-picker/segment-picker.module.css',
27+
'components/segment-picker/border-radius-size.module.css',
28+
'components/segment-picker/item/segment-picker-item-shape.module.css',
29+
]}
30+
componentPath="components/segment-picker/segment-picker.tsx"
31+
exampleCode={exampleCode}
32+
hardcodedData={{
33+
props: toPropMap(props),
34+
}}
35+
/>
36+
);
37+
}

0 commit comments

Comments
 (0)