PlSegmentedButton
알약 하나에 담긴 두 개 이상의 선택지 중 정확히 하나가 선택됩니다. 타일이 떠난 세그먼트에서 고른 세그먼트로 미끄러집니다.
import { PlSegment, PlSegmentedButton } from 'plass-ui';
<PlSegmentedButton aria-label="Period" value={period} onValueChange={setPeriod}>
<PlSegment value="day">Day</PlSegment>
<PlSegment value="week">Week</PlSegment>
</PlSegmentedButton>;import 'package:plass_ui/plass_ui.dart';
PlSegmentedButton<String>(
semanticLabel: 'Period',
value: period,
onChanged: (String next) => setState(() => period = next),
segments: const <PlSegment<String>>[
PlSegment<String>(value: 'day', label: Text('Day')),
PlSegment<String>(value: 'week', label: Text('Week')),
],
);Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| variant공통 | 'solid' | 'glass' | 'ghost' | 'glass' | 홈과 그 위를 타는 타일의 재질. solid는 색 유리 키가 홈을 타고, glass는 맑은 타일, ghost는 홈 없음 |
| size공통 | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 세그먼트의 높이와 타입 스케일. PlButton과 같은 사다리 |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | 'default' | 'compact' | 'default' | 여백만 바꿉니다. 높이와 글자 크기는 그대로 |
| elevation공통 | 0 | 1 | 2 | 3 | 0 | 홈의 그림자 깊이. 홈은 페이지에 파인 것이므로 기본값은 0입니다 |
| value | string | number | null | — | 선택된 세그먼트. onValueChange와 함께 controlled로 씁니다 |
| defaultValue | string | number | null | null | uncontrolled일 때 처음 선택된 세그먼트 |
| onValueChange | (value: string | number | null) => void | — | 선택이 바뀔 때 호출됩니다 |
| fullWidth | boolean | false | 세그먼트들이 전체 너비를 균등하게 나눠 가집니다 |
| readOnly | boolean | false | 선택은 보이지만 바꿀 수 없습니다 |
| disabled | boolean | false | 모든 세그먼트가 반응하지 않습니다 |
| name | string | — | form 제출 시 이 값을 식별하는 이름 |
| children | ReactNode | — | PlSegment 목록 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| segments * | List<PlSegment<T>> | — | 선택지들. children이 아니라 설명의 목록입니다 — 묶음이 roving focus와 화살표 키, 미끄러지는 타일을 소유합니다 |
| value * | T? | — | 선택된 세그먼트. onValueChange와 함께 controlled로 씁니다 |
| onChanged | ValueChanged<T>? | — | 선택이 바뀔 때 호출됩니다 |
| variant공통 | PlassVariant | PlassVariant.glass | 홈과 그 위를 타는 타일의 재질. solid는 색 유리 키가 홈을 타고, glass는 맑은 타일, ghost는 홈 없음 |
| size공통 | PlassSize | PlassSize.md | 세그먼트의 높이와 타입 스케일. PlButton과 같은 사다리 |
| color공통 | PlassColor | PlassColor.primary | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | PlassDensity | PlassDensity.standard | 여백만 바꿉니다. 높이와 글자 크기는 그대로 |
| elevation공통 | int | 0 | 홈의 그림자 깊이. 홈은 페이지에 파인 것이므로 기본값은 0입니다 |
| fullWidth | bool | false | 세그먼트들이 전체 너비를 균등하게 나눠 가집니다 |
| readOnly | bool | false | 선택은 보이지만 바꿀 수 없습니다 |
| disabled | bool | false | 모든 세그먼트가 반응하지 않습니다 |
| semanticLabel | String? | — | 묶음을 스크린 리더가 부를 이름. 눈에 보이는 자기 라벨이 없습니다 |
네이티브 <div> 속성은 그대로 전달됩니다. color는 위 표의 color와 충돌해서, defaultValue와 onChange는 이 묶음이 각각 세그먼트 값으로서의 defaultValue와 onValueChange로 쓰기 때문에 제외됩니다.
묶음은 세그먼트 값의 타입에 대해 제네릭입니다(PlSegmentedButton<String>, PlSegmentedButton<Period>). 그래서 value와 onChanged가 dynamic이 아니라 타입을 가지고, 패키지의 다른 모든 컨트롤과 마찬가지로 controlled입니다.
PlSegment
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| value * | string | number | — | 세그먼트를 식별하는 값. onValueChange가 보고하는 것 |
| startIcon | ReactNode | — | 라벨 앞에 놓이는 내용. 1.2em으로 그려져 라벨 크기를 따라갑니다 |
| endIcon | ReactNode | — | 라벨 뒤 — 개수, 상태 점 |
| disabled | boolean | false | 고를 수 없지만 여전히 묶음의 일부입니다 |
| children | ReactNode | — | 세그먼트의 라벨 |
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| value * | T | — | 세그먼트를 식별하는 값. onValueChange가 보고하는 것 |
| label | Widget? | — | 세그먼트의 라벨 |
| startIcon | Widget? | — | 라벨 앞에 놓이는 내용. 1.2em으로 그려져 라벨 크기를 따라갑니다 |
| endIcon | Widget? | — | 라벨 뒤 — 개수, 상태 점 |
| disabled | bool | false | 고를 수 없지만 여전히 묶음의 일부입니다 |
variant, size, density는 세그먼트에 주는 것이 아니라 감싸는 PlSegmentedButton에서 내려받습니다. 세 번째 세그먼트만 크기가 다른 segmented button은 segmented button이 아닙니다.
세그먼트는 **위젯이 아니라 설명인 PlSegment**입니다. radio 옵션이 그런 것과 같은 이유로, 묶음이 roving focus와 화살표 키, 그리고 세그먼트 사이를 미끄러지는 타일을 소유하므로 어느 것이 선택되었고 각각이 어디 있는지를 알아야 합니다.
variant도 size도 density도 가지지 않으며, 가질 수도 없습니다. 세 번째 세그먼트만 크기가 다른 segmented button은 segmented button이 아닙니다.
라이브러리 전체에서 공유 축(variant size color density elevation)이 뜻하는 바는 prop 규칙에 있습니다.
Segmented button, tabs, select 중 고르기
- Segmented button: 이미 화면에 있는 것을 걸러 내는, 짧고 서로 배타적인 선택지 몇 개. 기간, 범위, 레이아웃.
- Tabs: 선택이 내용 패널 전체를 바꿀 때.
- Select: 선택지가 다섯 개를 넘거나, 하나하나가 길 때.
Examples
variant
홈은 --plass-well을 씁니다. 라이브러리의 유일한 inset 그림자이자 solid field가 그려지는 것과 같은 그림자이고, 쓰이는 곳은 이 둘뿐입니다. 홈과 채워진 field는 둘 다 무언가가 들어앉는 상자입니다. slider의 레일은 그런 상자가 아니라서 더 이상 이 그림자를 쓰지 않습니다. 레일은 따라 보는 선입니다.
solid는 타일에 색 계열의 그러데이션을 넣고 그 아래에 같은 계열의 틴트 그림자를 깝니다. 디자인 언어의 문장을 그대로 옮긴 것입니다. 홈을 타고 가는 색 유리 키. glass와 ghost는 대신 맑은 유리판을 들어 올리고 라벨은 accent 색으로 둡니다.
import { PlSegment, PlSegmentedButton } from 'plass-ui';
export default function SegmentedButtonVariants() {
return (
<div className="flex flex-col items-start gap-4">
{(['solid', 'glass', 'ghost'] as const).map((variant) => (
<PlSegmentedButton key={variant} variant={variant} aria-label={variant} defaultValue="grid">
<PlSegment value="grid">Grid</PlSegment>
<PlSegment value="list">List</PlSegment>
<PlSegment value="table">Table</PlSegment>
</PlSegmentedButton>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
const List<PlSegment<String>> _views = <PlSegment<String>>[
PlSegment<String>(value: 'grid', label: Text('Grid')),
PlSegment<String>(value: 'list', label: Text('List')),
PlSegment<String>(value: 'table', label: Text('Table')),
];
class SegmentedButtonVariants extends StatelessWidget {
const SegmentedButtonVariants({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 16,
children: <Widget>[
for (final variant in PlassVariant.values)
PlSegmentedButton<String>(
variant: variant,
semanticLabel: variant.name,
segments: _views,
value: 'grid',
onChanged: (String next) {},
),
],
);
}
}color
import { PlSegment, PlSegmentedButton } from 'plass-ui';
export default function SegmentedButtonColors() {
return (
<div className="flex flex-col items-start gap-3">
{(['primary', 'success', 'warning', 'danger'] as const).map((color) => (
<PlSegmentedButton
key={color}
variant="solid"
color={color}
size="sm"
aria-label={color}
defaultValue="on"
>
<PlSegment value="on">On</PlSegment>
<PlSegment value="auto">Auto</PlSegment>
<PlSegment value="off">Off</PlSegment>
</PlSegmentedButton>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
const List<PlSegment<String>> _modes = <PlSegment<String>>[
PlSegment<String>(value: 'on', label: Text('On')),
PlSegment<String>(value: 'auto', label: Text('Auto')),
PlSegment<String>(value: 'off', label: Text('Off')),
];
class SegmentedButtonColors extends StatelessWidget {
const SegmentedButtonColors({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: <Widget>[
for (final color in <PlassColor>[
PlassColor.primary,
PlassColor.success,
PlassColor.warning,
PlassColor.danger,
])
PlSegmentedButton<String>(
variant: PlassVariant.solid,
color: color,
size: PlassSize.sm,
semanticLabel: color.name,
segments: _modes,
value: 'on',
onChanged: (String next) {},
),
],
);
}
}size
PlButton과 같은 높이 사다리를 씁니다. 툴바 안의 segmented button이 옆의 버튼들과 줄을 맞춥니다.
import { PlSegment, PlSegmentedButton } from 'plass-ui';
export default function SegmentedButtonSizes() {
return (
<div className="flex flex-col items-start gap-3">
{(['xs', 'sm', 'md', 'lg'] as const).map((size) => (
<PlSegmentedButton key={size} size={size} aria-label={size} defaultValue="a">
<PlSegment value="a">First</PlSegment>
<PlSegment value="b">Second</PlSegment>
</PlSegmentedButton>
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
const List<PlSegment<String>> _pair = <PlSegment<String>>[
PlSegment<String>(value: 'a', label: Text('First')),
PlSegment<String>(value: 'b', label: Text('Second')),
];
class SegmentedButtonSizes extends StatelessWidget {
const SegmentedButtonSizes({super.key});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
spacing: 12,
children: <Widget>[
for (final size in PlassSize.values)
PlSegmentedButton<String>(
size: size,
semanticLabel: size.name,
segments: _pair,
value: 'a',
onChanged: (String next) {},
),
],
);
}
}fullWidth
세그먼트들이 한 줄을 균등하게 나눠 가집니다. 타일은 배치가 끝날 때마다 다시 측정되므로, 컨테이너 너비가 변해도 자기 세그먼트 아래에 남아 있습니다.
import { PlSegment, PlSegmentedButton } from 'plass-ui';
export default function SegmentedButtonFullWidth() {
return (
<PlSegmentedButton fullWidth aria-label="Delivery" defaultValue="standard" className="max-w-md">
<PlSegment value="standard">Standard</PlSegment>
<PlSegment value="express">Express</PlSegment>
<PlSegment value="pickup">Pick up</PlSegment>
</PlSegmentedButton>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SegmentedButtonFullWidth extends StatefulWidget {
const SegmentedButtonFullWidth({super.key});
@override
State<SegmentedButtonFullWidth> createState() => _SegmentedButtonFullWidthState();
}
class _SegmentedButtonFullWidthState extends State<SegmentedButtonFullWidth> {
String _delivery = 'standard';
@override
Widget build(BuildContext context) {
return SizedBox(
width: 448,
child: PlSegmentedButton<String>(
fullWidth: true,
semanticLabel: 'Delivery',
value: _delivery,
onChanged: (String next) => setState(() => _delivery = next),
segments: const <PlSegment<String>>[
PlSegment<String>(value: 'standard', label: Text('Standard')),
PlSegment<String>(value: 'express', label: Text('Express')),
PlSegment<String>(value: 'pickup', label: Text('Pick up')),
],
),
);
}
}startIcon과 endIcon
둘 다 em으로 크기가 정해지므로 라벨을 따라갑니다. 아이콘만 있는 세그먼트에는 aria-label이 필요합니다.
import { PlSegment, PlSegmentedButton } from 'plass-ui';
function GridIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<rect x="2.5" y="2.5" width="4.5" height="4.5" rx="1" />
<rect x="9" y="2.5" width="4.5" height="4.5" rx="1" />
<rect x="2.5" y="9" width="4.5" height="4.5" rx="1" />
<rect x="9" y="9" width="4.5" height="4.5" rx="1" />
</svg>
);
}
function ListIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path d="M5 4h9M5 8h9M5 12h9M2.5 4h.01M2.5 8h.01M2.5 12h.01" strokeLinecap="round" />
</svg>
);
}
export default function SegmentedButtonIcons() {
return (
<PlSegmentedButton aria-label="Layout" defaultValue="grid">
<PlSegment value="grid" startIcon={<GridIcon />}>
Grid
</PlSegment>
<PlSegment value="list" startIcon={<ListIcon />} endIcon={<span>12</span>}>
List
</PlSegment>
</PlSegmentedButton>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
/// Four squares, and three rules with a bullet — the two layout glyphs.
class _LayoutGlyph extends StatelessWidget {
const _LayoutGlyph({required this.grid});
final bool grid;
@override
Widget build(BuildContext context) {
final theme = IconTheme.of(context);
return CustomPaint(
size: Size.square(theme.size ?? 16),
painter: _LayoutPainter(grid: grid, color: theme.color ?? const Color(0xFF000000)),
);
}
}
class _LayoutPainter extends CustomPainter {
const _LayoutPainter({required this.grid, required this.color});
final bool grid;
final Color color;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..strokeCap = StrokeCap.round
..color = color;
canvas
..save()
..scale(size.shortestSide / 16);
if (grid) {
for (final origin in const <Offset>[
Offset(2.5, 2.5),
Offset(9, 2.5),
Offset(2.5, 9),
Offset(9, 9),
]) {
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(origin.dx, origin.dy, 4.5, 4.5),
const Radius.circular(1),
),
paint,
);
}
} else {
for (final y in const <double>[4, 8, 12]) {
canvas
..drawLine(Offset(5, y), Offset(14, y), paint)
..drawLine(Offset(2.5, y), Offset(2.6, y), paint);
}
}
canvas.restore();
}
@override
bool shouldRepaint(_LayoutPainter oldDelegate) {
return oldDelegate.grid != grid || oldDelegate.color != color;
}
}
class SegmentedButtonIcons extends StatefulWidget {
const SegmentedButtonIcons({super.key});
@override
State<SegmentedButtonIcons> createState() => _SegmentedButtonIconsState();
}
class _SegmentedButtonIconsState extends State<SegmentedButtonIcons> {
String _layout = 'grid';
@override
Widget build(BuildContext context) {
return PlSegmentedButton<String>(
semanticLabel: 'Layout',
value: _layout,
onChanged: (String next) => setState(() => _layout = next),
segments: const <PlSegment<String>>[
PlSegment<String>(value: 'grid', startIcon: _LayoutGlyph(grid: true), label: Text('Grid')),
PlSegment<String>(
value: 'list',
startIcon: _LayoutGlyph(grid: false),
label: Text('List'),
endIcon: Text('12'),
),
],
);
}
}Accessibility
- 묶음은
role="radiogroup"이고 각 세그먼트는 진짜 radio입니다. 접근성 논거는 이것이 전부입니다. segmented button은 "이 중 정확히 하나" 입니다.aria-pressed토글로 만들었다면 독립된 스위치 네 개를 읽어 주고, 그중 셋은 마침 꺼져 있는 상태가 됩니다. - 묶음 전체가 tab stop 하나를 차지하고, ← → ↑ ↓로 그 안에서 움직입니다. roving tab index는 Base UI의 것입니다.
- 묶음에
aria-label을 주세요. 눈에 보이는 자기 라벨이 없고, 이름 없는 그룹은 스크린리더가 "radio group"이라고만 읽습니다. - focus ring은 안쪽으로 그려집니다. 홈 안의 세그먼트에 바깥쪽 ring을 그리면 이웃 위에 덧칠됩니다.
- 타일은
transform이 아니라left,top,width,height를 애니메이션합니다. 빈 상자라서 이동하는 동안 다시 샘플링되는 글자가 없습니다. 무언가 움직이는 것이 존재 이유인 컴포넌트에서도 no-transform 규칙이 살아남는 이유입니다. - 아무것도 선택되지 않은 묶음의 첫 선택은 왼쪽 끝에서 날아오지 않고 제자리에 나타납니다. 앉을 자리가 생기기 전까지 타일을 마운트하지 않기 때문입니다.
- 각 세그먼트는 서로 배타적인 묶음의 하나로, 선택 여부와 함께 알려집니다. segmented button은 "이 중 정확히 하나" 입니다. 토글로 만들었다면 독립된 스위치 네 개를 읽어 주고, 그중 셋은 마침 꺼져 있는 상태가 됩니다.
- 묶음 전체가 focus stop 하나를 차지합니다. 정확히 한 세그먼트만 tab 순서에 있고 나머지는
ExcludeFocus로 감싸여 있습니다. ← → ↑ ↓가 선택을 옮기고, 양 끝에서 순환합니다. - focus ring은 안쪽으로 그려집니다. 홈 안의 세그먼트에 바깥쪽 ring을 그리면 이웃 위에 덧칠됩니다.
- 타일은 측정된 사각형을 애니메이션합니다. 빈 상자라서 이동하는 동안 다시 샘플링되는 글자가 없습니다.
- 묶음에
semanticLabel을 주세요. 눈에 보이는 자기 라벨이 없습니다.
React 빌드와 다른 점
| React | Flutter | 이유 |
|---|---|---|
<PlSegment> children | 설명으로서의 segments | 묶음이 roving focus와 화살표 키, 미끄러지는 타일을 소유하므로 어느 것이 선택되었고 각각이 어디 있는지 알아야 합니다. |
defaultValue / onValueChange | value / onChanged | Flutter 자신의 컨트롤이 controlled이고, 콜백 이름도 Flutter의 것입니다. |
string | number인 값 | 제네릭 T | Dart에는 제네릭이 있어, 관례로 제한하는 대신 타입이 검사됩니다. |
| 타일 위의 CSS 커스텀 속성 넷 | 측정된 Rect와 AnimatedPositioned | 같은 생각(선택된 세그먼트를 재고, 상자를 애니메이션한다)을 Flutter의 말로 한 것입니다. 어느 쪽도 transform하지 않습니다. |
aria-label | semanticLabel | Flutter의 이름입니다. |
name과 hidden input | — | 포함될 네이티브 form 제출이 없습니다. |