@varialkit/segmentcontrol 0.1.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.
package/docs.md ADDED
@@ -0,0 +1,88 @@
1
+ # SegmentControl
2
+
3
+ SegmentControl lets users switch between related views or data sets with a single, compact control.
4
+
5
+ ## Usage
6
+
7
+ ```tsx
8
+ import { SegmentControl } from "@solara/segmentcontrol";
9
+
10
+ export function ViewSwitcher() {
11
+ const [value, setValue] = React.useState("overview");
12
+
13
+ return (
14
+ <SegmentControl value={value} onChange={setValue} size="medium">
15
+ <SegmentControl.Segment value="overview">Overview</SegmentControl.Segment>
16
+ <SegmentControl.Segment value="activity">Activity</SegmentControl.Segment>
17
+ <SegmentControl.Segment value="settings">Settings</SegmentControl.Segment>
18
+ </SegmentControl>
19
+ );
20
+ }
21
+ ```
22
+
23
+ ## Notes
24
+
25
+ - Provide an `ariaLabel` when using icon-only segments.
26
+ - Use `fullWidth` when the control should span its container.
27
+ - Prefer 2-5 segments to keep labels readable.
28
+ - By default, segments size to their content for stable label alignment; `fullWidth` stretches them evenly.
29
+
30
+ ## Segment Props
31
+
32
+ Each `SegmentControl.Segment` accepts the following props:
33
+
34
+ | Prop | Type | Description |
35
+ | --- | --- | --- |
36
+ | `value` | `string` | Segment value used for selection. |
37
+ | `icon` | `ReactNode` | Optional leading icon rendered before the label. |
38
+ | `ariaLabel` | `string` | Required when the segment has no visible label. |
39
+ | `disabled` | `boolean` | Disable the segment. |
40
+
41
+ ### Icons
42
+
43
+ Use the `icon` prop with an icon name or full `IconProps`. The SegmentControl renders
44
+ the `Icon` component internally for consistent sizing and theming. Icons inherit the
45
+ segment text color via `currentColor`.
46
+
47
+ ```tsx
48
+ <SegmentControl value={value} onChange={setValue}>
49
+ <SegmentControl.Segment value="overview" icon="data_spreadsheet_search_24">
50
+ Overview
51
+ </SegmentControl.Segment>
52
+ <SegmentControl.Segment value="activity" icon="arrow_line_up_16">
53
+ Activity
54
+ </SegmentControl.Segment>
55
+ </SegmentControl>
56
+ ```
57
+
58
+ ### Icon-Only Segments
59
+
60
+ Provide `ariaLabel` and pass an `Icon` as the segment content.
61
+
62
+ ```tsx
63
+ <SegmentControl value={value} onChange={setValue}>
64
+ <SegmentControl.Segment value="overview" ariaLabel="Overview" icon="data_spreadsheet_search_24" />
65
+ <SegmentControl.Segment value="activity" ariaLabel="Activity" icon="arrow_line_up_16" />
66
+ <SegmentControl.Segment value="settings" ariaLabel="Settings" icon="arrow_swap_16" />
67
+ </SegmentControl>
68
+ ```
69
+
70
+ ## Moving Background
71
+
72
+ Set `movingBackground` to render a single animated background that follows hover/focus and then returns to the active segment.
73
+
74
+ - The background is positioned and sized to the hovered segment.
75
+ - On pointer leave or blur, it aligns to the active value.
76
+ - The indicator respects responsive sizing and density changes.
77
+ - Hover and keyboard focus behave the same way for accessibility parity.
78
+ - The active segment still drives selection state and `onChange` behavior.
79
+
80
+ ### How It Works
81
+
82
+ `SegmentControl` measures the hovered (or focused) segment and writes its position and size into CSS
83
+ variables. A single indicator element uses those variables to animate between segments, keeping the
84
+ DOM light and avoiding per-segment background transitions.
85
+
86
+ - A `ResizeObserver` updates the indicator when segment sizes change.
87
+ - When no segment is hovered, the indicator re-centers on the active value.
88
+ - If `movingBackground` is `false`, segments use per-button hover and active backgrounds.
package/examples.tsx ADDED
@@ -0,0 +1,218 @@
1
+ import React from "react";
2
+ import { iconNames } from "@solara/icons";
3
+ import type { SolaraIconName } from "@solara/icons";
4
+ import { SegmentControl } from "./src/SegmentControl";
5
+ import type { SegmentControlSize } from "./src/SegmentControl.types";
6
+
7
+ type PlaygroundProps = {
8
+ size: SegmentControlSize;
9
+ fullWidth: boolean;
10
+ withIcons: boolean;
11
+ movingBackground: boolean;
12
+ disabledValue: "none" | "overview" | "activity" | "settings";
13
+ iconName: SolaraIconName;
14
+ };
15
+
16
+ const SegmentControlPlayground = ({
17
+ size,
18
+ fullWidth,
19
+ withIcons,
20
+ movingBackground,
21
+ disabledValue,
22
+ iconName,
23
+ }: PlaygroundProps) => {
24
+ const [value, setValue] = React.useState("overview");
25
+
26
+ React.useEffect(() => {
27
+ if (disabledValue === "none" || disabledValue !== value) return;
28
+ const fallback = ["overview", "activity", "settings"].find(
29
+ (option) => option !== disabledValue
30
+ );
31
+ if (fallback) setValue(fallback);
32
+ }, [disabledValue, value]);
33
+
34
+ const segments = [
35
+ { value: "overview", label: "Overview" },
36
+ { value: "activity", label: "Activity" },
37
+ { value: "settings", label: "Settings" },
38
+ ];
39
+
40
+ return (
41
+ <SegmentControl
42
+ value={value}
43
+ onChange={setValue}
44
+ size={size}
45
+ fullWidth={fullWidth}
46
+ movingBackground={movingBackground}
47
+ >
48
+ {segments.map((segment) => (
49
+ <SegmentControl.Segment
50
+ key={segment.value}
51
+ value={segment.value}
52
+ disabled={disabledValue === segment.value}
53
+ icon={withIcons ? iconName : undefined}
54
+ >
55
+ {segment.label}
56
+ </SegmentControl.Segment>
57
+ ))}
58
+ </SegmentControl>
59
+ );
60
+ };
61
+
62
+ export const stories = {
63
+ playground: {
64
+ title: "Playground",
65
+ description: "Explore size, width, and disabled segment states.",
66
+ render: (props: PlaygroundProps) => <SegmentControlPlayground {...props} />,
67
+ controls: [
68
+ {
69
+ name: "size",
70
+ type: "select",
71
+ options: ["small", "medium", "large"],
72
+ },
73
+ {
74
+ name: "fullWidth",
75
+ type: "boolean",
76
+ label: "Full Width",
77
+ },
78
+ {
79
+ name: "withIcons",
80
+ type: "boolean",
81
+ label: "With Icons",
82
+ },
83
+ {
84
+ name: "movingBackground",
85
+ type: "boolean",
86
+ label: "Moving Background",
87
+ },
88
+ {
89
+ name: "disabledValue",
90
+ type: "select",
91
+ options: ["none", "overview", "activity", "settings"],
92
+ label: "Disabled Segment",
93
+ },
94
+ {
95
+ name: "iconName",
96
+ type: "select",
97
+ options: iconNames,
98
+ label: "Icon Name",
99
+ },
100
+ ],
101
+ initialProps: {
102
+ size: "medium",
103
+ fullWidth: false,
104
+ withIcons: false,
105
+ movingBackground: false,
106
+ disabledValue: "none",
107
+ iconName: (iconNames[0] ?? "data_spreadsheet_search_24") as SolaraIconName,
108
+ },
109
+ },
110
+ sizes: {
111
+ title: "Sizes",
112
+ description: "Small, medium, and large segment controls.",
113
+ showProps: false,
114
+ render: () => (
115
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
116
+ <SegmentControl value="overview" onChange={() => undefined} size="small">
117
+ <SegmentControl.Segment value="overview">Overview</SegmentControl.Segment>
118
+ <SegmentControl.Segment value="activity">Activity</SegmentControl.Segment>
119
+ <SegmentControl.Segment value="settings">Settings</SegmentControl.Segment>
120
+ </SegmentControl>
121
+ <SegmentControl value="overview" onChange={() => undefined} size="medium">
122
+ <SegmentControl.Segment value="overview">Overview</SegmentControl.Segment>
123
+ <SegmentControl.Segment value="activity">Activity</SegmentControl.Segment>
124
+ <SegmentControl.Segment value="settings">Settings</SegmentControl.Segment>
125
+ </SegmentControl>
126
+ <SegmentControl value="overview" onChange={() => undefined} size="large">
127
+ <SegmentControl.Segment value="overview">Overview</SegmentControl.Segment>
128
+ <SegmentControl.Segment value="activity">Activity</SegmentControl.Segment>
129
+ <SegmentControl.Segment value="settings">Settings</SegmentControl.Segment>
130
+ </SegmentControl>
131
+ </div>
132
+ ),
133
+ code: `import { SegmentControl } from "@solara/segmentcontrol";
134
+
135
+ export function Example() {
136
+ return (
137
+ <div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
138
+ <SegmentControl value="overview" onChange={() => undefined} size="small">
139
+ <SegmentControl.Segment value="overview">Overview</SegmentControl.Segment>
140
+ <SegmentControl.Segment value="activity">Activity</SegmentControl.Segment>
141
+ <SegmentControl.Segment value="settings">Settings</SegmentControl.Segment>
142
+ </SegmentControl>
143
+ <SegmentControl value="overview" onChange={() => undefined} size="medium">
144
+ <SegmentControl.Segment value="overview">Overview</SegmentControl.Segment>
145
+ <SegmentControl.Segment value="activity">Activity</SegmentControl.Segment>
146
+ <SegmentControl.Segment value="settings">Settings</SegmentControl.Segment>
147
+ </SegmentControl>
148
+ <SegmentControl value="overview" onChange={() => undefined} size="large">
149
+ <SegmentControl.Segment value="overview">Overview</SegmentControl.Segment>
150
+ <SegmentControl.Segment value="activity">Activity</SegmentControl.Segment>
151
+ <SegmentControl.Segment value="settings">Settings</SegmentControl.Segment>
152
+ </SegmentControl>
153
+ </div>
154
+ );
155
+ }
156
+ `,
157
+ },
158
+ iconOnly: {
159
+ title: "Icon Only",
160
+ description: "Provide aria-labels when using icons without text.",
161
+ showProps: false,
162
+ render: () => (
163
+ <SegmentControl value="overview" onChange={() => undefined}>
164
+ <SegmentControl.Segment value="overview" ariaLabel="Overview" icon="data_spreadsheet_search_24" />
165
+ <SegmentControl.Segment value="activity" ariaLabel="Activity" icon="arrow_line_up_16" />
166
+ <SegmentControl.Segment value="settings" ariaLabel="Settings" icon="arrow_swap_16" />
167
+ </SegmentControl>
168
+ ),
169
+ code: `import { SegmentControl } from "@solara/segmentcontrol";
170
+
171
+ export function Example() {
172
+ return (
173
+ <SegmentControl value="overview" onChange={() => undefined}>
174
+ <SegmentControl.Segment value="overview" ariaLabel="Overview" icon="data_spreadsheet_search_24" />
175
+ <SegmentControl.Segment value="activity" ariaLabel="Activity" icon="arrow_line_up_16" />
176
+ <SegmentControl.Segment value="settings" ariaLabel="Settings" icon="arrow_swap_16" />
177
+ </SegmentControl>
178
+ );
179
+ }
180
+ `,
181
+ },
182
+ icons: {
183
+ title: "Icons",
184
+ description: "Use leading icons alongside labels.",
185
+ showProps: false,
186
+ render: () => (
187
+ <SegmentControl value="overview" onChange={() => undefined}>
188
+ <SegmentControl.Segment value="overview" icon="data_spreadsheet_search_24">
189
+ Overview
190
+ </SegmentControl.Segment>
191
+ <SegmentControl.Segment value="activity" icon="arrow_line_up_16">
192
+ Activity
193
+ </SegmentControl.Segment>
194
+ <SegmentControl.Segment value="settings" icon="arrow_swap_16">
195
+ Settings
196
+ </SegmentControl.Segment>
197
+ </SegmentControl>
198
+ ),
199
+ code: `import { SegmentControl } from "@solara/segmentcontrol";
200
+
201
+ export function Example() {
202
+ return (
203
+ <SegmentControl value="overview" onChange={() => undefined}>
204
+ <SegmentControl.Segment value="overview" icon="data_spreadsheet_search_24">
205
+ Overview
206
+ </SegmentControl.Segment>
207
+ <SegmentControl.Segment value="activity" icon="arrow_line_up_16">
208
+ Activity
209
+ </SegmentControl.Segment>
210
+ <SegmentControl.Segment value="settings" icon="arrow_swap_16">
211
+ Settings
212
+ </SegmentControl.Segment>
213
+ </SegmentControl>
214
+ );
215
+ }
216
+ `,
217
+ },
218
+ };
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@varialkit/segmentcontrol",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "types": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./examples": "./examples.tsx"
10
+ },
11
+ "dependencies": {
12
+ "@varialkit/icons": "0.1.0"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "docs.md",
17
+ "examples.tsx"
18
+ ],
19
+ "peerDependencies": {
20
+ "react": "^19.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/react": "19.0.10",
24
+ "react": "19.0.0"
25
+ }
26
+ }
@@ -0,0 +1,213 @@
1
+ .solara-segment-control {
2
+ --segment-control-bg: var(--color-surface-100);
3
+ --segment-control-surface-color: var(--color-surface-100);
4
+ --segment-control-surface-color-rgb: var(--color-surface-100-rgb);
5
+ --surface-border-color: var(--color-divider-secondary);
6
+ --surface-border-color-rgb: var(--color-divider-secondary-rgb);
7
+ --surface-opacity: 1;
8
+ --surface-blur: 0px;
9
+ --surface-shadow: var(--elevation-1);
10
+ --segment-control-text: var(--color-text-secondary);
11
+ --segment-control-text-hover: var(--color-text-primary);
12
+ --segment-control-text-active: var(--color-text-primary);
13
+ --segment-control-text-disabled: var(--color-text-secondary);
14
+ --segment-control-bg-active: var(--color-surface-0);
15
+ --segment-control-bg-hover: var(--color-surface-200);
16
+ --segment-control-bg-indicator: var(--segment-control-bg-active);
17
+ --segment-control-shadow: var(--elevation-1);
18
+ --segment-padding-y: var(--space-2);
19
+ --segment-padding-x: var(--space-4);
20
+ --segment-font-size: var(--font-size-body-scaled);
21
+ --segment-line-height: var(--line-height-body-scaled);
22
+ --segment-icon-size: 16px;
23
+ --segment-gap: var(--space-2);
24
+ --segment-height: var(--space-9);
25
+
26
+ position: relative;
27
+ display: inline-flex;
28
+ align-items: center;
29
+ gap: calc(var(--space-1) * var(--spacing-multiplier));
30
+ border-radius: var(--radius-pill);
31
+ background-color: rgba(var(--segment-control-surface-color-rgb), var(--surface-opacity));
32
+ padding: calc(var(--space-1) * var(--spacing-multiplier));
33
+ box-shadow: transparent;
34
+ border: 1px solid var(--surface-border-color);
35
+ width: auto;
36
+ height: calc(var(--segment-height) * var(--spacing-multiplier));
37
+ font-family: var(--font-body);
38
+ backdrop-filter: blur(var(--surface-blur));
39
+ }
40
+
41
+ .solara-segment-control__indicator {
42
+ // The moving background is positioned via CSS variables set in JS.
43
+ position: absolute;
44
+ left: 0;
45
+ top: 0;
46
+ width: var(--segment-indicator-width, 0px);
47
+ height: var(--segment-indicator-height, 0px);
48
+ transform: translate(
49
+ var(--segment-indicator-left, 0px),
50
+ var(--segment-indicator-top, 0px)
51
+ );
52
+ background-color: var(--segment-control-bg-indicator);
53
+ border-radius: var(--radius-pill);
54
+ box-shadow: var(--segment-control-shadow);
55
+ opacity: var(--segment-indicator-opacity, 0);
56
+ transition: transform 0.2s ease, width 0.2s ease, height 0.2s ease, opacity 0.2s ease;
57
+ pointer-events: none;
58
+ z-index: 0;
59
+ }
60
+
61
+ .solara-segment-control--moving-bg {
62
+ .solara-segment-control__segment {
63
+ background-color: transparent;
64
+ box-shadow: none;
65
+ position: relative;
66
+ z-index: 1;
67
+ }
68
+
69
+ .solara-segment-control__segment:not(:disabled):hover {
70
+ background-color: transparent;
71
+ }
72
+
73
+ .solara-segment-control__segment[data-state="active"] {
74
+ background-color: transparent;
75
+ box-shadow: none;
76
+ }
77
+ }
78
+
79
+ .solara-segment-control--full-width {
80
+ // Full width keeps the control stretched evenly across its container.
81
+ width: 100%;
82
+ }
83
+
84
+ .solara-segment-control--size-small {
85
+ --segment-padding-y: var(--space-1);
86
+ --segment-padding-x: var(--space-3);
87
+ --segment-font-size: var(--font-size-caption-scaled);
88
+ --segment-line-height: var(--line-height-caption-scaled);
89
+ --segment-icon-size: 12px;
90
+ --segment-height: var(--space-7);
91
+ }
92
+
93
+ .solara-segment-control--size-large {
94
+ --segment-padding-y: var(--space-2);
95
+ --segment-padding-x: var(--space-5);
96
+ --segment-font-size: var(--font-size-h6-scaled);
97
+ --segment-line-height: var(--line-height-h6-scaled);
98
+ --segment-icon-size: 20px;
99
+ --segment-height: var(--space-10);
100
+ }
101
+
102
+ .solara-segment-control__segment {
103
+ position: relative;
104
+ display: inline-flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+ gap: calc(var(--segment-gap) * var(--spacing-multiplier));
108
+ padding: calc(var(--segment-padding-y) * var(--spacing-multiplier))
109
+ calc(var(--segment-padding-x) * var(--spacing-multiplier));
110
+ margin: 0;
111
+ font-size: var(--segment-font-size);
112
+ font-weight: 500;
113
+ line-height: var(--segment-line-height);
114
+ color: var(--segment-control-text);
115
+ background-color: transparent;
116
+ border: none;
117
+ border-radius: var(--radius-pill);
118
+ cursor: pointer;
119
+ transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease;
120
+ white-space: nowrap;
121
+ height: 100%;
122
+ // Default to content-sized segments for stable label alignment.
123
+ flex: 0 0 auto;
124
+ min-width: 0;
125
+ }
126
+
127
+ .solara-segment-control--full-width {
128
+ .solara-segment-control__segment {
129
+ // Override content sizing when full width is requested.
130
+ flex: 1 1 0%;
131
+ }
132
+ }
133
+
134
+ .solara-segment-control__segment:focus-visible {
135
+ outline: none;
136
+ box-shadow: 0 0 0 3px var(--color-focus-halo);
137
+ z-index: 1;
138
+ }
139
+
140
+ .solara-segment-control__segment:not(:disabled):hover {
141
+ color: var(--segment-control-text-hover);
142
+ background-color: var(--segment-control-bg-hover);
143
+ }
144
+
145
+ .solara-segment-control__segment[data-state="active"] {
146
+ color: var(--segment-control-text-active);
147
+ background-color: var(--segment-control-bg-active);
148
+ box-shadow: var(--elevation-1);
149
+ font-weight: 600;
150
+ }
151
+
152
+ .solara-segment-control__segment:disabled,
153
+ .solara-segment-control__segment[data-disabled="true"] {
154
+ color: var(--segment-control-text-disabled);
155
+ cursor: not-allowed;
156
+ opacity: 0.6;
157
+ }
158
+
159
+ .solara-segment-control__icon {
160
+ display: inline-flex;
161
+ align-items: center;
162
+ justify-content: center;
163
+ width: var(--segment-icon-size);
164
+ height: var(--segment-icon-size);
165
+ flex-shrink: 0;
166
+
167
+ .solara-icon,
168
+ > svg {
169
+ width: 100%;
170
+ height: 100%;
171
+ }
172
+ }
173
+
174
+ .solara-segment-control__icon .solara-icon [stroke]:not([stroke="none"]) {
175
+ stroke: currentColor;
176
+ }
177
+
178
+ .solara-segment-control__icon .solara-icon [fill]:not([fill="none"]) {
179
+ fill: currentColor;
180
+ }
181
+
182
+ .solara-segment-control__label {
183
+ display: inline-flex;
184
+ align-items: center;
185
+ min-width: 0;
186
+ }
187
+
188
+ :root[data-surface-default="translucent"] .solara-segment-control,
189
+ :root[data-surface-segment-control="translucent"] .solara-segment-control,
190
+ .solara-segment-control[data-surface-style="translucent"] {
191
+ --surface-opacity: var(--opacity-translucent-medium);
192
+ --surface-blur: 0px;
193
+ }
194
+
195
+ :root[data-surface-default="glass"] .solara-segment-control,
196
+ :root[data-surface-segment-control="glass"] .solara-segment-control,
197
+ .solara-segment-control[data-surface-style="glass"] {
198
+ --surface-opacity: var(--opacity-translucent-heavy);
199
+ --surface-blur: var(--blur-medium);
200
+ --surface-border-color: rgba(var(--surface-border-color-rgb), var(--surface-border-alpha-glass));
201
+ }
202
+
203
+ .solara-segment-control[data-surface-style="solid"] {
204
+ --surface-opacity: 1;
205
+ --surface-blur: 0px;
206
+ --surface-border-color: var(--color-divider-secondary);
207
+ }
208
+
209
+ :root[data-surface-segment-control="solid"] .solara-segment-control {
210
+ --surface-opacity: 1;
211
+ --surface-blur: 0px;
212
+ --surface-border-color: var(--color-divider-secondary);
213
+ }
@@ -0,0 +1,297 @@
1
+ import React, { Children, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Icon } from "@solara/icons";
3
+ import type { IconProps } from "@solara/icons";
4
+ import type { SegmentControlProps, SegmentControlSize, SegmentProps } from "./SegmentControl.types";
5
+ import "./SegmentControl.scss";
6
+
7
+ const sizeAliasMap: Record<SegmentControlSize, "small" | "medium" | "large"> = {
8
+ sm: "small",
9
+ md: "medium",
10
+ lg: "large",
11
+ small: "small",
12
+ medium: "medium",
13
+ large: "large",
14
+ };
15
+
16
+ const classNames = (...classes: Array<string | undefined | false | null>) =>
17
+ classes.filter(Boolean).join(" ");
18
+
19
+ const normalizeIconProps = (icon: NonNullable<SegmentProps["icon"]>): IconProps =>
20
+ typeof icon === "string" ? { name: icon } : icon;
21
+
22
+ const resolveIconProps = (icon: NonNullable<SegmentProps["icon"]>): IconProps => {
23
+ const iconProps = normalizeIconProps(icon);
24
+
25
+ return {
26
+ ...iconProps,
27
+ style: {
28
+ ...iconProps.style,
29
+ color: "currentColor",
30
+ },
31
+ };
32
+ };
33
+
34
+ const Segment = ({
35
+ value,
36
+ disabled = false,
37
+ children,
38
+ className,
39
+ ...props
40
+ }: SegmentProps) => {
41
+ void value;
42
+ void disabled;
43
+ void children;
44
+ void className;
45
+ void props;
46
+ // This component is used as a type guard and for prop documentation.
47
+ // The actual rendering is handled by the parent SegmentControl.
48
+ return null;
49
+ };
50
+
51
+ type SegmentControlComponent = React.FC<SegmentControlProps> & {
52
+ Segment: typeof Segment;
53
+ };
54
+
55
+ /**
56
+ * A segmented control for toggling between related views or data sets.
57
+ */
58
+ export const SegmentControl: SegmentControlComponent = ({
59
+ value,
60
+ onChange,
61
+ fullWidth = false,
62
+ size = "medium",
63
+ movingBackground = false,
64
+ surfaceStyle,
65
+ children,
66
+ className,
67
+ style,
68
+ ...props
69
+ }: SegmentControlProps) => {
70
+ const resolvedSize = sizeAliasMap[size];
71
+ // Container ref is used to compute relative offsets for the moving indicator.
72
+ const containerRef = useRef<HTMLDivElement | null>(null);
73
+ // Each segment stores its DOM node so we can measure width/position on demand.
74
+ const segmentRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
75
+ // Tracks the segment currently under pointer/focus so the indicator can follow it.
76
+ const hoverValueRef = useRef<string | null>(null);
77
+ const [indicatorMetrics, setIndicatorMetrics] = useState<{
78
+ left: number;
79
+ top: number;
80
+ width: number;
81
+ height: number;
82
+ opacity: number;
83
+ } | null>(null);
84
+
85
+ const validChildren = useMemo(
86
+ () =>
87
+ Children.toArray(children).filter(
88
+ (child) =>
89
+ React.isValidElement(child) &&
90
+ (child.type === Segment || child.type === SegmentControl.Segment)
91
+ ) as React.ReactElement<SegmentProps>[],
92
+ [children]
93
+ );
94
+
95
+ // Keep indicator positioning logic centralized so hover, focus, and resize share it.
96
+ const setIndicatorFromValue = useCallback(
97
+ (segmentValue: string | null) => {
98
+ if (!movingBackground) return;
99
+
100
+ const container = containerRef.current;
101
+ if (!container || !segmentValue) {
102
+ // Hide the indicator if there's no valid target (e.g. during teardown).
103
+ setIndicatorMetrics((previous) =>
104
+ previous ? { ...previous, opacity: 0 } : null
105
+ );
106
+ return;
107
+ }
108
+
109
+ const target = segmentRefs.current.get(segmentValue);
110
+ if (!target) {
111
+ setIndicatorMetrics((previous) =>
112
+ previous ? { ...previous, opacity: 0 } : null
113
+ );
114
+ return;
115
+ }
116
+
117
+ const containerRect = container.getBoundingClientRect();
118
+ const targetRect = target.getBoundingClientRect();
119
+
120
+ // Translate segment coordinates into container-local offsets for CSS vars.
121
+ setIndicatorMetrics({
122
+ left: targetRect.left - containerRect.left,
123
+ top: targetRect.top - containerRect.top,
124
+ width: targetRect.width,
125
+ height: targetRect.height,
126
+ opacity: 1,
127
+ });
128
+ },
129
+ [movingBackground]
130
+ );
131
+
132
+ // When selection changes (or on first mount), align the indicator to the active segment.
133
+ useEffect(() => {
134
+ if (!movingBackground) return;
135
+ if (hoverValueRef.current) return;
136
+ setIndicatorFromValue(value);
137
+ }, [movingBackground, value, validChildren, resolvedSize, setIndicatorFromValue]);
138
+
139
+ // ResizeObserver keeps the indicator aligned if segment sizes change responsively.
140
+ useEffect(() => {
141
+ if (!movingBackground) return;
142
+ const container = containerRef.current;
143
+ if (!container || typeof ResizeObserver === "undefined") return;
144
+
145
+ const observer = new ResizeObserver(() => {
146
+ // If the user is hovering, keep the indicator on that segment.
147
+ const targetValue = hoverValueRef.current ?? value;
148
+ setIndicatorFromValue(targetValue);
149
+ });
150
+
151
+ observer.observe(container);
152
+ return () => observer.disconnect();
153
+ }, [movingBackground, value, setIndicatorFromValue]);
154
+
155
+ const handleSegmentRef = useCallback(
156
+ (segmentValue: string) => (node: HTMLButtonElement | null) => {
157
+ if (node) {
158
+ segmentRefs.current.set(segmentValue, node);
159
+ } else {
160
+ segmentRefs.current.delete(segmentValue);
161
+ }
162
+ },
163
+ []
164
+ );
165
+
166
+ if (validChildren.length === 0) {
167
+ console.warn("SegmentControl requires at least one Segment child");
168
+ return null;
169
+ }
170
+
171
+ // Surface styling is applied via data attributes so theme defaults remain centralized.
172
+ const surfaceAttributes: Record<string, string | undefined> = {};
173
+ const surfaceStyleVars: React.CSSProperties = {};
174
+
175
+ if (surfaceStyle) {
176
+ if (typeof surfaceStyle === "string") {
177
+ surfaceAttributes["data-surface-style"] = surfaceStyle;
178
+ } else {
179
+ surfaceAttributes["data-surface-style"] = "custom";
180
+ if (surfaceStyle.opacity !== undefined) {
181
+ surfaceStyleVars["--surface-opacity"] = surfaceStyle.opacity.toString();
182
+ }
183
+ if (surfaceStyle.blur !== undefined) {
184
+ surfaceStyleVars["--surface-blur"] = `${surfaceStyle.blur}px`;
185
+ }
186
+ if (surfaceStyle.borderColor !== undefined) {
187
+ surfaceStyleVars["--surface-border-color"] = surfaceStyle.borderColor;
188
+ }
189
+ if (surfaceStyle.shadow !== undefined) {
190
+ surfaceStyleVars["--surface-shadow"] = surfaceStyle.shadow;
191
+ }
192
+ }
193
+ }
194
+
195
+ return (
196
+ <div
197
+ ref={containerRef}
198
+ className={classNames(
199
+ "solara-segment-control",
200
+ `solara-segment-control--size-${resolvedSize}`,
201
+ fullWidth ? "solara-segment-control--full-width" : null,
202
+ movingBackground ? "solara-segment-control--moving-bg" : null,
203
+ className
204
+ )}
205
+ role="tablist"
206
+ style={{ ...surfaceStyleVars, ...style }}
207
+ onMouseLeave={() => {
208
+ // When the pointer leaves the control, snap back to the active segment.
209
+ hoverValueRef.current = null;
210
+ setIndicatorFromValue(value);
211
+ }}
212
+ {...surfaceAttributes}
213
+ {...props}
214
+ >
215
+ {movingBackground ? (
216
+ // Single moving background element sized and positioned by CSS variables.
217
+ <span
218
+ className="solara-segment-control__indicator"
219
+ aria-hidden="true"
220
+ style={
221
+ indicatorMetrics
222
+ ? ({
223
+ "--segment-indicator-left": `${indicatorMetrics.left}px`,
224
+ "--segment-indicator-top": `${indicatorMetrics.top}px`,
225
+ "--segment-indicator-width": `${indicatorMetrics.width}px`,
226
+ "--segment-indicator-height": `${indicatorMetrics.height}px`,
227
+ "--segment-indicator-opacity": `${indicatorMetrics.opacity}`,
228
+ } as React.CSSProperties)
229
+ : undefined
230
+ }
231
+ />
232
+ ) : null}
233
+ {validChildren.map((child) => {
234
+ const isActive = value === child.props.value;
235
+ const isDisabled = child.props.disabled;
236
+ const icon = child.props.icon;
237
+ const hasLabel = Boolean(child.props.children);
238
+ const ariaLabel =
239
+ child.props.ariaLabel ??
240
+ (typeof child.props.children === "string" ? child.props.children : undefined);
241
+
242
+ return (
243
+ <button
244
+ key={child.props.value}
245
+ type="button"
246
+ role="tab"
247
+ aria-selected={isActive}
248
+ aria-disabled={isDisabled}
249
+ aria-label={ariaLabel}
250
+ title={child.props.title}
251
+ disabled={isDisabled}
252
+ onClick={() => !isDisabled && onChange(child.props.value)}
253
+ onMouseEnter={() => {
254
+ if (!movingBackground || isDisabled) return;
255
+ // Hovering should move the background even if the segment isn't active.
256
+ hoverValueRef.current = child.props.value;
257
+ setIndicatorFromValue(child.props.value);
258
+ }}
259
+ onFocus={() => {
260
+ if (!movingBackground || isDisabled) return;
261
+ // Keyboard focus should behave like hover for accessibility parity.
262
+ hoverValueRef.current = child.props.value;
263
+ setIndicatorFromValue(child.props.value);
264
+ }}
265
+ onBlur={() => {
266
+ if (!movingBackground) return;
267
+ // Restore indicator when focus moves away.
268
+ hoverValueRef.current = null;
269
+ setIndicatorFromValue(value);
270
+ }}
271
+ className={classNames(
272
+ "solara-segment-control__segment",
273
+ child.props.className
274
+ )}
275
+ data-state={isActive ? "active" : "inactive"}
276
+ data-disabled={isDisabled ? "true" : undefined}
277
+ ref={handleSegmentRef(child.props.value)}
278
+ >
279
+ {icon ? (
280
+ <span className="solara-segment-control__icon" aria-hidden="true">
281
+ <Icon {...resolveIconProps(icon)} />
282
+ </span>
283
+ ) : null}
284
+ {hasLabel ? (
285
+ <span className="solara-segment-control__label">{child.props.children}</span>
286
+ ) : null}
287
+ </button>
288
+ );
289
+ })}
290
+ </div>
291
+ );
292
+ };
293
+
294
+ SegmentControl.Segment = Segment;
295
+ SegmentControl.displayName = "SegmentControl";
296
+
297
+ export { Segment };
@@ -0,0 +1,82 @@
1
+ import React from "react";
2
+ import type { IconProps } from "@solara/icons";
3
+
4
+ export type SegmentControlSize = "small" | "medium" | "large" | "sm" | "md" | "lg";
5
+
6
+ export type SegmentControlSurfaceStyle =
7
+ | "solid"
8
+ | "translucent"
9
+ | "glass"
10
+ | {
11
+ opacity?: number;
12
+ blur?: number;
13
+ borderColor?: string;
14
+ shadow?: string;
15
+ };
16
+
17
+ export interface SegmentControlProps
18
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
19
+ /**
20
+ * The currently active segment value.
21
+ */
22
+ value: string;
23
+ /**
24
+ * Callback when the active segment changes.
25
+ */
26
+ onChange: (value: string) => void;
27
+ /**
28
+ * Whether the segment control should take up the full width of its container.
29
+ */
30
+ fullWidth?: boolean;
31
+ /**
32
+ * Size of the segment control.
33
+ */
34
+ size?: SegmentControlSize;
35
+ /**
36
+ * Controls surface material treatment without changing layout tokens.
37
+ */
38
+ surfaceStyle?: SegmentControlSurfaceStyle;
39
+ /**
40
+ * When true, uses a single animated background that moves between segments.
41
+ */
42
+ movingBackground?: boolean;
43
+ /**
44
+ * The segments to render.
45
+ */
46
+ children: React.ReactNode;
47
+ /**
48
+ * Additional class name.
49
+ */
50
+ className?: string;
51
+ }
52
+
53
+ export interface SegmentProps {
54
+ /**
55
+ * The value of the segment.
56
+ */
57
+ value: string;
58
+ /**
59
+ * Whether the segment is disabled.
60
+ */
61
+ disabled?: boolean;
62
+ /**
63
+ * Optional leading icon. Accepts an icon name or full IconProps.
64
+ */
65
+ icon?: IconProps | IconProps["name"];
66
+ /**
67
+ * Optional title used for accessibility (tooltip).
68
+ */
69
+ title?: string;
70
+ /**
71
+ * Optional aria-label for icon-only segments.
72
+ */
73
+ ariaLabel?: string;
74
+ /**
75
+ * The content of the segment.
76
+ */
77
+ children?: React.ReactNode;
78
+ /**
79
+ * Additional class name.
80
+ */
81
+ className?: string;
82
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { SegmentControl, Segment } from "./SegmentControl";
2
+ export type { SegmentControlProps, SegmentControlSize, SegmentProps } from "./SegmentControl.types";