@windrun-huaiin/third-ui 29.1.0 → 29.2.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/fuma/base/custom-header.js +6 -3
- package/dist/fuma/base/custom-header.mjs +6 -3
- package/dist/main/alert-dialog/confirm-dialog.d.ts +6 -3
- package/dist/main/alert-dialog/confirm-dialog.js +7 -7
- package/dist/main/alert-dialog/confirm-dialog.mjs +8 -8
- package/dist/main/alert-dialog/dialog-loading-action.d.ts +13 -0
- package/dist/main/alert-dialog/dialog-loading-action.js +42 -0
- package/dist/main/alert-dialog/dialog-loading-action.mjs +40 -0
- package/dist/main/alert-dialog/high-priority-confirm-dialog.d.ts +6 -3
- package/dist/main/alert-dialog/high-priority-confirm-dialog.js +10 -4
- package/dist/main/alert-dialog/high-priority-confirm-dialog.mjs +11 -5
- package/dist/main/alert-dialog/index.d.ts +1 -0
- package/dist/main/alert-dialog/info-dialog.d.ts +5 -2
- package/dist/main/alert-dialog/info-dialog.js +6 -5
- package/dist/main/alert-dialog/info-dialog.mjs +7 -6
- package/dist/main/alert-dialog/undoable-confirm-dialog.d.ts +7 -4
- package/dist/main/alert-dialog/undoable-confirm-dialog.js +18 -17
- package/dist/main/alert-dialog/undoable-confirm-dialog.mjs +19 -18
- package/dist/main/buttons/gradient-button.d.ts +3 -1
- package/dist/main/buttons/gradient-button.js +29 -3
- package/dist/main/buttons/gradient-button.mjs +29 -3
- package/dist/main/buttons/index.d.ts +1 -0
- package/dist/main/buttons/index.js +3 -0
- package/dist/main/buttons/index.mjs +1 -0
- package/dist/main/buttons/use-press-feedback.d.ts +18 -0
- package/dist/main/buttons/use-press-feedback.js +42 -0
- package/dist/main/buttons/use-press-feedback.mjs +39 -0
- package/dist/main/buttons/x-button.d.ts +3 -0
- package/dist/main/buttons/x-button.js +36 -6
- package/dist/main/buttons/x-button.mjs +36 -6
- package/dist/main/calendar/calendar-date-range-input.d.ts +17 -0
- package/dist/main/calendar/calendar-date-range-input.js +81 -0
- package/dist/main/calendar/calendar-date-range-input.mjs +79 -0
- package/dist/main/calendar/calendar-status-view.d.ts +23 -0
- package/dist/main/calendar/calendar-status-view.js +155 -0
- package/dist/main/calendar/calendar-status-view.mjs +153 -0
- package/dist/main/calendar/index.d.ts +3 -0
- package/dist/main/calendar/index.js +12 -0
- package/dist/main/calendar/index.mjs +4 -0
- package/dist/main/calendar/random-date-range-dialog.d.ts +18 -0
- package/dist/main/calendar/random-date-range-dialog.js +451 -0
- package/dist/main/calendar/random-date-range-dialog.mjs +449 -0
- package/package.json +6 -1
- package/src/fuma/base/custom-header.tsx +6 -3
- package/src/main/alert-dialog/confirm-dialog.tsx +54 -47
- package/src/main/alert-dialog/dialog-loading-action.tsx +78 -0
- package/src/main/alert-dialog/high-priority-confirm-dialog.tsx +63 -48
- package/src/main/alert-dialog/index.ts +1 -0
- package/src/main/alert-dialog/info-dialog.tsx +52 -44
- package/src/main/alert-dialog/undoable-confirm-dialog.tsx +90 -82
- package/src/main/buttons/gradient-button.tsx +36 -3
- package/src/main/buttons/index.ts +1 -0
- package/src/main/buttons/use-press-feedback.ts +58 -0
- package/src/main/buttons/x-button.tsx +53 -11
- package/src/main/calendar/calendar-date-range-input.tsx +173 -0
- package/src/main/calendar/calendar-status-view.tsx +365 -0
- package/src/main/calendar/index.ts +5 -0
- package/src/main/calendar/random-date-range-dialog.tsx +753 -0
|
@@ -12,9 +12,14 @@ import {
|
|
|
12
12
|
} from "@windrun-huaiin/base-ui/lib";
|
|
13
13
|
import Link from "next/link";
|
|
14
14
|
import React, { useState } from 'react';
|
|
15
|
+
import { PressFeedback, resolvePressFeedbackMode, usePressFeedback } from './use-press-feedback';
|
|
15
16
|
|
|
16
17
|
type GradientButtonVariant = 'default' | 'soft' | 'subtle';
|
|
17
18
|
|
|
19
|
+
const PRESS_FEEDBACK_MS = 180;
|
|
20
|
+
const gradientPressSubtleClass = 'translate-y-px scale-[0.98] shadow-inner brightness-95';
|
|
21
|
+
const gradientPressSolidClass = 'translate-y-[2px] scale-[0.96] shadow-[inset_0_2px_4px_rgba(15,23,42,0.22)] brightness-90';
|
|
22
|
+
|
|
18
23
|
export interface GradientButtonProps {
|
|
19
24
|
title: React.ReactNode;
|
|
20
25
|
icon?: React.ReactNode;
|
|
@@ -30,6 +35,7 @@ export interface GradientButtonProps {
|
|
|
30
35
|
loadingText?: React.ReactNode;
|
|
31
36
|
preventDoubleClick?: boolean;
|
|
32
37
|
variant?: GradientButtonVariant;
|
|
38
|
+
pressFeedback?: PressFeedback;
|
|
33
39
|
}
|
|
34
40
|
|
|
35
41
|
export function GradientButton({
|
|
@@ -47,8 +53,11 @@ export function GradientButton({
|
|
|
47
53
|
preventDoubleClick = true,
|
|
48
54
|
iconClassName,
|
|
49
55
|
variant = 'default',
|
|
56
|
+
pressFeedback,
|
|
50
57
|
}: GradientButtonProps) {
|
|
51
58
|
const [isLoading, setIsLoading] = useState(false);
|
|
59
|
+
const pressMode = resolvePressFeedbackMode(pressFeedback);
|
|
60
|
+
const { pressedKey, flash, getPressProps } = usePressFeedback<'root'>(PRESS_FEEDBACK_MS);
|
|
52
61
|
const actualLoadingText = loadingText || title?.toString().trim() || 'Loading...';
|
|
53
62
|
|
|
54
63
|
const defaultIconClass = "h-4 w-4";
|
|
@@ -94,6 +103,12 @@ export function GradientButton({
|
|
|
94
103
|
};
|
|
95
104
|
|
|
96
105
|
const isDisabled = disabled || isLoading;
|
|
106
|
+
const isPressed = pressMode !== 'none' && pressedKey === 'root' && !disabled;
|
|
107
|
+
const pressClassName = isPressed
|
|
108
|
+
? pressMode === 'solid'
|
|
109
|
+
? gradientPressSolidClass
|
|
110
|
+
: gradientPressSubtleClass
|
|
111
|
+
: null;
|
|
97
112
|
const displayTitle = isLoading ? actualLoadingText : title;
|
|
98
113
|
const iconProvided = icon !== undefined;
|
|
99
114
|
|
|
@@ -163,11 +178,13 @@ export function GradientButton({
|
|
|
163
178
|
const buttonClassName = cn(
|
|
164
179
|
baseButtonStyles,
|
|
165
180
|
variantClassName,
|
|
166
|
-
'text-base font-bold transition-
|
|
181
|
+
'text-base font-bold transition-[transform,background-color,filter,box-shadow,border-color,color] duration-300 rounded-full',
|
|
167
182
|
alignmentClass,
|
|
183
|
+
pressClassName,
|
|
168
184
|
isDisabled && 'opacity-50 cursor-not-allowed',
|
|
169
185
|
className,
|
|
170
186
|
);
|
|
187
|
+
const pressProps = pressMode !== 'none' && !isDisabled ? getPressProps('root') : {};
|
|
171
188
|
|
|
172
189
|
return (
|
|
173
190
|
<div className={`flex flex-row gap-3 ${getAlignmentClass()}`}>
|
|
@@ -175,8 +192,14 @@ export function GradientButton({
|
|
|
175
192
|
<button
|
|
176
193
|
type="button"
|
|
177
194
|
className={buttonClassName}
|
|
178
|
-
onClick={
|
|
195
|
+
onClick={(event) => {
|
|
196
|
+
if (!isDisabled && pressMode !== 'none') {
|
|
197
|
+
flash('root');
|
|
198
|
+
}
|
|
199
|
+
handleClick(event);
|
|
200
|
+
}}
|
|
179
201
|
disabled={isDisabled}
|
|
202
|
+
{...pressProps}
|
|
180
203
|
>
|
|
181
204
|
{buttonContent}
|
|
182
205
|
</button>
|
|
@@ -185,8 +208,18 @@ export function GradientButton({
|
|
|
185
208
|
href={href || "#"}
|
|
186
209
|
className={cn(buttonClassName, "no-underline hover:no-underline")}
|
|
187
210
|
{...(openInNewTab ? { target: "_blank", rel: preserveReferrer ? 'noopener' : 'noopener noreferrer' } : {})}
|
|
188
|
-
onClick={
|
|
211
|
+
onClick={(event) => {
|
|
212
|
+
if (isDisabled) {
|
|
213
|
+
event.preventDefault();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (pressMode !== 'none') {
|
|
218
|
+
flash('root');
|
|
219
|
+
}
|
|
220
|
+
}}
|
|
189
221
|
aria-disabled={isDisabled}
|
|
222
|
+
{...pressProps}
|
|
190
223
|
>
|
|
191
224
|
{buttonContent}
|
|
192
225
|
</Link>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export type PressFeedbackKey = string;
|
|
6
|
+
export type PressFeedbackMode = 'none' | 'subtle' | 'solid';
|
|
7
|
+
export type PressFeedback = boolean | PressFeedbackMode;
|
|
8
|
+
|
|
9
|
+
export interface PressFeedbackProps<T extends PressFeedbackKey> {
|
|
10
|
+
onPointerDown: () => void;
|
|
11
|
+
onPointerUp: () => void;
|
|
12
|
+
onPointerLeave: () => void;
|
|
13
|
+
onPointerCancel: () => void;
|
|
14
|
+
onBlur: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolvePressFeedbackMode(pressFeedback?: PressFeedback): PressFeedbackMode {
|
|
18
|
+
if (pressFeedback === false || pressFeedback === 'none') {
|
|
19
|
+
return 'none';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (pressFeedback === 'solid') {
|
|
23
|
+
return 'solid';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return 'subtle';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function usePressFeedback<T extends PressFeedbackKey>(durationMs = 180) {
|
|
30
|
+
const [pressedKey, setPressedKey] = useState<T | null>(null);
|
|
31
|
+
|
|
32
|
+
function release(key: T) {
|
|
33
|
+
setPressedKey((current) => (current === key ? null : current));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function trigger(key: T) {
|
|
37
|
+
setPressedKey(key);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function flash(key: T) {
|
|
41
|
+
setPressedKey(key);
|
|
42
|
+
window.setTimeout(() => {
|
|
43
|
+
setPressedKey((current) => (current === key ? null : current));
|
|
44
|
+
}, durationMs);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getPressProps(key: T): PressFeedbackProps<T> {
|
|
48
|
+
return {
|
|
49
|
+
onPointerDown: () => trigger(key),
|
|
50
|
+
onPointerUp: () => release(key),
|
|
51
|
+
onPointerLeave: () => release(key),
|
|
52
|
+
onPointerCancel: () => release(key),
|
|
53
|
+
onBlur: () => release(key),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { pressedKey, trigger, release, flash, getPressProps };
|
|
58
|
+
}
|
|
@@ -4,8 +4,14 @@ import React, { useState, useRef, useEffect, ReactNode } from 'react'
|
|
|
4
4
|
import { ChevronDownIcon, Loader2Icon } from '@windrun-huaiin/base-ui/icons'
|
|
5
5
|
import { themeBgColor, themeBorderColor, themeIconColor, themeMainBgColor } from '@windrun-huaiin/base-ui/lib'
|
|
6
6
|
import { cn } from '@windrun-huaiin/lib/utils'
|
|
7
|
+
import { PressFeedback, resolvePressFeedbackMode, usePressFeedback } from './use-press-feedback'
|
|
7
8
|
|
|
8
9
|
type XButtonVariant = 'default' | 'soft' | 'subtle'
|
|
10
|
+
type XButtonPressKey = 'single' | 'main' | 'dropdown'
|
|
11
|
+
|
|
12
|
+
const PRESS_FEEDBACK_MS = 180
|
|
13
|
+
const xButtonPressSubtleClass = 'translate-y-px scale-[0.98] shadow-inner brightness-95'
|
|
14
|
+
const xButtonPressSolidClass = 'translate-y-[2px] scale-[0.95] shadow-[inset_0_2px_4px_rgba(15,23,42,0.18)] brightness-95'
|
|
9
15
|
|
|
10
16
|
interface BaseButtonConfig {
|
|
11
17
|
icon: ReactNode
|
|
@@ -30,6 +36,7 @@ interface SingleButtonProps {
|
|
|
30
36
|
className?: string
|
|
31
37
|
iconClassName?: string
|
|
32
38
|
variant?: XButtonVariant
|
|
39
|
+
pressFeedback?: PressFeedback
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
interface SplitButtonProps {
|
|
@@ -43,6 +50,7 @@ interface SplitButtonProps {
|
|
|
43
50
|
dropdownButtonClassName?: string
|
|
44
51
|
iconClassName?: string
|
|
45
52
|
variant?: XButtonVariant
|
|
53
|
+
pressFeedback?: PressFeedback
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
type xButtonProps = SingleButtonProps | SplitButtonProps
|
|
@@ -50,7 +58,9 @@ type xButtonProps = SingleButtonProps | SplitButtonProps
|
|
|
50
58
|
export function XButton(props: xButtonProps) {
|
|
51
59
|
const [isLoading, setIsLoading] = useState(false)
|
|
52
60
|
const [menuOpen, setMenuOpen] = useState(false)
|
|
53
|
-
const
|
|
61
|
+
const splitRef = useRef<HTMLDivElement>(null)
|
|
62
|
+
const pressMode = resolvePressFeedbackMode(props.pressFeedback)
|
|
63
|
+
const { pressedKey, flash, getPressProps } = usePressFeedback<XButtonPressKey>(PRESS_FEEDBACK_MS)
|
|
54
64
|
|
|
55
65
|
const { iconClassName } = props
|
|
56
66
|
const defaultIconClass = "w-5 h-5"
|
|
@@ -76,7 +86,7 @@ export function XButton(props: xButtonProps) {
|
|
|
76
86
|
useEffect(() => {
|
|
77
87
|
if (props.type === 'split') {
|
|
78
88
|
const handleClickOutside = (event: MouseEvent) => {
|
|
79
|
-
if (
|
|
89
|
+
if (splitRef.current && !splitRef.current.contains(event.target as Node)) {
|
|
80
90
|
setMenuOpen(false)
|
|
81
91
|
}
|
|
82
92
|
}
|
|
@@ -104,7 +114,8 @@ export function XButton(props: xButtonProps) {
|
|
|
104
114
|
}
|
|
105
115
|
}
|
|
106
116
|
|
|
107
|
-
const
|
|
117
|
+
const getButtonPressClass = () => pressMode === 'solid' ? xButtonPressSolidClass : xButtonPressSubtleClass
|
|
118
|
+
const baseButtonClass = "flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold transition-[transform,background-color,filter,box-shadow,border-color,color]"
|
|
108
119
|
const singleButtonVariantClass = variant === 'soft'
|
|
109
120
|
? cn(
|
|
110
121
|
themeBgColor,
|
|
@@ -141,25 +152,38 @@ export function XButton(props: xButtonProps) {
|
|
|
141
152
|
"bg-transparent hover:bg-neutral-50 dark:hover:bg-neutral-800 sm:border-l",
|
|
142
153
|
themeIconColor,
|
|
143
154
|
"border-neutral-200 dark:border-neutral-800"
|
|
144
|
-
|
|
145
|
-
|
|
155
|
+
)
|
|
156
|
+
: "bg-neutral-200 dark:bg-neutral-800 text-neutral-700 dark:text-white hover:bg-neutral-300 dark:hover:bg-neutral-700 sm:border-l sm:border-neutral-300 sm:dark:border-neutral-700"
|
|
157
|
+
const splitContainerVariantClass = variant === 'soft'
|
|
158
|
+
? cn('border', themeBorderColor)
|
|
159
|
+
: variant === 'subtle'
|
|
160
|
+
? 'border border-neutral-200 dark:border-neutral-800'
|
|
161
|
+
: null
|
|
146
162
|
const disabledClass = "opacity-60 cursor-not-allowed"
|
|
147
163
|
|
|
148
164
|
if (props.type === 'single') {
|
|
149
165
|
const { button, loadingText, minWidth = 'min-w-[110px]', className = '' } = props
|
|
150
166
|
const isDisabled = button.disabled || isLoading
|
|
167
|
+
const isPressed = pressMode !== 'none' && pressedKey === 'single' && !button.disabled
|
|
151
168
|
const actualLoadingText = loadingText || button.text?.trim() || 'Loading...'
|
|
152
169
|
|
|
153
170
|
return (
|
|
154
171
|
<button
|
|
155
|
-
onClick={() =>
|
|
172
|
+
onClick={() => {
|
|
173
|
+
if (!isDisabled && pressMode !== 'none') {
|
|
174
|
+
flash('single')
|
|
175
|
+
}
|
|
176
|
+
handleButtonClick(button.onClick)
|
|
177
|
+
}}
|
|
156
178
|
disabled={isDisabled}
|
|
179
|
+
{...(pressMode !== 'none' && !isDisabled ? getPressProps('single') : {})}
|
|
157
180
|
className={cn(
|
|
158
181
|
"w-full sm:w-auto",
|
|
159
182
|
minWidth,
|
|
160
183
|
baseButtonClass,
|
|
161
184
|
singleButtonVariantClass,
|
|
162
185
|
"rounded-full",
|
|
186
|
+
isPressed && getButtonPressClass(),
|
|
163
187
|
isDisabled && disabledClass,
|
|
164
188
|
className
|
|
165
189
|
)}
|
|
@@ -182,21 +206,33 @@ export function XButton(props: xButtonProps) {
|
|
|
182
206
|
|
|
183
207
|
const { mainButton, menuItems, loadingText, menuWidth = 'w-full sm:w-40', className = '', mainButtonClassName = '', dropdownButtonClassName = '' } = props
|
|
184
208
|
const isMainDisabled = mainButton.disabled || isLoading
|
|
209
|
+
const isMainPressed = pressMode !== 'none' && pressedKey === 'main' && !mainButton.disabled
|
|
210
|
+
const isDropdownPressed = pressMode !== 'none' && pressedKey === 'dropdown' && !isLoading
|
|
185
211
|
const actualLoadingText = loadingText || mainButton.text?.trim() || 'Loading...'
|
|
186
212
|
|
|
187
213
|
return (
|
|
188
214
|
<div className={cn(
|
|
189
|
-
"relative flex flex-row items-stretch w-full sm:w-
|
|
215
|
+
"relative inline-flex flex-row items-stretch w-full sm:w-fit rounded-full gap-0",
|
|
216
|
+
splitContainerVariantClass,
|
|
190
217
|
menuOpen && "z-90",
|
|
191
218
|
className
|
|
192
|
-
)}
|
|
219
|
+
)}
|
|
220
|
+
ref={splitRef}
|
|
221
|
+
>
|
|
193
222
|
<button
|
|
194
|
-
onClick={() =>
|
|
223
|
+
onClick={() => {
|
|
224
|
+
if (!isMainDisabled && pressMode !== 'none') {
|
|
225
|
+
flash('main')
|
|
226
|
+
}
|
|
227
|
+
handleButtonClick(mainButton.onClick)
|
|
228
|
+
}}
|
|
195
229
|
disabled={isMainDisabled}
|
|
230
|
+
{...(pressMode !== 'none' && !isMainDisabled ? getPressProps('main') : {})}
|
|
196
231
|
className={cn(
|
|
197
232
|
"flex-1 min-w-0 sm:min-w-[100px] sm:flex-initial rounded-l-full",
|
|
198
233
|
baseButtonClass,
|
|
199
234
|
splitMainButtonVariantClass,
|
|
235
|
+
isMainPressed && getButtonPressClass(),
|
|
200
236
|
isMainDisabled && disabledClass,
|
|
201
237
|
mainButtonClassName
|
|
202
238
|
)}
|
|
@@ -218,12 +254,19 @@ export function XButton(props: xButtonProps) {
|
|
|
218
254
|
|
|
219
255
|
<button
|
|
220
256
|
type="button"
|
|
221
|
-
onClick={() =>
|
|
257
|
+
onClick={() => {
|
|
258
|
+
if (!isLoading && pressMode !== 'none') {
|
|
259
|
+
flash('dropdown')
|
|
260
|
+
}
|
|
261
|
+
setMenuOpen(!menuOpen)
|
|
262
|
+
}}
|
|
222
263
|
disabled={isLoading}
|
|
264
|
+
{...(pressMode !== 'none' && !isLoading ? getPressProps('dropdown') : {})}
|
|
223
265
|
className={cn(
|
|
224
266
|
"w-12 rounded-r-full",
|
|
225
267
|
baseButtonClass,
|
|
226
268
|
splitDropdownVariantClass,
|
|
269
|
+
isDropdownPressed && getButtonPressClass(),
|
|
227
270
|
isLoading && disabledClass,
|
|
228
271
|
dropdownButtonClassName
|
|
229
272
|
)}
|
|
@@ -234,7 +277,6 @@ export function XButton(props: xButtonProps) {
|
|
|
234
277
|
|
|
235
278
|
{menuOpen && (
|
|
236
279
|
<div
|
|
237
|
-
ref={menuRef}
|
|
238
280
|
className={cn(
|
|
239
281
|
"absolute top-full right-0 mt-2 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg z-50 overflow-hidden",
|
|
240
282
|
menuWidth
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { CalendarDaysIcon, XIcon } from '@windrun-huaiin/base-ui/icons';
|
|
5
|
+
import { themeIconColor } from '@windrun-huaiin/base-ui/lib';
|
|
6
|
+
import { cn } from '@windrun-huaiin/lib/utils';
|
|
7
|
+
import {
|
|
8
|
+
type PressFeedback,
|
|
9
|
+
resolvePressFeedbackMode,
|
|
10
|
+
usePressFeedback,
|
|
11
|
+
} from '../buttons/use-press-feedback';
|
|
12
|
+
import { RandomDateRangeDialog, type RandomCalendarRange } from './random-date-range-dialog';
|
|
13
|
+
|
|
14
|
+
export type CalendarDateRangeValue = RandomCalendarRange;
|
|
15
|
+
|
|
16
|
+
export type CalendarDateRangeInputProps = {
|
|
17
|
+
value: CalendarDateRangeValue;
|
|
18
|
+
onChange: (value: CalendarDateRangeValue) => void;
|
|
19
|
+
placeholder?: string;
|
|
20
|
+
defaultRangeDays?: number;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
className?: string;
|
|
23
|
+
showDayCount?: boolean;
|
|
24
|
+
dayCountUnit?: string;
|
|
25
|
+
themedCalendarIcon?: boolean;
|
|
26
|
+
clearPressFeedback?: PressFeedback;
|
|
27
|
+
onOpenChange?: (open: boolean) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type DateRangeInputPressKey = 'clear';
|
|
31
|
+
|
|
32
|
+
const DEFAULT_PLACEHOLDER = '滑动窗口日期';
|
|
33
|
+
const DEFAULT_RANGE_DAYS = 7;
|
|
34
|
+
const CLEAR_PRESS_FEEDBACK_MS = 180;
|
|
35
|
+
|
|
36
|
+
function parseDateString(value: string): Date {
|
|
37
|
+
return new Date(`${value}T00:00:00.000Z`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getTodayString(): string {
|
|
41
|
+
return new Date().toISOString().slice(0, 10);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function getInclusiveDayCount(value: CalendarDateRangeValue): number {
|
|
45
|
+
if (!value.startDate || !value.endDate) {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const startTime = parseDateString(value.startDate).getTime();
|
|
50
|
+
const endTime = parseDateString(value.endDate).getTime();
|
|
51
|
+
|
|
52
|
+
return Math.max(0, Math.floor(Math.abs(endTime - startTime) / 86400000) + 1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getRangeLabel(value: CalendarDateRangeValue, showDayCount: boolean, dayCountUnit: string): string | null {
|
|
56
|
+
if (!value.startDate || !value.endDate) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const dateLabel = `${value.startDate} ~ ${value.endDate}`;
|
|
61
|
+
|
|
62
|
+
if (!showDayCount) {
|
|
63
|
+
return dateLabel;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return `${dateLabel} · ${getInclusiveDayCount(value)}${dayCountUnit}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function CalendarDateRangeInput({
|
|
70
|
+
value,
|
|
71
|
+
onChange,
|
|
72
|
+
placeholder = DEFAULT_PLACEHOLDER,
|
|
73
|
+
defaultRangeDays = DEFAULT_RANGE_DAYS,
|
|
74
|
+
disabled = false,
|
|
75
|
+
className,
|
|
76
|
+
showDayCount = false,
|
|
77
|
+
dayCountUnit = 'D',
|
|
78
|
+
themedCalendarIcon = true,
|
|
79
|
+
clearPressFeedback = 'subtle',
|
|
80
|
+
onOpenChange,
|
|
81
|
+
}: CalendarDateRangeInputProps) {
|
|
82
|
+
const [open, setOpen] = useState(false);
|
|
83
|
+
const pressMode = resolvePressFeedbackMode(clearPressFeedback);
|
|
84
|
+
const { pressedKey, flash, getPressProps } = usePressFeedback<DateRangeInputPressKey>(CLEAR_PRESS_FEEDBACK_MS);
|
|
85
|
+
const label = getRangeLabel(value, showDayCount, dayCountUnit);
|
|
86
|
+
const hasValue = Boolean(value.startDate || value.endDate);
|
|
87
|
+
const isClearPressed = pressMode !== 'none' && pressedKey === 'clear' && !disabled;
|
|
88
|
+
|
|
89
|
+
function handleOpenChange(nextOpen: boolean) {
|
|
90
|
+
setOpen(nextOpen);
|
|
91
|
+
onOpenChange?.(nextOpen);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function handleClear() {
|
|
95
|
+
onChange({ startDate: null, endDate: null });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<>
|
|
100
|
+
<div
|
|
101
|
+
role="button"
|
|
102
|
+
tabIndex={disabled ? -1 : 0}
|
|
103
|
+
aria-disabled={disabled}
|
|
104
|
+
onClick={() => {
|
|
105
|
+
if (!disabled) {
|
|
106
|
+
handleOpenChange(true);
|
|
107
|
+
}
|
|
108
|
+
}}
|
|
109
|
+
onKeyDown={(event) => {
|
|
110
|
+
if (disabled) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
115
|
+
event.preventDefault();
|
|
116
|
+
handleOpenChange(true);
|
|
117
|
+
}
|
|
118
|
+
}}
|
|
119
|
+
className={cn(
|
|
120
|
+
'flex h-11 w-full cursor-pointer items-center rounded-2xl border border-border/70 bg-background/80 text-left text-sm shadow-sm transition hover:bg-accent/40',
|
|
121
|
+
disabled && 'cursor-not-allowed opacity-60 hover:bg-background/80',
|
|
122
|
+
className
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
<span className="flex min-w-0 flex-1 items-center gap-2 px-3">
|
|
126
|
+
<CalendarDaysIcon
|
|
127
|
+
className={cn('h-4 w-4 shrink-0', themedCalendarIcon ? themeIconColor : 'text-muted-foreground')}
|
|
128
|
+
/>
|
|
129
|
+
<span className={cn('truncate', label ? 'text-foreground' : 'text-muted-foreground')}>
|
|
130
|
+
{label ?? placeholder}
|
|
131
|
+
</span>
|
|
132
|
+
</span>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
disabled={disabled || !hasValue}
|
|
136
|
+
onClick={(event) => {
|
|
137
|
+
event.stopPropagation();
|
|
138
|
+
if (disabled || !hasValue) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (pressMode !== 'none') {
|
|
143
|
+
flash('clear');
|
|
144
|
+
}
|
|
145
|
+
handleClear();
|
|
146
|
+
}}
|
|
147
|
+
className={cn(
|
|
148
|
+
'mr-1 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-[transform,background-color,color,box-shadow]',
|
|
149
|
+
hasValue
|
|
150
|
+
? 'hover:bg-black/10 hover:text-foreground dark:hover:bg-white/12'
|
|
151
|
+
: 'cursor-default opacity-35',
|
|
152
|
+
isClearPressed &&
|
|
153
|
+
'scale-90 bg-black/15 text-foreground shadow-inner dark:bg-white/18'
|
|
154
|
+
)}
|
|
155
|
+
aria-label="Clear date range"
|
|
156
|
+
title="Clear date range"
|
|
157
|
+
{...(pressMode !== 'none' && !disabled && hasValue ? getPressProps('clear') : {})}
|
|
158
|
+
>
|
|
159
|
+
<XIcon className="h-4 w-4" />
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
<RandomDateRangeDialog
|
|
163
|
+
open={open}
|
|
164
|
+
value={value}
|
|
165
|
+
anchorDate={value.startDate ?? getTodayString()}
|
|
166
|
+
defaultRangeDays={defaultRangeDays}
|
|
167
|
+
onOpenChange={handleOpenChange}
|
|
168
|
+
onApply={onChange}
|
|
169
|
+
onClear={onChange}
|
|
170
|
+
/>
|
|
171
|
+
</>
|
|
172
|
+
);
|
|
173
|
+
}
|