@uniai-fe/uds-primitives 0.0.18 → 0.0.19
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.
- package/dist/styles.css +45 -44
- package/package.json +1 -1
- package/src/components/segmented-control/markup/Container.tsx +255 -0
- package/src/components/segmented-control/markup/Indicator.tsx +31 -0
- package/src/components/segmented-control/markup/List.tsx +125 -0
- package/src/components/segmented-control/markup/index.ts +1 -1
- package/src/components/segmented-control/styles/index.scss +51 -48
- package/src/components/segmented-control/types/index.ts +39 -1
- package/src/theme/ThemeProvider.tsx +3 -1
- package/src/components/segmented-control/markup/SegmentedControl.tsx +0 -129
package/dist/styles.css
CHANGED
|
@@ -3112,94 +3112,95 @@ figure.chip {
|
|
|
3112
3112
|
--segmented-label-color: var(--color-label-neutral, #797e86);
|
|
3113
3113
|
--segmented-label-active-color: var(--color-label-strong, #181a1b);
|
|
3114
3114
|
--segmented-disabled-opacity: 0.4;
|
|
3115
|
+
--segmented-gap: 2px;
|
|
3115
3116
|
--segmented-item-padding-x: 22px;
|
|
3116
3117
|
--segmented-item-padding-y: 4px;
|
|
3117
3118
|
--segmented-item-font-size: var(--font-heading-xxsmall-size, 15px);
|
|
3118
3119
|
--segmented-item-font-weight: var(--font-heading-xxsmall-weight, 500);
|
|
3119
3120
|
--segmented-item-line-height: var(--font-heading-xxsmall-line-height, 1.5);
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
align-items: stretch;
|
|
3121
|
+
position: relative;
|
|
3122
|
+
display: block;
|
|
3123
|
+
box-sizing: border-box;
|
|
3124
3124
|
padding: var(--segmented-padding);
|
|
3125
3125
|
border-radius: var(--segmented-radius);
|
|
3126
3126
|
background: var(--segmented-bg);
|
|
3127
3127
|
width: fit-content;
|
|
3128
|
-
height: var(--segmented-height);
|
|
3129
|
-
font-size: 0;
|
|
3128
|
+
min-height: var(--segmented-height);
|
|
3130
3129
|
isolation: isolate;
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
.segmented-control:where(.rt-SegmentedControlRoot) {
|
|
3134
|
-
/* Radix Theme 기본 inline-grid를 덮어 동일한 sizing 시스템에서만 layout이 될 수 있도록 한다. */
|
|
3135
|
-
display: grid;
|
|
3130
|
+
overflow: hidden;
|
|
3136
3131
|
}
|
|
3137
3132
|
|
|
3138
3133
|
.segmented-control:where([data-keep-selected=true]) {
|
|
3139
3134
|
--segmented-disabled-opacity: 0.3;
|
|
3140
3135
|
}
|
|
3141
3136
|
|
|
3142
|
-
.segmented-control
|
|
3143
|
-
border-radius: calc(var(--segmented-radius) - var(--segmented-padding));
|
|
3144
|
-
background: transparent;
|
|
3145
|
-
box-shadow: none;
|
|
3146
|
-
overflow: hidden;
|
|
3147
|
-
}
|
|
3148
|
-
|
|
3149
|
-
.segmented-control :where(.rt-SegmentedControlIndicator)::before {
|
|
3150
|
-
content: "";
|
|
3137
|
+
.segmented-control-indicator {
|
|
3151
3138
|
position: absolute;
|
|
3152
|
-
|
|
3139
|
+
top: var(--segmented-padding);
|
|
3140
|
+
bottom: var(--segmented-padding);
|
|
3141
|
+
left: 0;
|
|
3142
|
+
width: 0px;
|
|
3143
|
+
height: calc(100% - var(--segmented-padding) * 2);
|
|
3144
|
+
margin: 0;
|
|
3153
3145
|
border-radius: calc(var(--segmented-radius) - var(--segmented-padding));
|
|
3154
3146
|
background: var(--segmented-indicator-bg);
|
|
3155
3147
|
box-shadow: var(--segmented-indicator-shadow);
|
|
3148
|
+
transition: transform 0.2s ease, width 0.2s ease, opacity 0.2s ease;
|
|
3149
|
+
pointer-events: none;
|
|
3150
|
+
z-index: 0;
|
|
3156
3151
|
}
|
|
3157
3152
|
|
|
3158
|
-
.segmented-control
|
|
3159
|
-
|
|
3153
|
+
.segmented-control-indicator[data-visible=false] {
|
|
3154
|
+
opacity: 0;
|
|
3160
3155
|
}
|
|
3161
3156
|
|
|
3162
|
-
.segmented-control
|
|
3163
|
-
background: transparent;
|
|
3164
|
-
padding: 0;
|
|
3165
|
-
border: none;
|
|
3166
|
-
min-width: 0;
|
|
3157
|
+
.segmented-control-list {
|
|
3167
3158
|
display: flex;
|
|
3159
|
+
column-gap: var(--segmented-gap);
|
|
3160
|
+
row-gap: 0;
|
|
3161
|
+
margin: 0;
|
|
3162
|
+
padding: 0;
|
|
3163
|
+
list-style: none;
|
|
3164
|
+
position: relative;
|
|
3165
|
+
z-index: 1;
|
|
3168
3166
|
}
|
|
3169
3167
|
|
|
3170
|
-
.segmented-control
|
|
3171
|
-
|
|
3172
|
-
|
|
3168
|
+
.segmented-control-item {
|
|
3169
|
+
list-style: none;
|
|
3170
|
+
margin: 0;
|
|
3173
3171
|
padding: 0;
|
|
3174
|
-
font-size: 0;
|
|
3175
3172
|
}
|
|
3176
3173
|
|
|
3177
|
-
.segmented-control-
|
|
3174
|
+
.segmented-control-button {
|
|
3175
|
+
position: relative;
|
|
3176
|
+
z-index: 1;
|
|
3177
|
+
display: flex;
|
|
3178
|
+
align-items: center;
|
|
3179
|
+
justify-content: center;
|
|
3178
3180
|
width: 100%;
|
|
3179
|
-
height: 100%;
|
|
3180
3181
|
border: none;
|
|
3181
3182
|
background: transparent;
|
|
3182
3183
|
cursor: pointer;
|
|
3183
|
-
|
|
3184
|
-
.segmented-control-item .rt-SegmentedControlItemLabel {
|
|
3185
|
-
display: flex;
|
|
3186
|
-
align-items: center;
|
|
3187
|
-
justify-content: center;
|
|
3188
|
-
padding: var(--segmented-item-padding-y) var(--segmented-item-padding-x);
|
|
3184
|
+
min-width: 0;
|
|
3189
3185
|
border-radius: calc(var(--segmented-radius) - var(--segmented-padding));
|
|
3186
|
+
padding: var(--segmented-item-padding-y) var(--segmented-item-padding-x);
|
|
3187
|
+
transition: color 0.2s ease;
|
|
3190
3188
|
}
|
|
3191
3189
|
|
|
3192
|
-
.segmented-control-
|
|
3190
|
+
.segmented-control-button:where([data-disabled=true]) {
|
|
3193
3191
|
cursor: not-allowed;
|
|
3194
3192
|
opacity: var(--segmented-disabled-opacity);
|
|
3195
3193
|
}
|
|
3196
3194
|
|
|
3197
|
-
.segmented-control-
|
|
3195
|
+
.segmented-control-button:where(:focus-visible) {
|
|
3198
3196
|
outline: 2px solid var(--color-focus-ring, var(--color-primary-default));
|
|
3199
3197
|
outline-offset: 2px;
|
|
3200
3198
|
}
|
|
3201
3199
|
|
|
3202
|
-
.segmented-control-
|
|
3200
|
+
.segmented-control-button-label {
|
|
3201
|
+
display: flex;
|
|
3202
|
+
align-items: center;
|
|
3203
|
+
justify-content: center;
|
|
3203
3204
|
font-size: var(--segmented-item-font-size);
|
|
3204
3205
|
font-weight: var(--segmented-item-font-weight);
|
|
3205
3206
|
line-height: var(--segmented-item-line-height);
|
|
@@ -3208,7 +3209,7 @@ figure.chip {
|
|
|
3208
3209
|
transition: color 0.2s ease;
|
|
3209
3210
|
}
|
|
3210
3211
|
|
|
3211
|
-
.segmented-control-
|
|
3212
|
+
.segmented-control-button:where([data-state=on]) .segmented-control-button-label {
|
|
3212
3213
|
color: var(--segmented-label-active-color);
|
|
3213
3214
|
}
|
|
3214
3215
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import clsx from "clsx";
|
|
2
|
+
import {
|
|
3
|
+
forwardRef,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useLayoutEffect,
|
|
7
|
+
useMemo,
|
|
8
|
+
useRef,
|
|
9
|
+
useState,
|
|
10
|
+
type KeyboardEvent,
|
|
11
|
+
} from "react";
|
|
12
|
+
import type {
|
|
13
|
+
SegmentedControlIndicatorRect,
|
|
14
|
+
SegmentedControlProps,
|
|
15
|
+
SegmentedControlValue,
|
|
16
|
+
} from "../types";
|
|
17
|
+
import { SegmentedControlIndicator } from "./Indicator";
|
|
18
|
+
import { SegmentedControlList } from "./List";
|
|
19
|
+
|
|
20
|
+
// 빈 문자열이나 undefined를 내부 상태에서 공통적으로 undefined로 처리해 keepSelected 로직을 단순화한다.
|
|
21
|
+
const toNullableValue = (value?: SegmentedControlValue) =>
|
|
22
|
+
value === undefined || value === "" ? undefined : value;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @component SegmentedControl
|
|
26
|
+
* @desc keepSelected 토글과 indicator/list 레이어를 native로 구성한 Segmented Control 루트 컴포넌트.
|
|
27
|
+
* @param {SegmentedControlProps} props 루트에 전달되는 props.
|
|
28
|
+
* @param {SegmentedControlOption[]} props.options 선택지 배열.
|
|
29
|
+
* @param {string} props.ariaLabel 라디오 그룹 접근성 라벨.
|
|
30
|
+
* @param {boolean} [props.keepSelected=true] 이미 on 상태인 항목을 다시 눌러도 선택 해제를 막을지 여부.
|
|
31
|
+
* @param {SegmentedControlValue} [props.value] 제어형 값.
|
|
32
|
+
* @param {SegmentedControlValue} [props.defaultValue] 비제어 초기 값.
|
|
33
|
+
* @param {(value: SegmentedControlValue) => void} [props.onValueChange] 값 변경 콜백.
|
|
34
|
+
* @param {string} [props.className] 최상위 className.
|
|
35
|
+
*/
|
|
36
|
+
const SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
|
|
37
|
+
(
|
|
38
|
+
{
|
|
39
|
+
options,
|
|
40
|
+
ariaLabel,
|
|
41
|
+
keepSelected = true,
|
|
42
|
+
className,
|
|
43
|
+
onValueChange,
|
|
44
|
+
value: valueProp,
|
|
45
|
+
defaultValue,
|
|
46
|
+
...restProps
|
|
47
|
+
},
|
|
48
|
+
forwardedRef,
|
|
49
|
+
) => {
|
|
50
|
+
// value prop 제공 여부로 제어형/비제어형을 구분한다.
|
|
51
|
+
const isControlled = valueProp !== undefined;
|
|
52
|
+
// keepSelected=false일 때 해제를 허용하기 위해 내부 값은 undefined 허용으로 둔다.
|
|
53
|
+
const [uncontrolledValue, setUncontrolledValue] = useState<
|
|
54
|
+
SegmentedControlValue | undefined
|
|
55
|
+
>(toNullableValue(defaultValue));
|
|
56
|
+
const selectedValue = toNullableValue(
|
|
57
|
+
isControlled ? valueProp : uncontrolledValue,
|
|
58
|
+
);
|
|
59
|
+
// 루트 컨테이너 ref는 indicator 측정과 ResizeObserver 구독에 활용된다.
|
|
60
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
61
|
+
// 각 버튼 ref 배열. Arrow/Home/End 탐색 시 focus를 직접 이동한다.
|
|
62
|
+
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
|
63
|
+
// indicatorRect는 indicator figure의 width/translateX를 계산하는 단일 소스다.
|
|
64
|
+
const [indicatorRect, setIndicatorRect] =
|
|
65
|
+
useState<SegmentedControlIndicatorRect>({
|
|
66
|
+
width: 0,
|
|
67
|
+
left: 0,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const emitChange = useCallback(
|
|
71
|
+
(nextValue: SegmentedControlValue | undefined) => {
|
|
72
|
+
if (!isControlled) {
|
|
73
|
+
setUncontrolledValue(nextValue);
|
|
74
|
+
}
|
|
75
|
+
onValueChange?.(nextValue ?? "");
|
|
76
|
+
},
|
|
77
|
+
[isControlled, onValueChange],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// 제어형/비제어형을 통합해 현재 선택된 value를 계산한다.
|
|
81
|
+
const resolvedValue = useMemo(
|
|
82
|
+
() => selectedValue ?? undefined,
|
|
83
|
+
[selectedValue],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// 현재 value에 해당하는 index. 값이 없으면 첫 번째 활성 항목을 포커스 대상으로 사용한다.
|
|
87
|
+
const selectedIndex = useMemo(
|
|
88
|
+
() => options.findIndex(option => option.value === selectedValue),
|
|
89
|
+
[options, selectedValue],
|
|
90
|
+
);
|
|
91
|
+
const fallbackIndex = useMemo(() => {
|
|
92
|
+
return options.findIndex(option => !option.disabled);
|
|
93
|
+
}, [options]);
|
|
94
|
+
const focusableIndex =
|
|
95
|
+
selectedIndex >= 0
|
|
96
|
+
? selectedIndex
|
|
97
|
+
: fallbackIndex >= 0
|
|
98
|
+
? fallbackIndex
|
|
99
|
+
: -1;
|
|
100
|
+
|
|
101
|
+
// Arrow 키 이동 시 다음 사용 가능한 index를 찾는다. disabled 옵션은 자동으로 건너뛴다.
|
|
102
|
+
const getNextEnabledIndex = useCallback(
|
|
103
|
+
(currentIndex: number, direction: 1 | -1) => {
|
|
104
|
+
if (options.length === 0) {
|
|
105
|
+
return -1;
|
|
106
|
+
}
|
|
107
|
+
let index = currentIndex;
|
|
108
|
+
for (let i = 0; i < options.length; i += 1) {
|
|
109
|
+
index = (index + direction + options.length) % options.length;
|
|
110
|
+
const option = options[index];
|
|
111
|
+
if (option && !option.disabled) {
|
|
112
|
+
return index;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return currentIndex;
|
|
116
|
+
},
|
|
117
|
+
[options],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Button ref를 통해 포커스를 직접 이동한다.
|
|
121
|
+
const focusItemAt = useCallback((index: number) => {
|
|
122
|
+
const node = itemRefs.current[index];
|
|
123
|
+
if (node) {
|
|
124
|
+
node.focus();
|
|
125
|
+
}
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
const handleArrowNavigation = useCallback(
|
|
129
|
+
(event: KeyboardEvent<HTMLButtonElement>, currentIndex: number) => {
|
|
130
|
+
if (options.length === 0) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const key = event.key;
|
|
134
|
+
if (
|
|
135
|
+
key !== "ArrowRight" &&
|
|
136
|
+
key !== "ArrowLeft" &&
|
|
137
|
+
key !== "ArrowUp" &&
|
|
138
|
+
key !== "ArrowDown"
|
|
139
|
+
) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
event.preventDefault();
|
|
143
|
+
const direction = key === "ArrowRight" || key === "ArrowDown" ? 1 : -1;
|
|
144
|
+
const nextIndex = getNextEnabledIndex(currentIndex, direction);
|
|
145
|
+
if (nextIndex === currentIndex) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const nextOption = options[nextIndex];
|
|
149
|
+
if (nextOption) {
|
|
150
|
+
focusItemAt(nextIndex);
|
|
151
|
+
emitChange(nextOption.value);
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
[emitChange, focusItemAt, getNextEnabledIndex, options],
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 선택된 버튼의 위치 + root padding을 사용해 indicator 좌표를 계산한다.
|
|
159
|
+
* transform 이동만 수행하므로 padding 값을 더해 container 내부에 고정한다.
|
|
160
|
+
*/
|
|
161
|
+
const measureIndicator = useCallback(() => {
|
|
162
|
+
if (!resolvedValue) {
|
|
163
|
+
setIndicatorRect({ width: 0, left: 0 });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const index = options.findIndex(option => option.value === resolvedValue);
|
|
167
|
+
if (index === -1) {
|
|
168
|
+
setIndicatorRect({ width: 0, left: 0 });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const node = itemRefs.current[index];
|
|
172
|
+
if (!node) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const paddingLeft =
|
|
176
|
+
typeof window !== "undefined" && rootRef.current
|
|
177
|
+
? Number.parseFloat(
|
|
178
|
+
window.getComputedStyle(rootRef.current).paddingLeft ?? "0",
|
|
179
|
+
)
|
|
180
|
+
: 0;
|
|
181
|
+
const normalizedLeft = node.offsetLeft + paddingLeft;
|
|
182
|
+
setIndicatorRect({
|
|
183
|
+
width: node.offsetWidth,
|
|
184
|
+
left: Number.isNaN(normalizedLeft) ? 0 : normalizedLeft,
|
|
185
|
+
});
|
|
186
|
+
}, [options, resolvedValue]);
|
|
187
|
+
|
|
188
|
+
// DOM이 렌더된 직후 선택된 항목 기준으로 indicator를 한 번 맞춘다.
|
|
189
|
+
useLayoutEffect(() => {
|
|
190
|
+
measureIndicator();
|
|
191
|
+
}, [measureIndicator]);
|
|
192
|
+
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
if (!rootRef.current || typeof ResizeObserver === "undefined") {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// root padding이나 label 길이에 따른 폭 변경을 추적해 indicator 위치를 재계산한다.
|
|
198
|
+
const observer = new ResizeObserver(() => {
|
|
199
|
+
measureIndicator();
|
|
200
|
+
});
|
|
201
|
+
observer.observe(rootRef.current);
|
|
202
|
+
return () => observer.disconnect();
|
|
203
|
+
}, [measureIndicator]);
|
|
204
|
+
|
|
205
|
+
const setRootRef = useCallback(
|
|
206
|
+
(node: HTMLDivElement | null) => {
|
|
207
|
+
rootRef.current = node;
|
|
208
|
+
if (typeof forwardedRef === "function") {
|
|
209
|
+
forwardedRef(node);
|
|
210
|
+
} else if (forwardedRef) {
|
|
211
|
+
(forwardedRef as { current: HTMLDivElement | null }).current = node;
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
[forwardedRef],
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// 첫 렌더에서 width=0이면 indicator를 숨겨 깜박임을 방지한다.
|
|
218
|
+
const indicatorVisible = indicatorRect.width > 0;
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div
|
|
222
|
+
{...restProps}
|
|
223
|
+
ref={setRootRef}
|
|
224
|
+
role="radiogroup"
|
|
225
|
+
aria-label={ariaLabel}
|
|
226
|
+
className={clsx(
|
|
227
|
+
"segmented-control segmented-control-container",
|
|
228
|
+
className,
|
|
229
|
+
)}
|
|
230
|
+
data-keep-selected={keepSelected ? "true" : undefined}
|
|
231
|
+
>
|
|
232
|
+
{/* indicator와 list를 분리해 DOM 구조가 단순한 flex 계층을 유지한다. */}
|
|
233
|
+
<SegmentedControlIndicator
|
|
234
|
+
rect={indicatorRect}
|
|
235
|
+
visible={indicatorVisible}
|
|
236
|
+
/>
|
|
237
|
+
<SegmentedControlList
|
|
238
|
+
options={options}
|
|
239
|
+
keepSelected={keepSelected}
|
|
240
|
+
selectedValue={selectedValue}
|
|
241
|
+
focusableIndex={focusableIndex}
|
|
242
|
+
fallbackIndex={fallbackIndex}
|
|
243
|
+
itemRefs={itemRefs}
|
|
244
|
+
onSelect={emitChange}
|
|
245
|
+
onFocusItemAt={focusItemAt}
|
|
246
|
+
onArrowNavigate={handleArrowNavigation}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
SegmentedControl.displayName = "SegmentedControl";
|
|
254
|
+
|
|
255
|
+
export { SegmentedControl };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { SegmentedControlIndicatorProps } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @component SegmentedControlIndicator
|
|
5
|
+
* @desc 선택된 항목 아래를 따라 이동하는 시각적 indicator 레이어.
|
|
6
|
+
* @param {SegmentedControlIndicatorProps} props indicator 렌더링에 사용되는 props.
|
|
7
|
+
* @param {SegmentedControlIndicatorRect} props.rect width/left 정보를 담은 사각형 값.
|
|
8
|
+
* @param {boolean} props.visible indicator 표시 여부.
|
|
9
|
+
*/
|
|
10
|
+
const SegmentedControlIndicator = ({
|
|
11
|
+
rect,
|
|
12
|
+
visible,
|
|
13
|
+
}: SegmentedControlIndicatorProps) => {
|
|
14
|
+
const style = visible
|
|
15
|
+
? {
|
|
16
|
+
width: `${rect.width}px`,
|
|
17
|
+
transform: `translateX(${rect.left}px)`,
|
|
18
|
+
}
|
|
19
|
+
: undefined;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<figure
|
|
23
|
+
className="segmented-control-indicator"
|
|
24
|
+
style={style}
|
|
25
|
+
data-visible={visible ? "true" : "false"}
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export { SegmentedControlIndicator };
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SegmentedControlButtonEvent,
|
|
3
|
+
SegmentedControlListProps,
|
|
4
|
+
} from "../types";
|
|
5
|
+
import type { KeyboardEvent } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @component SegmentedControlList
|
|
9
|
+
* @desc Segmented Control의 버튼 리스트 레이어로, 포커스/키보드/keepSelected 로직을 캡슐화한다.
|
|
10
|
+
* @param {SegmentedControlListProps} props 리스트 렌더링에 사용되는 props.
|
|
11
|
+
* @param {SegmentedControlOption[]} props.options 렌더링할 옵션 배열.
|
|
12
|
+
* @param {boolean} props.keepSelected 동일 항목 재선택 시 해제 여부.
|
|
13
|
+
* @param {SegmentedControlValue | undefined} props.selectedValue 현재 선택된 값.
|
|
14
|
+
* @param {number} props.focusableIndex 포커스를 받을 기본 index.
|
|
15
|
+
* @param {number} props.fallbackIndex 첫 번째 활성화 index.
|
|
16
|
+
* @param {MutableRefObject<Array<HTMLButtonElement | null>>} props.itemRefs 버튼 ref 목록.
|
|
17
|
+
* @param {(value: SegmentedControlValue | undefined) => void} props.onSelect 값 변경 콜백.
|
|
18
|
+
* @param {(index: number) => void} props.onFocusItemAt index 포커스 함수.
|
|
19
|
+
* @param {(event: KeyboardEvent<HTMLButtonElement>, currentIndex: number) => void} props.onArrowNavigate 화살표 키 처리 콜백.
|
|
20
|
+
*/
|
|
21
|
+
const SegmentedControlList = ({
|
|
22
|
+
options,
|
|
23
|
+
keepSelected,
|
|
24
|
+
selectedValue,
|
|
25
|
+
focusableIndex,
|
|
26
|
+
fallbackIndex,
|
|
27
|
+
itemRefs,
|
|
28
|
+
onSelect,
|
|
29
|
+
onFocusItemAt,
|
|
30
|
+
onArrowNavigate,
|
|
31
|
+
}: SegmentedControlListProps) => (
|
|
32
|
+
<ul className="segmented-control-list">
|
|
33
|
+
{options.map((option, index) => {
|
|
34
|
+
const isDisabled = Boolean(option.disabled);
|
|
35
|
+
const isSelected = selectedValue === option.value;
|
|
36
|
+
const tabIndex =
|
|
37
|
+
isDisabled || focusableIndex === -1
|
|
38
|
+
? -1
|
|
39
|
+
: index === focusableIndex
|
|
40
|
+
? 0
|
|
41
|
+
: -1;
|
|
42
|
+
|
|
43
|
+
// 모든 상호작용에서 공통으로 disabled 상태를 막는다.
|
|
44
|
+
const stopIfDisabled = (event: SegmentedControlButtonEvent) => {
|
|
45
|
+
if (!isDisabled) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
event.preventDefault();
|
|
49
|
+
event.stopPropagation();
|
|
50
|
+
return true;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// keepSelected=false일 때만 동일 버튼을 눌러 selection을 해제한다.
|
|
54
|
+
const handleClick = (event: SegmentedControlButtonEvent) => {
|
|
55
|
+
if (stopIfDisabled(event)) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (!keepSelected && isSelected) {
|
|
59
|
+
onSelect(undefined);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
onSelect(option.value);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Home/End는 양 끝으로, Arrow는 상위에서 전달된 로직을 호출한다.
|
|
66
|
+
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
67
|
+
if (stopIfDisabled(event)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (event.key === "Home") {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
if (fallbackIndex >= 0) {
|
|
73
|
+
onFocusItemAt(fallbackIndex);
|
|
74
|
+
const fallbackOption = options[fallbackIndex];
|
|
75
|
+
if (fallbackOption) {
|
|
76
|
+
onSelect(fallbackOption.value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (event.key === "End") {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
for (let i = options.length - 1; i >= 0; i -= 1) {
|
|
84
|
+
const candidate = options[i];
|
|
85
|
+
if (!candidate.disabled) {
|
|
86
|
+
onFocusItemAt(i);
|
|
87
|
+
onSelect(candidate.value);
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
onArrowNavigate(event, index);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<li className="segmented-control-item" key={option.value}>
|
|
98
|
+
<button
|
|
99
|
+
ref={node => {
|
|
100
|
+
itemRefs.current[index] = node;
|
|
101
|
+
}}
|
|
102
|
+
type="button"
|
|
103
|
+
role="radio"
|
|
104
|
+
aria-checked={isSelected}
|
|
105
|
+
tabIndex={tabIndex}
|
|
106
|
+
className="segmented-control-button"
|
|
107
|
+
data-state={isSelected ? "on" : "off"}
|
|
108
|
+
data-disabled={isDisabled ? "true" : undefined}
|
|
109
|
+
onPointerDown={stopIfDisabled}
|
|
110
|
+
onFocus={stopIfDisabled}
|
|
111
|
+
onKeyDown={handleKeyDown}
|
|
112
|
+
onClick={handleClick}
|
|
113
|
+
disabled={isDisabled}
|
|
114
|
+
>
|
|
115
|
+
<span className="segmented-control-button-label">
|
|
116
|
+
{option.label}
|
|
117
|
+
</span>
|
|
118
|
+
</button>
|
|
119
|
+
</li>
|
|
120
|
+
);
|
|
121
|
+
})}
|
|
122
|
+
</ul>
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
export { SegmentedControlList };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "./
|
|
1
|
+
export * from "./Container";
|
|
@@ -10,96 +10,98 @@
|
|
|
10
10
|
--segmented-label-color: var(--color-label-neutral, #797e86);
|
|
11
11
|
--segmented-label-active-color: var(--color-label-strong, #181a1b);
|
|
12
12
|
--segmented-disabled-opacity: 0.4;
|
|
13
|
+
--segmented-gap: 2px;
|
|
13
14
|
--segmented-item-padding-x: 22px;
|
|
14
15
|
--segmented-item-padding-y: 4px;
|
|
15
16
|
--segmented-item-font-size: var(--font-heading-xxsmall-size, 15px);
|
|
16
17
|
--segmented-item-font-weight: var(--font-heading-xxsmall-weight, 500);
|
|
17
18
|
--segmented-item-line-height: var(--font-heading-xxsmall-line-height, 1.5);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
align-items: stretch;
|
|
19
|
+
position: relative;
|
|
20
|
+
display: block;
|
|
21
|
+
box-sizing: border-box;
|
|
22
22
|
padding: var(--segmented-padding);
|
|
23
23
|
border-radius: var(--segmented-radius);
|
|
24
24
|
background: var(--segmented-bg);
|
|
25
25
|
width: fit-content;
|
|
26
|
-
height: var(--segmented-height);
|
|
27
|
-
font-size: 0;
|
|
26
|
+
min-height: var(--segmented-height);
|
|
28
27
|
isolation: isolate;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
.segmented-control:where(.rt-SegmentedControlRoot) {
|
|
32
|
-
/* Radix Theme 기본 inline-grid를 덮어 동일한 sizing 시스템에서만 layout이 될 수 있도록 한다. */
|
|
33
|
-
display: grid;
|
|
28
|
+
overflow: hidden;
|
|
34
29
|
}
|
|
35
30
|
|
|
36
31
|
.segmented-control:where([data-keep-selected="true"]) {
|
|
37
32
|
--segmented-disabled-opacity: 0.3;
|
|
38
33
|
}
|
|
39
34
|
|
|
40
|
-
.segmented-control
|
|
41
|
-
border-radius: calc(var(--segmented-radius) - var(--segmented-padding));
|
|
42
|
-
background: transparent;
|
|
43
|
-
box-shadow: none;
|
|
44
|
-
overflow: hidden;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
.segmented-control :where(.rt-SegmentedControlIndicator)::before {
|
|
48
|
-
content: "";
|
|
35
|
+
.segmented-control-indicator {
|
|
49
36
|
position: absolute;
|
|
50
|
-
|
|
37
|
+
top: var(--segmented-padding);
|
|
38
|
+
bottom: var(--segmented-padding);
|
|
39
|
+
left: 0;
|
|
40
|
+
width: 0px;
|
|
41
|
+
height: calc(100% - (var(--segmented-padding) * 2));
|
|
42
|
+
margin: 0;
|
|
51
43
|
border-radius: calc(var(--segmented-radius) - var(--segmented-padding));
|
|
52
44
|
background: var(--segmented-indicator-bg);
|
|
53
45
|
box-shadow: var(--segmented-indicator-shadow);
|
|
46
|
+
transition:
|
|
47
|
+
transform 0.2s ease,
|
|
48
|
+
width 0.2s ease,
|
|
49
|
+
opacity 0.2s ease;
|
|
50
|
+
pointer-events: none;
|
|
51
|
+
z-index: 0;
|
|
54
52
|
}
|
|
55
53
|
|
|
56
|
-
.segmented-control
|
|
57
|
-
|
|
54
|
+
.segmented-control-indicator[data-visible="false"] {
|
|
55
|
+
opacity: 0;
|
|
58
56
|
}
|
|
59
57
|
|
|
60
|
-
.segmented-control
|
|
61
|
-
background: transparent;
|
|
62
|
-
padding: 0;
|
|
63
|
-
border: none;
|
|
64
|
-
min-width: 0;
|
|
58
|
+
.segmented-control-list {
|
|
65
59
|
display: flex;
|
|
60
|
+
column-gap: var(--segmented-gap);
|
|
61
|
+
row-gap: 0;
|
|
62
|
+
margin: 0;
|
|
63
|
+
padding: 0;
|
|
64
|
+
list-style: none;
|
|
65
|
+
position: relative;
|
|
66
|
+
z-index: 1;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
.segmented-control
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
.segmented-control-item {
|
|
70
|
+
list-style: none;
|
|
71
|
+
margin: 0;
|
|
71
72
|
padding: 0;
|
|
72
|
-
font-size: 0;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
.segmented-control-
|
|
75
|
+
.segmented-control-button {
|
|
76
|
+
position: relative;
|
|
77
|
+
z-index: 1;
|
|
78
|
+
display: flex;
|
|
79
|
+
align-items: center;
|
|
80
|
+
justify-content: center;
|
|
76
81
|
width: 100%;
|
|
77
|
-
height: 100%;
|
|
78
82
|
border: none;
|
|
79
83
|
background: transparent;
|
|
80
84
|
cursor: pointer;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
padding: var(--segmented-item-padding-y) var(--segmented-item-padding-x);
|
|
86
|
-
border-radius: calc(var(--segmented-radius) - var(--segmented-padding));
|
|
87
|
-
}
|
|
85
|
+
min-width: 0;
|
|
86
|
+
border-radius: calc(var(--segmented-radius) - var(--segmented-padding));
|
|
87
|
+
padding: var(--segmented-item-padding-y) var(--segmented-item-padding-x);
|
|
88
|
+
transition: color 0.2s ease;
|
|
88
89
|
}
|
|
89
|
-
|
|
90
|
+
|
|
91
|
+
.segmented-control-button:where([data-disabled="true"]) {
|
|
90
92
|
cursor: not-allowed;
|
|
91
93
|
opacity: var(--segmented-disabled-opacity);
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
.segmented-control-
|
|
96
|
+
.segmented-control-button:where(:focus-visible) {
|
|
95
97
|
outline: 2px solid var(--color-focus-ring, var(--color-primary-default));
|
|
96
98
|
outline-offset: 2px;
|
|
97
99
|
}
|
|
98
100
|
|
|
99
|
-
.segmented-control-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
.segmented-control-button-label {
|
|
102
|
+
display: flex;
|
|
103
|
+
align-items: center;
|
|
104
|
+
justify-content: center;
|
|
103
105
|
font-size: var(--segmented-item-font-size);
|
|
104
106
|
font-weight: var(--segmented-item-font-weight);
|
|
105
107
|
line-height: var(--segmented-item-line-height);
|
|
@@ -108,6 +110,7 @@
|
|
|
108
110
|
transition: color 0.2s ease;
|
|
109
111
|
}
|
|
110
112
|
|
|
111
|
-
.segmented-control-
|
|
113
|
+
.segmented-control-button:where([data-state="on"])
|
|
114
|
+
.segmented-control-button-label {
|
|
112
115
|
color: var(--segmented-label-active-color);
|
|
113
116
|
}
|
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ComponentPropsWithoutRef,
|
|
3
|
+
FocusEvent,
|
|
4
|
+
KeyboardEvent,
|
|
5
|
+
MouseEvent,
|
|
6
|
+
MutableRefObject,
|
|
7
|
+
PointerEvent,
|
|
8
|
+
} from "react";
|
|
2
9
|
import type { SegmentedControl as RadixSegmentedControlNamespace } from "@radix-ui/themes";
|
|
3
10
|
|
|
4
11
|
/**
|
|
@@ -36,3 +43,34 @@ export interface SegmentedControlProps extends Omit<
|
|
|
36
43
|
ariaLabel: string;
|
|
37
44
|
keepSelected?: boolean;
|
|
38
45
|
}
|
|
46
|
+
|
|
47
|
+
export interface SegmentedControlIndicatorRect {
|
|
48
|
+
width: number;
|
|
49
|
+
left: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SegmentedControlIndicatorProps {
|
|
53
|
+
rect: SegmentedControlIndicatorRect;
|
|
54
|
+
visible: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type SegmentedControlButtonEvent =
|
|
58
|
+
| MouseEvent<HTMLButtonElement>
|
|
59
|
+
| PointerEvent<HTMLButtonElement>
|
|
60
|
+
| KeyboardEvent<HTMLButtonElement>
|
|
61
|
+
| FocusEvent<HTMLButtonElement>;
|
|
62
|
+
|
|
63
|
+
export interface SegmentedControlListProps {
|
|
64
|
+
options: SegmentedControlOption[];
|
|
65
|
+
keepSelected: boolean;
|
|
66
|
+
selectedValue?: SegmentedControlValue;
|
|
67
|
+
focusableIndex: number;
|
|
68
|
+
fallbackIndex: number;
|
|
69
|
+
itemRefs: MutableRefObject<Array<HTMLButtonElement | null>>;
|
|
70
|
+
onSelect: (value: SegmentedControlValue | undefined) => void;
|
|
71
|
+
onFocusItemAt: (index: number) => void;
|
|
72
|
+
onArrowNavigate: (
|
|
73
|
+
event: KeyboardEvent<HTMLButtonElement>,
|
|
74
|
+
currentIndex: number,
|
|
75
|
+
) => void;
|
|
76
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Theme } from "@radix-ui/themes";
|
|
2
|
-
import "@radix-ui/themes/
|
|
2
|
+
import "@radix-ui/themes/components.css";
|
|
3
|
+
import "@radix-ui/themes/layout.css";
|
|
4
|
+
import "@radix-ui/themes/utilities.css";
|
|
3
5
|
import type { PropsWithChildren } from "react";
|
|
4
6
|
import { defaultThemeOptions, type ThemeProviderProps } from "./config";
|
|
5
7
|
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import { SegmentedControl as RadixSegmentedControl } from "@radix-ui/themes";
|
|
2
|
-
import clsx from "clsx";
|
|
3
|
-
import {
|
|
4
|
-
forwardRef,
|
|
5
|
-
useMemo,
|
|
6
|
-
useState,
|
|
7
|
-
type KeyboardEvent,
|
|
8
|
-
type MouseEvent,
|
|
9
|
-
type PointerEvent,
|
|
10
|
-
} from "react";
|
|
11
|
-
import type { SegmentedControlProps, SegmentedControlValue } from "../types";
|
|
12
|
-
|
|
13
|
-
const toNullableValue = (value?: SegmentedControlValue) =>
|
|
14
|
-
value === undefined || value === "" ? undefined : value;
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* SegmentedControl — Radix SegmentedControl wrapper with keepSelected toggle.
|
|
18
|
-
* @component
|
|
19
|
-
* @param {SegmentedControlProps} props
|
|
20
|
-
* @param {SegmentedControlOption[]} props.options 렌더링할 옵션 배열.
|
|
21
|
-
* @param {string} props.ariaLabel 스크린리더용 라벨(필수).
|
|
22
|
-
* @param {boolean} [props.keepSelected=true] 선택된 항목을 다시 클릭했을 때 해제하지 않고 유지할지 여부.
|
|
23
|
-
* @param {SegmentedControlValue} [props.value] 제어형 value.
|
|
24
|
-
* @param {SegmentedControlValue} [props.defaultValue] 비제어 초기 value.
|
|
25
|
-
* @param {(value: SegmentedControlValue) => void} [props.onValueChange] 값 변경 콜백.
|
|
26
|
-
* @param {string} [props.className] root className.
|
|
27
|
-
*/
|
|
28
|
-
const SegmentedControl = forwardRef<HTMLDivElement, SegmentedControlProps>(
|
|
29
|
-
(
|
|
30
|
-
{
|
|
31
|
-
options,
|
|
32
|
-
ariaLabel,
|
|
33
|
-
keepSelected = true,
|
|
34
|
-
className,
|
|
35
|
-
onValueChange,
|
|
36
|
-
value: valueProp,
|
|
37
|
-
defaultValue,
|
|
38
|
-
...restProps
|
|
39
|
-
},
|
|
40
|
-
forwardedRef,
|
|
41
|
-
) => {
|
|
42
|
-
const isControlled = valueProp !== undefined;
|
|
43
|
-
const [uncontrolledValue, setUncontrolledValue] = useState<
|
|
44
|
-
SegmentedControlValue | undefined
|
|
45
|
-
>(toNullableValue(defaultValue));
|
|
46
|
-
const selectedValue = toNullableValue(
|
|
47
|
-
isControlled ? valueProp : uncontrolledValue,
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
const emitChange = (nextValue: SegmentedControlValue | undefined) => {
|
|
51
|
-
if (!isControlled) {
|
|
52
|
-
setUncontrolledValue(nextValue);
|
|
53
|
-
}
|
|
54
|
-
onValueChange?.(nextValue ?? "");
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
const handleRootValueChange = (nextValue: string) => {
|
|
58
|
-
emitChange(nextValue);
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const resolvedValue = useMemo(
|
|
62
|
-
() => selectedValue ?? undefined,
|
|
63
|
-
[selectedValue],
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
return (
|
|
67
|
-
<RadixSegmentedControl.Root
|
|
68
|
-
{...restProps}
|
|
69
|
-
ref={forwardedRef}
|
|
70
|
-
aria-label={ariaLabel}
|
|
71
|
-
className={clsx("segmented-control", className)}
|
|
72
|
-
value={resolvedValue}
|
|
73
|
-
onValueChange={handleRootValueChange}
|
|
74
|
-
data-keep-selected={keepSelected ? "true" : undefined}
|
|
75
|
-
>
|
|
76
|
-
{options.map(option => {
|
|
77
|
-
const isDisabled = Boolean(option.disabled);
|
|
78
|
-
const isSelected = selectedValue === option.value;
|
|
79
|
-
|
|
80
|
-
const preventDisabledInteraction = (
|
|
81
|
-
event:
|
|
82
|
-
| MouseEvent<HTMLButtonElement>
|
|
83
|
-
| PointerEvent<HTMLButtonElement>
|
|
84
|
-
| KeyboardEvent<HTMLButtonElement>,
|
|
85
|
-
) => {
|
|
86
|
-
if (!isDisabled) {
|
|
87
|
-
return;
|
|
88
|
-
}
|
|
89
|
-
event.preventDefault();
|
|
90
|
-
event.stopPropagation();
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
|
|
94
|
-
preventDisabledInteraction(event);
|
|
95
|
-
if (isDisabled) {
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
if (!keepSelected && isSelected) {
|
|
99
|
-
event.preventDefault();
|
|
100
|
-
emitChange(undefined);
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
return (
|
|
105
|
-
<RadixSegmentedControl.Item
|
|
106
|
-
key={option.value}
|
|
107
|
-
value={option.value}
|
|
108
|
-
className="segmented-control-item"
|
|
109
|
-
aria-disabled={isDisabled || undefined}
|
|
110
|
-
data-disabled={isDisabled ? "true" : undefined}
|
|
111
|
-
tabIndex={isDisabled ? -1 : undefined}
|
|
112
|
-
onPointerDown={preventDisabledInteraction}
|
|
113
|
-
onKeyDown={preventDisabledInteraction}
|
|
114
|
-
onClick={handleClick}
|
|
115
|
-
>
|
|
116
|
-
<span className="segmented-control-item-label">
|
|
117
|
-
{option.label}
|
|
118
|
-
</span>
|
|
119
|
-
</RadixSegmentedControl.Item>
|
|
120
|
-
);
|
|
121
|
-
})}
|
|
122
|
-
</RadixSegmentedControl.Root>
|
|
123
|
-
);
|
|
124
|
-
},
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
SegmentedControl.displayName = "SegmentedControl";
|
|
128
|
-
|
|
129
|
-
export { SegmentedControl };
|