Skip to content

Commit c238f66

Browse files
authored
feat(tabs,button): migrate TabItem to BaseButton, add TabItem maxWidth with truncation tooltip, disallow Button children prop (#2689)
<!-- How to write a good PR title: - Follow [the Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/). - Give as much context as necessary and as little as possible - Prefix it with [WIP] while it’s a work in progress --> ## Self Checklist - [x] I wrote a PR title in **English** and added an appropriate **label** to the PR. - [x] I wrote the commit message in **English** and to follow [**the Conventional Commits specification**](https://www.conventionalcommits.org/en/v1.0.0/). - [x] I [added the **changeset**](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md) about the changes that needed to be released. (or didn't have to) - [x] I wrote or updated **documentation** related to the changes. (or didn't have to) - [x] I wrote or updated **tests** related to the changes. (or didn't have to) - [x] I tested the changes in various browsers. (or didn't have to) - Windows: Chrome, Edge, (Optional) Firefox - macOS: Chrome, Edge, Safari, (Optional) Firefox ## Related Issue <!-- Link issues here, e.g. Fixes #1234 --> Fixes #2688 ## Summary - refactor(button): disallow children prop - refactor(tabs): migrate TabItem to BaseButton - feat(tabs): add maxWidth and truncation tooltip This PR migrates TabItem from Button to BaseButton, introduces an optional maxWidth prop for TabItem, and shows a Tooltip only when the label is truncated. It also disallows the children prop in Button to enforce the intended API. ## Details - `Tabs` - Migrate TabItem to BaseButton to simplify styling and reduce Button dependencies - Port only tertiary + monochrome-light styles - Default background transparent; hover uses var(--bg-black-lighter) - Border radius: s/m = radius-8, l = radius-12 - Inactive text color aligned to var(--txt-black-darker) - Add maxWidth to TabItem; ellipsis detection via useElementTruncated - Tooltip shows the full label only when truncated (disabled otherwise) - `Button` - Disallow children prop (typed as never) to encourage using text, leftContent, rightContent - `Docs` - Update Tabs stories to cover long labels and truncation behavior ## Breaking change? (Yes/No) Yes - Button no longer accepts children. Passing children will produce type errors. - Migration: - Replace `<Button><span>Label</span></Button>` with `<Button text="Label" />` - Move any leading/trailing visuals into leftContent/rightContent - Example: - Before: `<Button><Icon />Save</Button>` - After: `<Button text="Save" leftContent={<Icon />} />` ## References [related thread](https://desk.channel.io/root/threads/groups/WebBezier-124831/68bea56a6f3b4b747751/68bea56a6f3b4b747751) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - Breaking Changes - Button이 더 이상 children을 허용하지 않습니다. 대신 text, leftContent, rightContent를 사용하세요. - New Features - Tabs: TabItem에 선택적 maxWidth 속성 추가. - Tab 라벨이 실제로 잘릴 때만 Tooltip이 자동으로 표시됩니다. - 콘텐츠 절단 감지 훅 추가로 툴팁 표시가 동적으로 처리됩니다. - Style - Tabs: 크기별 Tab 버튼 레이아웃, 활성/호버/비활성 상태 스타일(.TabItemButton 등) 추가. - Chores - 배포용 변경 로그(changeset) 추가 및 마이너 릴리스 준비. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 00ad952 commit c238f66

File tree

8 files changed

+182
-17
lines changed

8 files changed

+182
-17
lines changed

.changeset/five-ties-agree.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@channel.io/bezier-react': minor
3+
---
4+
5+
Add `maxWidth` prop to `TabItem` and show `Tooltip` only when truncated.

.changeset/forty-pillows-reply.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@channel.io/bezier-react': minor
3+
---
4+
5+
Remove unused `children` prop from `Button` component.

packages/bezier-react/src/components/Button/Button.types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ interface ButtonOwnProps {
7979
}
8080

8181
export interface ButtonProps
82-
extends BezierComponentProps<'button'>,
82+
extends Omit<BezierComponentProps<'button'>, 'children'>,
8383
PolymorphicProps,
8484
SizeProps<ButtonSize>,
8585
DisableProps,

packages/bezier-react/src/components/Tabs/Tabs.module.scss

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,20 @@ $tab-item-indicator-height: 3px;
3131
display: flex;
3232
}
3333

34-
.TabItem {
34+
.TabItemButton {
35+
position: relative;
3536
top: 4px;
37+
3638
overflow: visible;
3739

40+
box-sizing: border-box;
41+
42+
background-color: transparent;
43+
44+
transition:
45+
background-color var(--transition-s),
46+
box-shadow var(--transition-s);
47+
3848
&::after {
3949
content: '';
4050

@@ -51,6 +61,56 @@ $tab-item-indicator-height: 3px;
5161
transition: height var(--transition-s);
5262
}
5363

64+
&:where(.size-s) {
65+
min-width: 24px;
66+
height: 24px;
67+
padding: 0 4px;
68+
border-radius: var(--radius-8);
69+
70+
& :where(.TabItemButtonText) {
71+
padding: 0 3px;
72+
}
73+
}
74+
75+
&:where(.size-m) {
76+
min-width: 36px;
77+
height: 36px;
78+
padding: 0 10px;
79+
border-radius: var(--radius-8);
80+
}
81+
82+
&:where(.size-l) {
83+
min-width: 44px;
84+
height: 44px;
85+
padding: 0 12px;
86+
border-radius: var(--radius-12);
87+
}
88+
89+
&:where(.size-m, .size-l) {
90+
& :where(.TabItemButtonContent) {
91+
gap: 2px;
92+
}
93+
94+
& :where(.TabItemButtonText) {
95+
padding: 0 4px;
96+
}
97+
}
98+
99+
&:where(.active, :hover):where(:not(:disabled)) {
100+
background-color: var(--bg-black-lighter);
101+
}
102+
103+
/* NOTE: If there is no text, button is square, so padding is 0 */
104+
&:not(:has(.TabItemButtonText)) {
105+
padding: 0;
106+
}
107+
108+
&:disabled {
109+
cursor: not-allowed;
110+
opacity: var(--opacity-disabled);
111+
}
112+
113+
/* stylelint-disable-next-line no-descending-specificity */
54114
&:where([data-state='active']) {
55115
color: var(--bgtxt-blue-normal);
56116

packages/bezier-react/src/components/Tabs/Tabs.stories.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,12 @@ function TabsComposition({
6666
<TabItems>
6767
<TabItem value="One">Tab1</TabItem>
6868
<TabItem value="Two">Tab2</TabItem>
69-
<TabItem value="Three">Tab3</TabItem>
69+
<TabItem
70+
value="Three"
71+
maxWidth={50}
72+
>
73+
LongLongLabelTab
74+
</TabItem>
7075
</TabItems>
7176

7277
<TabActions>
@@ -95,7 +100,7 @@ function TabsComposition({
95100
</TabContent>
96101
<TabContent value="Three">
97102
<Center height={100}>
98-
<Text color="txt-black-darkest">Tab3 content</Text>
103+
<Text color="txt-black-darkest">LongLongLabelTab content</Text>
99104
</Center>
100105
</TabContent>
101106
</Tabs>

packages/bezier-react/src/components/Tabs/Tabs.tsx

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
'use client'
22

3-
import { type JSX, forwardRef, useMemo } from 'react'
3+
import { type JSX, forwardRef, useMemo, useRef } from 'react'
44
import * as React from 'react'
55

66
import { OpenInNewIcon } from '@channel.io/bezier-icons'
77
import * as TabsPrimitive from '@radix-ui/react-tabs'
88
import * as ToolbarPrimitive from '@radix-ui/react-toolbar'
99
import classNames from 'classnames'
1010

11+
import useElementTruncated from '~/src/hooks/useElementTruncated'
1112
import { createContext } from '~/src/utils/react'
1213
import { isNil } from '~/src/utils/type'
1314

1415
import { BaseButton } from '~/src/components/BaseButton'
15-
import { Button } from '~/src/components/Button'
1616
import { Icon } from '~/src/components/Icon'
1717
import {
1818
type TabActionElement,
@@ -27,6 +27,7 @@ import {
2727
type TabsProps,
2828
} from '~/src/components/Tabs/Tabs.types'
2929
import { Text } from '~/src/components/Text'
30+
import { Tooltip } from '~/src/components/Tooltip'
3031

3132
import styles from './Tabs.module.scss'
3233

@@ -130,16 +131,66 @@ function getButtonSizeBy(size: TabSize) {
130131
)[size]
131132
}
132133

134+
function getTypography(size: TabSize) {
135+
return (
136+
{
137+
s: '13',
138+
m: '14',
139+
l: '15',
140+
} as const
141+
)[size]
142+
}
143+
144+
const TabItemButton = forwardRef<HTMLButtonElement, TabItemProps>(
145+
function TabItemButton(
146+
{ className, disabled, value, children, maxWidth, style, ...rest },
147+
forwardedRef
148+
) {
149+
const contentRef = useRef<HTMLElement>(null)
150+
const isTruncated = useElementTruncated(contentRef)
151+
152+
const { size } = useTabListContext()
153+
154+
return (
155+
<Tooltip
156+
content={children}
157+
disabled={!isTruncated}
158+
offset={6}
159+
>
160+
<BaseButton
161+
className={classNames(
162+
styles.TabItemButton,
163+
styles[`size-${getButtonSizeBy(size)}`],
164+
className
165+
)}
166+
disabled={disabled}
167+
ref={forwardedRef}
168+
style={{ maxWidth, ...style }}
169+
{...rest}
170+
>
171+
<Text
172+
ref={contentRef}
173+
className={styles.TabItemButtonText}
174+
typo={getTypography(size)}
175+
bold
176+
truncated
177+
>
178+
{children}
179+
</Text>
180+
</BaseButton>
181+
</Tooltip>
182+
)
183+
}
184+
)
185+
133186
/**
134187
* `TabItem` is a button that activates its associated content.
135188
*/
136189
export const TabItem = forwardRef<HTMLButtonElement, TabItemProps>(
137190
function TabItem(
138-
{ className, disabled, value, children, ...rest },
191+
{ className, disabled, value, children, maxWidth, style, ...rest },
139192
forwardedRef
140193
) {
141-
const { size } = useTabListContext()
142-
143194
if (typeof children !== 'string') {
144195
return null
145196
}
@@ -150,16 +201,17 @@ export const TabItem = forwardRef<HTMLButtonElement, TabItemProps>(
150201
value={value}
151202
asChild
152203
>
153-
<Button
154-
className={classNames(styles.TabItem, className)}
155-
disabled={disabled}
156-
text={children}
157-
size={getButtonSizeBy(size)}
158-
colorVariant="monochrome-light"
159-
styleVariant="tertiary"
204+
<TabItemButton
160205
ref={forwardedRef}
206+
className={className}
207+
disabled={disabled}
208+
value={value}
209+
maxWidth={maxWidth}
210+
style={style}
161211
{...rest}
162-
/>
212+
>
213+
{children}
214+
</TabItemButton>
163215
</TabsPrimitive.TabsTrigger>
164216
)
165217
}

packages/bezier-react/src/components/Tabs/Tabs.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type React from 'react'
2+
import { type CSSProperties } from 'react'
23

34
import {
45
type BezierComponentProps,
@@ -52,6 +53,7 @@ interface TabItemOwnProps {
5253
* A unique value that associates the trigger with a content.
5354
*/
5455
value: string
56+
maxWidth?: CSSProperties['maxWidth']
5557
}
5658

5759
interface TabContentOwnProps {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { RefObject } from 'react'
2+
import { useEffect, useState } from 'react'
3+
4+
const useElementTruncated = <Element extends HTMLElement>(
5+
ref: RefObject<Element | null>
6+
): boolean => {
7+
const [isTruncated, setTruncated] = useState(false)
8+
9+
useEffect(
10+
function initResizeObserver() {
11+
if (ref.current) {
12+
const resizeObserver = new ResizeObserver((entries) => {
13+
const firstEntry = entries[0]
14+
if (firstEntry.target) {
15+
setTruncated(
16+
firstEntry.target.scrollWidth > firstEntry.target.clientWidth ||
17+
firstEntry.target.scrollHeight > firstEntry.target.clientHeight
18+
)
19+
}
20+
})
21+
resizeObserver.observe(ref.current)
22+
23+
return function cleanup() {
24+
return resizeObserver.disconnect()
25+
}
26+
}
27+
28+
return undefined
29+
},
30+
[ref]
31+
)
32+
33+
return isTruncated
34+
}
35+
36+
export default useElementTruncated

0 commit comments

Comments
 (0)