@uniai-fe/uds-primitives 0.7.5 → 0.8.1
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 +136 -0
- package/package.json +7 -7
- package/src/components/switch/index.tsx +11 -4
- package/src/components/switch/markup/text-type/Label.tsx +22 -0
- package/src/components/switch/markup/text-type/List.tsx +132 -0
- package/src/components/switch/markup/text-type/SwitchText.tsx +237 -0
- package/src/components/switch/markup/text-type/index.ts +1 -0
- package/src/components/switch/styles/switch.scss +117 -0
- package/src/components/switch/styles/variables.scss +25 -0
- package/src/components/switch/types/index.ts +1 -0
- package/src/components/switch/types/text.ts +128 -0
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.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "UNIAI Design System; Primitives Components Package",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
"@mantine/core": "^8",
|
|
63
63
|
"@mantine/dates": "^8",
|
|
64
64
|
"@mantine/hooks": "^8",
|
|
65
|
-
"@uniai-fe/uds-foundation": "^0.4.
|
|
66
|
-
"@uniai-fe/util-functions": "^0.
|
|
65
|
+
"@uniai-fe/uds-foundation": "^0.4.8",
|
|
66
|
+
"@uniai-fe/util-functions": "^0.3.0",
|
|
67
67
|
"react": "^19",
|
|
68
68
|
"react-daum-postcode": "^4.0.0",
|
|
69
69
|
"react-dom": "^19",
|
|
@@ -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.
|
|
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.
|
|
108
|
-
"react-hook-form": "^7.
|
|
109
|
-
"sass": "^1.
|
|
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,10 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Switch;
|
|
2
|
+
* Switch; namespace 배럴 export
|
|
3
3
|
* @desc
|
|
4
|
-
* - `Switch`: on/off 전환만 담당하는
|
|
5
|
-
* - `
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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;
|