@uniai-fe/uds-primitives 0.7.4 → 0.8.0

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
@@ -843,6 +843,30 @@
843
843
  0px 2px 8px rgba(0, 0, 0, 0.08), 0px 0px 4px rgba(0, 0, 0, 0.12);
844
844
  --switch-focus-ring: var(--color-primary-focus);
845
845
  --switch-disabled-opacity: 0.4;
846
+ --switch-text-width: fit-content;
847
+ --switch-text-height-medium: 32px;
848
+ --switch-text-height-large: 40px;
849
+ --switch-text-padding: 2px;
850
+ --switch-text-radius-medium: 6px;
851
+ --switch-text-radius-large: 10px;
852
+ --switch-text-item-radius-medium: 4px;
853
+ --switch-text-item-radius-large: 8px;
854
+ --switch-text-item-min-width: 64px;
855
+ --switch-text-item-width: auto;
856
+ --switch-text-item-padding-x: 12px;
857
+ --switch-text-background: var(--color-background-alternative-cool-gray);
858
+ --switch-text-item-background-selected: var(--color-surface-static-white);
859
+ --switch-text-label-color: var(--color-label-standard);
860
+ --switch-text-label-font-size-medium: var(--font-body-xxsmall-size);
861
+ --switch-text-label-font-size-large: var(--font-body-xsmall-size);
862
+ --switch-text-label-line-height-medium: 1em;
863
+ --switch-text-label-line-height-large: 1em;
864
+ --switch-text-label-letter-spacing-medium: 0px;
865
+ --switch-text-label-letter-spacing-large: 0.2px;
866
+ --switch-text-label-font-weight: var(--font-body-xxsmall-weight);
867
+ --switch-text-label-font-weight-selected: 600;
868
+ --switch-text-item-shadow-selected:
869
+ 0px 2px 8px rgba(0, 0, 0, 0.04), 0px 0px 2px rgba(0, 0, 0, 0.04);
846
870
  --tab-height-small: 40px;
847
871
  --tab-height-medium: 48px;
848
872
  --tab-height-large: 56px;
@@ -5319,6 +5343,118 @@ figure.chip {
5319
5343
  transform: translateX(calc(var(--switch-track-width) - var(--switch-handle-size) - var(--switch-track-padding) * 2));
5320
5344
  }
5321
5345
 
5346
+ .switch-text {
5347
+ --switch-text-height: var(--switch-text-height-medium);
5348
+ --switch-text-radius: var(--switch-text-radius-medium);
5349
+ --switch-text-item-radius: var(--switch-text-item-radius-medium);
5350
+ --switch-text-label-font-size: var(--switch-text-label-font-size-medium);
5351
+ --switch-text-label-line-height: var(--switch-text-label-line-height-medium);
5352
+ --switch-text-label-letter-spacing: var(
5353
+ --switch-text-label-letter-spacing-medium
5354
+ );
5355
+ display: flex;
5356
+ box-sizing: border-box;
5357
+ width: var(--switch-text-width);
5358
+ max-width: 100%;
5359
+ min-height: var(--switch-text-height);
5360
+ padding: var(--switch-text-padding);
5361
+ border-radius: var(--switch-text-radius);
5362
+ background-color: var(--switch-text-background);
5363
+ overflow: auto hidden;
5364
+ }
5365
+
5366
+ .switch-text:where([data-size=large]) {
5367
+ --switch-text-height: var(--switch-text-height-large);
5368
+ --switch-text-radius: var(--switch-text-radius-large);
5369
+ --switch-text-item-radius: var(--switch-text-item-radius-large);
5370
+ --switch-text-label-font-size: var(--switch-text-label-font-size-large);
5371
+ --switch-text-label-line-height: var(--switch-text-label-line-height-large);
5372
+ --switch-text-label-letter-spacing: var(
5373
+ --switch-text-label-letter-spacing-large
5374
+ );
5375
+ }
5376
+
5377
+ .switch-text:where([data-disabled=true]) {
5378
+ opacity: var(--switch-disabled-opacity);
5379
+ }
5380
+
5381
+ .switch-text-list {
5382
+ position: relative;
5383
+ z-index: 1;
5384
+ display: flex;
5385
+ width: auto;
5386
+ min-width: 0;
5387
+ margin: 0;
5388
+ padding: 0;
5389
+ list-style: none;
5390
+ }
5391
+
5392
+ .switch-text-item {
5393
+ flex: 0 0 auto;
5394
+ min-width: var(--switch-text-item-min-width);
5395
+ margin: 0;
5396
+ padding: 0;
5397
+ list-style: none;
5398
+ }
5399
+
5400
+ .switch-text:where([data-width=custom]) .switch-text-list {
5401
+ width: 100%;
5402
+ }
5403
+
5404
+ .switch-text:where([data-width=custom]) .switch-text-item {
5405
+ flex: 1 1 0;
5406
+ }
5407
+
5408
+ .switch-text:where([data-item-width=custom]) .switch-text-item {
5409
+ flex: 0 0 var(--switch-text-item-width);
5410
+ width: var(--switch-text-item-width);
5411
+ }
5412
+
5413
+ .switch-text-button {
5414
+ display: flex;
5415
+ align-items: center;
5416
+ justify-content: center;
5417
+ width: 100%;
5418
+ min-width: var(--switch-text-item-min-width);
5419
+ min-height: calc(var(--switch-text-height) - var(--switch-text-padding) * 2);
5420
+ padding: 0 var(--switch-text-item-padding-x);
5421
+ border: none;
5422
+ border-radius: var(--switch-text-item-radius);
5423
+ background-color: transparent;
5424
+ color: var(--switch-text-label-color);
5425
+ cursor: pointer;
5426
+ font-size: var(--switch-text-label-font-size);
5427
+ font-weight: var(--switch-text-label-font-weight);
5428
+ line-height: var(--switch-text-label-line-height);
5429
+ letter-spacing: var(--switch-text-label-letter-spacing);
5430
+ text-align: center;
5431
+ transition: background-color 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease;
5432
+ }
5433
+
5434
+ .switch-text-button:where([data-state=on]) {
5435
+ background-color: var(--switch-text-item-background-selected);
5436
+ box-shadow: var(--switch-text-item-shadow-selected);
5437
+ font-weight: var(--switch-text-label-font-weight-selected);
5438
+ }
5439
+
5440
+ .switch-text-button:where([data-disabled=true]) {
5441
+ cursor: not-allowed;
5442
+ }
5443
+
5444
+ .switch-text-button:where(:focus-visible) {
5445
+ outline: 2px solid var(--switch-focus-ring);
5446
+ outline-offset: 2px;
5447
+ }
5448
+
5449
+ .switch-text-label {
5450
+ display: flex;
5451
+ align-items: center;
5452
+ justify-content: center;
5453
+ font: inherit;
5454
+ color: inherit;
5455
+ white-space: nowrap;
5456
+ }
5457
+
5322
5458
 
5323
5459
 
5324
5460
  .tab-root {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniai-fe/uds-primitives",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "description": "UNIAI Design System; Primitives Components Package",
5
5
  "type": "module",
6
6
  "private": false,
@@ -96,7 +96,7 @@
96
96
  "@mantine/hooks": "^8.3.18",
97
97
  "@svgr/webpack": "^8.1.0",
98
98
  "@types/node": "^24.12.3",
99
- "@types/react": "^19.2.16",
99
+ "@types/react": "^19.2.17",
100
100
  "@types/react-dom": "^19.2.3",
101
101
  "@uniai-fe/eslint-config": "workspace:*",
102
102
  "@uniai-fe/next-devkit": "workspace:*",
@@ -104,9 +104,9 @@
104
104
  "@uniai-fe/uds-foundation": "workspace:*",
105
105
  "@uniai-fe/util-functions": "workspace:*",
106
106
  "eslint": "^9.39.2",
107
- "prettier": "^3.8.3",
108
- "react-hook-form": "^7.77.0",
109
- "sass": "^1.100.0",
107
+ "prettier": "^3.8.4",
108
+ "react-hook-form": "^7.80.0",
109
+ "sass": "^1.101.0",
110
110
  "typescript": "5.9.3"
111
111
  }
112
112
  }
@@ -1,9 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import clsx from "clsx";
4
- import { forwardRef } from "react";
4
+ import { forwardRef, useId } from "react";
5
5
  import { PopOver } from "../../pop-over";
6
6
  import type { CalendarRootProps } from "../types";
7
+ import {
8
+ getCalendarTriggerClassName,
9
+ isOwnCalendarTriggerTarget,
10
+ } from "../utils";
7
11
  import CalendarContainer from "./layout/Container";
8
12
  import CalendarCore from "./Core";
9
13
 
@@ -62,6 +66,8 @@ const CalendarRoot = forwardRef<HTMLElement, CalendarRootProps>(
62
66
  ) => {
63
67
  // disabled/readOnly 상태에서는 PopOver 토글을 막는다.
64
68
  const isInteractive = !disabled && !readOnly;
69
+ const triggerId = useId();
70
+ const triggerClassName = getCalendarTriggerClassName(triggerId);
65
71
 
66
72
  const calendarNode = (
67
73
  <CalendarContainer
@@ -99,7 +105,8 @@ const CalendarRoot = forwardRef<HTMLElement, CalendarRootProps>(
99
105
  <PopOver.Trigger
100
106
  asChild
101
107
  disabled={!isInteractive}
102
- className={clsx("calendar-trigger", className)}
108
+ className={clsx("calendar-trigger", triggerClassName, className)}
109
+ data-calendar-trigger-id={triggerId}
103
110
  >
104
111
  {/* children은 asChild 계약상 단일 element여야 하며, 해당 요소가 props를 DOM으로 전달해야 한다. */}
105
112
  {children}
@@ -113,9 +120,8 @@ const CalendarRoot = forwardRef<HTMLElement, CalendarRootProps>(
113
120
  withPortal={withPortal}
114
121
  portalContainer={portalContainer}
115
122
  onInteractOutside={event => {
116
- // 변경 설명: trigger 클릭은 outside dismiss에서 제외해 close 후 즉시 reopen 경합을 방지한다.
117
- const nextTarget = event.target as HTMLElement | null;
118
- if (nextTarget?.closest(".calendar-trigger")) {
123
+ // 자기 trigger 클릭만 outside dismiss에서 제외해 close 후 즉시 reopen 경합을 방지한다.
124
+ if (isOwnCalendarTriggerTarget(event.target, triggerId)) {
119
125
  event.preventDefault();
120
126
  }
121
127
  }}
@@ -1,9 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import clsx from "clsx";
4
- import { forwardRef } from "react";
4
+ import { forwardRef, useId } from "react";
5
5
  import { PopOver } from "../../../pop-over";
6
6
  import type { CalendarRangeRootProps } from "../../types";
7
+ import {
8
+ getCalendarTriggerClassName,
9
+ isOwnCalendarTriggerTarget,
10
+ } from "../../utils";
7
11
  import CalendarContainer from "../layout/Container";
8
12
  import CalendarRangeCore from "./Core";
9
13
 
@@ -60,6 +64,8 @@ const CalendarRangeRoot = forwardRef<HTMLElement, CalendarRangeRootProps>(
60
64
  ) => {
61
65
  // disabled/readOnly 상태에서는 range PopOver 토글을 막는다.
62
66
  const isInteractive = !disabled && !readOnly;
67
+ const triggerId = useId();
68
+ const triggerClassName = getCalendarTriggerClassName(triggerId);
63
69
 
64
70
  const calendarNode = (
65
71
  <CalendarContainer
@@ -97,7 +103,8 @@ const CalendarRangeRoot = forwardRef<HTMLElement, CalendarRangeRootProps>(
97
103
  <PopOver.Trigger
98
104
  asChild
99
105
  disabled={!isInteractive}
100
- className={clsx("calendar-trigger", className)}
106
+ className={clsx("calendar-trigger", triggerClassName, className)}
107
+ data-calendar-trigger-id={triggerId}
101
108
  >
102
109
  {/* children은 asChild 계약상 단일 element여야 하며, 해당 요소가 props를 DOM으로 전달해야 한다. */}
103
110
  {children}
@@ -111,9 +118,8 @@ const CalendarRangeRoot = forwardRef<HTMLElement, CalendarRangeRootProps>(
111
118
  withPortal={withPortal}
112
119
  portalContainer={portalContainer}
113
120
  onInteractOutside={event => {
114
- // 변경 설명: trigger 클릭은 outside dismiss에서 제외해 close 후 즉시 reopen 경합을 방지한다.
115
- const nextTarget = event.target as HTMLElement | null;
116
- if (nextTarget?.closest(".calendar-trigger")) {
121
+ // 자기 trigger 클릭만 outside dismiss에서 제외해 close 후 즉시 reopen 경합을 방지한다.
122
+ if (isOwnCalendarTriggerTarget(event.target, triggerId)) {
117
123
  event.preventDefault();
118
124
  }
119
125
  }}
@@ -1 +1,2 @@
1
+ export * from "./trigger";
1
2
  export * from "./value-mapper";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Calendar Utils; Trigger DOM 식별 className 생성 함수.
3
+ * React rendering key가 아니라 Radix PopOver trigger DOM을 식별하기 위한 className을 만든다.
4
+ * @param {string} triggerId React useId로 생성한 Calendar Root 인스턴스 식별자
5
+ * @returns {string} CSS selector에 사용할 수 있는 trigger className
6
+ */
7
+ export const getCalendarTriggerClassName = (triggerId: string): string =>
8
+ `calendar-trigger-${triggerId.replaceAll(":", "")}`;
9
+
10
+ /**
11
+ * Calendar Utils; 자기 trigger outside interaction 판별 함수.
12
+ * PopOver content 밖 interaction 중 현재 Calendar Root의 trigger 클릭만 dismiss 예외로 처리한다.
13
+ * @param {EventTarget | null} target PopOver outside interaction target
14
+ * @param {string} triggerId React useId로 생성한 Calendar Root 인스턴스 식별자
15
+ * @returns {boolean} target이 현재 Calendar Root의 trigger 내부이면 true
16
+ */
17
+ export const isOwnCalendarTriggerTarget = (
18
+ target: EventTarget | null,
19
+ triggerId: string,
20
+ ): boolean => {
21
+ if (!(target instanceof HTMLElement)) {
22
+ return false;
23
+ }
24
+
25
+ return Boolean(target.closest(`[data-calendar-trigger-id="${triggerId}"]`));
26
+ };
@@ -45,6 +45,7 @@ const INPUT_DATE_TABLE_FORMAT = "YY-MM-DD";
45
45
  * @param {InputCalendarTexts} [props.texts] 기본 Date 문구
46
46
  * @param {unknown} [props.timePicker] TimePicker 확장용 예약 슬롯(현재 미구현)
47
47
  * @param {boolean} [props.calendarOpened] calendar 열림 제어 상태
48
+ * @param {(open: boolean) => void} [props.onCalendarOpen] calendar 열림 변경 이벤트
48
49
  * @param {(event: MouseEvent<Element>) => void} [props.onClick] trigger 클릭 핸들러
49
50
  * @param {string} [props.id] trigger id
50
51
  * @param {ReactNode} [props.trigger] 커스텀 trigger 슬롯
@@ -71,6 +72,7 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
71
72
  footer,
72
73
  texts,
73
74
  calendarOpened,
75
+ onCalendarOpen,
74
76
  onClick: triggerOnClick,
75
77
  id,
76
78
  trigger,
@@ -82,6 +84,20 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
82
84
  const [internalCalendarOpen, setInternalCalendarOpen] = useState(false);
83
85
  const resolvedCalendarOpen = calendarOpened ?? internalCalendarOpen;
84
86
 
87
+ /**
88
+ * Input Date Template; Calendar open 상태 변경 전달 함수.
89
+ * 비제어 사용을 위해 내부 상태를 갱신하고, 제어형 사용을 위해 외부 owner에게 next open 값을 전달한다.
90
+ * @param {boolean} nextOpen 다음 calendar open 상태
91
+ * @returns {void}
92
+ */
93
+ const handleCalendarOpenChange = useCallback(
94
+ (nextOpen: boolean) => {
95
+ setInternalCalendarOpen(nextOpen);
96
+ onCalendarOpen?.(nextOpen);
97
+ },
98
+ [onCalendarOpen],
99
+ );
100
+
85
101
  // useUncontrolled로 제어형/비제어 값을 모두 허용한다.
86
102
  const [calendarValue, setCalendarValue] = useUncontrolled<CalendarValue>({
87
103
  value,
@@ -93,7 +109,12 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
93
109
  },
94
110
  });
95
111
 
96
- // react-hook-form register onChange를 수동 호출해 값 직렬화를 맞춘다.
112
+ /**
113
+ * Input Date Template; RHF hidden input 변경 이벤트 동기화 함수.
114
+ * trigger 표시값과 form 저장값을 분리하기 위해 CalendarValue를 직렬화한 synthetic change를 register에 전달한다.
115
+ * @param {CalendarValue} nextValue 다음 calendar 선택 값
116
+ * @returns {void}
117
+ */
97
118
  const emitRegisterChange = useCallback(
98
119
  (nextValue: CalendarValue) => {
99
120
  if (!register?.onChange) {
@@ -115,6 +136,12 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
115
136
  [register],
116
137
  );
117
138
 
139
+ /**
140
+ * Input Date Template; calendar value 갱신 함수.
141
+ * 내부/제어형 value pipeline을 갱신한 뒤 RHF register 저장값을 같은 CalendarValue 기준으로 동기화한다.
142
+ * @param {CalendarValue} nextValue 다음 calendar 선택 값
143
+ * @returns {void}
144
+ */
118
145
  const updateValue = useCallback(
119
146
  (nextValue: CalendarValue) => {
120
147
  setCalendarValue(nextValue);
@@ -133,10 +160,22 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
133
160
  );
134
161
  const serializedCalendarValue = serializeCalendarValue(calendarValue);
135
162
 
163
+ /**
164
+ * Input Date Template; trigger click 위임 함수.
165
+ * Calendar Root의 PopOver toggle은 asChild trigger props가 담당하고, 이 함수는 소비자가 넘긴 trigger onClick만 보존한다.
166
+ * @param {ReactMouseEvent<Element>} event trigger click event
167
+ * @returns {void}
168
+ */
136
169
  const handleTriggerClick = (event: ReactMouseEvent<Element>) => {
137
170
  triggerOnClick?.(event);
138
171
  };
139
172
 
173
+ /**
174
+ * Input Date Template; Calendar Core change adapter 함수.
175
+ * Calendar.Root의 onChange 값을 Template value/RHF 동기화 pipeline으로 연결한다.
176
+ * @param {CalendarValue} nextValue 다음 calendar 선택 값
177
+ * @returns {void}
178
+ */
140
179
  const handleCalendarChange = (nextValue: CalendarValue) => {
141
180
  updateValue(nextValue);
142
181
  };
@@ -147,8 +186,8 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
147
186
  disabled={disabled}
148
187
  onClear={() => updateValue(createEmptyValue())}
149
188
  onToday={() => updateValue(getTodayValue())}
150
- // 변경: Apply 버튼 클릭 시 calendar를 닫는다.
151
- onApply={() => setInternalCalendarOpen(false)}
189
+ // Apply 버튼 클릭 시 calendar를 닫는다.
190
+ onApply={() => handleCalendarOpenChange(false)}
152
191
  texts={texts}
153
192
  />
154
193
  );
@@ -194,7 +233,7 @@ const InputDateTemplate = forwardRef<HTMLDivElement, InputCalendarProps>(
194
233
  header={header}
195
234
  footer={footerContent}
196
235
  open={resolvedCalendarOpen}
197
- onOpenChange={setInternalCalendarOpen}
236
+ onOpenChange={handleCalendarOpenChange}
198
237
  >
199
238
  {triggerNode}
200
239
  </Calendar.Root>
@@ -3,6 +3,7 @@
3
3
  import type { ChangeEvent, MouseEvent as ReactMouseEvent } from "react";
4
4
  import { forwardRef, useCallback, useMemo, useState } from "react";
5
5
  import { useUncontrolled } from "@mantine/hooks";
6
+ import type { UseFormRegisterReturn } from "react-hook-form";
6
7
  import { Calendar } from "../../../../calendar";
7
8
  import type {
8
9
  InputCalendarRangeProps,
@@ -48,6 +49,7 @@ const INPUT_DATE_RANGE_TABLE_FORMAT = "YY-MM-DD";
48
49
  * @param {ReactNode} [props.footer] 패널 footer 콘텐츠
49
50
  * @param {InputCalendarTexts} [props.texts] 기본 Date 문구
50
51
  * @param {boolean} [props.calendarOpened] calendar 열림 제어 상태
52
+ * @param {(open: boolean) => void} [props.onCalendarOpen] calendar 열림 변경 이벤트
51
53
  * @param {string} [props.id] trigger id
52
54
  * @param {ReactNode} [props.trigger] 커스텀 trigger 슬롯
53
55
  * @param {(props: InputCalendarTriggerRenderProps) => ReactNode} [props.renderTrigger] 커스텀 trigger 렌더 함수
@@ -81,6 +83,7 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
81
83
  footer,
82
84
  texts,
83
85
  calendarOpened,
86
+ onCalendarOpen,
84
87
  onClick: triggerOnClick,
85
88
  id,
86
89
  trigger,
@@ -92,6 +95,20 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
92
95
  const [internalCalendarOpen, setInternalCalendarOpen] = useState(false);
93
96
  const resolvedCalendarOpen = calendarOpened ?? internalCalendarOpen;
94
97
 
98
+ /**
99
+ * Input Date Range Template; Calendar open 상태 변경 전달 함수.
100
+ * 비제어 range calendar 상태를 갱신하고, 제어형 owner가 열림 상태를 조율할 수 있게 next open 값을 전달한다.
101
+ * @param {boolean} nextOpen 다음 range calendar open 상태
102
+ * @returns {void}
103
+ */
104
+ const handleCalendarOpenChange = useCallback(
105
+ (nextOpen: boolean) => {
106
+ setInternalCalendarOpen(nextOpen);
107
+ onCalendarOpen?.(nextOpen);
108
+ },
109
+ [onCalendarOpen],
110
+ );
111
+
95
112
  // Calendar.Range tuple 계약을 그대로 유지하며 제어형/비제어 값을 모두 허용한다.
96
113
  const [calendarValue, setCalendarValue] =
97
114
  useUncontrolled<CalendarRangeValue>({
@@ -104,11 +121,23 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
104
121
  },
105
122
  });
106
123
 
107
- // react-hook-form register onChange를 start/end 각각 호출해 hidden input 저장값을 맞춘다.
124
+ /**
125
+ * Input Date Range Template; RHF hidden input 변경 이벤트 동기화 함수.
126
+ * range tuple의 start/end 값을 각각 직렬화해 startRegister/endRegister에 synthetic change로 전달한다.
127
+ * @param {CalendarRangeValue} nextValue 다음 range calendar 선택 값
128
+ * @returns {void}
129
+ */
108
130
  const emitRegisterChange = useCallback(
109
131
  (nextValue: CalendarRangeValue) => {
132
+ /**
133
+ * Input Date Range Template; 단일 range endpoint register 동기화 함수.
134
+ * start 또는 end register 한쪽에 직렬화 값을 전달한다.
135
+ * @param {UseFormRegisterReturn | undefined} register RHF register 결과
136
+ * @param {CalendarValue} nextCalendarValue 다음 start/end 선택 값
137
+ * @returns {void}
138
+ */
110
139
  const emit = (
111
- register: typeof startRegister | typeof endRegister | undefined,
140
+ register: UseFormRegisterReturn | undefined,
112
141
  nextCalendarValue: CalendarValue,
113
142
  ) => {
114
143
  if (!register?.onChange) {
@@ -136,6 +165,12 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
136
165
  [endRegister, startRegister],
137
166
  );
138
167
 
168
+ /**
169
+ * Input Date Range Template; range value 갱신 함수.
170
+ * Calendar.Range tuple 값을 갱신하고 start/end hidden input 저장값을 같은 tuple 기준으로 동기화한다.
171
+ * @param {CalendarRangeValue} nextValue 다음 range calendar 선택 값
172
+ * @returns {void}
173
+ */
139
174
  const updateValue = useCallback(
140
175
  (nextValue: CalendarRangeValue) => {
141
176
  setCalendarValue(nextValue);
@@ -155,10 +190,22 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
155
190
  const serializedStartValue = serializeCalendarValue(calendarValue[0]);
156
191
  const serializedEndValue = serializeCalendarValue(calendarValue[1]);
157
192
 
193
+ /**
194
+ * Input Date Range Template; trigger click 위임 함수.
195
+ * Calendar.Range Root의 PopOver toggle은 asChild trigger props가 담당하고, 이 함수는 소비자가 넘긴 trigger onClick만 보존한다.
196
+ * @param {ReactMouseEvent<Element>} event trigger click event
197
+ * @returns {void}
198
+ */
158
199
  const handleTriggerClick = (event: ReactMouseEvent<Element>) => {
159
200
  triggerOnClick?.(event);
160
201
  };
161
202
 
203
+ /**
204
+ * Input Date Range Template; Calendar Range Core change adapter 함수.
205
+ * Calendar.Range.Root의 onChange 값을 Template tuple/RHF 동기화 pipeline으로 연결한다.
206
+ * @param {CalendarRangeValue} nextValue 다음 range calendar 선택 값
207
+ * @returns {void}
208
+ */
162
209
  const handleCalendarChange = (nextValue: CalendarRangeValue) => {
163
210
  updateValue(nextValue);
164
211
  };
@@ -190,7 +237,7 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
190
237
  className="input-date-range-apply-button"
191
238
  disabled={disabled}
192
239
  label={texts?.apply}
193
- onClick={() => setInternalCalendarOpen(false)}
240
+ onClick={() => handleCalendarOpenChange(false)}
194
241
  />
195
242
  </InputDateFooterUtilContainer>
196
243
  </InputDateFooterTemplateContainer>
@@ -234,7 +281,7 @@ const InputDateRangeTemplate = forwardRef<HTMLElement, InputCalendarRangeProps>(
234
281
  header={header}
235
282
  footer={footerContent}
236
283
  open={resolvedCalendarOpen}
237
- onOpenChange={setInternalCalendarOpen}
284
+ onOpenChange={handleCalendarOpenChange}
238
285
  >
239
286
  {triggerNode}
240
287
  </Calendar.Range.Root>
@@ -151,6 +151,7 @@ export interface InputCalendarTriggerRenderProps {
151
151
  * @property {InputCalendarTexts} [texts] 기본 Date 문구
152
152
  * @property {unknown} [timePicker] TimePicker 확장용 예약 슬롯(현재 미구현)
153
153
  * @property {boolean} [calendarOpened] calendar 열림 제어 여부
154
+ * @property {(open: boolean) => void} [onCalendarOpen] calendar 열림 변경 이벤트
154
155
  * @property {ReactNode} [trigger] 커스텀 trigger 슬롯
155
156
  * @property {(props: InputCalendarTriggerRenderProps) => ReactNode} [renderTrigger] 커스텀 trigger 렌더 함수
156
157
  */
@@ -213,6 +214,10 @@ export interface InputCalendarProps extends InputCalendarTriggerProps {
213
214
  * - 지정되면 제어형(open state controlled)으로 동작한다.
214
215
  */
215
216
  calendarOpened?: boolean;
217
+ /**
218
+ * calendar 열림 변경 이벤트
219
+ */
220
+ onCalendarOpen?: (open: boolean) => void;
216
221
  /**
217
222
  * 커스텀 trigger 슬롯
218
223
  */
@@ -243,6 +248,7 @@ export interface InputCalendarProps extends InputCalendarTriggerProps {
243
248
  * @property {ReactNode} [footer] 커스텀 footer 콘텐츠
244
249
  * @property {InputCalendarTexts} [texts] 기본 Date 문구
245
250
  * @property {boolean} [calendarOpened] calendar 열림 제어 여부
251
+ * @property {(open: boolean) => void} [onCalendarOpen] calendar 열림 변경 이벤트
246
252
  * @property {string} [id] trigger id
247
253
  * @property {ReactNode} [trigger] 커스텀 trigger 슬롯
248
254
  * @property {(props: InputCalendarTriggerRenderProps) => ReactNode} [renderTrigger] 커스텀 trigger 렌더 함수
@@ -323,6 +329,10 @@ export interface InputCalendarRangeProps {
323
329
  * - 지정되면 제어형(open state controlled)으로 동작한다.
324
330
  */
325
331
  calendarOpened?: boolean;
332
+ /**
333
+ * calendar 열림 변경 이벤트
334
+ */
335
+ onCalendarOpen?: (open: boolean) => void;
326
336
  /**
327
337
  * trigger id
328
338
  */
@@ -1,10 +1,17 @@
1
1
  /**
2
- * Switch; standard toggle leaf 카테고리 배럴
2
+ * Switch; namespace 배럴 export
3
3
  * @desc
4
- * - `Switch`: on/off 전환만 담당하는 leaf switch 컴포넌트다.
5
- * - `SwitchProps`, `SwitchSize`: public 계약 타입이다.
4
+ * - `Switch`: on/off 전환만 담당하는 callable standard toggle 컴포넌트다.
5
+ * - `Switch.Text`: options 배열 기반 단일 선택 text switch다.
6
+ * - hooks/types/utils/img/styles 도구 레이어 외 raw component name은 root public surface로 열지 않는다.
6
7
  */
7
8
  import "./index.scss";
8
9
 
9
- export * from "./markup";
10
+ import { Switch as SwitchBase } from "./markup";
11
+ import { SwitchText } from "./markup/text-type";
12
+
13
+ export const Switch = Object.assign(SwitchBase, {
14
+ Text: SwitchText,
15
+ });
16
+
10
17
  export type * from "./types";
@@ -0,0 +1,22 @@
1
+ import clsx from "clsx";
2
+ import { Slot } from "../../../slot";
3
+ import type { SwitchTextLabelProps } from "../../types";
4
+
5
+ /**
6
+ * Switch.Text Label; 옵션 라벨 텍스트 슬롯.
7
+ * @component
8
+ * @param {SwitchTextLabelProps} props
9
+ * @param {React.ReactNode} [props.children] 라벨 콘텐츠.
10
+ * @param {string} [props.className] 라벨 className.
11
+ */
12
+ export default function SwitchTextLabel({
13
+ className,
14
+ ...restProps
15
+ }: SwitchTextLabelProps) {
16
+ return (
17
+ <Slot.Text
18
+ className={clsx("switch-text-label", className)}
19
+ {...restProps}
20
+ />
21
+ );
22
+ }
@@ -0,0 +1,132 @@
1
+ import type { KeyboardEvent } from "react";
2
+ import type { SwitchTextButtonEvent, SwitchTextListProps } from "../../types";
3
+ import SwitchTextLabel from "./Label";
4
+
5
+ /**
6
+ * Switch.Text List; radio 성격의 option item 리스트.
7
+ * @component
8
+ * @param {SwitchTextListProps} props 리스트 렌더링에 사용되는 props.
9
+ * @param {SwitchTextOption[]} props.options 렌더링할 옵션 배열.
10
+ * @param {boolean} props.disabled 전체 비활성 여부.
11
+ * @param {SwitchTextValue | undefined} props.selectedValue 현재 선택된 값.
12
+ * @param {number} props.focusableIndex 포커스를 받을 기본 index.
13
+ * @param {number} props.fallbackIndex 첫 번째 활성화 index.
14
+ * @param {RefObject<Array<HTMLButtonElement | null>>} props.itemRefs 버튼 ref 목록.
15
+ * @param {(value: SwitchTextValue) => void} props.onSelect 값 변경 콜백.
16
+ * @param {(index: number) => void} props.onFocusItemAt index 포커스 함수.
17
+ * @param {(event: KeyboardEvent<HTMLButtonElement>, currentIndex: number) => void} props.onArrowNavigate 화살표 키 처리 콜백.
18
+ */
19
+ const SwitchTextList = ({
20
+ options,
21
+ disabled,
22
+ selectedValue,
23
+ focusableIndex,
24
+ fallbackIndex,
25
+ itemRefs,
26
+ onSelect,
27
+ onFocusItemAt,
28
+ onArrowNavigate,
29
+ }: SwitchTextListProps) => (
30
+ <ul className="switch-text-list">
31
+ {options.map((option, index) => {
32
+ const isDisabled = disabled || Boolean(option.disabled);
33
+ const isSelected = selectedValue === option.value;
34
+ const tabIndex =
35
+ isDisabled || focusableIndex === -1
36
+ ? -1
37
+ : index === focusableIndex
38
+ ? 0
39
+ : -1;
40
+
41
+ /**
42
+ * disabled item의 pointer/key 이벤트를 같은 기준으로 차단한다.
43
+ * @param {SwitchTextButtonEvent} event button interaction event.
44
+ * @returns {boolean} 차단 여부.
45
+ */
46
+ const onStopIfDisabled = (event: SwitchTextButtonEvent): boolean => {
47
+ if (!isDisabled) {
48
+ return false;
49
+ }
50
+
51
+ event.preventDefault();
52
+ event.stopPropagation();
53
+ return true;
54
+ };
55
+
56
+ /**
57
+ * item click 선택 처리. 이미 선택된 항목은 해제하지 않는다.
58
+ * @param {SwitchTextButtonEvent} event button interaction event.
59
+ */
60
+ const onClick = (event: SwitchTextButtonEvent) => {
61
+ if (onStopIfDisabled(event) || isSelected) {
62
+ return;
63
+ }
64
+
65
+ onSelect(option.value);
66
+ };
67
+
68
+ /**
69
+ * Home/End는 첫/마지막 enabled item으로 이동하고, arrow key는 root 로직에 위임한다.
70
+ * @param {KeyboardEvent<HTMLButtonElement>} event keyboard event.
71
+ */
72
+ const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
73
+ if (onStopIfDisabled(event)) {
74
+ return;
75
+ }
76
+
77
+ if (event.key === "Home") {
78
+ event.preventDefault();
79
+ if (fallbackIndex >= 0) {
80
+ const fallbackOption = options[fallbackIndex];
81
+ onFocusItemAt(fallbackIndex);
82
+ if (fallbackOption) {
83
+ onSelect(fallbackOption.value);
84
+ }
85
+ }
86
+ return;
87
+ }
88
+
89
+ if (event.key === "End") {
90
+ event.preventDefault();
91
+ for (let i = options.length - 1; i >= 0; i -= 1) {
92
+ const candidate = options[i];
93
+ if (candidate && !disabled && !candidate.disabled) {
94
+ onFocusItemAt(i);
95
+ onSelect(candidate.value);
96
+ break;
97
+ }
98
+ }
99
+ return;
100
+ }
101
+
102
+ onArrowNavigate(event, index);
103
+ };
104
+
105
+ return (
106
+ <li className="switch-text-item" key={option.value}>
107
+ <button
108
+ ref={node => {
109
+ itemRefs.current[index] = node;
110
+ }}
111
+ type="button"
112
+ role="radio"
113
+ aria-checked={isSelected}
114
+ aria-disabled={isDisabled ? "true" : undefined}
115
+ tabIndex={tabIndex}
116
+ className="switch-text-button"
117
+ data-state={isSelected ? "on" : "off"}
118
+ data-disabled={isDisabled ? "true" : undefined}
119
+ onPointerDown={onStopIfDisabled}
120
+ onKeyDown={onKeyDown}
121
+ onClick={onClick}
122
+ disabled={isDisabled}
123
+ >
124
+ <SwitchTextLabel>{option.label}</SwitchTextLabel>
125
+ </button>
126
+ </li>
127
+ );
128
+ })}
129
+ </ul>
130
+ );
131
+
132
+ export { SwitchTextList };
@@ -0,0 +1,237 @@
1
+ import { styleBaseSize } from "@uniai-fe/util-functions";
2
+ import clsx from "clsx";
3
+ import {
4
+ forwardRef,
5
+ useCallback,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ type CSSProperties,
11
+ type KeyboardEvent,
12
+ } from "react";
13
+ import type {
14
+ SwitchTextOption,
15
+ SwitchTextProps,
16
+ SwitchTextValue,
17
+ } from "../../types";
18
+ import { SwitchTextList } from "./List";
19
+
20
+ /**
21
+ * 첫 enabled option value를 찾는다.
22
+ * @function
23
+ * @param {SwitchTextOption[]} options 선택 가능한 option 배열.
24
+ * @returns {SwitchTextValue | undefined} 첫 enabled option value.
25
+ */
26
+ const findFirstEnabledValue = (
27
+ options: SwitchTextOption[],
28
+ ): SwitchTextValue | undefined => {
29
+ return options.find(option => !option.disabled)?.value;
30
+ };
31
+
32
+ /**
33
+ * Switch.Text; options 배열 기반 단일 선택 text switch.
34
+ * @component
35
+ * @param {SwitchTextProps} props
36
+ * @param {SwitchTextOption[]} props.options 렌더할 옵션 배열.
37
+ * @param {string} props.ariaLabel 접근성 라벨.
38
+ * @param {"medium" | "large"} [props.size="medium"] Figma text switch scale.
39
+ * @param {number | string} [props.width] container width.
40
+ * @param {number | string} [props.itemWidth] item width.
41
+ * @param {SwitchTextValue} [props.value] 제어형 선택값.
42
+ * @param {SwitchTextValue} [props.defaultValue] 비제어 초기 선택값.
43
+ * @param {(value: SwitchTextValue) => void} [props.onValueChange] 선택값 변경 콜백.
44
+ * @param {boolean} [props.disabled=false] 전체 비활성 여부.
45
+ * @example
46
+ * <Switch.Text ariaLabel="기간 선택" options={options} defaultValue="daily" />
47
+ */
48
+ const SwitchText = forwardRef<HTMLDivElement, SwitchTextProps>(
49
+ (
50
+ {
51
+ options,
52
+ ariaLabel,
53
+ size = "medium",
54
+ width,
55
+ itemWidth,
56
+ value,
57
+ defaultValue,
58
+ onValueChange,
59
+ disabled = false,
60
+ className,
61
+ style,
62
+ ...restProps
63
+ },
64
+ forwardedRef,
65
+ ) => {
66
+ // value prop 제공 여부로 제어형/비제어형을 구분한다.
67
+ const isControlled = value !== undefined;
68
+ // defaultValue가 없으면 첫 enabled option을 기본 선택해 선택 해제 불가 정책을 유지한다.
69
+ const initialValue = defaultValue ?? findFirstEnabledValue(options);
70
+ const [uncontrolledValue, setUncontrolledValue] = useState<
71
+ SwitchTextValue | undefined
72
+ >(initialValue);
73
+ const selectedValue = isControlled ? value : uncontrolledValue;
74
+ // itemRefs는 roving tabindex focus 이동에만 사용한다.
75
+ const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
76
+ // fallbackIndex는 선택값이 없거나 사라졌을 때 포커스 가능한 첫 item이다.
77
+ const fallbackIndex = useMemo(
78
+ () => options.findIndex(option => !option.disabled),
79
+ [options],
80
+ );
81
+ const selectedIndex = useMemo(
82
+ () => options.findIndex(option => option.value === selectedValue),
83
+ [options, selectedValue],
84
+ );
85
+ const focusableIndex =
86
+ selectedIndex >= 0
87
+ ? selectedIndex
88
+ : fallbackIndex >= 0
89
+ ? fallbackIndex
90
+ : -1;
91
+
92
+ useEffect(() => {
93
+ if (isControlled) {
94
+ return;
95
+ }
96
+
97
+ // options 변경으로 현재 비제어 선택값이 사라지면 첫 enabled option으로 복구한다.
98
+ const hasSelectedOption = options.some(
99
+ option => option.value === uncontrolledValue && !option.disabled,
100
+ );
101
+ if (hasSelectedOption) {
102
+ return;
103
+ }
104
+
105
+ setUncontrolledValue(findFirstEnabledValue(options));
106
+ }, [isControlled, options, uncontrolledValue]);
107
+
108
+ /**
109
+ * 선택값 변경을 제어형/비제어형 공통 경로로 처리한다.
110
+ * @param {SwitchTextValue} nextValue 다음 선택값.
111
+ */
112
+ const onSelect = useCallback(
113
+ (nextValue: SwitchTextValue) => {
114
+ if (!isControlled) {
115
+ setUncontrolledValue(nextValue);
116
+ }
117
+ onValueChange?.(nextValue);
118
+ },
119
+ [isControlled, onValueChange],
120
+ );
121
+
122
+ /**
123
+ * index에 해당하는 item button으로 focus를 이동한다.
124
+ * @param {number} index focus 대상 option index.
125
+ */
126
+ const onFocusItemAt = useCallback((index: number) => {
127
+ itemRefs.current[index]?.focus();
128
+ }, []);
129
+
130
+ /**
131
+ * 현재 index 기준 다음 enabled option index를 계산한다.
132
+ * @param {number} currentIndex 현재 option index.
133
+ * @param {1 | -1} direction 탐색 방향.
134
+ * @returns {number} 다음 enabled option index.
135
+ */
136
+ const getNextEnabledIndex = useCallback(
137
+ (currentIndex: number, direction: 1 | -1): number => {
138
+ if (options.length === 0) {
139
+ return -1;
140
+ }
141
+
142
+ let index = currentIndex;
143
+ for (let i = 0; i < options.length; i += 1) {
144
+ index = (index + direction + options.length) % options.length;
145
+ const option = options[index];
146
+ if (option && !option.disabled) {
147
+ return index;
148
+ }
149
+ }
150
+
151
+ return currentIndex;
152
+ },
153
+ [options],
154
+ );
155
+
156
+ /**
157
+ * Arrow key 입력 시 focus와 선택값을 다음 enabled item으로 함께 이동한다.
158
+ * @param {KeyboardEvent<HTMLButtonElement>} event keyboard event.
159
+ * @param {number} currentIndex 현재 option index.
160
+ */
161
+ const onArrowNavigate = useCallback(
162
+ (event: KeyboardEvent<HTMLButtonElement>, currentIndex: number) => {
163
+ if (
164
+ event.key !== "ArrowRight" &&
165
+ event.key !== "ArrowLeft" &&
166
+ event.key !== "ArrowUp" &&
167
+ event.key !== "ArrowDown"
168
+ ) {
169
+ return;
170
+ }
171
+
172
+ event.preventDefault();
173
+ const direction =
174
+ event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
175
+ const nextIndex = getNextEnabledIndex(currentIndex, direction);
176
+ if (nextIndex === currentIndex) {
177
+ return;
178
+ }
179
+
180
+ const nextOption = options[nextIndex];
181
+ if (nextOption) {
182
+ onFocusItemAt(nextIndex);
183
+ onSelect(nextOption.value);
184
+ }
185
+ },
186
+ [getNextEnabledIndex, onFocusItemAt, onSelect, options],
187
+ );
188
+
189
+ const resolvedWidth =
190
+ width !== undefined ? styleBaseSize("px", width) : undefined;
191
+ const resolvedItemWidth =
192
+ itemWidth !== undefined ? styleBaseSize("px", itemWidth) : undefined;
193
+ const rootStyle =
194
+ resolvedWidth || resolvedItemWidth
195
+ ? ({
196
+ ...(style ?? {}),
197
+ ...(resolvedWidth
198
+ ? { ["--switch-text-width" as const]: resolvedWidth }
199
+ : {}),
200
+ ...(resolvedItemWidth
201
+ ? { ["--switch-text-item-width" as const]: resolvedItemWidth }
202
+ : {}),
203
+ } as CSSProperties)
204
+ : style;
205
+
206
+ return (
207
+ <div
208
+ {...restProps}
209
+ ref={forwardedRef}
210
+ role="radiogroup"
211
+ aria-label={ariaLabel}
212
+ className={clsx("switch-text", className)}
213
+ data-size={size}
214
+ data-width={resolvedWidth ? "custom" : undefined}
215
+ data-disabled={disabled ? "true" : undefined}
216
+ data-item-width={resolvedItemWidth ? "custom" : undefined}
217
+ style={rootStyle}
218
+ >
219
+ <SwitchTextList
220
+ options={options}
221
+ disabled={disabled}
222
+ selectedValue={selectedValue}
223
+ focusableIndex={focusableIndex}
224
+ fallbackIndex={fallbackIndex}
225
+ itemRefs={itemRefs}
226
+ onSelect={onSelect}
227
+ onFocusItemAt={onFocusItemAt}
228
+ onArrowNavigate={onArrowNavigate}
229
+ />
230
+ </div>
231
+ );
232
+ },
233
+ );
234
+
235
+ SwitchText.displayName = "Switch.Text";
236
+
237
+ export { SwitchText };
@@ -0,0 +1 @@
1
+ export { SwitchText } from "./SwitchText";
@@ -61,3 +61,120 @@
61
61
  )
62
62
  );
63
63
  }
64
+
65
+ .switch-text {
66
+ --switch-text-height: var(--switch-text-height-medium);
67
+ --switch-text-radius: var(--switch-text-radius-medium);
68
+ --switch-text-item-radius: var(--switch-text-item-radius-medium);
69
+ --switch-text-label-font-size: var(--switch-text-label-font-size-medium);
70
+ --switch-text-label-line-height: var(--switch-text-label-line-height-medium);
71
+ --switch-text-label-letter-spacing: var(
72
+ --switch-text-label-letter-spacing-medium
73
+ );
74
+ display: flex;
75
+ box-sizing: border-box;
76
+ width: var(--switch-text-width);
77
+ max-width: 100%;
78
+ min-height: var(--switch-text-height);
79
+ padding: var(--switch-text-padding);
80
+ border-radius: var(--switch-text-radius);
81
+ background-color: var(--switch-text-background);
82
+ overflow: auto hidden;
83
+ }
84
+
85
+ .switch-text:where([data-size="large"]) {
86
+ --switch-text-height: var(--switch-text-height-large);
87
+ --switch-text-radius: var(--switch-text-radius-large);
88
+ --switch-text-item-radius: var(--switch-text-item-radius-large);
89
+ --switch-text-label-font-size: var(--switch-text-label-font-size-large);
90
+ --switch-text-label-line-height: var(--switch-text-label-line-height-large);
91
+ --switch-text-label-letter-spacing: var(
92
+ --switch-text-label-letter-spacing-large
93
+ );
94
+ }
95
+
96
+ .switch-text:where([data-disabled="true"]) {
97
+ opacity: var(--switch-disabled-opacity);
98
+ }
99
+
100
+ .switch-text-list {
101
+ position: relative;
102
+ z-index: 1;
103
+ display: flex;
104
+ width: auto;
105
+ min-width: 0;
106
+ margin: 0;
107
+ padding: 0;
108
+ list-style: none;
109
+ }
110
+
111
+ .switch-text-item {
112
+ flex: 0 0 auto;
113
+ min-width: var(--switch-text-item-min-width);
114
+ margin: 0;
115
+ padding: 0;
116
+ list-style: none;
117
+ }
118
+
119
+ .switch-text:where([data-width="custom"]) .switch-text-list {
120
+ width: 100%;
121
+ }
122
+
123
+ .switch-text:where([data-width="custom"]) .switch-text-item {
124
+ flex: 1 1 0;
125
+ }
126
+
127
+ .switch-text:where([data-item-width="custom"]) .switch-text-item {
128
+ flex: 0 0 var(--switch-text-item-width);
129
+ width: var(--switch-text-item-width);
130
+ }
131
+
132
+ .switch-text-button {
133
+ display: flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ width: 100%;
137
+ min-width: var(--switch-text-item-min-width);
138
+ min-height: calc(
139
+ var(--switch-text-height) - (var(--switch-text-padding) * 2)
140
+ );
141
+ padding: 0 var(--switch-text-item-padding-x);
142
+ border: none;
143
+ border-radius: var(--switch-text-item-radius);
144
+ background-color: transparent;
145
+ color: var(--switch-text-label-color);
146
+ cursor: pointer;
147
+ font-size: var(--switch-text-label-font-size);
148
+ font-weight: var(--switch-text-label-font-weight);
149
+ line-height: var(--switch-text-label-line-height);
150
+ letter-spacing: var(--switch-text-label-letter-spacing);
151
+ text-align: center;
152
+ transition:
153
+ background-color 0.2s ease,
154
+ box-shadow 0.2s ease,
155
+ opacity 0.2s ease;
156
+ }
157
+
158
+ .switch-text-button:where([data-state="on"]) {
159
+ background-color: var(--switch-text-item-background-selected);
160
+ box-shadow: var(--switch-text-item-shadow-selected);
161
+ font-weight: var(--switch-text-label-font-weight-selected);
162
+ }
163
+
164
+ .switch-text-button:where([data-disabled="true"]) {
165
+ cursor: not-allowed;
166
+ }
167
+
168
+ .switch-text-button:where(:focus-visible) {
169
+ outline: 2px solid var(--switch-focus-ring);
170
+ outline-offset: 2px;
171
+ }
172
+
173
+ .switch-text-label {
174
+ display: flex;
175
+ align-items: center;
176
+ justify-content: center;
177
+ font: inherit;
178
+ color: inherit;
179
+ white-space: nowrap;
180
+ }
@@ -18,4 +18,29 @@
18
18
  0px 2px 8px rgba(0, 0, 0, 0.08), 0px 0px 4px rgba(0, 0, 0, 0.12);
19
19
  --switch-focus-ring: var(--color-primary-focus);
20
20
  --switch-disabled-opacity: 0.4;
21
+
22
+ --switch-text-width: fit-content;
23
+ --switch-text-height-medium: 32px;
24
+ --switch-text-height-large: 40px;
25
+ --switch-text-padding: 2px;
26
+ --switch-text-radius-medium: 6px;
27
+ --switch-text-radius-large: 10px;
28
+ --switch-text-item-radius-medium: 4px;
29
+ --switch-text-item-radius-large: 8px;
30
+ --switch-text-item-min-width: 64px;
31
+ --switch-text-item-width: auto;
32
+ --switch-text-item-padding-x: 12px;
33
+ --switch-text-background: var(--color-background-alternative-cool-gray);
34
+ --switch-text-item-background-selected: var(--color-surface-static-white);
35
+ --switch-text-label-color: var(--color-label-standard);
36
+ --switch-text-label-font-size-medium: var(--font-body-xxsmall-size);
37
+ --switch-text-label-font-size-large: var(--font-body-xsmall-size);
38
+ --switch-text-label-line-height-medium: 1em;
39
+ --switch-text-label-line-height-large: 1em;
40
+ --switch-text-label-letter-spacing-medium: 0px;
41
+ --switch-text-label-letter-spacing-large: 0.2px;
42
+ --switch-text-label-font-weight: var(--font-body-xxsmall-weight);
43
+ --switch-text-label-font-weight-selected: 600;
44
+ --switch-text-item-shadow-selected:
45
+ 0px 2px 8px rgba(0, 0, 0, 0.04), 0px 0px 2px rgba(0, 0, 0, 0.04);
21
46
  }
@@ -1 +1,2 @@
1
1
  export type * from "./switch";
2
+ export type * from "./text";
@@ -0,0 +1,128 @@
1
+ import type {
2
+ ComponentPropsWithoutRef,
3
+ KeyboardEvent,
4
+ MouseEvent,
5
+ PointerEvent,
6
+ ReactNode,
7
+ RefObject,
8
+ } from "react";
9
+ import type { SlotTextProps } from "../../slot";
10
+
11
+ /**
12
+ * Switch.Text value 타입. 문자열 value를 사용한다.
13
+ */
14
+ export type SwitchTextValue = string;
15
+
16
+ /**
17
+ * Switch.Text size; Figma text switch height/typography 축
18
+ * @typedef {"medium" | "large"} SwitchTextSize
19
+ */
20
+ export type SwitchTextSize = "medium" | "large";
21
+
22
+ /**
23
+ * Switch.Text width; number는 px로 변환하고, string은 그대로 사용한다.
24
+ * @typedef {number | string} SwitchTextWidth
25
+ */
26
+ export type SwitchTextWidth = number | string;
27
+
28
+ /**
29
+ * Switch.Text option 정의.
30
+ * @property {SwitchTextValue} value 고유 value.
31
+ * @property {ReactNode} label 표시 라벨.
32
+ * @property {boolean} [disabled] 비활성화 여부.
33
+ */
34
+ export interface SwitchTextOption {
35
+ /**
36
+ * 고유 value.
37
+ */
38
+ value: SwitchTextValue;
39
+ /**
40
+ * 표시 라벨.
41
+ */
42
+ label: ReactNode;
43
+ /**
44
+ * 비활성화 여부.
45
+ */
46
+ disabled?: boolean;
47
+ }
48
+
49
+ type SwitchTextRootProps = Omit<
50
+ ComponentPropsWithoutRef<"div">,
51
+ "children" | "onChange" | "role"
52
+ >;
53
+
54
+ /**
55
+ * Switch.Text props; options 배열 기반 단일 선택 public props.
56
+ * @property {SwitchTextOption[]} options 렌더할 옵션 배열.
57
+ * @property {string} ariaLabel 접근성 라벨.
58
+ * @property {"medium" | "large"} [size] Figma text switch scale.
59
+ * @property {number | string} [width] container width.
60
+ * @property {number | string} [itemWidth] item width.
61
+ * @property {SwitchTextValue} [value] 제어형 선택값.
62
+ * @property {SwitchTextValue} [defaultValue] 비제어 초기 선택값.
63
+ * @property {(value: SwitchTextValue) => void} [onValueChange] 선택값 변경 콜백.
64
+ * @property {boolean} [disabled] 전체 비활성 여부.
65
+ */
66
+ export interface SwitchTextProps extends SwitchTextRootProps {
67
+ /**
68
+ * 렌더할 옵션 배열.
69
+ */
70
+ options: SwitchTextOption[];
71
+ /**
72
+ * 접근성 라벨.
73
+ */
74
+ ariaLabel: string;
75
+ /**
76
+ * Figma text switch scale.
77
+ */
78
+ size?: SwitchTextSize;
79
+ /**
80
+ * container width.
81
+ */
82
+ width?: SwitchTextWidth;
83
+ /**
84
+ * item width.
85
+ */
86
+ itemWidth?: SwitchTextWidth;
87
+ /**
88
+ * 제어형 선택값.
89
+ */
90
+ value?: SwitchTextValue;
91
+ /**
92
+ * 비제어 초기 선택값.
93
+ */
94
+ defaultValue?: SwitchTextValue;
95
+ /**
96
+ * 선택값 변경 콜백.
97
+ */
98
+ onValueChange?: (value: SwitchTextValue) => void;
99
+ /**
100
+ * 전체 비활성 여부.
101
+ */
102
+ disabled?: boolean;
103
+ }
104
+
105
+ export type SwitchTextButtonEvent =
106
+ | MouseEvent<HTMLButtonElement>
107
+ | PointerEvent<HTMLButtonElement>
108
+ | KeyboardEvent<HTMLButtonElement>;
109
+
110
+ export interface SwitchTextListProps {
111
+ options: SwitchTextOption[];
112
+ disabled: boolean;
113
+ selectedValue?: SwitchTextValue;
114
+ focusableIndex: number;
115
+ fallbackIndex: number;
116
+ itemRefs: RefObject<Array<HTMLButtonElement | null>>;
117
+ onSelect: (value: SwitchTextValue) => void;
118
+ onFocusItemAt: (index: number) => void;
119
+ onArrowNavigate: (
120
+ event: KeyboardEvent<HTMLButtonElement>,
121
+ currentIndex: number,
122
+ ) => void;
123
+ }
124
+
125
+ /**
126
+ * Switch.Text Label props.
127
+ */
128
+ export type SwitchTextLabelProps = SlotTextProps;