@windrun-huaiin/third-ui 29.0.4 → 29.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/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 +7 -3
- package/dist/main/alert-dialog/confirm-dialog.js +11 -6
- package/dist/main/alert-dialog/confirm-dialog.mjs +13 -8
- package/dist/main/alert-dialog/dialog-loading-action.d.ts +12 -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 +13 -4
- package/dist/main/alert-dialog/high-priority-confirm-dialog.mjs +14 -5
- package/dist/main/alert-dialog/index.d.ts +1 -0
- package/dist/main/alert-dialog/info-dialog.d.ts +4 -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 +8 -4
- package/dist/main/alert-dialog/undoable-confirm-dialog.js +23 -16
- package/dist/main/alert-dialog/undoable-confirm-dialog.mjs +24 -17
- 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 +15 -0
- package/dist/main/calendar/random-date-range-dialog.js +447 -0
- package/dist/main/calendar/random-date-range-dialog.mjs +445 -0
- package/dist/main/features.js +2 -2
- package/dist/main/features.mjs +1 -1
- package/dist/main/usage.js +2 -2
- package/dist/main/usage.mjs +1 -1
- package/package.json +9 -4
- package/src/fuma/base/custom-header.tsx +6 -3
- package/src/main/alert-dialog/confirm-dialog.tsx +59 -46
- package/src/main/alert-dialog/dialog-loading-action.tsx +63 -0
- package/src/main/alert-dialog/high-priority-confirm-dialog.tsx +67 -48
- package/src/main/alert-dialog/index.ts +1 -0
- package/src/main/alert-dialog/info-dialog.tsx +50 -44
- package/src/main/alert-dialog/undoable-confirm-dialog.tsx +96 -81
- 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 +741 -0
- package/src/main/features.tsx +1 -1
- package/src/main/usage.tsx +1 -1
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
dialogTitleClass,
|
|
22
22
|
secondaryButtonClass,
|
|
23
23
|
} from './dialog-styles';
|
|
24
|
+
import type { ConfirmDialogEmphasis } from './confirm-dialog';
|
|
25
|
+
import { DialogLoadingAction, DialogActionHandler, useDialogLoadingAction } from './dialog-loading-action';
|
|
24
26
|
|
|
25
27
|
export interface UndoableConfirmDialogProps {
|
|
26
28
|
open: boolean;
|
|
@@ -32,10 +34,12 @@ export interface UndoableConfirmDialogProps {
|
|
|
32
34
|
cancelText?: string;
|
|
33
35
|
confirmText?: string;
|
|
34
36
|
undoText?: string;
|
|
37
|
+
emphasis?: ConfirmDialogEmphasis;
|
|
35
38
|
countdownSeconds?: number;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
loadingActions?: readonly DialogLoadingAction[];
|
|
40
|
+
onCancel?: DialogActionHandler;
|
|
41
|
+
onConfirm: DialogActionHandler;
|
|
42
|
+
onUndo?: DialogActionHandler;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
export function UndoableConfirmDialog({
|
|
@@ -48,7 +52,9 @@ export function UndoableConfirmDialog({
|
|
|
48
52
|
cancelText = 'Cancel',
|
|
49
53
|
confirmText = 'Delete',
|
|
50
54
|
undoText = 'Undo',
|
|
55
|
+
emphasis = 'confirm',
|
|
51
56
|
countdownSeconds = 5,
|
|
57
|
+
loadingActions,
|
|
52
58
|
onCancel,
|
|
53
59
|
onConfirm,
|
|
54
60
|
onUndo,
|
|
@@ -59,6 +65,9 @@ export function UndoableConfirmDialog({
|
|
|
59
65
|
const [confirming, setConfirming] = React.useState(false);
|
|
60
66
|
const timeoutRef = React.useRef<number | null>(null);
|
|
61
67
|
const intervalRef = React.useRef<number | null>(null);
|
|
68
|
+
const cancelButtonClass = emphasis === 'cancel' ? dangerButtonClass : secondaryButtonClass;
|
|
69
|
+
const confirmButtonClass = emphasis === 'cancel' ? secondaryButtonClass : dangerButtonClass;
|
|
70
|
+
const { dialogLoading, runDialogAction } = useDialogLoadingAction({ loadingActions, onOpenChange });
|
|
62
71
|
|
|
63
72
|
const clearTimers = React.useCallback(() => {
|
|
64
73
|
if (timeoutRef.current) {
|
|
@@ -95,12 +104,11 @@ export function UndoableConfirmDialog({
|
|
|
95
104
|
setConfirming(true);
|
|
96
105
|
|
|
97
106
|
try {
|
|
98
|
-
await onConfirm
|
|
99
|
-
onOpenChange(false);
|
|
107
|
+
await runDialogAction('confirm', onConfirm);
|
|
100
108
|
} finally {
|
|
101
109
|
setConfirming(false);
|
|
102
110
|
}
|
|
103
|
-
}, [clearTimers, onConfirm,
|
|
111
|
+
}, [clearTimers, onConfirm, runDialogAction]);
|
|
104
112
|
|
|
105
113
|
const startCountdown = () => {
|
|
106
114
|
clearTimers();
|
|
@@ -118,98 +126,105 @@ export function UndoableConfirmDialog({
|
|
|
118
126
|
|
|
119
127
|
const handleCancel = () => {
|
|
120
128
|
resetState();
|
|
121
|
-
|
|
122
|
-
onCancel?.();
|
|
129
|
+
void runDialogAction('cancel', onCancel);
|
|
123
130
|
};
|
|
124
|
-
|
|
125
|
-
const handleUndo = () => {
|
|
131
|
+
const handleClose = React.useCallback(() => {
|
|
126
132
|
resetState();
|
|
127
133
|
onOpenChange(false);
|
|
128
|
-
|
|
134
|
+
}, [onOpenChange, resetState]);
|
|
135
|
+
|
|
136
|
+
const handleUndo = async () => {
|
|
137
|
+
resetState();
|
|
138
|
+
await runDialogAction('undo', onUndo);
|
|
129
139
|
};
|
|
130
140
|
|
|
131
141
|
const displayTitle = pending ? pendingTitle ?? title : title;
|
|
132
142
|
const displayDescription = pending ? pendingDescription ?? description : description;
|
|
133
143
|
return (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
<
|
|
149
|
-
<
|
|
150
|
-
<
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
type="button"
|
|
158
|
-
className={closeButtonClass}
|
|
159
|
-
onClick={pending ? handleUndo : handleCancel}
|
|
160
|
-
aria-label="Close"
|
|
161
|
-
disabled={confirming}
|
|
162
|
-
>
|
|
163
|
-
<XIcon className="size-4" />
|
|
164
|
-
</button>
|
|
165
|
-
</div>
|
|
166
|
-
|
|
167
|
-
<AlertDialogDescription className={cn(dialogDescriptionClass, 'min-h-[44px]')}>
|
|
168
|
-
<span>{displayDescription}</span>
|
|
169
|
-
</AlertDialogDescription>
|
|
170
|
-
|
|
171
|
-
<div className="flex h-12 items-center justify-center py-1">
|
|
172
|
-
<div className="flex items-baseline justify-center gap-2">
|
|
173
|
-
<span className={cn('text-4xl font-black leading-none tabular-nums', pending && 'animate-bounce', themeIconColor)}>
|
|
174
|
-
{pending ? remainingSeconds : safeCountdownSeconds}
|
|
175
|
-
</span>
|
|
176
|
-
<span className={cn('text-sm font-bold', themeIconColor)}>
|
|
177
|
-
s
|
|
178
|
-
</span>
|
|
179
|
-
</div>
|
|
180
|
-
</div>
|
|
181
|
-
|
|
182
|
-
<div className={cn(dialogFooterClass, 'min-h-[88px] sm:min-h-10 sm:items-center')}>
|
|
183
|
-
{pending ? (
|
|
144
|
+
<>
|
|
145
|
+
<AlertDialog open={open} onOpenChange={(nextOpen) => {
|
|
146
|
+
if (!nextOpen) {
|
|
147
|
+
handleClose();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
onOpenChange(nextOpen);
|
|
152
|
+
}}>
|
|
153
|
+
<AlertDialogContent
|
|
154
|
+
className={cn(dialogContentClass, 'border-red-300 dark:border-red-700')}
|
|
155
|
+
overlayClassName={dialogThemedOverlayClass}
|
|
156
|
+
onOverlayClick={pending ? undefined : handleClose}
|
|
157
|
+
>
|
|
158
|
+
<div className={dialogHeaderClass}>
|
|
159
|
+
<AlertDialogTitle asChild>
|
|
160
|
+
<div className={dialogTitleClass}>
|
|
161
|
+
<span className="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-red-100 text-red-600 ring-1 ring-red-200 dark:bg-red-950 dark:text-red-300 dark:ring-red-900">
|
|
162
|
+
{pending ? <Trash2Icon className="size-5" /> : <CircleAlertIcon className="size-5" />}
|
|
163
|
+
</span>
|
|
164
|
+
<span className="min-w-0 truncate">{displayTitle}</span>
|
|
165
|
+
</div>
|
|
166
|
+
</AlertDialogTitle>
|
|
184
167
|
<button
|
|
185
168
|
type="button"
|
|
186
|
-
|
|
187
|
-
|
|
169
|
+
className={closeButtonClass}
|
|
170
|
+
onClick={handleClose}
|
|
171
|
+
aria-label="Close"
|
|
188
172
|
disabled={confirming}
|
|
189
173
|
>
|
|
190
|
-
<
|
|
191
|
-
{undoText}
|
|
174
|
+
<XIcon className="size-4" />
|
|
192
175
|
</button>
|
|
193
|
-
|
|
194
|
-
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<AlertDialogDescription className={cn(dialogDescriptionClass, 'min-h-[44px]')}>
|
|
179
|
+
<span>{displayDescription}</span>
|
|
180
|
+
</AlertDialogDescription>
|
|
181
|
+
|
|
182
|
+
<div className="flex h-12 items-center justify-center py-1">
|
|
183
|
+
<div className="flex items-baseline justify-center gap-2">
|
|
184
|
+
<span className={cn('text-4xl font-black leading-none tabular-nums', pending && 'animate-bounce', themeIconColor)}>
|
|
185
|
+
{pending ? remainingSeconds : safeCountdownSeconds}
|
|
186
|
+
</span>
|
|
187
|
+
<span className={cn('text-sm font-bold', themeIconColor)}>
|
|
188
|
+
s
|
|
189
|
+
</span>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<div className={cn(dialogFooterClass, 'min-h-[88px] sm:min-h-10 sm:items-center')}>
|
|
194
|
+
{pending ? (
|
|
195
195
|
<button
|
|
196
196
|
type="button"
|
|
197
|
-
onClick={
|
|
197
|
+
onClick={() => {
|
|
198
|
+
void handleUndo();
|
|
199
|
+
}}
|
|
198
200
|
className={secondaryButtonClass}
|
|
201
|
+
disabled={confirming}
|
|
199
202
|
>
|
|
200
|
-
|
|
203
|
+
<Undo2Icon className="mr-1.5 size-4" />
|
|
204
|
+
{undoText}
|
|
201
205
|
</button>
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
206
|
+
) : (
|
|
207
|
+
<>
|
|
208
|
+
<button
|
|
209
|
+
type="button"
|
|
210
|
+
onClick={handleCancel}
|
|
211
|
+
className={cancelButtonClass}
|
|
212
|
+
>
|
|
213
|
+
{cancelText}
|
|
214
|
+
</button>
|
|
215
|
+
<button
|
|
216
|
+
type="button"
|
|
217
|
+
onClick={startCountdown}
|
|
218
|
+
className={confirmButtonClass}
|
|
219
|
+
>
|
|
220
|
+
{confirmText}
|
|
221
|
+
</button>
|
|
222
|
+
</>
|
|
223
|
+
)}
|
|
224
|
+
</div>
|
|
225
|
+
</AlertDialogContent>
|
|
226
|
+
</AlertDialog>
|
|
227
|
+
{dialogLoading}
|
|
228
|
+
</>
|
|
214
229
|
);
|
|
215
230
|
}
|
|
@@ -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
|