Skip to content

Commit 63c0673

Browse files
authored
feat(react): redesign collapse API and refresh segmented (#111)
* feat(collapse): redesign collapse API and styles * test: update snapshot * fix: refactor segment * chore: align changeset for collapse redesign
1 parent 58c2ea2 commit 63c0673

22 files changed

Lines changed: 650 additions & 216 deletions

File tree

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
---
22
'@tiny-design/react': minor
3+
'@tiny-design/icons': minor
34
'@tiny-design/tokens': minor
5+
'@tiny-design/charts': minor
46
---
57

6-
Redesign the Collapse component API, styles, and docs, and align the related tokens.
8+
Redesign the Collapse component API, styles, and docs, align the related tokens, and keep
9+
the fixed-version package group in sync for release.

apps/docs/public/llms-full.txt

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1712,21 +1712,31 @@ import React from 'react';
17121712
import { BaseProps, SizeType } from '../_utils/props';
17131713

17141714
export interface SegmentedOption {
1715-
label: React.ReactNode;
1716-
value: string | number;
1715+
value: SegmentedValue;
1716+
label?: React.ReactNode;
17171717
disabled?: boolean;
17181718
icon?: React.ReactNode;
1719+
title?: string;
1720+
className?: string;
17191721
}
17201722

17211723
export type SegmentedValue = string | number;
17221724

17231725
export interface SegmentedProps
17241726
extends BaseProps,
1725-
Omit<React.PropsWithoutRef<JSX.IntrinsicElements['div']>, 'onChange'> {
1726-
options: (string | number | SegmentedOption)[];
1727+
Omit<
1728+
React.PropsWithoutRef<JSX.IntrinsicElements['div']>,
1729+
'children' | 'defaultValue' | 'onChange'
1730+
> {
1731+
options: SegmentedOption[];
1732+
name?: string;
17271733
value?: SegmentedValue;
17281734
defaultValue?: SegmentedValue;
1729-
onChange?: (value: SegmentedValue) => void;
1735+
onChange?: (
1736+
value: SegmentedValue,
1737+
option: SegmentedOption,
1738+
event: React.ChangeEvent<HTMLInputElement>
1739+
) => void;
17301740
block?: boolean;
17311741
disabled?: boolean;
17321742
size?: SizeType;

apps/docs/public/llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ Components that accept sizes use: `'sm' | 'md' | 'lg'`
121121
- **NativeSelect** — Native HTML select wrapper.
122122
- **Radio** — Single selection. `Radio.Group` for groups.
123123
- **Rate** — Star rating component.
124-
- **Segmented** — Toggle between a set of options.
124+
- **Segmented** — Segmented single-choice control for switching between mutually exclusive options.
125125
- **Select** — Select value from dropdown options. Props: `options`, `mode` (`'multiple'|'tags'`), `searchable`.
126126
- **Slider** — Drag slider within range. Props: `min`, `max`, `step`, `range`.
127127
- **SplitButton** — Button with attached dropdown menu.

apps/docs/src/containers/theme-studio/theme-document-adapter.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,11 @@ export function buildThemeDocumentFromDraft(draft: ThemeEditorDraft): ThemeDocum
279279
'input-number.height.md': fields.fieldHeightMd,
280280
'input-number.height.lg': fields.fieldHeightLg,
281281
'segmented.bg': fields.muted,
282-
'segmented.active-bg': fields.card,
282+
'segmented.item-bg-hover': fields.secondary,
283+
'segmented.item-bg-selected': fields.card,
284+
'segmented.item-color': fields.mutedForeground,
285+
'segmented.item-color-selected': fields.baseForeground,
286+
'segmented.item-shadow-focus': fields.shadowFocus,
283287
'segmented.radius': fields.radius,
284288
'tag.bg': fields.secondary,
285289
'tag.color': fields.secondaryForeground,

packages/mcp/src/data/components.json

Lines changed: 120 additions & 58 deletions
Large diffs are not rendered by default.

packages/react/src/flex/demo/Align.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@ import type { SegmentedValue } from '@tiny-design/react';
44

55
export default function AlignDemo() {
66
const justifyOptions = [
7-
'flex-start',
8-
'center',
9-
'flex-end',
10-
'space-between',
11-
'space-around',
12-
'space-evenly',
7+
{ label: 'flex-start', value: 'flex-start' },
8+
{ label: 'center', value: 'center' },
9+
{ label: 'flex-end', value: 'flex-end' },
10+
{ label: 'space-between', value: 'space-between' },
11+
{ label: 'space-around', value: 'space-around' },
12+
{ label: 'space-evenly', value: 'space-evenly' },
13+
];
14+
const alignOptions = [
15+
{ label: 'flex-start', value: 'flex-start' },
16+
{ label: 'center', value: 'center' },
17+
{ label: 'flex-end', value: 'flex-end' },
1318
];
14-
const alignOptions = ['flex-start', 'center', 'flex-end'];
1519

1620
const [justify, setJustify] = React.useState('flex-start');
1721
const [align, setAlign] = React.useState('flex-start');

packages/react/src/segmented/__tests__/__snapshots__/segmented.test.tsx.snap

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,46 +7,63 @@ exports[`<Segmented /> should match the snapshot 1`] = `
77
role="radiogroup"
88
>
99
<label
10-
class="ty-segmented__item ty-segmented__item_active"
10+
class="ty-segmented__item"
1111
>
1212
<input
13-
checked=""
13+
aria-label="Daily"
1414
class="ty-segmented__input"
15+
name=":r0:"
1516
type="radio"
16-
value="Daily"
17+
value="daily"
1718
/>
1819
<span
19-
class="ty-segmented__label"
20+
class="ty-segmented__item-content"
2021
>
21-
Daily
22+
<span
23+
class="ty-segmented__label"
24+
>
25+
Daily
26+
</span>
2227
</span>
2328
</label>
2429
<label
2530
class="ty-segmented__item"
2631
>
2732
<input
33+
aria-label="Weekly"
2834
class="ty-segmented__input"
35+
name=":r0:"
2936
type="radio"
30-
value="Weekly"
37+
value="weekly"
3138
/>
3239
<span
33-
class="ty-segmented__label"
40+
class="ty-segmented__item-content"
3441
>
35-
Weekly
42+
<span
43+
class="ty-segmented__label"
44+
>
45+
Weekly
46+
</span>
3647
</span>
3748
</label>
3849
<label
3950
class="ty-segmented__item"
4051
>
4152
<input
53+
aria-label="Monthly"
4254
class="ty-segmented__input"
55+
name=":r0:"
4356
type="radio"
44-
value="Monthly"
57+
value="monthly"
4558
/>
4659
<span
47-
class="ty-segmented__label"
60+
class="ty-segmented__item-content"
4861
>
49-
Monthly
62+
<span
63+
class="ty-segmented__label"
64+
>
65+
Monthly
66+
</span>
5067
</span>
5168
</label>
5269
</div>

packages/react/src/segmented/__tests__/segmented.test.tsx

Lines changed: 82 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,65 +3,127 @@ import { render, fireEvent } from '@testing-library/react';
33
import Segmented from '../index';
44

55
describe('<Segmented />', () => {
6+
const options = [
7+
{ label: 'Daily', value: 'daily' },
8+
{ label: 'Weekly', value: 'weekly' },
9+
{ label: 'Monthly', value: 'monthly' },
10+
];
11+
612
it('should match the snapshot', () => {
7-
const { asFragment } = render(<Segmented options={['Daily', 'Weekly', 'Monthly']} />);
13+
const { asFragment } = render(<Segmented options={options} />);
814
expect(asFragment()).toMatchSnapshot();
915
});
1016

1117
it('should render correctly', () => {
12-
const { container } = render(<Segmented options={['A', 'B', 'C']} />);
18+
const { container } = render(
19+
<Segmented
20+
options={[
21+
{ label: 'A', value: 'a' },
22+
{ label: 'B', value: 'b' },
23+
{ label: 'C', value: 'c' },
24+
]}
25+
/>
26+
);
1327
expect(container.firstChild).toHaveClass('ty-segmented');
1428
});
1529

1630
it('should render options', () => {
17-
const { getByText } = render(<Segmented options={['Foo', 'Bar']} />);
31+
const { getByText } = render(
32+
<Segmented
33+
options={[
34+
{ label: 'Foo', value: 'foo' },
35+
{ label: 'Bar', value: 'bar' },
36+
]}
37+
/>
38+
);
1839
expect(getByText('Foo')).toBeInTheDocument();
1940
expect(getByText('Bar')).toBeInTheDocument();
2041
});
2142

2243
it('should select default value', () => {
2344
const { container } = render(
24-
<Segmented options={['A', 'B', 'C']} defaultValue="B" />
45+
<Segmented
46+
options={[
47+
{ label: 'A', value: 'a' },
48+
{ label: 'B', value: 'b' },
49+
{ label: 'C', value: 'c' },
50+
]}
51+
defaultValue="b"
52+
/>
2553
);
2654
const active = container.querySelector('.ty-segmented__item_active');
2755
expect(active).toBeTruthy();
2856
expect(active!).toHaveTextContent('B');
2957
});
3058

31-
it('should handle onChange', () => {
32-
const onChange = jest.fn();
33-
const { getByText } = render(
34-
<Segmented options={['A', 'B']} onChange={onChange} />
35-
);
36-
fireEvent.click(getByText('B'));
37-
expect(onChange).toHaveBeenCalledWith('B');
59+
it('should not select any option by default', () => {
60+
const { container } = render(<Segmented options={options} />);
61+
expect(container.querySelector('.ty-segmented__item_active')).toBeNull();
3862
});
3963

40-
it('should support object options', () => {
41-
const { getByText } = render(
64+
it('should handle onChange', () => {
65+
const onChange = jest.fn();
66+
const { getByLabelText } = render(
4267
<Segmented
4368
options={[
44-
{ label: 'Option A', value: 'a' },
45-
{ label: 'Option B', value: 'b' },
69+
{ label: 'A', value: 'a' },
70+
{ label: 'B', value: 'b' },
4671
]}
72+
onChange={onChange}
4773
/>
4874
);
49-
expect(getByText('Option A')).toBeInTheDocument();
75+
fireEvent.click(getByLabelText('B'));
76+
expect(onChange).toHaveBeenCalledWith(
77+
'b',
78+
{ label: 'B', value: 'b' },
79+
expect.any(Object)
80+
);
5081
});
5182

5283
it('should support block mode', () => {
5384
const { container } = render(
54-
<Segmented options={['A', 'B']} block />
85+
<Segmented
86+
options={[
87+
{ label: 'A', value: 'a' },
88+
{ label: 'B', value: 'b' },
89+
]}
90+
block
91+
/>
5592
);
5693
expect(container.firstChild).toHaveClass('ty-segmented_block');
5794
});
5895

5996
it('should support disabled', () => {
6097
const onChange = jest.fn();
61-
const { getByText } = render(
62-
<Segmented options={['A', 'B']} disabled onChange={onChange} />
98+
const { getByLabelText } = render(
99+
<Segmented
100+
options={[
101+
{ label: 'A', value: 'a' },
102+
{ label: 'B', value: 'b' },
103+
]}
104+
disabled
105+
onChange={onChange}
106+
/>
63107
);
64-
fireEvent.click(getByText('B'));
108+
fireEvent.click(getByLabelText('B'));
65109
expect(onChange).not.toHaveBeenCalled();
66110
});
111+
112+
it('should reset uncontrolled selection when option is removed', () => {
113+
const { container, rerender } = render(
114+
<Segmented options={options} defaultValue="weekly" />
115+
);
116+
117+
rerender(
118+
<Segmented
119+
options={[
120+
{ label: 'Daily', value: 'daily' },
121+
{ label: 'Monthly', value: 'monthly' },
122+
]}
123+
defaultValue="weekly"
124+
/>
125+
);
126+
127+
expect(container.querySelector('.ty-segmented__item_active')).toBeNull();
128+
});
67129
});

packages/react/src/segmented/demo/Basic.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import { Segmented } from '@tiny-design/react';
44
export default function BasicDemo() {
55
return (
66
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
7-
<Segmented options={['Daily', 'Weekly', 'Monthly', 'Yearly']} />
7+
<Segmented
8+
options={[
9+
{ label: 'Daily', value: 'daily' },
10+
{ label: 'Weekly', value: 'weekly' },
11+
{ label: 'Monthly', value: 'monthly' },
12+
{ label: 'Yearly', value: 'yearly' },
13+
]}
14+
/>
815
<Segmented
916
options={[
1017
{ label: 'Small', value: 'sm' },
@@ -13,7 +20,14 @@ export default function BasicDemo() {
1320
]}
1421
size="sm"
1522
/>
16-
<Segmented options={['Map', 'Transit', 'Satellite']} block />
23+
<Segmented
24+
options={[
25+
{ label: 'Map', value: 'map' },
26+
{ label: 'Transit', value: 'transit' },
27+
{ label: 'Satellite', value: 'satellite' },
28+
]}
29+
block
30+
/>
1731
</div>
1832
);
19-
}
33+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import React from 'react';
2+
import { Segmented, Text } from '@tiny-design/react';
3+
4+
const options = [
5+
{ label: 'List', value: 'list' },
6+
{ label: 'Board', value: 'board' },
7+
{ label: 'Timeline', value: 'timeline' },
8+
];
9+
10+
export default function ControlledDemo() {
11+
const [value, setValue] = React.useState('list');
12+
13+
return (
14+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
15+
<Segmented
16+
options={options}
17+
value={value}
18+
onChange={(nextValue) => setValue(String(nextValue))}
19+
/>
20+
<Text type="secondary">Current value: {value}</Text>
21+
</div>
22+
);
23+
}

0 commit comments

Comments
 (0)