@plexui/ui 0.3.0 → 0.4.1

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.
@@ -1,2 +1,2 @@
1
- export { SegmentedControl } from "./SegmentedControl";
1
+ export { Tabs as SegmentedControl } from "../Tabs";
2
2
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/components/SegmentedControl/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAA","sourcesContent":["export { SegmentedControl } from \"./SegmentedControl\"\nexport type {\n SegmentedControlBadgeProp,\n SegmentedControlOptionProps,\n SegmentedControlProps,\n SizeVariant,\n} from \"./SegmentedControl\"\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/components/SegmentedControl/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,IAAI,gBAAgB,EAAE,MAAM,SAAS,CAAA","sourcesContent":["export { Tabs as SegmentedControl } from \"../Tabs\"\nexport type {\n TabsBadgeProp as SegmentedControlBadgeProp,\n TabProps as SegmentedControlOptionProps,\n TabsProps as SegmentedControlProps,\n SizeVariant,\n} from \"../Tabs\"\n"]}
@@ -0,0 +1,153 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import clsx from "clsx";
4
+ import { ToggleGroup } from "radix-ui";
5
+ import { useCallback, useLayoutEffect, useRef } from "react";
6
+ import { useResizeObserver } from "usehooks-ts";
7
+ import { handlePressableMouseEnter, waitForAnimationFrame } from "../../lib/helpers";
8
+ import {} from "../../types";
9
+ import { LoadingIndicator } from "../Indicator";
10
+ import s from "./Tabs.module.css";
11
+ export const Tabs = ({ value, onChange, children, variant = "segmented", orientation = "horizontal", block, pill = true, size = "md", gutterSize, className, onClick, ...restProps }) => {
12
+ const rootRef = useRef(null);
13
+ const thumbRef = useRef(null);
14
+ const prevSizeRef = useRef(size);
15
+ const isVertical = orientation === "vertical";
16
+ const applyThumbSizing = useCallback((attemptScroll) => {
17
+ const root = rootRef.current;
18
+ const thumb = thumbRef.current;
19
+ if (!root || !thumb) {
20
+ return;
21
+ }
22
+ // Get selected node
23
+ const activeNode = root?.querySelector('[data-state="on"]');
24
+ // Impossible
25
+ if (!activeNode) {
26
+ return;
27
+ }
28
+ if (isVertical) {
29
+ const rootHeight = root.clientHeight;
30
+ let targetHeight = Math.floor(activeNode.clientHeight);
31
+ const targetOffset = activeNode.offsetTop;
32
+ // Detect subpixel edge case
33
+ if (rootHeight - (targetHeight + targetOffset) < 2) {
34
+ targetHeight = targetHeight - 1;
35
+ }
36
+ thumb.style.height = `${Math.floor(targetHeight)}px`;
37
+ thumb.style.width = "";
38
+ thumb.style.transform = `translateY(${targetOffset}px)`;
39
+ // Scroll into view if needed
40
+ if (root.scrollHeight > rootHeight) {
41
+ const buffer = rootHeight * 0.15;
42
+ const scrollTop = root.scrollTop;
43
+ const top = activeNode.offsetTop;
44
+ const bottom = top + targetHeight;
45
+ if (top < scrollTop + buffer || bottom > scrollTop + rootHeight - buffer) {
46
+ if (attemptScroll) {
47
+ activeNode.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
48
+ }
49
+ }
50
+ }
51
+ }
52
+ else {
53
+ const rootWidth = root.clientWidth;
54
+ let targetWidth = Math.floor(activeNode.clientWidth);
55
+ const targetOffset = activeNode.offsetLeft;
56
+ // Detect if the thumb is moving too far to the edge of the container.
57
+ // This would most commonly be due to subpixel widths adding up to excessive distance.
58
+ if (rootWidth - (targetWidth + targetOffset) < 2) {
59
+ targetWidth = targetWidth - 1;
60
+ }
61
+ thumb.style.width = `${Math.floor(targetWidth)}px`;
62
+ thumb.style.height = "";
63
+ thumb.style.transform = `translateX(${targetOffset}px)`;
64
+ // If the control is scrollable, ensure the active option is visible
65
+ if (root.scrollWidth > rootWidth) {
66
+ // Only scroll items near the edge, but not the inner 2/3.
67
+ const buffer = rootWidth * 0.15;
68
+ const scrollLeft = root.scrollLeft;
69
+ const left = activeNode.offsetLeft;
70
+ const right = left + targetWidth;
71
+ if (left < scrollLeft + buffer || right > scrollLeft + rootWidth - buffer) {
72
+ // Cheap trick to avoid unintentional scroll on mount - transition is set after mounting
73
+ if (attemptScroll) {
74
+ activeNode.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });
75
+ }
76
+ }
77
+ }
78
+ }
79
+ }, [isVertical]);
80
+ useResizeObserver({
81
+ // @ts-expect-error(2322) -- bug in types: https://github.com/juliencrn/usehooks-ts/issues/663
82
+ ref: rootRef,
83
+ onResize: () => {
84
+ const thumb = thumbRef.current;
85
+ if (!thumb) {
86
+ return;
87
+ }
88
+ // Perform the size update instantly
89
+ const currentTransition = thumb.style.transition;
90
+ thumb.style.transition = "";
91
+ applyThumbSizing(false);
92
+ thumb.style.transition = currentTransition;
93
+ },
94
+ });
95
+ const transitionProperty = isVertical
96
+ ? "height 300ms var(--cubic-enter), transform 300ms var(--cubic-enter)"
97
+ : "width 300ms var(--cubic-enter), transform 300ms var(--cubic-enter)";
98
+ useLayoutEffect(() => {
99
+ const root = rootRef.current;
100
+ const thumb = thumbRef.current;
101
+ if (!root || !thumb) {
102
+ return;
103
+ }
104
+ const sizeChanged = prevSizeRef.current !== size;
105
+ prevSizeRef.current = size;
106
+ if (sizeChanged) {
107
+ // Size changed - disable transition, wait for CSS, then apply sizing
108
+ const currentTransition = thumb.style.transition;
109
+ thumb.style.transition = "";
110
+ waitForAnimationFrame(() => {
111
+ applyThumbSizing(false);
112
+ waitForAnimationFrame(() => {
113
+ thumb.style.transition = currentTransition;
114
+ });
115
+ });
116
+ }
117
+ else {
118
+ // Normal update (value change, etc.)
119
+ waitForAnimationFrame(() => {
120
+ applyThumbSizing(!!thumb.style.transition);
121
+ // Apply transition after initial calculation is set
122
+ if (!thumb.style.transition) {
123
+ waitForAnimationFrame(() => {
124
+ thumb.style.transition = transitionProperty;
125
+ });
126
+ }
127
+ });
128
+ }
129
+ }, [applyThumbSizing, value, size, gutterSize, pill, transitionProperty]);
130
+ const handleValueChange = (nextValue) => {
131
+ // Only trigger onChange when a value exists
132
+ // Disallow toggling off enabled items
133
+ if (nextValue && onChange)
134
+ onChange(nextValue);
135
+ };
136
+ // Only apply pill for segmented variant
137
+ const isPill = variant === "segmented" && pill;
138
+ return (_jsxs(ToggleGroup.Root, { ref: rootRef, className: clsx(s.Tabs, className), type: "single", value: value, loop: false, onValueChange: handleValueChange, onClick: onClick, "data-variant": variant, "data-orientation": orientation, "data-block": block ? "" : undefined, "data-pill": isPill ? "" : undefined, "data-size": size, "data-gutter-size": gutterSize, ...restProps, children: [_jsx("div", { className: s.TabsThumb, ref: thumbRef }), children] }));
139
+ };
140
+ // Type guard for badge object form
141
+ const isBadgeObject = (badge) => {
142
+ return badge != null && typeof badge === "object" && "content" in badge;
143
+ };
144
+ const Tab = ({ children, icon, badge, ...restProps }) => {
145
+ // Normalize badge prop
146
+ const badgeProps = badge != null ? (isBadgeObject(badge) ? badge : { content: badge }) : null;
147
+ return (_jsx(ToggleGroup.Item, { className: s.Tab, ...restProps, onPointerEnter: handlePressableMouseEnter, children: _jsxs("span", { className: s.TabContent, children: [icon, children && _jsx("span", { children: children }), badgeProps && (_jsx("span", { className: s.TabBadge, "data-color": badgeProps.color ?? "secondary", "data-variant": badgeProps.variant ?? "soft", "data-pill": badgeProps.pill ? "" : undefined, children: badgeProps.loading ? _jsx(LoadingIndicator, {}) : badgeProps.content }))] }) }));
148
+ };
149
+ // Attach sub-components
150
+ Tabs.Tab = Tab;
151
+ // Backward-compat alias
152
+ Tabs.Option = Tab;
153
+ //# sourceMappingURL=Tabs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Tabs.js","sourceRoot":"","sources":["../../../../src/components/Tabs/Tabs.tsx"],"names":[],"mappings":"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","sourcesContent":["\"use client\"\n\nimport clsx from \"clsx\"\nimport { ToggleGroup } from \"radix-ui\"\nimport { useCallback, useLayoutEffect, useRef } from \"react\"\nimport { useResizeObserver } from \"usehooks-ts\"\nimport { handlePressableMouseEnter, waitForAnimationFrame } from \"../../lib/helpers\"\nimport { type ControlSize, type SemanticColors, type Sizes, type Variants } from \"../../types\"\nimport { LoadingIndicator } from \"../Indicator\"\nimport s from \"./Tabs.module.css\"\n\nexport type SizeVariant = \"2xs\" | \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"\n\nexport type TabsVariant = \"segmented\" | \"underline\"\nexport type TabsOrientation = \"horizontal\" | \"vertical\"\n\nexport type TabsProps<T extends string> = {\n /**\n * Controlled value for the group\n */\n \"value\": T\n /** Callback for when a new value is selected */\n \"onChange\"?: (nextValue: T) => void\n /** Callback any time the control is clicked (even if a new value was not selected) */\n \"onClick\"?: () => void\n /**\n * Text read aloud to screen readers when the control is focused\n */\n \"aria-label\": string\n /**\n * Visual variant of the tab group\n * - `\"segmented\"` — background container with sliding highlight (default)\n * - `\"underline\"` — no background, animated line indicator under active tab\n * @default \"segmented\"\n */\n \"variant\"?: TabsVariant\n /**\n * Orientation of the tab layout\n * @default \"horizontal\"\n */\n \"orientation\"?: TabsOrientation\n /**\n * Controls the size of the tabs\n *\n * | 3xs | 2xs | xs | sm | md | lg | xl | 2xl | 3xl |\n * | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- |\n * | `22px` | `24px` | `26px` | `28px` | `32px` | `36px` | `40px` | `44px` | `48px` |\n *\n * @default md\n */\n \"size\"?: ControlSize\n /**\n * Controls gutter on the edges of the button, defaults to value from `size`.\n *\n * | 2xs | xs | sm | md | lg | xl |\n * | ------ | ------ | ------ | ------ | ------ | ------ |\n * | `6px` | `8px` | `10px` | `12px` | `14px` | `16px` |\n */\n \"gutterSize\"?: Sizes<\"2xs\" | \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\">\n /** Disable the entire group */\n \"disabled\"?: boolean\n /**\n * Display the control as a block element with equal width segments\n * @default false\n */\n \"block\"?: boolean\n /**\n * Determines if the tabs should be a fully rounded pill shape.\n * Only applies to the `\"segmented\"` variant.\n * @default true\n */\n \"pill\"?: boolean\n \"className\"?: string\n \"children\": React.ReactNode\n}\n\nexport const Tabs = <T extends string>({\n value,\n onChange,\n children,\n variant = \"segmented\",\n orientation = \"horizontal\",\n block,\n pill = true,\n size = \"md\",\n gutterSize,\n className,\n onClick,\n ...restProps\n}: TabsProps<T>) => {\n const rootRef = useRef<HTMLDivElement>(null)\n const thumbRef = useRef<HTMLDivElement>(null)\n const prevSizeRef = useRef(size)\n const isVertical = orientation === \"vertical\"\n\n const applyThumbSizing = useCallback(\n (attemptScroll: boolean) => {\n const root = rootRef.current\n const thumb = thumbRef.current\n\n if (!root || !thumb) {\n return\n }\n\n // Get selected node\n const activeNode = root?.querySelector<HTMLDivElement>('[data-state=\"on\"]')\n\n // Impossible\n if (!activeNode) {\n return\n }\n\n if (isVertical) {\n const rootHeight = root.clientHeight\n let targetHeight = Math.floor(activeNode.clientHeight)\n const targetOffset = activeNode.offsetTop\n\n // Detect subpixel edge case\n if (rootHeight - (targetHeight + targetOffset) < 2) {\n targetHeight = targetHeight - 1\n }\n\n thumb.style.height = `${Math.floor(targetHeight)}px`\n thumb.style.width = \"\"\n thumb.style.transform = `translateY(${targetOffset}px)`\n\n // Scroll into view if needed\n if (root.scrollHeight > rootHeight) {\n const buffer = rootHeight * 0.15\n const scrollTop = root.scrollTop\n const top = activeNode.offsetTop\n const bottom = top + targetHeight\n if (top < scrollTop + buffer || bottom > scrollTop + rootHeight - buffer) {\n if (attemptScroll) {\n activeNode.scrollIntoView({ block: \"center\", inline: \"nearest\", behavior: \"smooth\" })\n }\n }\n }\n } else {\n const rootWidth = root.clientWidth\n let targetWidth = Math.floor(activeNode.clientWidth)\n const targetOffset = activeNode.offsetLeft\n\n // Detect if the thumb is moving too far to the edge of the container.\n // This would most commonly be due to subpixel widths adding up to excessive distance.\n if (rootWidth - (targetWidth + targetOffset) < 2) {\n targetWidth = targetWidth - 1\n }\n\n thumb.style.width = `${Math.floor(targetWidth)}px`\n thumb.style.height = \"\"\n thumb.style.transform = `translateX(${targetOffset}px)`\n\n // If the control is scrollable, ensure the active option is visible\n if (root.scrollWidth > rootWidth) {\n // Only scroll items near the edge, but not the inner 2/3.\n const buffer = rootWidth * 0.15\n const scrollLeft = root.scrollLeft\n const left = activeNode.offsetLeft\n const right = left + targetWidth\n if (left < scrollLeft + buffer || right > scrollLeft + rootWidth - buffer) {\n // Cheap trick to avoid unintentional scroll on mount - transition is set after mounting\n if (attemptScroll) {\n activeNode.scrollIntoView({ block: \"nearest\", inline: \"center\", behavior: \"smooth\" })\n }\n }\n }\n }\n },\n [isVertical],\n )\n\n useResizeObserver({\n // @ts-expect-error(2322) -- bug in types: https://github.com/juliencrn/usehooks-ts/issues/663\n ref: rootRef,\n onResize: () => {\n const thumb = thumbRef.current\n\n if (!thumb) {\n return\n }\n\n // Perform the size update instantly\n const currentTransition = thumb.style.transition\n thumb.style.transition = \"\"\n applyThumbSizing(false)\n thumb.style.transition = currentTransition\n },\n })\n\n const transitionProperty = isVertical\n ? \"height 300ms var(--cubic-enter), transform 300ms var(--cubic-enter)\"\n : \"width 300ms var(--cubic-enter), transform 300ms var(--cubic-enter)\"\n\n useLayoutEffect(() => {\n const root = rootRef.current\n const thumb = thumbRef.current\n\n if (!root || !thumb) {\n return\n }\n\n const sizeChanged = prevSizeRef.current !== size\n prevSizeRef.current = size\n\n if (sizeChanged) {\n // Size changed - disable transition, wait for CSS, then apply sizing\n const currentTransition = thumb.style.transition\n thumb.style.transition = \"\"\n\n waitForAnimationFrame(() => {\n applyThumbSizing(false)\n waitForAnimationFrame(() => {\n thumb.style.transition = currentTransition\n })\n })\n } else {\n // Normal update (value change, etc.)\n waitForAnimationFrame(() => {\n applyThumbSizing(!!thumb.style.transition)\n\n // Apply transition after initial calculation is set\n if (!thumb.style.transition) {\n waitForAnimationFrame(() => {\n thumb.style.transition = transitionProperty\n })\n }\n })\n }\n }, [applyThumbSizing, value, size, gutterSize, pill, transitionProperty])\n\n const handleValueChange = (nextValue: T) => {\n // Only trigger onChange when a value exists\n // Disallow toggling off enabled items\n if (nextValue && onChange) onChange(nextValue)\n }\n\n // Only apply pill for segmented variant\n const isPill = variant === \"segmented\" && pill\n\n return (\n <ToggleGroup.Root\n ref={rootRef}\n className={clsx(s.Tabs, className)}\n type=\"single\"\n value={value}\n loop={false}\n onValueChange={handleValueChange}\n onClick={onClick}\n data-variant={variant}\n data-orientation={orientation}\n data-block={block ? \"\" : undefined}\n data-pill={isPill ? \"\" : undefined}\n data-size={size}\n data-gutter-size={gutterSize}\n {...restProps}\n >\n <div className={s.TabsThumb} ref={thumbRef} />\n {children}\n </ToggleGroup.Root>\n )\n}\n\n/**\n * Badge configuration for Tabs.Tab\n */\nexport type TabsBadgeProp =\n | React.ReactNode\n | {\n content: React.ReactNode\n color?: SemanticColors<\n \"secondary\" | \"success\" | \"danger\" | \"warning\" | \"info\" | \"discovery\" | \"caution\"\n >\n variant?: Variants<\"soft\" | \"solid\">\n pill?: boolean\n loading?: boolean\n }\n\nexport type TabProps = {\n /**\n * Tab value\n */\n \"value\": string\n /**\n * Text read aloud to screen readers when the tab is focused\n */\n \"aria-label\"?: string\n /**\n * Text content to render in the tab\n */\n \"children\"?: React.ReactNode\n /**\n * Icon to render before the text content\n */\n \"icon\"?: React.ReactNode\n /**\n * Badge to render after the text content.\n * Can be a simple value or an object with content, color, variant, and loading options.\n * @example badge={5}\n * @example badge={{ content: 5, color: \"danger\" }}\n */\n \"badge\"?: TabsBadgeProp\n /**\n * Disable the individual tab\n */\n \"disabled\"?: boolean\n}\n\n// Type guard for badge object form\nconst isBadgeObject = (\n badge: TabsBadgeProp,\n): badge is Exclude<TabsBadgeProp, React.ReactNode> & { content: React.ReactNode } => {\n return badge != null && typeof badge === \"object\" && \"content\" in badge\n}\n\nconst Tab = ({ children, icon, badge, ...restProps }: TabProps) => {\n // Normalize badge prop\n const badgeProps = badge != null ? (isBadgeObject(badge) ? badge : { content: badge }) : null\n\n return (\n <ToggleGroup.Item\n className={s.Tab}\n {...restProps}\n onPointerEnter={handlePressableMouseEnter}\n >\n <span className={s.TabContent}>\n {icon}\n {children && <span>{children}</span>}\n {badgeProps && (\n <span\n className={s.TabBadge}\n data-color={badgeProps.color ?? \"secondary\"}\n data-variant={badgeProps.variant ?? \"soft\"}\n data-pill={badgeProps.pill ? \"\" : undefined}\n >\n {badgeProps.loading ? <LoadingIndicator /> : badgeProps.content}\n </span>\n )}\n </span>\n </ToggleGroup.Item>\n )\n}\n\n// Attach sub-components\nTabs.Tab = Tab\n// Backward-compat alias\nTabs.Option = Tab\n"]}
@@ -1,47 +1,113 @@
1
- @layer components {.SegmentedControl {
2
- --segmented-control-option-radius: calc(
3
- var(--segmented-control-radius) - var(--segmented-control-gutter)
4
- );
5
-
1
+ @layer components {.Tabs {
6
2
  position: relative;
7
- overflow: auto;
8
3
  display: inline-flex;
9
4
  flex-wrap: nowrap;
10
5
  gap: var(--segmented-control-gap);
11
- height: var(--segmented-control-size);
12
- padding: var(--segmented-control-gutter);
13
- border-radius: var(--segmented-control-radius);
14
- background: var(--segmented-control-background);
15
6
  font-size: var(--segmented-control-font-size);
16
7
  font-weight: var(--segmented-control-font-weight);
17
8
  -ms-overflow-style: none;
18
9
  scrollbar-width: none;
19
- /* Remove inline-flex baseline gap */
20
10
  vertical-align: middle;
21
11
  white-space: nowrap;
22
12
  }
23
13
 
24
- .SegmentedControl::-webkit-scrollbar {
14
+ .Tabs::-webkit-scrollbar {
25
15
  width: 0;
26
16
  height: 0;
27
17
  }
28
18
 
29
- .SegmentedControl::-webkit-scrollbar-track,
30
- .SegmentedControl::-webkit-scrollbar-thumb {
19
+ .Tabs::-webkit-scrollbar-track,
20
+ .Tabs::-webkit-scrollbar-thumb {
21
+ background: transparent;
22
+ }
23
+
24
+ /* =============================================
25
+ Variant: Segmented (default — existing behavior)
26
+ ============================================= */
27
+ .Tabs:where([data-variant="segmented"]) {
28
+ overflow: auto;
29
+ height: var(--segmented-control-size);
30
+ padding: var(--segmented-control-gutter);
31
+ border-radius: var(--segmented-control-radius);
32
+ background: var(--segmented-control-background);
33
+ }
34
+
35
+ .Tabs:where([data-variant="segmented"]:not([data-pill])) {
36
+ --segmented-control-option-radius: calc(
37
+ var(--segmented-control-radius) - var(--segmented-control-gutter)
38
+ );
39
+ }
40
+
41
+ .Tabs:where([data-variant="segmented"][data-pill]) {
42
+ border-radius: var(--radius-full);
43
+ --segmented-control-option-radius: var(--radius-full);
44
+ }
45
+
46
+ .Tabs:where([data-variant="segmented"][data-block]) {
47
+ overflow: hidden;
48
+ display: flex;
49
+ width: 100%;
50
+ white-space: normal;
51
+ }
52
+
53
+ /* =============================================
54
+ Variant: Underline
55
+ ============================================= */
56
+ .Tabs:where([data-variant="underline"]) {
57
+ --segmented-control-option-radius: 0;
58
+
59
+ overflow: auto;
60
+ height: var(--segmented-control-size);
61
+ padding: 0;
62
+ border-radius: 0;
31
63
  background: transparent;
64
+ gap: 0;
65
+ border-bottom: 1px solid var(--tabs-underline-border-color);
32
66
  }
33
67
 
34
- .SegmentedControl:where([data-block]) {
68
+ .Tabs:where([data-variant="underline"][data-block]) {
35
69
  overflow: hidden;
36
70
  display: flex;
37
71
  width: 100%;
38
72
  white-space: normal;
39
73
  }
40
74
 
75
+ /* =============================================
76
+ Vertical orientation
77
+ ============================================= */
78
+ .Tabs:where([data-orientation="vertical"]) {
79
+ flex-direction: column;
80
+ height: auto;
81
+ width: -moz-fit-content;
82
+ width: fit-content;
83
+ white-space: nowrap;
84
+ }
85
+
86
+ .Tabs:where([data-orientation="vertical"][data-variant="segmented"]) {
87
+ overflow-y: auto;
88
+ overflow-x: hidden;
89
+ }
90
+
91
+ .Tabs:where([data-orientation="vertical"][data-variant="underline"]) {
92
+ overflow-y: auto;
93
+ overflow-x: hidden;
94
+ border-bottom: none;
95
+ border-left: 1px solid var(--tabs-underline-border-color);
96
+ }
97
+
98
+ .Tabs:where([data-orientation="vertical"][data-block]) {
99
+ width: 100%;
100
+ }
101
+
102
+ .Tabs:where([data-orientation="vertical"][data-variant="segmented"][data-pill]) {
103
+ border-radius: calc(var(--segmented-control-size) / 2);
104
+ --segmented-control-option-radius: calc(var(--segmented-control-size) / 2);
105
+ }
106
+
41
107
  /* =============================================
42
108
  Sizes
43
109
  ============================================= */
44
- .SegmentedControl:where([data-size="3xs"]) {
110
+ .Tabs:where([data-size="3xs"]) {
45
111
  --segmented-control-size: var(--control-size-3xs);
46
112
  --segmented-control-font-size: var(--control-font-size-sm);
47
113
  --segmented-control-radius: var(--control-radius-sm);
@@ -60,7 +126,7 @@
60
126
  --option-badge-radius: var(--badge-radius-2xs);
61
127
  }
62
128
 
63
- .SegmentedControl:where([data-size="2xs"]) {
129
+ .Tabs:where([data-size="2xs"]) {
64
130
  --segmented-control-size: var(--control-size-2xs);
65
131
  --segmented-control-font-size: var(--control-font-size-sm);
66
132
  --segmented-control-radius: var(--control-radius-sm);
@@ -79,7 +145,7 @@
79
145
  --option-badge-radius: var(--badge-radius-2xs);
80
146
  }
81
147
 
82
- .SegmentedControl:where([data-size="xs"]) {
148
+ .Tabs:where([data-size="xs"]) {
83
149
  --segmented-control-size: var(--control-size-xs);
84
150
  --segmented-control-font-size: var(--control-font-size-md);
85
151
  --segmented-control-radius: var(--control-radius-sm);
@@ -98,7 +164,7 @@
98
164
  --option-badge-radius: var(--badge-radius-xs);
99
165
  }
100
166
 
101
- .SegmentedControl:where([data-size="sm"]) {
167
+ .Tabs:where([data-size="sm"]) {
102
168
  --segmented-control-size: var(--control-size-sm);
103
169
  --segmented-control-font-size: var(--control-font-size-md);
104
170
  --segmented-control-radius: var(--control-radius-md);
@@ -117,7 +183,7 @@
117
183
  --option-badge-radius: var(--badge-radius-sm);
118
184
  }
119
185
 
120
- .SegmentedControl:where([data-size="md"]) {
186
+ .Tabs:where([data-size="md"]) {
121
187
  --segmented-control-size: var(--control-size-md);
122
188
  --segmented-control-font-size: var(--control-font-size-md);
123
189
  --segmented-control-radius: var(--control-radius-md);
@@ -136,7 +202,7 @@
136
202
  --option-badge-radius: var(--badge-radius-md);
137
203
  }
138
204
 
139
- .SegmentedControl:where([data-size="lg"]) {
205
+ .Tabs:where([data-size="lg"]) {
140
206
  --segmented-control-size: var(--control-size-lg);
141
207
  --segmented-control-font-size: var(--control-font-size-md);
142
208
  --segmented-control-radius: var(--control-radius-md);
@@ -155,7 +221,7 @@
155
221
  --option-badge-radius: var(--badge-radius-md);
156
222
  }
157
223
 
158
- .SegmentedControl:where([data-size="xl"]) {
224
+ .Tabs:where([data-size="xl"]) {
159
225
  --segmented-control-size: var(--control-size-xl);
160
226
  --segmented-control-font-size: var(--control-font-size-md);
161
227
  --segmented-control-radius: var(--control-radius-lg);
@@ -174,7 +240,7 @@
174
240
  --option-badge-radius: var(--badge-radius-md);
175
241
  }
176
242
 
177
- .SegmentedControl:where([data-size="2xl"]) {
243
+ .Tabs:where([data-size="2xl"]) {
178
244
  --segmented-control-size: var(--control-size-2xl);
179
245
  --segmented-control-font-size: var(--control-font-size-lg);
180
246
  --segmented-control-radius: var(--control-radius-xl);
@@ -193,7 +259,7 @@
193
259
  --option-badge-radius: var(--badge-radius-lg);
194
260
  }
195
261
 
196
- .SegmentedControl:where([data-size="3xl"]) {
262
+ .Tabs:where([data-size="3xl"]) {
197
263
  --segmented-control-size: var(--control-size-3xl);
198
264
  --segmented-control-font-size: var(--control-font-size-lg);
199
265
  --segmented-control-radius: var(--control-radius-xl);
@@ -213,42 +279,35 @@
213
279
  }
214
280
 
215
281
  /* =============================================
216
- Gutter sizes
282
+ Gutter sizes (segmented variant only)
217
283
  ============================================= */
218
- .SegmentedControl:where([data-gutter-size="2xs"]) {
284
+ .Tabs:where([data-gutter-size="2xs"]) {
219
285
  --segmented-control-option-gutter: var(--control-gutter-2xs);
220
286
  }
221
287
 
222
- .SegmentedControl:where([data-gutter-size="xs"]) {
288
+ .Tabs:where([data-gutter-size="xs"]) {
223
289
  --segmented-control-option-gutter: var(--control-gutter-xs);
224
290
  }
225
291
 
226
- .SegmentedControl:where([data-gutter-size="sm"]) {
292
+ .Tabs:where([data-gutter-size="sm"]) {
227
293
  --segmented-control-option-gutter: var(--control-gutter-sm);
228
294
  }
229
295
 
230
- .SegmentedControl:where([data-gutter-size="md"]) {
296
+ .Tabs:where([data-gutter-size="md"]) {
231
297
  --segmented-control-option-gutter: var(--control-gutter-md);
232
298
  }
233
299
 
234
- .SegmentedControl:where([data-gutter-size="lg"]) {
300
+ .Tabs:where([data-gutter-size="lg"]) {
235
301
  --segmented-control-option-gutter: var(--control-gutter-lg);
236
302
  }
237
303
 
238
- .SegmentedControl:where([data-gutter-size="xl"]) {
304
+ .Tabs:where([data-gutter-size="xl"]) {
239
305
  --segmented-control-option-gutter: var(--control-gutter-xl);
240
- }
241
-
242
- /* =============================================
243
- Pill
244
- ============================================= */
245
- .SegmentedControl:where([data-pill]) {
246
- --segmented-control-radius: var(--radius-full);
247
- --segmented-control-option-radius: var(--radius-full);
248
- }.SegmentedControlOption {
306
+ }/* =============================================
307
+ Tab (trigger item)
308
+ ============================================= */.Tab {
249
309
  position: relative;
250
310
  padding: 0 var(--segmented-control-option-gutter);
251
- border-radius: var(--segmented-control-option-radius);
252
311
  color: var(--color-text-secondary);
253
312
  cursor: pointer;
254
313
  line-height: 1;
@@ -261,24 +320,28 @@
261
320
  transition-timing-function: var(--transition-ease-basic);
262
321
  }
263
322
 
264
- .SegmentedControlOption:focus {
323
+ .Tab:focus {
265
324
  outline: 0;
266
325
  }
267
326
 
268
- :where(.SegmentedControl[data-block]) .SegmentedControlOption {
327
+ /* =============================================
328
+ Segmented variant — Tab styles
329
+ ============================================= */
330
+ :where(.Tabs[data-variant="segmented"]) .Tab {
331
+ border-radius: var(--segmented-control-option-radius);
332
+ }
333
+
334
+ :where(.Tabs[data-variant="segmented"][data-block]) .Tab {
269
335
  flex: 1;
270
336
  }
271
337
 
272
- :where(.SegmentedControl[data-pill]) .SegmentedControlOption {
338
+ :where(.Tabs[data-variant="segmented"][data-pill]) .Tab {
273
339
  min-width: calc(var(--segmented-control-size) - 2 * var(--segmented-control-gutter));
274
340
  padding: 0 calc(var(--segmented-control-option-gutter) * var(--control-gutter-pill-scaling));
275
341
  }
276
342
 
277
- .SegmentedControlOption[data-state="on"]:focus-visible {
278
- outline: 2px solid var(--color-ring);
279
- }
280
-
281
- .SegmentedControlOption::before {
343
+ /* Hover highlight pseudo-element (segmented only) */
344
+ :where(.Tabs[data-variant="segmented"]) .Tab::before {
282
345
  position: absolute;
283
346
  inset: var(--segmented-control-option-highlight-gutter);
284
347
  border-radius: var(--segmented-control-option-radius);
@@ -293,55 +356,132 @@
293
356
  will-change: transform;
294
357
  }
295
358
 
296
- .SegmentedControlOption:active::before {
359
+ :where(.Tabs[data-variant="segmented"]) .Tab:active::before {
297
360
  transform: scale(var(--scale), 0.97);
298
361
  }
299
-
300
- .SegmentedControlOption svg {
301
- display: block;
302
- }
303
- @media (hover: hover) and (pointer: fine) {.SegmentedControlOption[data-state="off"]:where(:not([disabled])):hover {
362
+ @media (hover: hover) and (pointer: fine) {:where(.Tabs[data-variant="segmented"]) .Tab[data-state="off"]:where(:not([disabled])):hover {
304
363
  color: var(--color-text);
305
364
  }
306
365
 
307
- .SegmentedControlOption[data-state="off"]:where(:not([disabled])):hover::before {
366
+ :where(.Tabs[data-variant="segmented"]) .Tab[data-state="off"]:where(:not([disabled])):hover::before {
308
367
  opacity: 0.5;
309
368
  }
310
369
  }
311
370
 
312
- .SegmentedControlOption[data-state="off"]:where(:not([disabled])):focus-visible {
371
+ :where(.Tabs[data-variant="segmented"]) .Tab[data-state="off"]:where(:not([disabled])):focus-visible {
313
372
  color: var(--color-text);
314
373
  outline: 2px solid var(--color-ring);
315
374
  }
316
375
 
317
- .SegmentedControlOption[data-state="off"]:where(:not([disabled])):active::before {
376
+ :where(.Tabs[data-variant="segmented"]) .Tab[data-state="off"]:where(:not([disabled])):active::before {
318
377
  opacity: 0.75;
319
378
  }
320
379
 
321
- .SegmentedControlOption[data-state="on"] {
380
+ /* =============================================
381
+ Underline variant — Tab styles
382
+ ============================================= */
383
+ :where(.Tabs[data-variant="underline"]) .Tab {
384
+ border-radius: 0;
385
+ }
386
+
387
+ :where(.Tabs[data-variant="underline"][data-block]) .Tab {
388
+ flex: 1;
389
+ }
390
+ @media (hover: hover) and (pointer: fine) {:where(.Tabs[data-variant="underline"]) .Tab[data-state="off"]:where(:not([disabled])):hover {
391
+ color: var(--color-text);
392
+ }
393
+ }
394
+
395
+ :where(.Tabs[data-variant="underline"]) .Tab[data-state="off"]:where(:not([disabled])):focus-visible {
396
+ color: var(--color-text);
397
+ outline: 2px solid var(--color-ring);
398
+ outline-offset: -2px;
399
+ }
400
+
401
+ /* =============================================
402
+ Vertical orientation — Tab styles
403
+ ============================================= */
404
+ :where(.Tabs[data-orientation="vertical"]) .Tab {
405
+ justify-content: flex-start;
406
+ width: 100%;
407
+ height: var(--segmented-control-size);
408
+ }
409
+
410
+ :where(.Tabs[data-orientation="vertical"][data-block]) .Tab {
411
+ flex: none;
412
+ }
413
+
414
+ /* =============================================
415
+ Shared states
416
+ ============================================= */
417
+ .Tab[data-state="on"] {
322
418
  color: var(--color-text);
323
419
  }
324
420
 
325
- .SegmentedControlOption[data-disabled] {
421
+ .Tab[data-state="on"]:focus-visible {
422
+ outline: 2px solid var(--color-ring);
423
+ }
424
+
425
+ .Tab[data-disabled] {
326
426
  cursor: not-allowed;
327
427
  opacity: 0.5;
328
428
  }
329
429
 
330
- .SegmentedControlOption[data-disabled]::before {
430
+ .Tab[data-disabled]::before {
331
431
  opacity: 0 !important;
332
- }.SegmentedControlThumb {
432
+ }
433
+
434
+ .Tab svg {
435
+ display: block;
436
+ }/* =============================================
437
+ Thumb (animated indicator)
438
+ ============================================= */.TabsThumb {
333
439
  position: absolute;
334
- top: var(--segmented-control-gutter);
335
- bottom: var(--segmented-control-gutter);
336
- left: 0;
337
- border-radius: var(--segmented-control-option-radius);
338
- background: var(--segmented-control-thumb-background);
339
- box-shadow: var(--segmented-control-thumb-shadow);
340
440
  pointer-events: none;
341
441
  will-change: transform;
342
- }/* =============================================
343
- Option Content (icon + text + badge layout)
344
- ============================================= */.SegmentedControlOptionContent {
442
+ }
443
+
444
+ /* Segmented horizontal: background highlight */
445
+ :where(.Tabs[data-variant="segmented"][data-orientation="horizontal"]) .TabsThumb {
446
+ top: var(--segmented-control-gutter);
447
+ bottom: var(--segmented-control-gutter);
448
+ left: 0;
449
+ border-radius: var(--segmented-control-option-radius);
450
+ background: var(--segmented-control-thumb-background);
451
+ box-shadow: var(--segmented-control-thumb-shadow);
452
+ }
453
+
454
+ /* Segmented vertical: background highlight */
455
+ :where(.Tabs[data-variant="segmented"][data-orientation="vertical"]) .TabsThumb {
456
+ left: var(--segmented-control-gutter);
457
+ right: var(--segmented-control-gutter);
458
+ top: 0;
459
+ border-radius: var(--segmented-control-option-radius);
460
+ background: var(--segmented-control-thumb-background);
461
+ box-shadow: var(--segmented-control-thumb-shadow);
462
+ }
463
+
464
+ /* Underline horizontal: thin line at the bottom */
465
+ :where(.Tabs[data-variant="underline"][data-orientation="horizontal"]) .TabsThumb {
466
+ bottom: 0;
467
+ left: 0;
468
+ height: var(--tabs-underline-indicator-height);
469
+ border-radius: calc(var(--tabs-underline-indicator-height) / 2) calc(var(--tabs-underline-indicator-height) / 2) 0 0;
470
+ background: var(--tabs-underline-indicator-color);
471
+ box-shadow: none;
472
+ }
473
+
474
+ /* Underline vertical: thin line on the left */
475
+ :where(.Tabs[data-variant="underline"][data-orientation="vertical"]) .TabsThumb {
476
+ left: 0;
477
+ top: 0;
478
+ width: var(--tabs-underline-indicator-height);
479
+ border-radius: 0 calc(var(--tabs-underline-indicator-height) / 2) calc(var(--tabs-underline-indicator-height) / 2) 0;
480
+ background: var(--tabs-underline-indicator-color);
481
+ box-shadow: none;
482
+ }/* =============================================
483
+ Tab Content (icon + text + badge layout)
484
+ ============================================= */.TabContent {
345
485
  position: relative;
346
486
  display: flex;
347
487
  align-items: center;
@@ -349,17 +489,17 @@
349
489
  }
350
490
 
351
491
  /* Icon auto-sizing (same as Button) */
352
- .SegmentedControlOptionContent svg:where(:not([data-no-autosize])) {
492
+ .TabContent svg:where(:not([data-no-autosize])) {
353
493
  width: var(--segmented-control-icon-size);
354
494
  height: var(--segmented-control-icon-size);
355
495
  }
356
496
 
357
497
  /* Negative margin for icon when followed by text */
358
- .SegmentedControlOptionContent svg:where(:first-child:not(:only-child)) {
498
+ .TabContent svg:where(:first-child:not(:only-child)) {
359
499
  margin-left: var(--segmented-control-icon-offset);
360
500
  }/* =============================================
361
- Option Badge (CSS-only, no Badge component)
362
- ============================================= */.OptionBadge {
501
+ Tab Badge (CSS-only, no Badge component)
502
+ ============================================= */.TabBadge {
363
503
  display: inline-flex;
364
504
  align-items: center;
365
505
  height: var(--option-badge-size);
@@ -377,37 +517,37 @@
377
517
  /* =============================================
378
518
  Soft variant (default)
379
519
  ============================================= */
380
- .OptionBadge[data-variant="soft"]:where([data-color="secondary"]) {
520
+ .TabBadge[data-variant="soft"]:where([data-color="secondary"]) {
381
521
  --option-badge-bg: var(--color-background-secondary-soft-alpha);
382
522
  --option-badge-color: var(--color-text-secondary-soft);
383
523
  }
384
524
 
385
- .OptionBadge[data-variant="soft"]:where([data-color="success"]) {
525
+ .TabBadge[data-variant="soft"]:where([data-color="success"]) {
386
526
  --option-badge-bg: var(--color-background-success-soft-alpha);
387
527
  --option-badge-color: var(--color-text-success-soft);
388
528
  }
389
529
 
390
- .OptionBadge[data-variant="soft"]:where([data-color="warning"]) {
530
+ .TabBadge[data-variant="soft"]:where([data-color="warning"]) {
391
531
  --option-badge-bg: var(--color-background-warning-soft-alpha);
392
532
  --option-badge-color: var(--color-text-warning-soft);
393
533
  }
394
534
 
395
- .OptionBadge[data-variant="soft"]:where([data-color="danger"]) {
535
+ .TabBadge[data-variant="soft"]:where([data-color="danger"]) {
396
536
  --option-badge-bg: var(--color-background-danger-soft-alpha);
397
537
  --option-badge-color: var(--color-text-danger-soft);
398
538
  }
399
539
 
400
- .OptionBadge[data-variant="soft"]:where([data-color="info"]) {
540
+ .TabBadge[data-variant="soft"]:where([data-color="info"]) {
401
541
  --option-badge-bg: var(--color-background-info-soft-alpha);
402
542
  --option-badge-color: var(--color-text-info-soft);
403
543
  }
404
544
 
405
- .OptionBadge[data-variant="soft"]:where([data-color="discovery"]) {
545
+ .TabBadge[data-variant="soft"]:where([data-color="discovery"]) {
406
546
  --option-badge-bg: var(--color-background-discovery-soft-alpha);
407
547
  --option-badge-color: var(--color-text-discovery-soft);
408
548
  }
409
549
 
410
- .OptionBadge[data-variant="soft"]:where([data-color="caution"]) {
550
+ .TabBadge[data-variant="soft"]:where([data-color="caution"]) {
411
551
  --option-badge-bg: var(--color-background-caution-soft-alpha);
412
552
  --option-badge-color: var(--color-text-caution-soft);
413
553
  }
@@ -415,37 +555,37 @@
415
555
  /* =============================================
416
556
  Solid variant
417
557
  ============================================= */
418
- .OptionBadge[data-variant="solid"]:where([data-color="secondary"]) {
558
+ .TabBadge[data-variant="solid"]:where([data-color="secondary"]) {
419
559
  --option-badge-bg: var(--color-background-secondary-solid);
420
560
  --option-badge-color: var(--color-text-secondary-solid);
421
561
  }
422
562
 
423
- .OptionBadge[data-variant="solid"]:where([data-color="success"]) {
563
+ .TabBadge[data-variant="solid"]:where([data-color="success"]) {
424
564
  --option-badge-bg: var(--color-background-success-solid);
425
565
  --option-badge-color: var(--color-text-success-solid);
426
566
  }
427
567
 
428
- .OptionBadge[data-variant="solid"]:where([data-color="warning"]) {
568
+ .TabBadge[data-variant="solid"]:where([data-color="warning"]) {
429
569
  --option-badge-bg: var(--color-background-warning-solid);
430
570
  --option-badge-color: var(--color-text-warning-solid);
431
571
  }
432
572
 
433
- .OptionBadge[data-variant="solid"]:where([data-color="danger"]) {
573
+ .TabBadge[data-variant="solid"]:where([data-color="danger"]) {
434
574
  --option-badge-bg: var(--color-background-danger-solid);
435
575
  --option-badge-color: var(--color-text-danger-solid);
436
576
  }
437
577
 
438
- .OptionBadge[data-variant="solid"]:where([data-color="info"]) {
578
+ .TabBadge[data-variant="solid"]:where([data-color="info"]) {
439
579
  --option-badge-bg: var(--color-background-info-solid);
440
580
  --option-badge-color: var(--color-text-info-solid);
441
581
  }
442
582
 
443
- .OptionBadge[data-variant="solid"]:where([data-color="discovery"]) {
583
+ .TabBadge[data-variant="solid"]:where([data-color="discovery"]) {
444
584
  --option-badge-bg: var(--color-background-discovery-solid);
445
585
  --option-badge-color: var(--color-text-discovery-solid);
446
586
  }
447
587
 
448
- .OptionBadge[data-variant="solid"]:where([data-color="caution"]) {
588
+ .TabBadge[data-variant="solid"]:where([data-color="caution"]) {
449
589
  --option-badge-bg: var(--color-background-caution-solid);
450
590
  --option-badge-color: var(--color-text-caution-solid);
451
591
  }
@@ -453,42 +593,42 @@
453
593
  /* =============================================
454
594
  Outline variant
455
595
  ============================================= */
456
- .OptionBadge[data-variant="outline"] {
596
+ .TabBadge[data-variant="outline"] {
457
597
  background-color: transparent;
458
598
  box-shadow: 0 0 0 1px var(--option-badge-border) inset;
459
599
  }
460
600
 
461
- .OptionBadge[data-variant="outline"]:where([data-color="secondary"]) {
601
+ .TabBadge[data-variant="outline"]:where([data-color="secondary"]) {
462
602
  --option-badge-border: var(--color-border-secondary-outline);
463
603
  --option-badge-color: var(--color-text-secondary-outline);
464
604
  }
465
605
 
466
- .OptionBadge[data-variant="outline"]:where([data-color="success"]) {
606
+ .TabBadge[data-variant="outline"]:where([data-color="success"]) {
467
607
  --option-badge-border: var(--color-border-success-outline);
468
608
  --option-badge-color: var(--color-text-success-outline);
469
609
  }
470
610
 
471
- .OptionBadge[data-variant="outline"]:where([data-color="warning"]) {
611
+ .TabBadge[data-variant="outline"]:where([data-color="warning"]) {
472
612
  --option-badge-border: var(--color-border-warning-outline);
473
613
  --option-badge-color: var(--color-text-warning-outline);
474
614
  }
475
615
 
476
- .OptionBadge[data-variant="outline"]:where([data-color="danger"]) {
616
+ .TabBadge[data-variant="outline"]:where([data-color="danger"]) {
477
617
  --option-badge-border: var(--color-border-danger-outline);
478
618
  --option-badge-color: var(--color-text-danger-outline);
479
619
  }
480
620
 
481
- .OptionBadge[data-variant="outline"]:where([data-color="info"]) {
621
+ .TabBadge[data-variant="outline"]:where([data-color="info"]) {
482
622
  --option-badge-border: var(--color-border-info-outline);
483
623
  --option-badge-color: var(--color-text-info-outline);
484
624
  }
485
625
 
486
- .OptionBadge[data-variant="outline"]:where([data-color="discovery"]) {
626
+ .TabBadge[data-variant="outline"]:where([data-color="discovery"]) {
487
627
  --option-badge-border: var(--color-border-discovery-outline);
488
628
  --option-badge-color: var(--color-text-discovery-outline);
489
629
  }
490
630
 
491
- .OptionBadge[data-variant="outline"]:where([data-color="caution"]) {
631
+ .TabBadge[data-variant="outline"]:where([data-color="caution"]) {
492
632
  --option-badge-border: var(--color-border-caution-outline);
493
633
  --option-badge-color: var(--color-text-caution-outline);
494
634
  }
@@ -496,7 +636,7 @@
496
636
  /* =============================================
497
637
  Pill
498
638
  ============================================= */
499
- .OptionBadge[data-pill] {
639
+ .TabBadge[data-pill] {
500
640
  border-radius: var(--radius-full);
501
641
  padding: 0 calc(var(--option-badge-gutter) * var(--control-gutter-pill-scaling));
502
642
  }
@@ -0,0 +1,2 @@
1
+ export { Tabs } from "./Tabs";
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../src/components/Tabs/index.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAA","sourcesContent":["export { Tabs } from \"./Tabs\"\nexport type {\n TabsBadgeProp,\n TabsOrientation,\n TabsProps,\n TabsVariant,\n TabProps,\n SizeVariant,\n} from \"./Tabs\"\n"]}
@@ -216,6 +216,13 @@
216
216
  --segmented-control-thumb-shadow: 0 1px 4px -1px rgb(0 0 0 / 20%);
217
217
  --segmented-control-option-highlight-gutter: 1px;
218
218
 
219
+ /* =============================================
220
+ Tabs — Underline variant
221
+ ============================================= */
222
+ --tabs-underline-indicator-height: 2px;
223
+ --tabs-underline-indicator-color: var(--color-background-primary-solid);
224
+ --tabs-underline-border-color: var(--color-border);
225
+
219
226
  /* =============================================
220
227
  SelectControl
221
228
  ============================================= */
@@ -1,2 +1,2 @@
1
- export { SegmentedControl } from "./SegmentedControl";
2
- export type { SegmentedControlBadgeProp, SegmentedControlOptionProps, SegmentedControlProps, SizeVariant, } from "./SegmentedControl";
1
+ export { Tabs as SegmentedControl } from "../Tabs";
2
+ export type { TabsBadgeProp as SegmentedControlBadgeProp, TabProps as SegmentedControlOptionProps, TabsProps as SegmentedControlProps, SizeVariant, } from "../Tabs";
@@ -1,6 +1,8 @@
1
1
  import { type ControlSize, type SemanticColors, type Sizes, type Variants } from "../../types";
2
2
  export type SizeVariant = "2xs" | "xs" | "sm" | "md" | "lg" | "xl";
3
- export type SegmentedControlProps<T extends string> = {
3
+ export type TabsVariant = "segmented" | "underline";
4
+ export type TabsOrientation = "horizontal" | "vertical";
5
+ export type TabsProps<T extends string> = {
4
6
  /**
5
7
  * Controlled value for the group
6
8
  */
@@ -14,7 +16,19 @@ export type SegmentedControlProps<T extends string> = {
14
16
  */
15
17
  "aria-label": string;
16
18
  /**
17
- * Controls the size of the segmented control
19
+ * Visual variant of the tab group
20
+ * - `"segmented"` — background container with sliding highlight (default)
21
+ * - `"underline"` — no background, animated line indicator under active tab
22
+ * @default "segmented"
23
+ */
24
+ "variant"?: TabsVariant;
25
+ /**
26
+ * Orientation of the tab layout
27
+ * @default "horizontal"
28
+ */
29
+ "orientation"?: TabsOrientation;
30
+ /**
31
+ * Controls the size of the tabs
18
32
  *
19
33
  * | 3xs | 2xs | xs | sm | md | lg | xl | 2xl | 3xl |
20
34
  * | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- |
@@ -39,38 +53,40 @@ export type SegmentedControlProps<T extends string> = {
39
53
  */
40
54
  "block"?: boolean;
41
55
  /**
42
- * Determines if the segment control, and its options, should be a fully rounded pill shape.
56
+ * Determines if the tabs should be a fully rounded pill shape.
57
+ * Only applies to the `"segmented"` variant.
43
58
  * @default true
44
59
  */
45
60
  "pill"?: boolean;
46
61
  "className"?: string;
47
62
  "children": React.ReactNode;
48
63
  };
49
- export declare const SegmentedControl: {
50
- <T extends string>({ value, onChange, children, block, pill, size, gutterSize, className, onClick, ...restProps }: SegmentedControlProps<T>): import("react/jsx-runtime").JSX.Element;
51
- Option: ({ children, icon, badge, ...restProps }: SegmentedControlOptionProps) => import("react/jsx-runtime").JSX.Element;
64
+ export declare const Tabs: {
65
+ <T extends string>({ value, onChange, children, variant, orientation, block, pill, size, gutterSize, className, onClick, ...restProps }: TabsProps<T>): import("react/jsx-runtime").JSX.Element;
66
+ Tab: ({ children, icon, badge, ...restProps }: TabProps) => import("react/jsx-runtime").JSX.Element;
67
+ Option: ({ children, icon, badge, ...restProps }: TabProps) => import("react/jsx-runtime").JSX.Element;
52
68
  };
53
69
  /**
54
- * Badge configuration for SegmentedControl.Option
70
+ * Badge configuration for Tabs.Tab
55
71
  */
56
- export type SegmentedControlBadgeProp = React.ReactNode | {
72
+ export type TabsBadgeProp = React.ReactNode | {
57
73
  content: React.ReactNode;
58
74
  color?: SemanticColors<"secondary" | "success" | "danger" | "warning" | "info" | "discovery" | "caution">;
59
75
  variant?: Variants<"soft" | "solid">;
60
76
  pill?: boolean;
61
77
  loading?: boolean;
62
78
  };
63
- export type SegmentedControlOptionProps = {
79
+ export type TabProps = {
64
80
  /**
65
- * Option value
81
+ * Tab value
66
82
  */
67
83
  "value": string;
68
84
  /**
69
- * Text read aloud to screen readers when the option is focused
85
+ * Text read aloud to screen readers when the tab is focused
70
86
  */
71
87
  "aria-label"?: string;
72
88
  /**
73
- * Text content to render in the option
89
+ * Text content to render in the tab
74
90
  */
75
91
  "children"?: React.ReactNode;
76
92
  /**
@@ -83,9 +99,9 @@ export type SegmentedControlOptionProps = {
83
99
  * @example badge={5}
84
100
  * @example badge={{ content: 5, color: "danger" }}
85
101
  */
86
- "badge"?: SegmentedControlBadgeProp;
102
+ "badge"?: TabsBadgeProp;
87
103
  /**
88
- * Disable the individual option
104
+ * Disable the individual tab
89
105
  */
90
106
  "disabled"?: boolean;
91
107
  };
@@ -0,0 +1,2 @@
1
+ export { Tabs } from "./Tabs";
2
+ export type { TabsBadgeProp, TabsOrientation, TabsProps, TabsVariant, TabProps, SizeVariant, } from "./Tabs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plexui/ui",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Modern design system for building high-quality applications",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,118 +0,0 @@
1
- "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import clsx from "clsx";
4
- import { ToggleGroup } from "radix-ui";
5
- import { useCallback, useLayoutEffect, useRef } from "react";
6
- import { useResizeObserver } from "usehooks-ts";
7
- import { handlePressableMouseEnter, waitForAnimationFrame } from "../../lib/helpers";
8
- import {} from "../../types";
9
- import { LoadingIndicator } from "../Indicator";
10
- import s from "./SegmentedControl.module.css";
11
- export const SegmentedControl = ({ value, onChange, children, block, pill = true, size = "md", gutterSize, className, onClick, ...restProps }) => {
12
- const rootRef = useRef(null);
13
- const thumbRef = useRef(null);
14
- const prevSizeRef = useRef(size);
15
- const applyThumbSizing = useCallback((attemptScroll) => {
16
- const root = rootRef.current;
17
- const thumb = thumbRef.current;
18
- if (!root || !thumb) {
19
- return;
20
- }
21
- // Get selected node
22
- const activeNode = root?.querySelector('[data-state="on"]');
23
- // Impossible
24
- if (!activeNode) {
25
- return;
26
- }
27
- const rootWidth = root.clientWidth;
28
- let targetWidth = Math.floor(activeNode.clientWidth);
29
- const targetOffset = activeNode.offsetLeft;
30
- // Detect if the thumb is moving too far to the edge of the container.
31
- // This would most commonly be due to subpixel widths adding up to excessive distance.
32
- if (rootWidth - (targetWidth + targetOffset) < 2) {
33
- targetWidth = targetWidth - 1;
34
- }
35
- thumb.style.width = `${Math.floor(targetWidth)}px`;
36
- thumb.style.transform = `translateX(${targetOffset}px)`;
37
- // If the control is scrollable, ensure the active option is visible
38
- if (root.scrollWidth > rootWidth) {
39
- // Only scroll items near the edge, but not the inner 2/3.
40
- const buffer = rootWidth * 0.15;
41
- const scrollLeft = root.scrollLeft;
42
- const left = activeNode.offsetLeft;
43
- const right = left + targetWidth;
44
- if (left < scrollLeft + buffer || right > scrollLeft + rootWidth - buffer) {
45
- // Cheap trick to avoid unintentional scroll on mount - transition is set after mounting
46
- if (attemptScroll) {
47
- activeNode.scrollIntoView({ block: "nearest", inline: "center", behavior: "smooth" });
48
- }
49
- }
50
- }
51
- }, []);
52
- useResizeObserver({
53
- // @ts-expect-error(2322) -- bug in types: https://github.com/juliencrn/usehooks-ts/issues/663
54
- ref: rootRef,
55
- onResize: () => {
56
- const thumb = thumbRef.current;
57
- if (!thumb) {
58
- return;
59
- }
60
- // Perform the size update instantly
61
- const currentTransition = thumb.style.transition;
62
- thumb.style.transition = "";
63
- applyThumbSizing(false);
64
- thumb.style.transition = currentTransition;
65
- },
66
- });
67
- useLayoutEffect(() => {
68
- const root = rootRef.current;
69
- const thumb = thumbRef.current;
70
- if (!root || !thumb) {
71
- return;
72
- }
73
- const sizeChanged = prevSizeRef.current !== size;
74
- prevSizeRef.current = size;
75
- if (sizeChanged) {
76
- // Size changed - disable transition, wait for CSS, then apply sizing
77
- const currentTransition = thumb.style.transition;
78
- thumb.style.transition = "";
79
- waitForAnimationFrame(() => {
80
- applyThumbSizing(false);
81
- waitForAnimationFrame(() => {
82
- thumb.style.transition = currentTransition;
83
- });
84
- });
85
- }
86
- else {
87
- // Normal update (value change, etc.)
88
- waitForAnimationFrame(() => {
89
- applyThumbSizing(!!thumb.style.transition);
90
- // Apply transition after initial calculation is set
91
- if (!thumb.style.transition) {
92
- waitForAnimationFrame(() => {
93
- thumb.style.transition =
94
- "width 300ms var(--cubic-enter), transform 300ms var(--cubic-enter)";
95
- });
96
- }
97
- });
98
- }
99
- }, [applyThumbSizing, value, size, gutterSize, pill]);
100
- const handleValueChange = (nextValue) => {
101
- // Only trigger onChange when a value exists
102
- // Disallow toggling off enabled items
103
- if (nextValue && onChange)
104
- onChange(nextValue);
105
- };
106
- return (_jsxs(ToggleGroup.Root, { ref: rootRef, className: clsx(s.SegmentedControl, className), type: "single", value: value, loop: false, onValueChange: handleValueChange, onClick: onClick, "data-block": block ? "" : undefined, "data-pill": pill ? "" : undefined, "data-size": size, "data-gutter-size": gutterSize, ...restProps, children: [_jsx("div", { className: s.SegmentedControlThumb, ref: thumbRef }), children] }));
107
- };
108
- // Type guard for badge object form
109
- const isBadgeObject = (badge) => {
110
- return badge != null && typeof badge === "object" && "content" in badge;
111
- };
112
- const Segment = ({ children, icon, badge, ...restProps }) => {
113
- // Normalize badge prop
114
- const badgeProps = badge != null ? (isBadgeObject(badge) ? badge : { content: badge }) : null;
115
- return (_jsx(ToggleGroup.Item, { className: s.SegmentedControlOption, ...restProps, onPointerEnter: handlePressableMouseEnter, children: _jsxs("span", { className: s.SegmentedControlOptionContent, children: [icon, children && _jsx("span", { children: children }), badgeProps && (_jsx("span", { className: s.OptionBadge, "data-color": badgeProps.color ?? "secondary", "data-variant": badgeProps.variant ?? "soft", "data-pill": badgeProps.pill ? "" : undefined, children: badgeProps.loading ? _jsx(LoadingIndicator, {}) : badgeProps.content }))] }) }));
116
- };
117
- SegmentedControl.Option = Segment;
118
- //# sourceMappingURL=SegmentedControl.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"SegmentedControl.js","sourceRoot":"","sources":["../../../../src/components/SegmentedControl/SegmentedControl.tsx"],"names":[],"mappings":"AAAA,YAAY,CAAA;;AAEZ,OAAO,IAAI,MAAM,MAAM,CAAA;AACvB,OAAO,EAAE,WAAW,EAAE,MAAM,UAAU,CAAA;AACtC,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,EAAE,MAAM,OAAO,CAAA;AAC5D,OAAO,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAC/C,OAAO,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAA;AACpF,OAAO,EAAoE,MAAM,aAAa,CAAA;AAC9F,OAAO,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAA;AAC/C,OAAO,CAAC,MAAM,+BAA+B,CAAA;AAmD7C,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAmB,EACjD,KAAK,EACL,QAAQ,EACR,QAAQ,EACR,KAAK,EACL,IAAI,GAAG,IAAI,EACX,IAAI,GAAG,IAAI,EACX,UAAU,EACV,SAAS,EACT,OAAO,EACP,GAAG,SAAS,EACa,EAAE,EAAE;IAC7B,MAAM,OAAO,GAAG,MAAM,CAAiB,IAAI,CAAC,CAAA;IAC5C,MAAM,QAAQ,GAAG,MAAM,CAAiB,IAAI,CAAC,CAAA;IAC7C,MAAM,WAAW,GAAG,MAAM,CAAC,IAAI,CAAC,CAAA;IAEhC,MAAM,gBAAgB,GAAG,WAAW,CAAC,CAAC,aAAsB,EAAE,EAAE;QAC9D,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAA;QAC5B,MAAM,KAAK,GAAG,QAAQ,CAAC,OAAO,CAAA;QAE9B,IAAI,CAAC,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YACpB,OAAM;QACR,CAAC;QAED,oBAAoB;QACpB,MAAM,UAAU,GAAG,IAAI,EAAE,aAAa,CAAiB,mBAAmB,CAAC,CAAA;QAE3E,aAAa;QACb,IAAI,CAAC,UAAU,EAAE,CAAC;YAChB,OAAM;QACR,CAAC;QAED,MAAM,SAAS,GAAG,IAAI,CAAC,WAAW,CAAA;QAClC,IAAI,WAAW,GAAG,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,WAAW,CAAC,CAAA;QACpD,MAAM,YAAY,GAAG,UAAU,CAAC,UAAU,CAAA;QAE1C,sEAAsE;QACtE,sFAAsF;QACtF,IAAI,SAAS,GAAG,CAAC,WAAW,GAAG,YAAY,CAAC,GAAG,CAAC,EAAE,CAAC;YACjD,WAAW,GAAG,WAAW,GAAG,CAAC,CAAA;QAC/B,CAAC;QAED,KAAK,CAAC,KAAK,CAAC,KAAK,GAAG,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,IAAI,CAAA;QAClD,KAAK,CAAC,KAAK,CAAC,SAAS,GAAG,cAAc,YAAY,KAAK,CAAA;QAEvD,oEAAoE;QACpE,IAAI,IAAI,CAAC,WAAW,GAAG,SAAS,EAAE,CAAC;YACjC,0DAA0D;YAC1D,MAAM,MAAM,GAAG,SAAS,GAAG,IAAI,CAAA;YAC/B,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,CAAA;YAClC,MAAM,IAAI,GAAG,UAAU,CAAC,UAAU,CAAA;YAClC,MAAM,KAAK,GAAG,IAAI,GAAG,WAAW,CAAA;YAChC,IAAI,IAAI,GAAG,UAAU,GAAG,MAAM,IAAI,KAAK,GAAG,UAAU,GAAG,SAAS,GAAG,MAAM,EAAE,CAAC;gBAC1E,wFAAwF;gBACxF,IAAI,aAAa,EAAE,CAAC;oBAClB,UAAU,CAAC,cAAc,CAAC,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC,CAAA;gBACvF,CAAC;YACH,CAAC;QACH,CAAC;IACH,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,iBAAiB,CAAC;QAChB,8FAA8F;QAC9F,GAAG,EAAE,OAAO;QACZ,QAAQ,EAAE,GAAG,EAAE;YACb,MAAM,KAAK,GAAG,QAAQ,CAAC,OAAO,CAAA;YAE9B,IAAI,CAAC,KAAK,EAAE,CAAC;gBACX,OAAM;YACR,CAAC;YAED,oCAAoC;YACpC,MAAM,iBAAiB,GAAG,KAAK,CAAC,KAAK,CAAC,UAAU,CAAA;YAChD,KAAK,CAAC,KAAK,CAAC,UAAU,GAAG,EAAE,CAAA;YAC3B,gBAAgB,CAAC,KAAK,CAAC,CAAA;YACvB,KAAK,CAAC,KAAK,CAAC,UAAU,GAAG,iBAAiB,CAAA;QAC5C,CAAC;KACF,CAAC,CAAA;IAEF,eAAe,CAAC,GAAG,EAAE;QACnB,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAA;QAC5B,MAAM,KAAK,GAAG,QAAQ,CAAC,OAAO,CAAA;QAE9B,IAAI,CAAC,IAAI,IAAI,CAAC,KAAK,EAAE,CAAC;YACpB,OAAM;QACR,CAAC;QAED,MAAM,WAAW,GAAG,WAAW,CAAC,OAAO,KAAK,IAAI,CAAA;QAChD,WAAW,CAAC,OAAO,GAAG,IAAI,CAAA;QAE1B,IAAI,WAAW,EAAE,CAAC;YAChB,qEAAqE;YACrE,MAAM,iBAAiB,GAAG,KAAK,CAAC,KAAK,CAAC,UAAU,CAAA;YAChD,KAAK,CAAC,KAAK,CAAC,UAAU,GAAG,EAAE,CAAA;YAE3B,qBAAqB,CAAC,GAAG,EAAE;gBACzB,gBAAgB,CAAC,KAAK,CAAC,CAAA;gBACvB,qBAAqB,CAAC,GAAG,EAAE;oBACzB,KAAK,CAAC,KAAK,CAAC,UAAU,GAAG,iBAAiB,CAAA;gBAC5C,CAAC,CAAC,CAAA;YACJ,CAAC,CAAC,CAAA;QACJ,CAAC;aAAM,CAAC;YACN,qCAAqC;YACrC,qBAAqB,CAAC,GAAG,EAAE;gBACzB,gBAAgB,CAAC,CAAC,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,CAAC,CAAA;gBAE1C,oDAAoD;gBACpD,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,UAAU,EAAE,CAAC;oBAC5B,qBAAqB,CAAC,GAAG,EAAE;wBACzB,KAAK,CAAC,KAAK,CAAC,UAAU;4BACpB,oEAAoE,CAAA;oBACxE,CAAC,CAAC,CAAA;gBACJ,CAAC;YACH,CAAC,CAAC,CAAA;QACJ,CAAC;IACH,CAAC,EAAE,CAAC,gBAAgB,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,CAAC,CAAC,CAAA;IAErD,MAAM,iBAAiB,GAAG,CAAC,SAAY,EAAE,EAAE;QACzC,4CAA4C;QAC5C,sCAAsC;QACtC,IAAI,SAAS,IAAI,QAAQ;YAAE,QAAQ,CAAC,SAAS,CAAC,CAAA;IAChD,CAAC,CAAA;IAED,OAAO,CACL,MAAC,WAAW,CAAC,IAAI,IACf,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,IAAI,CAAC,CAAC,CAAC,gBAAgB,EAAE,SAAS,CAAC,EAC9C,IAAI,EAAC,QAAQ,EACb,KAAK,EAAE,KAAK,EACZ,IAAI,EAAE,KAAK,EACX,aAAa,EAAE,iBAAiB,EAChC,OAAO,EAAE,OAAO,gBACJ,KAAK,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,eACvB,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,eACrB,IAAI,sBACG,UAAU,KACxB,SAAS,aAEb,cAAK,SAAS,EAAE,CAAC,CAAC,qBAAqB,EAAE,GAAG,EAAE,QAAQ,GAAI,EACzD,QAAQ,IACQ,CACpB,CAAA;AACH,CAAC,CAAA;AA+CD,mCAAmC;AACnC,MAAM,aAAa,GAAG,CACpB,KAAgC,EAC6D,EAAE;IAC/F,OAAO,KAAK,IAAI,IAAI,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,SAAS,IAAI,KAAK,CAAA;AACzE,CAAC,CAAA;AAED,MAAM,OAAO,GAAG,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,GAAG,SAAS,EAA+B,EAAE,EAAE;IACvF,uBAAuB;IACvB,MAAM,UAAU,GAAG,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;IAE7F,OAAO,CACL,KAAC,WAAW,CAAC,IAAI,IACf,SAAS,EAAE,CAAC,CAAC,sBAAsB,KAC/B,SAAS,EACb,cAAc,EAAE,yBAAyB,YAEzC,gBAAM,SAAS,EAAE,CAAC,CAAC,6BAA6B,aAC7C,IAAI,EACJ,QAAQ,IAAI,yBAAO,QAAQ,GAAQ,EACnC,UAAU,IAAI,CACb,eACE,SAAS,EAAE,CAAC,CAAC,WAAW,gBACZ,UAAU,CAAC,KAAK,IAAI,WAAW,kBAC7B,UAAU,CAAC,OAAO,IAAI,MAAM,eAC/B,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,YAE1C,UAAU,CAAC,OAAO,CAAC,CAAC,CAAC,KAAC,gBAAgB,KAAG,CAAC,CAAC,CAAC,UAAU,CAAC,OAAO,GAC1D,CACR,IACI,GACU,CACpB,CAAA;AACH,CAAC,CAAA;AAED,gBAAgB,CAAC,MAAM,GAAG,OAAO,CAAA","sourcesContent":["\"use client\"\n\nimport clsx from \"clsx\"\nimport { ToggleGroup } from \"radix-ui\"\nimport { useCallback, useLayoutEffect, useRef } from \"react\"\nimport { useResizeObserver } from \"usehooks-ts\"\nimport { handlePressableMouseEnter, waitForAnimationFrame } from \"../../lib/helpers\"\nimport { type ControlSize, type SemanticColors, type Sizes, type Variants } from \"../../types\"\nimport { LoadingIndicator } from \"../Indicator\"\nimport s from \"./SegmentedControl.module.css\"\n\nexport type SizeVariant = \"2xs\" | \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"\n\nexport type SegmentedControlProps<T extends string> = {\n /**\n * Controlled value for the group\n */\n \"value\": T\n /** Callback for when a new value is selected */\n \"onChange\"?: (nextValue: T) => void\n /** Callback any time the control is clicked (even if a new value was not selected) */\n \"onClick\"?: () => void\n /**\n * Text read aloud to screen readers when the control is focused\n */\n \"aria-label\": string\n /**\n * Controls the size of the segmented control\n *\n * | 3xs | 2xs | xs | sm | md | lg | xl | 2xl | 3xl |\n * | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- | ------- |\n * | `22px` | `24px` | `26px` | `28px` | `32px` | `36px` | `40px` | `44px` | `48px` |\n *\n * @default md\n */\n \"size\"?: ControlSize\n /**\n * Controls gutter on the edges of the button, defaults to value from `size`.\n *\n * | 2xs | xs | sm | md | lg | xl |\n * | ------ | ------ | ------ | ------ | ------ | ------ |\n * | `6px` | `8px` | `10px` | `12px` | `14px` | `16px` |\n */\n \"gutterSize\"?: Sizes<\"2xs\" | \"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\">\n /** Disable the entire group */\n \"disabled\"?: boolean\n /**\n * Display the control as a block element with equal width segments\n * @default false\n */\n \"block\"?: boolean\n /**\n * Determines if the segment control, and its options, should be a fully rounded pill shape.\n * @default true\n */\n \"pill\"?: boolean\n \"className\"?: string\n \"children\": React.ReactNode\n}\n\nexport const SegmentedControl = <T extends string>({\n value,\n onChange,\n children,\n block,\n pill = true,\n size = \"md\",\n gutterSize,\n className,\n onClick,\n ...restProps\n}: SegmentedControlProps<T>) => {\n const rootRef = useRef<HTMLDivElement>(null)\n const thumbRef = useRef<HTMLDivElement>(null)\n const prevSizeRef = useRef(size)\n\n const applyThumbSizing = useCallback((attemptScroll: boolean) => {\n const root = rootRef.current\n const thumb = thumbRef.current\n\n if (!root || !thumb) {\n return\n }\n\n // Get selected node\n const activeNode = root?.querySelector<HTMLDivElement>('[data-state=\"on\"]')\n\n // Impossible\n if (!activeNode) {\n return\n }\n\n const rootWidth = root.clientWidth\n let targetWidth = Math.floor(activeNode.clientWidth)\n const targetOffset = activeNode.offsetLeft\n\n // Detect if the thumb is moving too far to the edge of the container.\n // This would most commonly be due to subpixel widths adding up to excessive distance.\n if (rootWidth - (targetWidth + targetOffset) < 2) {\n targetWidth = targetWidth - 1\n }\n\n thumb.style.width = `${Math.floor(targetWidth)}px`\n thumb.style.transform = `translateX(${targetOffset}px)`\n\n // If the control is scrollable, ensure the active option is visible\n if (root.scrollWidth > rootWidth) {\n // Only scroll items near the edge, but not the inner 2/3.\n const buffer = rootWidth * 0.15\n const scrollLeft = root.scrollLeft\n const left = activeNode.offsetLeft\n const right = left + targetWidth\n if (left < scrollLeft + buffer || right > scrollLeft + rootWidth - buffer) {\n // Cheap trick to avoid unintentional scroll on mount - transition is set after mounting\n if (attemptScroll) {\n activeNode.scrollIntoView({ block: \"nearest\", inline: \"center\", behavior: \"smooth\" })\n }\n }\n }\n }, [])\n\n useResizeObserver({\n // @ts-expect-error(2322) -- bug in types: https://github.com/juliencrn/usehooks-ts/issues/663\n ref: rootRef,\n onResize: () => {\n const thumb = thumbRef.current\n\n if (!thumb) {\n return\n }\n\n // Perform the size update instantly\n const currentTransition = thumb.style.transition\n thumb.style.transition = \"\"\n applyThumbSizing(false)\n thumb.style.transition = currentTransition\n },\n })\n\n useLayoutEffect(() => {\n const root = rootRef.current\n const thumb = thumbRef.current\n\n if (!root || !thumb) {\n return\n }\n\n const sizeChanged = prevSizeRef.current !== size\n prevSizeRef.current = size\n\n if (sizeChanged) {\n // Size changed - disable transition, wait for CSS, then apply sizing\n const currentTransition = thumb.style.transition\n thumb.style.transition = \"\"\n\n waitForAnimationFrame(() => {\n applyThumbSizing(false)\n waitForAnimationFrame(() => {\n thumb.style.transition = currentTransition\n })\n })\n } else {\n // Normal update (value change, etc.)\n waitForAnimationFrame(() => {\n applyThumbSizing(!!thumb.style.transition)\n\n // Apply transition after initial calculation is set\n if (!thumb.style.transition) {\n waitForAnimationFrame(() => {\n thumb.style.transition =\n \"width 300ms var(--cubic-enter), transform 300ms var(--cubic-enter)\"\n })\n }\n })\n }\n }, [applyThumbSizing, value, size, gutterSize, pill])\n\n const handleValueChange = (nextValue: T) => {\n // Only trigger onChange when a value exists\n // Disallow toggling off enabled items\n if (nextValue && onChange) onChange(nextValue)\n }\n\n return (\n <ToggleGroup.Root\n ref={rootRef}\n className={clsx(s.SegmentedControl, className)}\n type=\"single\"\n value={value}\n loop={false}\n onValueChange={handleValueChange}\n onClick={onClick}\n data-block={block ? \"\" : undefined}\n data-pill={pill ? \"\" : undefined}\n data-size={size}\n data-gutter-size={gutterSize}\n {...restProps}\n >\n <div className={s.SegmentedControlThumb} ref={thumbRef} />\n {children}\n </ToggleGroup.Root>\n )\n}\n\n/**\n * Badge configuration for SegmentedControl.Option\n */\nexport type SegmentedControlBadgeProp =\n | React.ReactNode\n | {\n content: React.ReactNode\n color?: SemanticColors<\n \"secondary\" | \"success\" | \"danger\" | \"warning\" | \"info\" | \"discovery\" | \"caution\"\n >\n variant?: Variants<\"soft\" | \"solid\">\n pill?: boolean\n loading?: boolean\n }\n\nexport type SegmentedControlOptionProps = {\n /**\n * Option value\n */\n \"value\": string\n /**\n * Text read aloud to screen readers when the option is focused\n */\n \"aria-label\"?: string\n /**\n * Text content to render in the option\n */\n \"children\"?: React.ReactNode\n /**\n * Icon to render before the text content\n */\n \"icon\"?: React.ReactNode\n /**\n * Badge to render after the text content.\n * Can be a simple value or an object with content, color, variant, and loading options.\n * @example badge={5}\n * @example badge={{ content: 5, color: \"danger\" }}\n */\n \"badge\"?: SegmentedControlBadgeProp\n /**\n * Disable the individual option\n */\n \"disabled\"?: boolean\n}\n\n// Type guard for badge object form\nconst isBadgeObject = (\n badge: SegmentedControlBadgeProp,\n): badge is Exclude<SegmentedControlBadgeProp, React.ReactNode> & { content: React.ReactNode } => {\n return badge != null && typeof badge === \"object\" && \"content\" in badge\n}\n\nconst Segment = ({ children, icon, badge, ...restProps }: SegmentedControlOptionProps) => {\n // Normalize badge prop\n const badgeProps = badge != null ? (isBadgeObject(badge) ? badge : { content: badge }) : null\n\n return (\n <ToggleGroup.Item\n className={s.SegmentedControlOption}\n {...restProps}\n onPointerEnter={handlePressableMouseEnter}\n >\n <span className={s.SegmentedControlOptionContent}>\n {icon}\n {children && <span>{children}</span>}\n {badgeProps && (\n <span\n className={s.OptionBadge}\n data-color={badgeProps.color ?? \"secondary\"}\n data-variant={badgeProps.variant ?? \"soft\"}\n data-pill={badgeProps.pill ? \"\" : undefined}\n >\n {badgeProps.loading ? <LoadingIndicator /> : badgeProps.content}\n </span>\n )}\n </span>\n </ToggleGroup.Item>\n )\n}\n\nSegmentedControl.Option = Segment\n"]}