@plexui/ui 0.3.0 → 0.4.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.
@@ -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,100 @@
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 {
31
21
  background: transparent;
32
22
  }
33
23
 
34
- .SegmentedControl:where([data-block]) {
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"][data-block]) {
35
36
  overflow: hidden;
36
37
  display: flex;
37
38
  width: 100%;
38
39
  white-space: normal;
39
40
  }
40
41
 
42
+ .Tabs:where([data-variant="segmented"][data-pill]) {
43
+ --segmented-control-radius: var(--radius-full);
44
+ --segmented-control-option-radius: var(--radius-full);
45
+ }
46
+
47
+ /* =============================================
48
+ Variant: Underline
49
+ ============================================= */
50
+ .Tabs:where([data-variant="underline"]) {
51
+ overflow: auto;
52
+ height: var(--segmented-control-size);
53
+ padding: 0;
54
+ border-radius: 0;
55
+ background: transparent;
56
+ gap: 0;
57
+ border-bottom: 1px solid var(--tabs-underline-border-color);
58
+ }
59
+
60
+ .Tabs:where([data-variant="underline"][data-block]) {
61
+ overflow: hidden;
62
+ display: flex;
63
+ width: 100%;
64
+ white-space: normal;
65
+ }
66
+
67
+ /* =============================================
68
+ Vertical orientation
69
+ ============================================= */
70
+ .Tabs:where([data-orientation="vertical"]) {
71
+ flex-direction: column;
72
+ height: auto;
73
+ width: -moz-fit-content;
74
+ width: fit-content;
75
+ white-space: nowrap;
76
+ }
77
+
78
+ .Tabs:where([data-orientation="vertical"][data-variant="segmented"]) {
79
+ overflow-y: auto;
80
+ overflow-x: hidden;
81
+ }
82
+
83
+ .Tabs:where([data-orientation="vertical"][data-variant="underline"]) {
84
+ overflow-y: auto;
85
+ overflow-x: hidden;
86
+ border-bottom: none;
87
+ border-left: 1px solid var(--tabs-underline-border-color);
88
+ }
89
+
90
+ .Tabs:where([data-orientation="vertical"][data-block]) {
91
+ width: 100%;
92
+ }
93
+
41
94
  /* =============================================
42
95
  Sizes
43
96
  ============================================= */
44
- .SegmentedControl:where([data-size="3xs"]) {
97
+ .Tabs:where([data-size="3xs"]) {
45
98
  --segmented-control-size: var(--control-size-3xs);
46
99
  --segmented-control-font-size: var(--control-font-size-sm);
47
100
  --segmented-control-radius: var(--control-radius-sm);
@@ -60,7 +113,7 @@
60
113
  --option-badge-radius: var(--badge-radius-2xs);
61
114
  }
62
115
 
63
- .SegmentedControl:where([data-size="2xs"]) {
116
+ .Tabs:where([data-size="2xs"]) {
64
117
  --segmented-control-size: var(--control-size-2xs);
65
118
  --segmented-control-font-size: var(--control-font-size-sm);
66
119
  --segmented-control-radius: var(--control-radius-sm);
@@ -79,7 +132,7 @@
79
132
  --option-badge-radius: var(--badge-radius-2xs);
80
133
  }
81
134
 
82
- .SegmentedControl:where([data-size="xs"]) {
135
+ .Tabs:where([data-size="xs"]) {
83
136
  --segmented-control-size: var(--control-size-xs);
84
137
  --segmented-control-font-size: var(--control-font-size-md);
85
138
  --segmented-control-radius: var(--control-radius-sm);
@@ -98,7 +151,7 @@
98
151
  --option-badge-radius: var(--badge-radius-xs);
99
152
  }
100
153
 
101
- .SegmentedControl:where([data-size="sm"]) {
154
+ .Tabs:where([data-size="sm"]) {
102
155
  --segmented-control-size: var(--control-size-sm);
103
156
  --segmented-control-font-size: var(--control-font-size-md);
104
157
  --segmented-control-radius: var(--control-radius-md);
@@ -117,7 +170,7 @@
117
170
  --option-badge-radius: var(--badge-radius-sm);
118
171
  }
119
172
 
120
- .SegmentedControl:where([data-size="md"]) {
173
+ .Tabs:where([data-size="md"]) {
121
174
  --segmented-control-size: var(--control-size-md);
122
175
  --segmented-control-font-size: var(--control-font-size-md);
123
176
  --segmented-control-radius: var(--control-radius-md);
@@ -136,7 +189,7 @@
136
189
  --option-badge-radius: var(--badge-radius-md);
137
190
  }
138
191
 
139
- .SegmentedControl:where([data-size="lg"]) {
192
+ .Tabs:where([data-size="lg"]) {
140
193
  --segmented-control-size: var(--control-size-lg);
141
194
  --segmented-control-font-size: var(--control-font-size-md);
142
195
  --segmented-control-radius: var(--control-radius-md);
@@ -155,7 +208,7 @@
155
208
  --option-badge-radius: var(--badge-radius-md);
156
209
  }
157
210
 
158
- .SegmentedControl:where([data-size="xl"]) {
211
+ .Tabs:where([data-size="xl"]) {
159
212
  --segmented-control-size: var(--control-size-xl);
160
213
  --segmented-control-font-size: var(--control-font-size-md);
161
214
  --segmented-control-radius: var(--control-radius-lg);
@@ -174,7 +227,7 @@
174
227
  --option-badge-radius: var(--badge-radius-md);
175
228
  }
176
229
 
177
- .SegmentedControl:where([data-size="2xl"]) {
230
+ .Tabs:where([data-size="2xl"]) {
178
231
  --segmented-control-size: var(--control-size-2xl);
179
232
  --segmented-control-font-size: var(--control-font-size-lg);
180
233
  --segmented-control-radius: var(--control-radius-xl);
@@ -193,7 +246,7 @@
193
246
  --option-badge-radius: var(--badge-radius-lg);
194
247
  }
195
248
 
196
- .SegmentedControl:where([data-size="3xl"]) {
249
+ .Tabs:where([data-size="3xl"]) {
197
250
  --segmented-control-size: var(--control-size-3xl);
198
251
  --segmented-control-font-size: var(--control-font-size-lg);
199
252
  --segmented-control-radius: var(--control-radius-xl);
@@ -213,42 +266,43 @@
213
266
  }
214
267
 
215
268
  /* =============================================
216
- Gutter sizes
269
+ Gutter sizes (segmented variant only)
217
270
  ============================================= */
218
- .SegmentedControl:where([data-gutter-size="2xs"]) {
271
+ .Tabs:where([data-gutter-size="2xs"]) {
219
272
  --segmented-control-option-gutter: var(--control-gutter-2xs);
220
273
  }
221
274
 
222
- .SegmentedControl:where([data-gutter-size="xs"]) {
275
+ .Tabs:where([data-gutter-size="xs"]) {
223
276
  --segmented-control-option-gutter: var(--control-gutter-xs);
224
277
  }
225
278
 
226
- .SegmentedControl:where([data-gutter-size="sm"]) {
279
+ .Tabs:where([data-gutter-size="sm"]) {
227
280
  --segmented-control-option-gutter: var(--control-gutter-sm);
228
281
  }
229
282
 
230
- .SegmentedControl:where([data-gutter-size="md"]) {
283
+ .Tabs:where([data-gutter-size="md"]) {
231
284
  --segmented-control-option-gutter: var(--control-gutter-md);
232
285
  }
233
286
 
234
- .SegmentedControl:where([data-gutter-size="lg"]) {
287
+ .Tabs:where([data-gutter-size="lg"]) {
235
288
  --segmented-control-option-gutter: var(--control-gutter-lg);
236
289
  }
237
290
 
238
- .SegmentedControl:where([data-gutter-size="xl"]) {
291
+ .Tabs:where([data-gutter-size="xl"]) {
239
292
  --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 {
293
+ }/* =============================================
294
+ Option radius (computed from parent for segmented)
295
+ ============================================= */.Tabs:where([data-variant="segmented"]) {
296
+ --segmented-control-option-radius: calc(
297
+ var(--segmented-control-radius) - var(--segmented-control-gutter)
298
+ );
299
+ }.Tabs:where([data-variant="underline"]) {
300
+ --segmented-control-option-radius: 0;
301
+ }/* =============================================
302
+ Tab (trigger item)
303
+ ============================================= */.Tab {
249
304
  position: relative;
250
305
  padding: 0 var(--segmented-control-option-gutter);
251
- border-radius: var(--segmented-control-option-radius);
252
306
  color: var(--color-text-secondary);
253
307
  cursor: pointer;
254
308
  line-height: 1;
@@ -261,24 +315,28 @@
261
315
  transition-timing-function: var(--transition-ease-basic);
262
316
  }
263
317
 
264
- .SegmentedControlOption:focus {
318
+ .Tab:focus {
265
319
  outline: 0;
266
320
  }
267
321
 
268
- :where(.SegmentedControl[data-block]) .SegmentedControlOption {
322
+ /* =============================================
323
+ Segmented variant — Tab styles
324
+ ============================================= */
325
+ :where(.Tabs[data-variant="segmented"]) .Tab {
326
+ border-radius: var(--segmented-control-option-radius);
327
+ }
328
+
329
+ :where(.Tabs[data-variant="segmented"][data-block]) .Tab {
269
330
  flex: 1;
270
331
  }
271
332
 
272
- :where(.SegmentedControl[data-pill]) .SegmentedControlOption {
333
+ :where(.Tabs[data-variant="segmented"][data-pill]) .Tab {
273
334
  min-width: calc(var(--segmented-control-size) - 2 * var(--segmented-control-gutter));
274
335
  padding: 0 calc(var(--segmented-control-option-gutter) * var(--control-gutter-pill-scaling));
275
336
  }
276
337
 
277
- .SegmentedControlOption[data-state="on"]:focus-visible {
278
- outline: 2px solid var(--color-ring);
279
- }
280
-
281
- .SegmentedControlOption::before {
338
+ /* Hover highlight pseudo-element (segmented only) */
339
+ :where(.Tabs[data-variant="segmented"]) .Tab::before {
282
340
  position: absolute;
283
341
  inset: var(--segmented-control-option-highlight-gutter);
284
342
  border-radius: var(--segmented-control-option-radius);
@@ -293,55 +351,131 @@
293
351
  will-change: transform;
294
352
  }
295
353
 
296
- .SegmentedControlOption:active::before {
354
+ :where(.Tabs[data-variant="segmented"]) .Tab:active::before {
297
355
  transform: scale(var(--scale), 0.97);
298
356
  }
299
-
300
- .SegmentedControlOption svg {
301
- display: block;
302
- }
303
- @media (hover: hover) and (pointer: fine) {.SegmentedControlOption[data-state="off"]:where(:not([disabled])):hover {
357
+ @media (hover: hover) and (pointer: fine) {:where(.Tabs[data-variant="segmented"]) .Tab[data-state="off"]:where(:not([disabled])):hover {
304
358
  color: var(--color-text);
305
359
  }
306
360
 
307
- .SegmentedControlOption[data-state="off"]:where(:not([disabled])):hover::before {
361
+ :where(.Tabs[data-variant="segmented"]) .Tab[data-state="off"]:where(:not([disabled])):hover::before {
308
362
  opacity: 0.5;
309
363
  }
310
364
  }
311
365
 
312
- .SegmentedControlOption[data-state="off"]:where(:not([disabled])):focus-visible {
366
+ :where(.Tabs[data-variant="segmented"]) .Tab[data-state="off"]:where(:not([disabled])):focus-visible {
313
367
  color: var(--color-text);
314
368
  outline: 2px solid var(--color-ring);
315
369
  }
316
370
 
317
- .SegmentedControlOption[data-state="off"]:where(:not([disabled])):active::before {
371
+ :where(.Tabs[data-variant="segmented"]) .Tab[data-state="off"]:where(:not([disabled])):active::before {
318
372
  opacity: 0.75;
319
373
  }
320
374
 
321
- .SegmentedControlOption[data-state="on"] {
375
+ /* =============================================
376
+ Underline variant — Tab styles
377
+ ============================================= */
378
+ :where(.Tabs[data-variant="underline"]) .Tab {
379
+ border-radius: 0;
380
+ }
381
+
382
+ :where(.Tabs[data-variant="underline"][data-block]) .Tab {
383
+ flex: 1;
384
+ }
385
+ @media (hover: hover) and (pointer: fine) {:where(.Tabs[data-variant="underline"]) .Tab[data-state="off"]:where(:not([disabled])):hover {
386
+ color: var(--color-text);
387
+ }
388
+ }
389
+
390
+ :where(.Tabs[data-variant="underline"]) .Tab[data-state="off"]:where(:not([disabled])):focus-visible {
391
+ color: var(--color-text);
392
+ outline: 2px solid var(--color-ring);
393
+ outline-offset: -2px;
394
+ }
395
+
396
+ /* =============================================
397
+ Vertical orientation — Tab styles
398
+ ============================================= */
399
+ :where(.Tabs[data-orientation="vertical"]) .Tab {
400
+ justify-content: flex-start;
401
+ width: 100%;
402
+ }
403
+
404
+ :where(.Tabs[data-orientation="vertical"][data-block]) .Tab {
405
+ flex: none;
406
+ }
407
+
408
+ /* =============================================
409
+ Shared states
410
+ ============================================= */
411
+ .Tab[data-state="on"] {
322
412
  color: var(--color-text);
323
413
  }
324
414
 
325
- .SegmentedControlOption[data-disabled] {
415
+ .Tab[data-state="on"]:focus-visible {
416
+ outline: 2px solid var(--color-ring);
417
+ }
418
+
419
+ .Tab[data-disabled] {
326
420
  cursor: not-allowed;
327
421
  opacity: 0.5;
328
422
  }
329
423
 
330
- .SegmentedControlOption[data-disabled]::before {
424
+ .Tab[data-disabled]::before {
331
425
  opacity: 0 !important;
332
- }.SegmentedControlThumb {
426
+ }
427
+
428
+ .Tab svg {
429
+ display: block;
430
+ }/* =============================================
431
+ Thumb (animated indicator)
432
+ ============================================= */.TabsThumb {
333
433
  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
434
  pointer-events: none;
341
435
  will-change: transform;
342
- }/* =============================================
343
- Option Content (icon + text + badge layout)
344
- ============================================= */.SegmentedControlOptionContent {
436
+ }
437
+
438
+ /* Segmented horizontal: background highlight */
439
+ :where(.Tabs[data-variant="segmented"][data-orientation="horizontal"]) .TabsThumb {
440
+ top: var(--segmented-control-gutter);
441
+ bottom: var(--segmented-control-gutter);
442
+ left: 0;
443
+ border-radius: var(--segmented-control-option-radius);
444
+ background: var(--segmented-control-thumb-background);
445
+ box-shadow: var(--segmented-control-thumb-shadow);
446
+ }
447
+
448
+ /* Segmented vertical: background highlight */
449
+ :where(.Tabs[data-variant="segmented"][data-orientation="vertical"]) .TabsThumb {
450
+ left: var(--segmented-control-gutter);
451
+ right: var(--segmented-control-gutter);
452
+ top: 0;
453
+ border-radius: var(--segmented-control-option-radius);
454
+ background: var(--segmented-control-thumb-background);
455
+ box-shadow: var(--segmented-control-thumb-shadow);
456
+ }
457
+
458
+ /* Underline horizontal: thin line at the bottom */
459
+ :where(.Tabs[data-variant="underline"][data-orientation="horizontal"]) .TabsThumb {
460
+ bottom: 0;
461
+ left: 0;
462
+ height: var(--tabs-underline-indicator-height);
463
+ border-radius: calc(var(--tabs-underline-indicator-height) / 2) calc(var(--tabs-underline-indicator-height) / 2) 0 0;
464
+ background: var(--tabs-underline-indicator-color);
465
+ box-shadow: none;
466
+ }
467
+
468
+ /* Underline vertical: thin line on the left */
469
+ :where(.Tabs[data-variant="underline"][data-orientation="vertical"]) .TabsThumb {
470
+ left: 0;
471
+ top: 0;
472
+ width: var(--tabs-underline-indicator-height);
473
+ border-radius: 0 calc(var(--tabs-underline-indicator-height) / 2) calc(var(--tabs-underline-indicator-height) / 2) 0;
474
+ background: var(--tabs-underline-indicator-color);
475
+ box-shadow: none;
476
+ }/* =============================================
477
+ Tab Content (icon + text + badge layout)
478
+ ============================================= */.TabContent {
345
479
  position: relative;
346
480
  display: flex;
347
481
  align-items: center;
@@ -349,17 +483,17 @@
349
483
  }
350
484
 
351
485
  /* Icon auto-sizing (same as Button) */
352
- .SegmentedControlOptionContent svg:where(:not([data-no-autosize])) {
486
+ .TabContent svg:where(:not([data-no-autosize])) {
353
487
  width: var(--segmented-control-icon-size);
354
488
  height: var(--segmented-control-icon-size);
355
489
  }
356
490
 
357
491
  /* Negative margin for icon when followed by text */
358
- .SegmentedControlOptionContent svg:where(:first-child:not(:only-child)) {
492
+ .TabContent svg:where(:first-child:not(:only-child)) {
359
493
  margin-left: var(--segmented-control-icon-offset);
360
494
  }/* =============================================
361
- Option Badge (CSS-only, no Badge component)
362
- ============================================= */.OptionBadge {
495
+ Tab Badge (CSS-only, no Badge component)
496
+ ============================================= */.TabBadge {
363
497
  display: inline-flex;
364
498
  align-items: center;
365
499
  height: var(--option-badge-size);
@@ -377,37 +511,37 @@
377
511
  /* =============================================
378
512
  Soft variant (default)
379
513
  ============================================= */
380
- .OptionBadge[data-variant="soft"]:where([data-color="secondary"]) {
514
+ .TabBadge[data-variant="soft"]:where([data-color="secondary"]) {
381
515
  --option-badge-bg: var(--color-background-secondary-soft-alpha);
382
516
  --option-badge-color: var(--color-text-secondary-soft);
383
517
  }
384
518
 
385
- .OptionBadge[data-variant="soft"]:where([data-color="success"]) {
519
+ .TabBadge[data-variant="soft"]:where([data-color="success"]) {
386
520
  --option-badge-bg: var(--color-background-success-soft-alpha);
387
521
  --option-badge-color: var(--color-text-success-soft);
388
522
  }
389
523
 
390
- .OptionBadge[data-variant="soft"]:where([data-color="warning"]) {
524
+ .TabBadge[data-variant="soft"]:where([data-color="warning"]) {
391
525
  --option-badge-bg: var(--color-background-warning-soft-alpha);
392
526
  --option-badge-color: var(--color-text-warning-soft);
393
527
  }
394
528
 
395
- .OptionBadge[data-variant="soft"]:where([data-color="danger"]) {
529
+ .TabBadge[data-variant="soft"]:where([data-color="danger"]) {
396
530
  --option-badge-bg: var(--color-background-danger-soft-alpha);
397
531
  --option-badge-color: var(--color-text-danger-soft);
398
532
  }
399
533
 
400
- .OptionBadge[data-variant="soft"]:where([data-color="info"]) {
534
+ .TabBadge[data-variant="soft"]:where([data-color="info"]) {
401
535
  --option-badge-bg: var(--color-background-info-soft-alpha);
402
536
  --option-badge-color: var(--color-text-info-soft);
403
537
  }
404
538
 
405
- .OptionBadge[data-variant="soft"]:where([data-color="discovery"]) {
539
+ .TabBadge[data-variant="soft"]:where([data-color="discovery"]) {
406
540
  --option-badge-bg: var(--color-background-discovery-soft-alpha);
407
541
  --option-badge-color: var(--color-text-discovery-soft);
408
542
  }
409
543
 
410
- .OptionBadge[data-variant="soft"]:where([data-color="caution"]) {
544
+ .TabBadge[data-variant="soft"]:where([data-color="caution"]) {
411
545
  --option-badge-bg: var(--color-background-caution-soft-alpha);
412
546
  --option-badge-color: var(--color-text-caution-soft);
413
547
  }
@@ -415,37 +549,37 @@
415
549
  /* =============================================
416
550
  Solid variant
417
551
  ============================================= */
418
- .OptionBadge[data-variant="solid"]:where([data-color="secondary"]) {
552
+ .TabBadge[data-variant="solid"]:where([data-color="secondary"]) {
419
553
  --option-badge-bg: var(--color-background-secondary-solid);
420
554
  --option-badge-color: var(--color-text-secondary-solid);
421
555
  }
422
556
 
423
- .OptionBadge[data-variant="solid"]:where([data-color="success"]) {
557
+ .TabBadge[data-variant="solid"]:where([data-color="success"]) {
424
558
  --option-badge-bg: var(--color-background-success-solid);
425
559
  --option-badge-color: var(--color-text-success-solid);
426
560
  }
427
561
 
428
- .OptionBadge[data-variant="solid"]:where([data-color="warning"]) {
562
+ .TabBadge[data-variant="solid"]:where([data-color="warning"]) {
429
563
  --option-badge-bg: var(--color-background-warning-solid);
430
564
  --option-badge-color: var(--color-text-warning-solid);
431
565
  }
432
566
 
433
- .OptionBadge[data-variant="solid"]:where([data-color="danger"]) {
567
+ .TabBadge[data-variant="solid"]:where([data-color="danger"]) {
434
568
  --option-badge-bg: var(--color-background-danger-solid);
435
569
  --option-badge-color: var(--color-text-danger-solid);
436
570
  }
437
571
 
438
- .OptionBadge[data-variant="solid"]:where([data-color="info"]) {
572
+ .TabBadge[data-variant="solid"]:where([data-color="info"]) {
439
573
  --option-badge-bg: var(--color-background-info-solid);
440
574
  --option-badge-color: var(--color-text-info-solid);
441
575
  }
442
576
 
443
- .OptionBadge[data-variant="solid"]:where([data-color="discovery"]) {
577
+ .TabBadge[data-variant="solid"]:where([data-color="discovery"]) {
444
578
  --option-badge-bg: var(--color-background-discovery-solid);
445
579
  --option-badge-color: var(--color-text-discovery-solid);
446
580
  }
447
581
 
448
- .OptionBadge[data-variant="solid"]:where([data-color="caution"]) {
582
+ .TabBadge[data-variant="solid"]:where([data-color="caution"]) {
449
583
  --option-badge-bg: var(--color-background-caution-solid);
450
584
  --option-badge-color: var(--color-text-caution-solid);
451
585
  }
@@ -453,42 +587,42 @@
453
587
  /* =============================================
454
588
  Outline variant
455
589
  ============================================= */
456
- .OptionBadge[data-variant="outline"] {
590
+ .TabBadge[data-variant="outline"] {
457
591
  background-color: transparent;
458
592
  box-shadow: 0 0 0 1px var(--option-badge-border) inset;
459
593
  }
460
594
 
461
- .OptionBadge[data-variant="outline"]:where([data-color="secondary"]) {
595
+ .TabBadge[data-variant="outline"]:where([data-color="secondary"]) {
462
596
  --option-badge-border: var(--color-border-secondary-outline);
463
597
  --option-badge-color: var(--color-text-secondary-outline);
464
598
  }
465
599
 
466
- .OptionBadge[data-variant="outline"]:where([data-color="success"]) {
600
+ .TabBadge[data-variant="outline"]:where([data-color="success"]) {
467
601
  --option-badge-border: var(--color-border-success-outline);
468
602
  --option-badge-color: var(--color-text-success-outline);
469
603
  }
470
604
 
471
- .OptionBadge[data-variant="outline"]:where([data-color="warning"]) {
605
+ .TabBadge[data-variant="outline"]:where([data-color="warning"]) {
472
606
  --option-badge-border: var(--color-border-warning-outline);
473
607
  --option-badge-color: var(--color-text-warning-outline);
474
608
  }
475
609
 
476
- .OptionBadge[data-variant="outline"]:where([data-color="danger"]) {
610
+ .TabBadge[data-variant="outline"]:where([data-color="danger"]) {
477
611
  --option-badge-border: var(--color-border-danger-outline);
478
612
  --option-badge-color: var(--color-text-danger-outline);
479
613
  }
480
614
 
481
- .OptionBadge[data-variant="outline"]:where([data-color="info"]) {
615
+ .TabBadge[data-variant="outline"]:where([data-color="info"]) {
482
616
  --option-badge-border: var(--color-border-info-outline);
483
617
  --option-badge-color: var(--color-text-info-outline);
484
618
  }
485
619
 
486
- .OptionBadge[data-variant="outline"]:where([data-color="discovery"]) {
620
+ .TabBadge[data-variant="outline"]:where([data-color="discovery"]) {
487
621
  --option-badge-border: var(--color-border-discovery-outline);
488
622
  --option-badge-color: var(--color-text-discovery-outline);
489
623
  }
490
624
 
491
- .OptionBadge[data-variant="outline"]:where([data-color="caution"]) {
625
+ .TabBadge[data-variant="outline"]:where([data-color="caution"]) {
492
626
  --option-badge-border: var(--color-border-caution-outline);
493
627
  --option-badge-color: var(--color-text-caution-outline);
494
628
  }
@@ -496,7 +630,7 @@
496
630
  /* =============================================
497
631
  Pill
498
632
  ============================================= */
499
- .OptionBadge[data-pill] {
633
+ .TabBadge[data-pill] {
500
634
  border-radius: var(--radius-full);
501
635
  padding: 0 calc(var(--option-badge-gutter) * var(--control-gutter-pill-scaling));
502
636
  }
@@ -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.0",
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":"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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 \"./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"]}