@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 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
- display: grid;
3121
- grid-auto-flow: column;
3122
- grid-auto-columns: 1fr;
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 :where(.rt-SegmentedControlIndicator) {
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
- inset: var(--segmented-padding);
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 :where(.rt-SegmentedControlItemSeparator) {
3159
- display: none;
3153
+ .segmented-control-indicator[data-visible=false] {
3154
+ opacity: 0;
3160
3155
  }
3161
3156
 
3162
- .segmented-control :where(.rt-SegmentedControlItem) {
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 :where(.rt-SegmentedControlItemLabel) {
3171
- gap: 0;
3172
- height: fit-content;
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-item {
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-item:where([data-disabled=true]) {
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-item:where(:focus-visible) {
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-item-label {
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-item:where([data-state=on]) .segmented-control-item-label {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-primitives",
3
- "version": "0.0.18",
3
+ "version": "0.0.19",
4
4
  "description": "UNIAI Design System; Primitives Components Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -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 "./SegmentedControl";
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
- display: grid;
19
- grid-auto-flow: column;
20
- grid-auto-columns: 1fr;
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 :where(.rt-SegmentedControlIndicator) {
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
- inset: var(--segmented-padding);
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 :where(.rt-SegmentedControlItemSeparator) {
57
- display: none;
54
+ .segmented-control-indicator[data-visible="false"] {
55
+ opacity: 0;
58
56
  }
59
57
 
60
- .segmented-control :where(.rt-SegmentedControlItem) {
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 :where(.rt-SegmentedControlItemLabel) {
69
- gap: 0;
70
- height: fit-content;
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-item {
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
- .rt-SegmentedControlItemLabel {
82
- display: flex;
83
- align-items: center;
84
- justify-content: center;
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
- .segmented-control-item:where([data-disabled="true"]) {
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-item:where(:focus-visible) {
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-item-label {
100
- // display: flex;
101
- // align-items: center;
102
- // justify-content: center;
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-item:where([data-state="on"]) .segmented-control-item-label {
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 { ComponentPropsWithoutRef } from "react";
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/styles.css";
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 };