@seed-design/react 2.1.0 → 2.2.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/lib/components/DatePicker/DatePicker.cjs +351 -0
- package/lib/components/DatePicker/DatePicker.d.ts +39 -0
- package/lib/components/DatePicker/DatePicker.d.ts.map +1 -0
- package/lib/components/DatePicker/DatePicker.js +345 -0
- package/lib/components/DatePicker/DatePicker.test.d.ts +2 -0
- package/lib/components/DatePicker/DatePicker.test.d.ts.map +1 -0
- package/lib/components/DatePicker/index.cjs +62 -0
- package/lib/components/DatePicker/index.d.ts +3 -0
- package/lib/components/DatePicker/index.d.ts.map +1 -0
- package/lib/components/DatePicker/index.js +3 -0
- package/lib/components/QuantityPicker/QuantityPicker.d.ts +13 -1
- package/lib/components/QuantityPicker/QuantityPicker.d.ts.map +1 -1
- package/lib/components/TimePicker/TimePicker.cjs +97 -0
- package/lib/components/TimePicker/TimePicker.d.ts +28 -0
- package/lib/components/TimePicker/TimePicker.d.ts.map +1 -0
- package/lib/components/TimePicker/TimePicker.js +94 -0
- package/lib/components/TimePicker/TimePicker.test.d.ts +2 -0
- package/lib/components/TimePicker/TimePicker.test.d.ts.map +1 -0
- package/lib/components/TimePicker/index.cjs +3 -0
- package/lib/components/TimePicker/index.d.ts +2 -0
- package/lib/components/TimePicker/index.d.ts.map +1 -0
- package/lib/components/TimePicker/index.js +2 -0
- package/lib/components/index.cjs +63 -1
- package/lib/components/index.d.ts +2 -0
- package/lib/components/index.d.ts.map +1 -1
- package/lib/components/index.js +5 -2
- package/lib/components/private/WheelPicker.cjs +64 -0
- package/lib/components/private/WheelPicker.d.ts +16 -0
- package/lib/components/private/WheelPicker.d.ts.map +1 -0
- package/lib/components/private/WheelPicker.js +59 -0
- package/lib/components/private/WheelPicker.test.d.ts +2 -0
- package/lib/components/private/WheelPicker.test.d.ts.map +1 -0
- package/lib/index.cjs +63 -1
- package/lib/index.js +5 -2
- package/package.json +8 -5
- package/src/components/DatePicker/DatePicker.test.tsx +234 -0
- package/src/components/DatePicker/DatePicker.tsx +574 -0
- package/src/components/DatePicker/index.ts +34 -0
- package/src/components/QuantityPicker/QuantityPicker.test.tsx +10 -0
- package/src/components/QuantityPicker/QuantityPicker.tsx +14 -2
- package/src/components/TimePicker/TimePicker.test.tsx +166 -0
- package/src/components/TimePicker/TimePicker.tsx +163 -0
- package/src/components/TimePicker/index.ts +6 -0
- package/src/components/index.ts +2 -0
- package/src/components/private/WheelPicker.test.tsx +54 -0
- package/src/components/private/WheelPicker.tsx +110 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { act, fireEvent, render } from "@testing-library/react";
|
|
2
|
+
import { afterAll, beforeAll, describe, expect, it, jest, mock } from "bun:test";
|
|
3
|
+
import { TimePicker } from "./TimePicker";
|
|
4
|
+
|
|
5
|
+
describe("TimePicker", () => {
|
|
6
|
+
const originalScrollTo = HTMLElement.prototype.scrollTo;
|
|
7
|
+
const originalMatchMedia = window.matchMedia;
|
|
8
|
+
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
|
9
|
+
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
HTMLElement.prototype.scrollTo = function scrollTo(options) {
|
|
13
|
+
if (typeof options === "object" && options.top !== undefined) {
|
|
14
|
+
this.scrollTop = options.top;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
window.matchMedia = mock(() => ({
|
|
18
|
+
matches: false,
|
|
19
|
+
media: "",
|
|
20
|
+
onchange: null,
|
|
21
|
+
addListener: () => {},
|
|
22
|
+
removeListener: () => {},
|
|
23
|
+
addEventListener: () => {},
|
|
24
|
+
removeEventListener: () => {},
|
|
25
|
+
dispatchEvent: () => false,
|
|
26
|
+
}));
|
|
27
|
+
window.requestAnimationFrame = (callback) => {
|
|
28
|
+
callback(0);
|
|
29
|
+
return 1;
|
|
30
|
+
};
|
|
31
|
+
window.cancelAnimationFrame = () => {};
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterAll(() => {
|
|
35
|
+
HTMLElement.prototype.scrollTo = originalScrollTo;
|
|
36
|
+
window.matchMedia = originalMatchMedia;
|
|
37
|
+
window.requestAnimationFrame = originalRequestAnimationFrame;
|
|
38
|
+
window.cancelAnimationFrame = originalCancelAnimationFrame;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("기본 접근성 이름과 12시간제 컬럼을 제공한다", () => {
|
|
42
|
+
const { getByRole, getAllByRole } = render(
|
|
43
|
+
<TimePicker defaultValue={{ hour: 10, minute: 5 }} />,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(getByRole("group")).toHaveAccessibleName("시간 선택");
|
|
47
|
+
const columns = getAllByRole("spinbutton");
|
|
48
|
+
expect(columns.map((column) => column.getAttribute("aria-label"))).toEqual([
|
|
49
|
+
"오전/오후",
|
|
50
|
+
"시",
|
|
51
|
+
"분",
|
|
52
|
+
]);
|
|
53
|
+
expect(columns.map((column) => column.getAttribute("aria-valuetext"))).toEqual([
|
|
54
|
+
"오전",
|
|
55
|
+
"10",
|
|
56
|
+
"05",
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("locale에 따라 컬럼 순서를 변경한다", () => {
|
|
61
|
+
const { getAllByRole, getByRole } = render(<TimePicker locale="en-US" />);
|
|
62
|
+
|
|
63
|
+
expect(getByRole("group")).toHaveAccessibleName("Select time");
|
|
64
|
+
expect(getAllByRole("spinbutton").map((column) => column.getAttribute("aria-label"))).toEqual([
|
|
65
|
+
"Hour",
|
|
66
|
+
"Minute",
|
|
67
|
+
"AM/PM",
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("일본어 locale에 맞는 기본 접근성 이름을 제공한다", () => {
|
|
72
|
+
const { getAllByRole, getByRole } = render(<TimePicker locale="ja-JP" />);
|
|
73
|
+
|
|
74
|
+
expect(getByRole("group")).toHaveAccessibleName("時刻を選択");
|
|
75
|
+
expect(getAllByRole("spinbutton").map((column) => column.getAttribute("aria-label"))).toEqual([
|
|
76
|
+
"午前/午後",
|
|
77
|
+
"時",
|
|
78
|
+
"分",
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("기본 접근성 이름을 명시적인 값으로 재정의한다", () => {
|
|
83
|
+
const { getAllByRole, getByRole } = render(
|
|
84
|
+
<TimePicker
|
|
85
|
+
locale="en-US"
|
|
86
|
+
aria-label="Appointment time"
|
|
87
|
+
periodAriaLabel="Period"
|
|
88
|
+
hourAriaLabel="Hours"
|
|
89
|
+
minuteAriaLabel="Minutes"
|
|
90
|
+
/>,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(getByRole("group")).toHaveAccessibleName("Appointment time");
|
|
94
|
+
expect(getAllByRole("spinbutton").map((column) => column.getAttribute("aria-label"))).toEqual([
|
|
95
|
+
"Hours",
|
|
96
|
+
"Minutes",
|
|
97
|
+
"Period",
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("controlled minute도 가장 가까운 minuteStep으로 반올림한다", () => {
|
|
102
|
+
const { getAllByRole } = render(<TimePicker value={{ hour: 9, minute: 13 }} minuteStep={5} />);
|
|
103
|
+
const minuteColumn = getAllByRole("spinbutton")[2];
|
|
104
|
+
|
|
105
|
+
expect(minuteColumn).toHaveAttribute("aria-valuetext", "15");
|
|
106
|
+
expect(minuteColumn.querySelector('[data-wheel-picker-value="13"]')).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("Wheel 정착 후 TimePickerValue를 전달한다", () => {
|
|
110
|
+
jest.useFakeTimers();
|
|
111
|
+
try {
|
|
112
|
+
const onValueChange = mock(() => {});
|
|
113
|
+
const { getAllByRole } = render(
|
|
114
|
+
<TimePicker
|
|
115
|
+
value={{ hour: 10, minute: 13 }}
|
|
116
|
+
minuteStep={10}
|
|
117
|
+
onValueChange={onValueChange}
|
|
118
|
+
/>,
|
|
119
|
+
);
|
|
120
|
+
const hourColumn = getAllByRole("spinbutton")[1];
|
|
121
|
+
|
|
122
|
+
fireEvent.keyDown(hourColumn, { key: "ArrowDown" });
|
|
123
|
+
act(() => jest.advanceTimersByTime(120));
|
|
124
|
+
|
|
125
|
+
expect(onValueChange).toHaveBeenCalledWith({ hour: 11, minute: 10 });
|
|
126
|
+
} finally {
|
|
127
|
+
jest.useRealTimers();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("Hour가 12시 경계를 지나면 Period를 부드럽게 전환한다", () => {
|
|
132
|
+
jest.useFakeTimers();
|
|
133
|
+
const previousScrollTo = HTMLElement.prototype.scrollTo;
|
|
134
|
+
const smoothScrolls: Array<{ element: HTMLElement; top: number }> = [];
|
|
135
|
+
try {
|
|
136
|
+
HTMLElement.prototype.scrollTo = function scrollTo(options) {
|
|
137
|
+
if (typeof options !== "object" || options.top === undefined) return;
|
|
138
|
+
|
|
139
|
+
this.scrollTop = options.top;
|
|
140
|
+
if (options.behavior === "smooth") {
|
|
141
|
+
smoothScrolls.push({ element: this, top: options.top });
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
const { getAllByRole } = render(<TimePicker defaultValue={{ hour: 11, minute: 0 }} />);
|
|
145
|
+
const [periodColumn, hourColumn] = getAllByRole("spinbutton");
|
|
146
|
+
|
|
147
|
+
fireEvent.keyDown(hourColumn, { key: "ArrowDown" });
|
|
148
|
+
act(() => jest.advanceTimersByTime(120));
|
|
149
|
+
|
|
150
|
+
expect(periodColumn).toHaveAttribute("aria-valuetext", "오후");
|
|
151
|
+
expect(smoothScrolls).toContainEqual({ element: periodColumn, top: 44 });
|
|
152
|
+
} finally {
|
|
153
|
+
HTMLElement.prototype.scrollTo = previousScrollTo;
|
|
154
|
+
jest.useRealTimers();
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("disabled는 컬럼을 tab 순서에서 제외한다", () => {
|
|
159
|
+
const { getAllByRole } = render(<TimePicker disabled />);
|
|
160
|
+
|
|
161
|
+
for (const column of getAllByRole("spinbutton")) {
|
|
162
|
+
expect(column).toHaveAttribute("aria-disabled", "true");
|
|
163
|
+
expect(column).toHaveAttribute("tabindex", "-1");
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { timePicker } from "@seed-design/css/recipes/time-picker";
|
|
4
|
+
import { useTimePicker, type UseTimePickerProps } from "@seed-design/react-time-picker";
|
|
5
|
+
import clsx from "clsx";
|
|
6
|
+
import * as React from "react";
|
|
7
|
+
import {
|
|
8
|
+
InternalWheelPickerColumn,
|
|
9
|
+
InternalWheelPickerRoot,
|
|
10
|
+
type InternalWheelPickerRootProps,
|
|
11
|
+
} from "../private/WheelPicker";
|
|
12
|
+
|
|
13
|
+
const ITEM_SIZE = 44;
|
|
14
|
+
const VISIBLE_ITEM_COUNT = 5;
|
|
15
|
+
const DEFAULT_ARIA_LABELS = {
|
|
16
|
+
ko: {
|
|
17
|
+
root: "시간 선택",
|
|
18
|
+
period: "오전/오후",
|
|
19
|
+
hour: "시",
|
|
20
|
+
minute: "분",
|
|
21
|
+
},
|
|
22
|
+
en: {
|
|
23
|
+
root: "Select time",
|
|
24
|
+
period: "AM/PM",
|
|
25
|
+
hour: "Hour",
|
|
26
|
+
minute: "Minute",
|
|
27
|
+
},
|
|
28
|
+
ja: {
|
|
29
|
+
root: "時刻を選択",
|
|
30
|
+
period: "午前/午後",
|
|
31
|
+
hour: "時",
|
|
32
|
+
minute: "分",
|
|
33
|
+
},
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
function getDefaultAriaLabels(locale: string | string[] | undefined) {
|
|
37
|
+
const [canonicalLocale = "ko-KR"] = Intl.getCanonicalLocales(locale ?? "ko-KR");
|
|
38
|
+
const language = new Intl.Locale(canonicalLocale).language;
|
|
39
|
+
|
|
40
|
+
if (language === "ko") return DEFAULT_ARIA_LABELS.ko;
|
|
41
|
+
if (language === "ja") return DEFAULT_ARIA_LABELS.ja;
|
|
42
|
+
return DEFAULT_ARIA_LABELS.en;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const classNames = timePicker();
|
|
46
|
+
|
|
47
|
+
const columnClassNames = {
|
|
48
|
+
period: classNames.periodColumn,
|
|
49
|
+
hour: classNames.hourColumn,
|
|
50
|
+
minute: classNames.minuteColumn,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export interface TimePickerProps
|
|
54
|
+
extends UseTimePickerProps,
|
|
55
|
+
Omit<
|
|
56
|
+
InternalWheelPickerRootProps,
|
|
57
|
+
| keyof UseTimePickerProps
|
|
58
|
+
| "asChild"
|
|
59
|
+
| "children"
|
|
60
|
+
| "columnsClassName"
|
|
61
|
+
| "fogSize"
|
|
62
|
+
| "itemSize"
|
|
63
|
+
| "readOnly"
|
|
64
|
+
| "scrollFogClassName"
|
|
65
|
+
| "selectionIndicatorClassName"
|
|
66
|
+
| "visibleItemCount"
|
|
67
|
+
> {
|
|
68
|
+
/**
|
|
69
|
+
* 오전·오후 컬럼의 접근성 이름입니다.
|
|
70
|
+
* 지정하지 않으면 `locale`에 맞는 기본값을 사용합니다.
|
|
71
|
+
*/
|
|
72
|
+
periodAriaLabel?: string;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 시 컬럼의 접근성 이름입니다.
|
|
76
|
+
* 지정하지 않으면 `locale`에 맞는 기본값을 사용합니다.
|
|
77
|
+
*/
|
|
78
|
+
hourAriaLabel?: string;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 분 컬럼의 접근성 이름입니다.
|
|
82
|
+
* 지정하지 않으면 `locale`에 맞는 기본값을 사용합니다.
|
|
83
|
+
*/
|
|
84
|
+
minuteAriaLabel?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 휠을 스크롤하여 오전·오후, 시, 분을 선택하는 12시간제 시간 선택 컴포넌트입니다.
|
|
89
|
+
*
|
|
90
|
+
* 값은 오전·오후를 별도 필드로 나누지 않고 24시간 형식의 `TimePickerValue`로 주고받습니다.
|
|
91
|
+
*/
|
|
92
|
+
export const TimePicker = React.forwardRef<HTMLDivElement, TimePickerProps>(
|
|
93
|
+
(
|
|
94
|
+
{
|
|
95
|
+
value,
|
|
96
|
+
defaultValue,
|
|
97
|
+
onValueChange,
|
|
98
|
+
minuteStep,
|
|
99
|
+
locale,
|
|
100
|
+
disabled,
|
|
101
|
+
periodAriaLabel,
|
|
102
|
+
hourAriaLabel,
|
|
103
|
+
minuteAriaLabel,
|
|
104
|
+
className,
|
|
105
|
+
"aria-label": ariaLabel,
|
|
106
|
+
"aria-labelledby": ariaLabelledby,
|
|
107
|
+
...props
|
|
108
|
+
},
|
|
109
|
+
ref,
|
|
110
|
+
) => {
|
|
111
|
+
const api = useTimePicker({
|
|
112
|
+
value,
|
|
113
|
+
defaultValue,
|
|
114
|
+
onValueChange,
|
|
115
|
+
minuteStep,
|
|
116
|
+
locale,
|
|
117
|
+
disabled,
|
|
118
|
+
});
|
|
119
|
+
const defaultAriaLabels = getDefaultAriaLabels(locale);
|
|
120
|
+
const ariaLabels = {
|
|
121
|
+
period: periodAriaLabel ?? defaultAriaLabels.period,
|
|
122
|
+
hour: hourAriaLabel ?? defaultAriaLabels.hour,
|
|
123
|
+
minute: minuteAriaLabel ?? defaultAriaLabels.minute,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<InternalWheelPickerRoot
|
|
128
|
+
ref={ref}
|
|
129
|
+
itemSize={ITEM_SIZE}
|
|
130
|
+
visibleItemCount={VISIBLE_ITEM_COUNT}
|
|
131
|
+
disabled={disabled}
|
|
132
|
+
aria-label={ariaLabelledby ? ariaLabel : (ariaLabel ?? defaultAriaLabels.root)}
|
|
133
|
+
aria-labelledby={ariaLabelledby}
|
|
134
|
+
className={clsx(classNames.root, className)}
|
|
135
|
+
columnsClassName={classNames.columns}
|
|
136
|
+
scrollFogClassName={classNames.scrollFog}
|
|
137
|
+
selectionIndicatorClassName={classNames.selectionIndicator}
|
|
138
|
+
{...props}
|
|
139
|
+
>
|
|
140
|
+
{api.columnOrder.map((type) => {
|
|
141
|
+
const column = api.columns[type];
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<InternalWheelPickerColumn
|
|
145
|
+
key={type}
|
|
146
|
+
aria-label={ariaLabels[type]}
|
|
147
|
+
className={columnClassNames[type]}
|
|
148
|
+
itemClassName={classNames.item}
|
|
149
|
+
options={column.options}
|
|
150
|
+
value={column.value}
|
|
151
|
+
onValueChange={column.onValueChange}
|
|
152
|
+
loop={column.loop}
|
|
153
|
+
valueChangeBehavior={type === "period" ? "smooth" : "auto"}
|
|
154
|
+
/>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</InternalWheelPickerRoot>
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
TimePicker.displayName = "TimePicker";
|
|
162
|
+
|
|
163
|
+
export type { MinuteStep, TimePickerValue } from "@seed-design/react-time-picker";
|
package/src/components/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export * from "./ContentPlaceholder";
|
|
|
20
20
|
export * from "./ContextualFloatingButton";
|
|
21
21
|
export * from "./ControlChip";
|
|
22
22
|
export * from "./Count";
|
|
23
|
+
export * from "./DatePicker";
|
|
23
24
|
export * from "./Dialog";
|
|
24
25
|
export * from "./Divider";
|
|
25
26
|
export * from "./ExtendedActionSheet";
|
|
@@ -80,5 +81,6 @@ export * from "./Tabs";
|
|
|
80
81
|
export * from "./TagGroup";
|
|
81
82
|
export * from "./Text";
|
|
82
83
|
export * from "./TextField";
|
|
84
|
+
export * from "./TimePicker";
|
|
83
85
|
export * from "./ToggleButton";
|
|
84
86
|
export * from "./VisuallyHidden";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { render } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "bun:test";
|
|
3
|
+
import { InternalWheelPickerColumn, InternalWheelPickerRoot } from "./WheelPicker";
|
|
4
|
+
|
|
5
|
+
const options = [
|
|
6
|
+
{ value: "a", label: "A" },
|
|
7
|
+
{ value: "b", label: "B" },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
describe("InternalWheelPicker", () => {
|
|
11
|
+
it("여러 컬럼을 하나의 ScrollFog와 Selection Indicator로 조합한다", () => {
|
|
12
|
+
const { container, getAllByRole, getByRole } = render(
|
|
13
|
+
<InternalWheelPickerRoot
|
|
14
|
+
aria-label="내부 휠 피커"
|
|
15
|
+
itemSize={40}
|
|
16
|
+
visibleItemCount={5}
|
|
17
|
+
fogSize={32}
|
|
18
|
+
className="root"
|
|
19
|
+
columnsClassName="columns"
|
|
20
|
+
scrollFogClassName="fog"
|
|
21
|
+
selectionIndicatorClassName="indicator"
|
|
22
|
+
>
|
|
23
|
+
<InternalWheelPickerColumn aria-label="첫 번째" options={options} />
|
|
24
|
+
<InternalWheelPickerColumn aria-label="두 번째" options={options} />
|
|
25
|
+
</InternalWheelPickerRoot>,
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const root = getByRole("group");
|
|
29
|
+
const fogs = container.querySelectorAll("[data-wheel-picker-scroll-fog]");
|
|
30
|
+
const indicator = container.querySelector("[data-wheel-picker-indicator]");
|
|
31
|
+
const columns = container.querySelector("[data-wheel-picker-columns]");
|
|
32
|
+
const [firstColumn] = getAllByRole("spinbutton");
|
|
33
|
+
|
|
34
|
+
expect(root).toHaveClass("seed-wheel-picker__root", "root");
|
|
35
|
+
expect(root).toHaveStyle({
|
|
36
|
+
"--seed-wheel-picker-item-size": "40px",
|
|
37
|
+
"--seed-wheel-picker-visible-item-count": "5",
|
|
38
|
+
"--seed-wheel-picker-viewport-size": "200px",
|
|
39
|
+
"--seed-wheel-picker-center-offset": "80px",
|
|
40
|
+
});
|
|
41
|
+
expect(fogs).toHaveLength(1);
|
|
42
|
+
expect(fogs[0]).toHaveClass("seed-wheel-picker__scrollFog", "fog");
|
|
43
|
+
expect(fogs[0]).toHaveStyle({
|
|
44
|
+
"--scroll-fog-size-top": "32px",
|
|
45
|
+
"--scroll-fog-size-bottom": "32px",
|
|
46
|
+
});
|
|
47
|
+
expect(indicator).toHaveClass("seed-wheel-picker__selectionIndicator", "indicator");
|
|
48
|
+
expect(indicator).toHaveAttribute("aria-hidden", "true");
|
|
49
|
+
expect(columns).toHaveClass("seed-wheel-picker__columns", "columns");
|
|
50
|
+
expect(getAllByRole("spinbutton")).toHaveLength(2);
|
|
51
|
+
expect(firstColumn).toHaveClass("seed-wheel-picker__column");
|
|
52
|
+
expect(firstColumn.firstElementChild).toHaveClass("seed-wheel-picker__item");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { wheelPicker } from "@seed-design/css/recipes/wheel-picker";
|
|
4
|
+
import { Primitive } from "@seed-design/react-primitive";
|
|
5
|
+
import * as HeadlessWheelPicker from "@seed-design/react-wheel-picker";
|
|
6
|
+
import clsx from "clsx";
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
import { ScrollFog, type ScrollFogProps } from "../ScrollFog/ScrollFog";
|
|
9
|
+
|
|
10
|
+
const classNames = wheelPicker();
|
|
11
|
+
|
|
12
|
+
type WheelPickerCssProperties = React.CSSProperties & {
|
|
13
|
+
"--seed-wheel-picker-item-size"?: string;
|
|
14
|
+
"--seed-wheel-picker-visible-item-count"?: number;
|
|
15
|
+
"--seed-wheel-picker-viewport-size"?: string;
|
|
16
|
+
"--seed-wheel-picker-center-offset"?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface InternalWheelPickerRootProps
|
|
20
|
+
extends Omit<HeadlessWheelPicker.WheelPickerRootProps, "asChild" | "children"> {
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
columnsClassName?: string;
|
|
23
|
+
scrollFogClassName?: string;
|
|
24
|
+
selectionIndicatorClassName?: string;
|
|
25
|
+
fogSize?: ScrollFogProps["size"];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const InternalWheelPickerRoot = React.forwardRef<
|
|
29
|
+
HTMLDivElement,
|
|
30
|
+
InternalWheelPickerRootProps
|
|
31
|
+
>(
|
|
32
|
+
(
|
|
33
|
+
{
|
|
34
|
+
children,
|
|
35
|
+
className,
|
|
36
|
+
columnsClassName,
|
|
37
|
+
scrollFogClassName,
|
|
38
|
+
selectionIndicatorClassName,
|
|
39
|
+
fogSize,
|
|
40
|
+
itemSize,
|
|
41
|
+
visibleItemCount,
|
|
42
|
+
style,
|
|
43
|
+
...props
|
|
44
|
+
},
|
|
45
|
+
ref,
|
|
46
|
+
) => {
|
|
47
|
+
const centerOffset = ((visibleItemCount - 1) / 2) * itemSize;
|
|
48
|
+
const wheelPickerStyle: WheelPickerCssProperties = {
|
|
49
|
+
...style,
|
|
50
|
+
"--seed-wheel-picker-item-size": `${itemSize}px`,
|
|
51
|
+
"--seed-wheel-picker-visible-item-count": visibleItemCount,
|
|
52
|
+
"--seed-wheel-picker-viewport-size": `${itemSize * visibleItemCount}px`,
|
|
53
|
+
"--seed-wheel-picker-center-offset": `${centerOffset}px`,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<HeadlessWheelPicker.WheelPickerRoot
|
|
58
|
+
ref={ref}
|
|
59
|
+
itemSize={itemSize}
|
|
60
|
+
visibleItemCount={visibleItemCount}
|
|
61
|
+
className={clsx(classNames.root, className)}
|
|
62
|
+
style={wheelPickerStyle}
|
|
63
|
+
{...props}
|
|
64
|
+
>
|
|
65
|
+
<Primitive.div
|
|
66
|
+
aria-hidden
|
|
67
|
+
className={clsx(classNames.selectionIndicator, selectionIndicatorClassName)}
|
|
68
|
+
data-wheel-picker-indicator=""
|
|
69
|
+
/>
|
|
70
|
+
<ScrollFog
|
|
71
|
+
className={clsx(classNames.scrollFog, scrollFogClassName)}
|
|
72
|
+
placement={["top", "bottom"]}
|
|
73
|
+
size={fogSize}
|
|
74
|
+
hideScrollBar
|
|
75
|
+
data-wheel-picker-scroll-fog=""
|
|
76
|
+
>
|
|
77
|
+
<Primitive.div
|
|
78
|
+
className={clsx(classNames.columns, columnsClassName)}
|
|
79
|
+
data-wheel-picker-columns=""
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
</Primitive.div>
|
|
83
|
+
</ScrollFog>
|
|
84
|
+
</HeadlessWheelPicker.WheelPickerRoot>
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
InternalWheelPickerRoot.displayName = "InternalWheelPickerRoot";
|
|
89
|
+
|
|
90
|
+
export interface InternalWheelPickerColumnProps
|
|
91
|
+
extends Omit<HeadlessWheelPicker.WheelPickerColumnProps, "asChild" | "renderOption"> {
|
|
92
|
+
itemClassName?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export const InternalWheelPickerColumn = React.forwardRef<
|
|
96
|
+
HTMLDivElement,
|
|
97
|
+
InternalWheelPickerColumnProps
|
|
98
|
+
>(({ className, itemClassName, ...props }, ref) => (
|
|
99
|
+
<HeadlessWheelPicker.WheelPickerColumn
|
|
100
|
+
ref={ref}
|
|
101
|
+
className={clsx(classNames.column, className)}
|
|
102
|
+
renderOption={(option, optionProps) => (
|
|
103
|
+
<Primitive.div className={clsx(classNames.item, itemClassName)} {...optionProps}>
|
|
104
|
+
{option.label}
|
|
105
|
+
</Primitive.div>
|
|
106
|
+
)}
|
|
107
|
+
{...props}
|
|
108
|
+
/>
|
|
109
|
+
));
|
|
110
|
+
InternalWheelPickerColumn.displayName = "InternalWheelPickerColumn";
|