@edux-design/buttons 0.1.1 → 0.1.2
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/CHANGELOG.md +6 -0
- package/dist/index.d.mts +67 -1
- package/dist/index.d.ts +67 -1
- package/dist/index.js +267 -0
- package/dist/index.mjs +273 -0
- package/package.json +1 -1
- package/src/demo/ButtonCounter.stories.jsx +101 -0
- package/src/elements/ButtonCounter.jsx +341 -0
- package/src/elements/ButtonCounter.test.jsx +100 -0
- package/src/index.js +1 -0
package/CHANGELOG.md
CHANGED
package/dist/index.d.mts
CHANGED
|
@@ -161,4 +161,70 @@ declare namespace ButtonGroupItem {
|
|
|
161
161
|
let displayName: string;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
/**
|
|
165
|
+
* ButtonCounter (stepper) component.
|
|
166
|
+
*
|
|
167
|
+
* Renders decrement/increment buttons with a central value display.
|
|
168
|
+
* Supports the same size + variant flags as the main Button.
|
|
169
|
+
*
|
|
170
|
+
* @typedef {React.HTMLAttributes<HTMLDivElement> & {
|
|
171
|
+
* value?: number,
|
|
172
|
+
* defaultValue?: number,
|
|
173
|
+
* onChange?: (value: number) => void,
|
|
174
|
+
* min?: number,
|
|
175
|
+
* max?: number,
|
|
176
|
+
* step?: number,
|
|
177
|
+
* children?: React.ReactNode,
|
|
178
|
+
* size?: "small"|"medium"|"large",
|
|
179
|
+
* isSecondary?: boolean,
|
|
180
|
+
* isDanger?: boolean,
|
|
181
|
+
* isBare?: boolean,
|
|
182
|
+
* isStretched?: boolean,
|
|
183
|
+
* disabled?: boolean,
|
|
184
|
+
* isEditable?: boolean,
|
|
185
|
+
* inputProps?: Omit<React.InputHTMLAttributes<HTMLInputElement>, "value"|"defaultValue"|"onChange"|"type">,
|
|
186
|
+
* decrementIcon?: React.ReactNode,
|
|
187
|
+
* incrementIcon?: React.ReactNode,
|
|
188
|
+
* startIcon?: React.ReactNode,
|
|
189
|
+
* endIcon?: React.ReactNode,
|
|
190
|
+
* decrementButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
191
|
+
* incrementButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
192
|
+
* className?: string,
|
|
193
|
+
* }} ButtonCounterProps
|
|
194
|
+
*/
|
|
195
|
+
/**
|
|
196
|
+
* @type {React.ForwardRefExoticComponent<ButtonCounterProps & React.RefAttributes<HTMLDivElement>>}
|
|
197
|
+
*/
|
|
198
|
+
declare const ButtonCounter: React.ForwardRefExoticComponent<ButtonCounterProps & React.RefAttributes<HTMLDivElement>>;
|
|
199
|
+
/**
|
|
200
|
+
* ButtonCounter (stepper) component.
|
|
201
|
+
*
|
|
202
|
+
* Renders decrement/increment buttons with a central value display.
|
|
203
|
+
* Supports the same size + variant flags as the main Button.
|
|
204
|
+
*/
|
|
205
|
+
type ButtonCounterProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
206
|
+
value?: number;
|
|
207
|
+
defaultValue?: number;
|
|
208
|
+
onChange?: (value: number) => void;
|
|
209
|
+
min?: number;
|
|
210
|
+
max?: number;
|
|
211
|
+
step?: number;
|
|
212
|
+
children?: React.ReactNode;
|
|
213
|
+
size?: "small" | "medium" | "large";
|
|
214
|
+
isSecondary?: boolean;
|
|
215
|
+
isDanger?: boolean;
|
|
216
|
+
isBare?: boolean;
|
|
217
|
+
isStretched?: boolean;
|
|
218
|
+
disabled?: boolean;
|
|
219
|
+
isEditable?: boolean;
|
|
220
|
+
inputProps?: Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "type">;
|
|
221
|
+
decrementIcon?: React.ReactNode;
|
|
222
|
+
incrementIcon?: React.ReactNode;
|
|
223
|
+
startIcon?: React.ReactNode;
|
|
224
|
+
endIcon?: React.ReactNode;
|
|
225
|
+
decrementButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
226
|
+
incrementButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
227
|
+
className?: string;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export { Button, ButtonCounter, ButtonGroup, ButtonGroupItem, IconButton, MenuButton };
|
package/dist/index.d.ts
CHANGED
|
@@ -161,4 +161,70 @@ declare namespace ButtonGroupItem {
|
|
|
161
161
|
let displayName: string;
|
|
162
162
|
}
|
|
163
163
|
|
|
164
|
-
|
|
164
|
+
/**
|
|
165
|
+
* ButtonCounter (stepper) component.
|
|
166
|
+
*
|
|
167
|
+
* Renders decrement/increment buttons with a central value display.
|
|
168
|
+
* Supports the same size + variant flags as the main Button.
|
|
169
|
+
*
|
|
170
|
+
* @typedef {React.HTMLAttributes<HTMLDivElement> & {
|
|
171
|
+
* value?: number,
|
|
172
|
+
* defaultValue?: number,
|
|
173
|
+
* onChange?: (value: number) => void,
|
|
174
|
+
* min?: number,
|
|
175
|
+
* max?: number,
|
|
176
|
+
* step?: number,
|
|
177
|
+
* children?: React.ReactNode,
|
|
178
|
+
* size?: "small"|"medium"|"large",
|
|
179
|
+
* isSecondary?: boolean,
|
|
180
|
+
* isDanger?: boolean,
|
|
181
|
+
* isBare?: boolean,
|
|
182
|
+
* isStretched?: boolean,
|
|
183
|
+
* disabled?: boolean,
|
|
184
|
+
* isEditable?: boolean,
|
|
185
|
+
* inputProps?: Omit<React.InputHTMLAttributes<HTMLInputElement>, "value"|"defaultValue"|"onChange"|"type">,
|
|
186
|
+
* decrementIcon?: React.ReactNode,
|
|
187
|
+
* incrementIcon?: React.ReactNode,
|
|
188
|
+
* startIcon?: React.ReactNode,
|
|
189
|
+
* endIcon?: React.ReactNode,
|
|
190
|
+
* decrementButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
191
|
+
* incrementButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
192
|
+
* className?: string,
|
|
193
|
+
* }} ButtonCounterProps
|
|
194
|
+
*/
|
|
195
|
+
/**
|
|
196
|
+
* @type {React.ForwardRefExoticComponent<ButtonCounterProps & React.RefAttributes<HTMLDivElement>>}
|
|
197
|
+
*/
|
|
198
|
+
declare const ButtonCounter: React.ForwardRefExoticComponent<ButtonCounterProps & React.RefAttributes<HTMLDivElement>>;
|
|
199
|
+
/**
|
|
200
|
+
* ButtonCounter (stepper) component.
|
|
201
|
+
*
|
|
202
|
+
* Renders decrement/increment buttons with a central value display.
|
|
203
|
+
* Supports the same size + variant flags as the main Button.
|
|
204
|
+
*/
|
|
205
|
+
type ButtonCounterProps = React.HTMLAttributes<HTMLDivElement> & {
|
|
206
|
+
value?: number;
|
|
207
|
+
defaultValue?: number;
|
|
208
|
+
onChange?: (value: number) => void;
|
|
209
|
+
min?: number;
|
|
210
|
+
max?: number;
|
|
211
|
+
step?: number;
|
|
212
|
+
children?: React.ReactNode;
|
|
213
|
+
size?: "small" | "medium" | "large";
|
|
214
|
+
isSecondary?: boolean;
|
|
215
|
+
isDanger?: boolean;
|
|
216
|
+
isBare?: boolean;
|
|
217
|
+
isStretched?: boolean;
|
|
218
|
+
disabled?: boolean;
|
|
219
|
+
isEditable?: boolean;
|
|
220
|
+
inputProps?: Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "defaultValue" | "onChange" | "type">;
|
|
221
|
+
decrementIcon?: React.ReactNode;
|
|
222
|
+
incrementIcon?: React.ReactNode;
|
|
223
|
+
startIcon?: React.ReactNode;
|
|
224
|
+
endIcon?: React.ReactNode;
|
|
225
|
+
decrementButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
226
|
+
incrementButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
227
|
+
className?: string;
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export { Button, ButtonCounter, ButtonGroup, ButtonGroupItem, IconButton, MenuButton };
|
package/dist/index.js
CHANGED
|
@@ -30,6 +30,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
30
30
|
var index_exports = {};
|
|
31
31
|
__export(index_exports, {
|
|
32
32
|
Button: () => Button,
|
|
33
|
+
ButtonCounter: () => ButtonCounter,
|
|
33
34
|
ButtonGroup: () => ButtonGroup,
|
|
34
35
|
ButtonGroupItem: () => ButtonGroupItem,
|
|
35
36
|
IconButton: () => IconButton,
|
|
@@ -526,9 +527,275 @@ function ButtonGroupItem(_props) {
|
|
|
526
527
|
return null;
|
|
527
528
|
}
|
|
528
529
|
ButtonGroupItem.displayName = "ButtonGroupItem";
|
|
530
|
+
|
|
531
|
+
// src/elements/ButtonCounter.jsx
|
|
532
|
+
var import_react7 = __toESM(require("react"));
|
|
533
|
+
var import_utils5 = require("@edux-design/utils");
|
|
534
|
+
var import_jsx_runtime6 = require("react/jsx-runtime");
|
|
535
|
+
var ButtonCounter = (0, import_react7.forwardRef)(function ButtonCounter2({
|
|
536
|
+
value,
|
|
537
|
+
defaultValue = 1,
|
|
538
|
+
onChange,
|
|
539
|
+
min = Number.NEGATIVE_INFINITY,
|
|
540
|
+
max = Number.POSITIVE_INFINITY,
|
|
541
|
+
step = 1,
|
|
542
|
+
children,
|
|
543
|
+
size = "medium",
|
|
544
|
+
isSecondary,
|
|
545
|
+
isDanger,
|
|
546
|
+
isBare,
|
|
547
|
+
isStretched,
|
|
548
|
+
disabled,
|
|
549
|
+
isEditable = false,
|
|
550
|
+
inputProps,
|
|
551
|
+
decrementIcon,
|
|
552
|
+
incrementIcon,
|
|
553
|
+
startIcon,
|
|
554
|
+
endIcon,
|
|
555
|
+
decrementButtonProps,
|
|
556
|
+
incrementButtonProps,
|
|
557
|
+
className,
|
|
558
|
+
...rest
|
|
559
|
+
}, ref) {
|
|
560
|
+
const isControlled = value !== void 0;
|
|
561
|
+
const [internalValue, setInternalValue] = (0, import_react7.useState)(defaultValue);
|
|
562
|
+
const currentValue = isControlled ? value : internalValue;
|
|
563
|
+
const clampedValue = (0, import_react7.useMemo)(() => {
|
|
564
|
+
const safe = Number.isFinite(currentValue) ? currentValue : defaultValue;
|
|
565
|
+
return Math.min(max, Math.max(min, safe));
|
|
566
|
+
}, [currentValue, defaultValue, max, min]);
|
|
567
|
+
const commitValue = (0, import_react7.useCallback)(
|
|
568
|
+
(next) => {
|
|
569
|
+
const clamped = Math.min(max, Math.max(min, next));
|
|
570
|
+
if (!isControlled) setInternalValue(clamped);
|
|
571
|
+
onChange == null ? void 0 : onChange(clamped);
|
|
572
|
+
},
|
|
573
|
+
[isControlled, max, min, onChange]
|
|
574
|
+
);
|
|
575
|
+
const decrementDisabled = !!disabled || !Number.isFinite(clampedValue) || clampedValue - step < min;
|
|
576
|
+
const incrementDisabled = !!disabled || !Number.isFinite(clampedValue) || clampedValue + step > max;
|
|
577
|
+
let variant = "primary";
|
|
578
|
+
if (disabled) {
|
|
579
|
+
variant = "disabled";
|
|
580
|
+
} else if (isDanger && isBare) {
|
|
581
|
+
variant = "dangerBare";
|
|
582
|
+
} else if (isDanger) {
|
|
583
|
+
variant = "danger";
|
|
584
|
+
} else if (isSecondary) {
|
|
585
|
+
variant = "secondary";
|
|
586
|
+
} else if (isBare) {
|
|
587
|
+
variant = "bare";
|
|
588
|
+
}
|
|
589
|
+
if (process.env.NODE_ENV !== "production") {
|
|
590
|
+
const count = [isSecondary, isDanger, isBare].filter(Boolean).length;
|
|
591
|
+
const hasAllowedCombo = isDanger && isBare && count === 2;
|
|
592
|
+
if (count > 1 && !hasAllowedCombo) {
|
|
593
|
+
console.warn(
|
|
594
|
+
"[ButtonCounter]: Multiple variant props passed. Using the highest priority only."
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
if (step <= 0) {
|
|
598
|
+
console.warn("[ButtonCounter]: step should be > 0.");
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
const CONTROL_SIZES = {
|
|
602
|
+
small: {
|
|
603
|
+
button: "h-[36px] w-[36px] text-sm",
|
|
604
|
+
display: "h-[36px] min-w-[48px] px-12 text-sm"
|
|
605
|
+
},
|
|
606
|
+
medium: {
|
|
607
|
+
button: "h-[44px] w-[44px] text-base",
|
|
608
|
+
display: "h-[44px] min-w-[56px] px-12 text-base"
|
|
609
|
+
},
|
|
610
|
+
large: {
|
|
611
|
+
button: "h-[56px] w-[56px] text-lg",
|
|
612
|
+
display: "h-[56px] min-w-[72px] px-16 text-lg"
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
const defaultsButton = "flex justify-center items-center focus:shadow-focus focus-visible:shadow-focus outline-none transition-all duration-300 ease-in-out cursor-pointer";
|
|
616
|
+
const defaultsDisplay = "flex items-center justify-center text-bold tracking-normal";
|
|
617
|
+
const width = isStretched ? "w-full" : "w-fit-content";
|
|
618
|
+
const variants = {
|
|
619
|
+
primary: {
|
|
620
|
+
button: "border-2 border-border-primary-emphasis bg-bg-primary-base text-white hover:bg-border-focus hover:text-fg-base hover:border-border-emphasis hover:shadow-md active:bg-border-focus active:bg-bg-focus-overlay active:text-fg-base",
|
|
621
|
+
display: "border-y-2 border-x-2 border-border-primary-emphasis bg-bg-primary-base text-white"
|
|
622
|
+
},
|
|
623
|
+
secondary: {
|
|
624
|
+
button: "border-2 border-border-primary-base bg-bg-base text-fg-primary-emphasis hover:shadow-md hover:bg-bg-primary-subtle active:bg-bg-primary-subtle-90",
|
|
625
|
+
display: "border-y-2 border-x-2 border-border-primary-base bg-bg-base text-fg-primary-emphasis"
|
|
626
|
+
},
|
|
627
|
+
bare: {
|
|
628
|
+
button: "bg-bg-base text-fg-primary-emphasis hover:bg-bg-minimal active:bg-bg-minimal-5",
|
|
629
|
+
display: "bg-bg-base text-fg-primary-emphasis"
|
|
630
|
+
},
|
|
631
|
+
dangerBare: {
|
|
632
|
+
button: "bg-bg-base text-fg-danger-emphasis hover:bg-bg-danger-subtle active:bg-bg-danger-subtle",
|
|
633
|
+
display: "bg-bg-base text-fg-danger-emphasis"
|
|
634
|
+
},
|
|
635
|
+
danger: {
|
|
636
|
+
button: "bg-bg-danger-base border-2 border-border-danger-emphasis text-white hover:bg-bg-danger-base-5 active:bg-bg-danger-base-10",
|
|
637
|
+
display: "border-y-2 border-x-2 border-border-danger-emphasis bg-bg-danger-base text-white"
|
|
638
|
+
},
|
|
639
|
+
disabled: {
|
|
640
|
+
button: "border-2 border-border-subtle bg-bg-inactive text-fg-inactive hover:no-underline cursor-not-allowed",
|
|
641
|
+
display: "border-y-2 border-x-2 border-border-subtle bg-bg-inactive text-fg-inactive"
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
const sizeStyles = CONTROL_SIZES[size] ?? CONTROL_SIZES.medium;
|
|
645
|
+
const variantStyles = variants[variant] ?? variants.primary;
|
|
646
|
+
const leftButtonStyles = (0, import_utils5.cx)(
|
|
647
|
+
defaultsButton,
|
|
648
|
+
sizeStyles.button,
|
|
649
|
+
"rounded-l-lg",
|
|
650
|
+
"relative z-10",
|
|
651
|
+
variantStyles.button,
|
|
652
|
+
variant !== "bare" && variant !== "dangerBare" ? "border-r-0" : null
|
|
653
|
+
);
|
|
654
|
+
const rightButtonStyles = (0, import_utils5.cx)(
|
|
655
|
+
defaultsButton,
|
|
656
|
+
sizeStyles.button,
|
|
657
|
+
"rounded-r-lg",
|
|
658
|
+
variantStyles.button,
|
|
659
|
+
variant !== "bare" && variant !== "dangerBare" ? "border-l-0" : null
|
|
660
|
+
);
|
|
661
|
+
const displayStyles = (0, import_utils5.cx)(
|
|
662
|
+
defaultsDisplay,
|
|
663
|
+
sizeStyles.display,
|
|
664
|
+
"relative z-0",
|
|
665
|
+
variantStyles.display,
|
|
666
|
+
isStretched ? "flex-1" : null
|
|
667
|
+
);
|
|
668
|
+
const effectiveDecrementIcon = decrementIcon ?? startIcon ?? "-";
|
|
669
|
+
const effectiveIncrementIcon = incrementIcon ?? endIcon ?? "+";
|
|
670
|
+
const [draftValue, setDraftValue] = (0, import_react7.useState)(String(clampedValue));
|
|
671
|
+
const [isEditing, setIsEditing] = (0, import_react7.useState)(false);
|
|
672
|
+
const inputRef = (0, import_react7.useRef)(null);
|
|
673
|
+
(0, import_react7.useEffect)(() => {
|
|
674
|
+
if (isEditing) return;
|
|
675
|
+
setDraftValue(String(clampedValue));
|
|
676
|
+
}, [clampedValue, isEditing]);
|
|
677
|
+
const commitDraft = (0, import_react7.useCallback)(() => {
|
|
678
|
+
const trimmed = String(draftValue).trim();
|
|
679
|
+
if (trimmed === "") {
|
|
680
|
+
setDraftValue(String(clampedValue));
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
const next = Number(trimmed);
|
|
684
|
+
if (!Number.isFinite(next)) {
|
|
685
|
+
setDraftValue(String(clampedValue));
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
commitValue(next);
|
|
689
|
+
}, [clampedValue, commitValue, draftValue]);
|
|
690
|
+
return /* @__PURE__ */ (0, import_jsx_runtime6.jsxs)(
|
|
691
|
+
"div",
|
|
692
|
+
{
|
|
693
|
+
ref,
|
|
694
|
+
className: (0, import_utils5.cx)("inline-flex items-stretch", width, className),
|
|
695
|
+
...rest,
|
|
696
|
+
children: [
|
|
697
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
698
|
+
"button",
|
|
699
|
+
{
|
|
700
|
+
...decrementButtonProps,
|
|
701
|
+
type: "button",
|
|
702
|
+
"aria-label": (decrementButtonProps == null ? void 0 : decrementButtonProps["aria-label"]) ?? "Decrease",
|
|
703
|
+
"aria-disabled": decrementDisabled,
|
|
704
|
+
disabled: decrementDisabled,
|
|
705
|
+
className: (0, import_utils5.cx)(
|
|
706
|
+
leftButtonStyles,
|
|
707
|
+
decrementDisabled && variant !== "disabled" ? variants.disabled.button : null,
|
|
708
|
+
decrementButtonProps == null ? void 0 : decrementButtonProps.className
|
|
709
|
+
),
|
|
710
|
+
onClick: (event) => {
|
|
711
|
+
var _a;
|
|
712
|
+
(_a = decrementButtonProps == null ? void 0 : decrementButtonProps.onClick) == null ? void 0 : _a.call(decrementButtonProps, event);
|
|
713
|
+
if (event.defaultPrevented) return;
|
|
714
|
+
commitValue(clampedValue - step);
|
|
715
|
+
},
|
|
716
|
+
children: effectiveDecrementIcon
|
|
717
|
+
}
|
|
718
|
+
),
|
|
719
|
+
isEditable && children == null ? /* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
720
|
+
"input",
|
|
721
|
+
{
|
|
722
|
+
...inputProps,
|
|
723
|
+
ref: inputRef,
|
|
724
|
+
type: "number",
|
|
725
|
+
inputMode: "numeric",
|
|
726
|
+
disabled: !!disabled,
|
|
727
|
+
"aria-label": (inputProps == null ? void 0 : inputProps["aria-label"]) ?? "Value",
|
|
728
|
+
className: (0, import_utils5.cx)(
|
|
729
|
+
displayStyles,
|
|
730
|
+
"w-[44px] text-center outline-none bg-transparent text-fg-base",
|
|
731
|
+
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-inner-spin-button]:m-0",
|
|
732
|
+
"focus:shadow-focus focus-visible:shadow-focus focus:z-15 focus-within:z-15",
|
|
733
|
+
inputProps == null ? void 0 : inputProps.className
|
|
734
|
+
),
|
|
735
|
+
value: draftValue,
|
|
736
|
+
onChange: (event) => {
|
|
737
|
+
var _a;
|
|
738
|
+
(_a = inputProps == null ? void 0 : inputProps.onChange) == null ? void 0 : _a.call(inputProps, event);
|
|
739
|
+
if (event.defaultPrevented) return;
|
|
740
|
+
setDraftValue(event.target.value);
|
|
741
|
+
},
|
|
742
|
+
onFocus: (event) => {
|
|
743
|
+
var _a;
|
|
744
|
+
(_a = inputProps == null ? void 0 : inputProps.onFocus) == null ? void 0 : _a.call(inputProps, event);
|
|
745
|
+
if (event.defaultPrevented) return;
|
|
746
|
+
setIsEditing(true);
|
|
747
|
+
},
|
|
748
|
+
onBlur: (event) => {
|
|
749
|
+
var _a;
|
|
750
|
+
(_a = inputProps == null ? void 0 : inputProps.onBlur) == null ? void 0 : _a.call(inputProps, event);
|
|
751
|
+
if (event.defaultPrevented) return;
|
|
752
|
+
setIsEditing(false);
|
|
753
|
+
commitDraft();
|
|
754
|
+
},
|
|
755
|
+
onKeyDown: (event) => {
|
|
756
|
+
var _a;
|
|
757
|
+
(_a = inputProps == null ? void 0 : inputProps.onKeyDown) == null ? void 0 : _a.call(inputProps, event);
|
|
758
|
+
if (event.defaultPrevented) return;
|
|
759
|
+
if (event.key === "Enter") {
|
|
760
|
+
event.currentTarget.blur();
|
|
761
|
+
}
|
|
762
|
+
if (event.key === "Escape") {
|
|
763
|
+
setDraftValue(String(clampedValue));
|
|
764
|
+
event.currentTarget.blur();
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
) : /* @__PURE__ */ (0, import_jsx_runtime6.jsx)("div", { className: (0, import_utils5.cx)(displayStyles, "select-none"), children: children ?? clampedValue }),
|
|
769
|
+
/* @__PURE__ */ (0, import_jsx_runtime6.jsx)(
|
|
770
|
+
"button",
|
|
771
|
+
{
|
|
772
|
+
...incrementButtonProps,
|
|
773
|
+
type: "button",
|
|
774
|
+
"aria-label": (incrementButtonProps == null ? void 0 : incrementButtonProps["aria-label"]) ?? "Increase",
|
|
775
|
+
"aria-disabled": incrementDisabled,
|
|
776
|
+
disabled: incrementDisabled,
|
|
777
|
+
className: (0, import_utils5.cx)(
|
|
778
|
+
rightButtonStyles,
|
|
779
|
+
incrementDisabled && variant !== "disabled" ? variants.disabled.button : null,
|
|
780
|
+
incrementButtonProps == null ? void 0 : incrementButtonProps.className
|
|
781
|
+
),
|
|
782
|
+
onClick: (event) => {
|
|
783
|
+
var _a;
|
|
784
|
+
(_a = incrementButtonProps == null ? void 0 : incrementButtonProps.onClick) == null ? void 0 : _a.call(incrementButtonProps, event);
|
|
785
|
+
if (event.defaultPrevented) return;
|
|
786
|
+
commitValue(clampedValue + step);
|
|
787
|
+
},
|
|
788
|
+
children: effectiveIncrementIcon
|
|
789
|
+
}
|
|
790
|
+
)
|
|
791
|
+
]
|
|
792
|
+
}
|
|
793
|
+
);
|
|
794
|
+
});
|
|
529
795
|
// Annotate the CommonJS export names for ESM import in node:
|
|
530
796
|
0 && (module.exports = {
|
|
531
797
|
Button,
|
|
798
|
+
ButtonCounter,
|
|
532
799
|
ButtonGroup,
|
|
533
800
|
ButtonGroupItem,
|
|
534
801
|
IconButton,
|
package/dist/index.mjs
CHANGED
|
@@ -495,8 +495,281 @@ function ButtonGroupItem(_props) {
|
|
|
495
495
|
return null;
|
|
496
496
|
}
|
|
497
497
|
ButtonGroupItem.displayName = "ButtonGroupItem";
|
|
498
|
+
|
|
499
|
+
// src/elements/ButtonCounter.jsx
|
|
500
|
+
import React7, {
|
|
501
|
+
forwardRef as forwardRef4,
|
|
502
|
+
useCallback as useCallback2,
|
|
503
|
+
useEffect as useEffect2,
|
|
504
|
+
useMemo as useMemo2,
|
|
505
|
+
useRef as useRef2,
|
|
506
|
+
useState as useState2
|
|
507
|
+
} from "react";
|
|
508
|
+
import { cx as cx5 } from "@edux-design/utils";
|
|
509
|
+
import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
510
|
+
var ButtonCounter = forwardRef4(function ButtonCounter2({
|
|
511
|
+
value,
|
|
512
|
+
defaultValue = 1,
|
|
513
|
+
onChange,
|
|
514
|
+
min = Number.NEGATIVE_INFINITY,
|
|
515
|
+
max = Number.POSITIVE_INFINITY,
|
|
516
|
+
step = 1,
|
|
517
|
+
children,
|
|
518
|
+
size = "medium",
|
|
519
|
+
isSecondary,
|
|
520
|
+
isDanger,
|
|
521
|
+
isBare,
|
|
522
|
+
isStretched,
|
|
523
|
+
disabled,
|
|
524
|
+
isEditable = false,
|
|
525
|
+
inputProps,
|
|
526
|
+
decrementIcon,
|
|
527
|
+
incrementIcon,
|
|
528
|
+
startIcon,
|
|
529
|
+
endIcon,
|
|
530
|
+
decrementButtonProps,
|
|
531
|
+
incrementButtonProps,
|
|
532
|
+
className,
|
|
533
|
+
...rest
|
|
534
|
+
}, ref) {
|
|
535
|
+
const isControlled = value !== void 0;
|
|
536
|
+
const [internalValue, setInternalValue] = useState2(defaultValue);
|
|
537
|
+
const currentValue = isControlled ? value : internalValue;
|
|
538
|
+
const clampedValue = useMemo2(() => {
|
|
539
|
+
const safe = Number.isFinite(currentValue) ? currentValue : defaultValue;
|
|
540
|
+
return Math.min(max, Math.max(min, safe));
|
|
541
|
+
}, [currentValue, defaultValue, max, min]);
|
|
542
|
+
const commitValue = useCallback2(
|
|
543
|
+
(next) => {
|
|
544
|
+
const clamped = Math.min(max, Math.max(min, next));
|
|
545
|
+
if (!isControlled) setInternalValue(clamped);
|
|
546
|
+
onChange == null ? void 0 : onChange(clamped);
|
|
547
|
+
},
|
|
548
|
+
[isControlled, max, min, onChange]
|
|
549
|
+
);
|
|
550
|
+
const decrementDisabled = !!disabled || !Number.isFinite(clampedValue) || clampedValue - step < min;
|
|
551
|
+
const incrementDisabled = !!disabled || !Number.isFinite(clampedValue) || clampedValue + step > max;
|
|
552
|
+
let variant = "primary";
|
|
553
|
+
if (disabled) {
|
|
554
|
+
variant = "disabled";
|
|
555
|
+
} else if (isDanger && isBare) {
|
|
556
|
+
variant = "dangerBare";
|
|
557
|
+
} else if (isDanger) {
|
|
558
|
+
variant = "danger";
|
|
559
|
+
} else if (isSecondary) {
|
|
560
|
+
variant = "secondary";
|
|
561
|
+
} else if (isBare) {
|
|
562
|
+
variant = "bare";
|
|
563
|
+
}
|
|
564
|
+
if (process.env.NODE_ENV !== "production") {
|
|
565
|
+
const count = [isSecondary, isDanger, isBare].filter(Boolean).length;
|
|
566
|
+
const hasAllowedCombo = isDanger && isBare && count === 2;
|
|
567
|
+
if (count > 1 && !hasAllowedCombo) {
|
|
568
|
+
console.warn(
|
|
569
|
+
"[ButtonCounter]: Multiple variant props passed. Using the highest priority only."
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
if (step <= 0) {
|
|
573
|
+
console.warn("[ButtonCounter]: step should be > 0.");
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const CONTROL_SIZES = {
|
|
577
|
+
small: {
|
|
578
|
+
button: "h-[36px] w-[36px] text-sm",
|
|
579
|
+
display: "h-[36px] min-w-[48px] px-12 text-sm"
|
|
580
|
+
},
|
|
581
|
+
medium: {
|
|
582
|
+
button: "h-[44px] w-[44px] text-base",
|
|
583
|
+
display: "h-[44px] min-w-[56px] px-12 text-base"
|
|
584
|
+
},
|
|
585
|
+
large: {
|
|
586
|
+
button: "h-[56px] w-[56px] text-lg",
|
|
587
|
+
display: "h-[56px] min-w-[72px] px-16 text-lg"
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
const defaultsButton = "flex justify-center items-center focus:shadow-focus focus-visible:shadow-focus outline-none transition-all duration-300 ease-in-out cursor-pointer";
|
|
591
|
+
const defaultsDisplay = "flex items-center justify-center text-bold tracking-normal";
|
|
592
|
+
const width = isStretched ? "w-full" : "w-fit-content";
|
|
593
|
+
const variants = {
|
|
594
|
+
primary: {
|
|
595
|
+
button: "border-2 border-border-primary-emphasis bg-bg-primary-base text-white hover:bg-border-focus hover:text-fg-base hover:border-border-emphasis hover:shadow-md active:bg-border-focus active:bg-bg-focus-overlay active:text-fg-base",
|
|
596
|
+
display: "border-y-2 border-x-2 border-border-primary-emphasis bg-bg-primary-base text-white"
|
|
597
|
+
},
|
|
598
|
+
secondary: {
|
|
599
|
+
button: "border-2 border-border-primary-base bg-bg-base text-fg-primary-emphasis hover:shadow-md hover:bg-bg-primary-subtle active:bg-bg-primary-subtle-90",
|
|
600
|
+
display: "border-y-2 border-x-2 border-border-primary-base bg-bg-base text-fg-primary-emphasis"
|
|
601
|
+
},
|
|
602
|
+
bare: {
|
|
603
|
+
button: "bg-bg-base text-fg-primary-emphasis hover:bg-bg-minimal active:bg-bg-minimal-5",
|
|
604
|
+
display: "bg-bg-base text-fg-primary-emphasis"
|
|
605
|
+
},
|
|
606
|
+
dangerBare: {
|
|
607
|
+
button: "bg-bg-base text-fg-danger-emphasis hover:bg-bg-danger-subtle active:bg-bg-danger-subtle",
|
|
608
|
+
display: "bg-bg-base text-fg-danger-emphasis"
|
|
609
|
+
},
|
|
610
|
+
danger: {
|
|
611
|
+
button: "bg-bg-danger-base border-2 border-border-danger-emphasis text-white hover:bg-bg-danger-base-5 active:bg-bg-danger-base-10",
|
|
612
|
+
display: "border-y-2 border-x-2 border-border-danger-emphasis bg-bg-danger-base text-white"
|
|
613
|
+
},
|
|
614
|
+
disabled: {
|
|
615
|
+
button: "border-2 border-border-subtle bg-bg-inactive text-fg-inactive hover:no-underline cursor-not-allowed",
|
|
616
|
+
display: "border-y-2 border-x-2 border-border-subtle bg-bg-inactive text-fg-inactive"
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
const sizeStyles = CONTROL_SIZES[size] ?? CONTROL_SIZES.medium;
|
|
620
|
+
const variantStyles = variants[variant] ?? variants.primary;
|
|
621
|
+
const leftButtonStyles = cx5(
|
|
622
|
+
defaultsButton,
|
|
623
|
+
sizeStyles.button,
|
|
624
|
+
"rounded-l-lg",
|
|
625
|
+
"relative z-10",
|
|
626
|
+
variantStyles.button,
|
|
627
|
+
variant !== "bare" && variant !== "dangerBare" ? "border-r-0" : null
|
|
628
|
+
);
|
|
629
|
+
const rightButtonStyles = cx5(
|
|
630
|
+
defaultsButton,
|
|
631
|
+
sizeStyles.button,
|
|
632
|
+
"rounded-r-lg",
|
|
633
|
+
variantStyles.button,
|
|
634
|
+
variant !== "bare" && variant !== "dangerBare" ? "border-l-0" : null
|
|
635
|
+
);
|
|
636
|
+
const displayStyles = cx5(
|
|
637
|
+
defaultsDisplay,
|
|
638
|
+
sizeStyles.display,
|
|
639
|
+
"relative z-0",
|
|
640
|
+
variantStyles.display,
|
|
641
|
+
isStretched ? "flex-1" : null
|
|
642
|
+
);
|
|
643
|
+
const effectiveDecrementIcon = decrementIcon ?? startIcon ?? "-";
|
|
644
|
+
const effectiveIncrementIcon = incrementIcon ?? endIcon ?? "+";
|
|
645
|
+
const [draftValue, setDraftValue] = useState2(String(clampedValue));
|
|
646
|
+
const [isEditing, setIsEditing] = useState2(false);
|
|
647
|
+
const inputRef = useRef2(null);
|
|
648
|
+
useEffect2(() => {
|
|
649
|
+
if (isEditing) return;
|
|
650
|
+
setDraftValue(String(clampedValue));
|
|
651
|
+
}, [clampedValue, isEditing]);
|
|
652
|
+
const commitDraft = useCallback2(() => {
|
|
653
|
+
const trimmed = String(draftValue).trim();
|
|
654
|
+
if (trimmed === "") {
|
|
655
|
+
setDraftValue(String(clampedValue));
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const next = Number(trimmed);
|
|
659
|
+
if (!Number.isFinite(next)) {
|
|
660
|
+
setDraftValue(String(clampedValue));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
commitValue(next);
|
|
664
|
+
}, [clampedValue, commitValue, draftValue]);
|
|
665
|
+
return /* @__PURE__ */ jsxs5(
|
|
666
|
+
"div",
|
|
667
|
+
{
|
|
668
|
+
ref,
|
|
669
|
+
className: cx5("inline-flex items-stretch", width, className),
|
|
670
|
+
...rest,
|
|
671
|
+
children: [
|
|
672
|
+
/* @__PURE__ */ jsx6(
|
|
673
|
+
"button",
|
|
674
|
+
{
|
|
675
|
+
...decrementButtonProps,
|
|
676
|
+
type: "button",
|
|
677
|
+
"aria-label": (decrementButtonProps == null ? void 0 : decrementButtonProps["aria-label"]) ?? "Decrease",
|
|
678
|
+
"aria-disabled": decrementDisabled,
|
|
679
|
+
disabled: decrementDisabled,
|
|
680
|
+
className: cx5(
|
|
681
|
+
leftButtonStyles,
|
|
682
|
+
decrementDisabled && variant !== "disabled" ? variants.disabled.button : null,
|
|
683
|
+
decrementButtonProps == null ? void 0 : decrementButtonProps.className
|
|
684
|
+
),
|
|
685
|
+
onClick: (event) => {
|
|
686
|
+
var _a;
|
|
687
|
+
(_a = decrementButtonProps == null ? void 0 : decrementButtonProps.onClick) == null ? void 0 : _a.call(decrementButtonProps, event);
|
|
688
|
+
if (event.defaultPrevented) return;
|
|
689
|
+
commitValue(clampedValue - step);
|
|
690
|
+
},
|
|
691
|
+
children: effectiveDecrementIcon
|
|
692
|
+
}
|
|
693
|
+
),
|
|
694
|
+
isEditable && children == null ? /* @__PURE__ */ jsx6(
|
|
695
|
+
"input",
|
|
696
|
+
{
|
|
697
|
+
...inputProps,
|
|
698
|
+
ref: inputRef,
|
|
699
|
+
type: "number",
|
|
700
|
+
inputMode: "numeric",
|
|
701
|
+
disabled: !!disabled,
|
|
702
|
+
"aria-label": (inputProps == null ? void 0 : inputProps["aria-label"]) ?? "Value",
|
|
703
|
+
className: cx5(
|
|
704
|
+
displayStyles,
|
|
705
|
+
"w-[44px] text-center outline-none bg-transparent text-fg-base",
|
|
706
|
+
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-inner-spin-button]:m-0",
|
|
707
|
+
"focus:shadow-focus focus-visible:shadow-focus focus:z-15 focus-within:z-15",
|
|
708
|
+
inputProps == null ? void 0 : inputProps.className
|
|
709
|
+
),
|
|
710
|
+
value: draftValue,
|
|
711
|
+
onChange: (event) => {
|
|
712
|
+
var _a;
|
|
713
|
+
(_a = inputProps == null ? void 0 : inputProps.onChange) == null ? void 0 : _a.call(inputProps, event);
|
|
714
|
+
if (event.defaultPrevented) return;
|
|
715
|
+
setDraftValue(event.target.value);
|
|
716
|
+
},
|
|
717
|
+
onFocus: (event) => {
|
|
718
|
+
var _a;
|
|
719
|
+
(_a = inputProps == null ? void 0 : inputProps.onFocus) == null ? void 0 : _a.call(inputProps, event);
|
|
720
|
+
if (event.defaultPrevented) return;
|
|
721
|
+
setIsEditing(true);
|
|
722
|
+
},
|
|
723
|
+
onBlur: (event) => {
|
|
724
|
+
var _a;
|
|
725
|
+
(_a = inputProps == null ? void 0 : inputProps.onBlur) == null ? void 0 : _a.call(inputProps, event);
|
|
726
|
+
if (event.defaultPrevented) return;
|
|
727
|
+
setIsEditing(false);
|
|
728
|
+
commitDraft();
|
|
729
|
+
},
|
|
730
|
+
onKeyDown: (event) => {
|
|
731
|
+
var _a;
|
|
732
|
+
(_a = inputProps == null ? void 0 : inputProps.onKeyDown) == null ? void 0 : _a.call(inputProps, event);
|
|
733
|
+
if (event.defaultPrevented) return;
|
|
734
|
+
if (event.key === "Enter") {
|
|
735
|
+
event.currentTarget.blur();
|
|
736
|
+
}
|
|
737
|
+
if (event.key === "Escape") {
|
|
738
|
+
setDraftValue(String(clampedValue));
|
|
739
|
+
event.currentTarget.blur();
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
) : /* @__PURE__ */ jsx6("div", { className: cx5(displayStyles, "select-none"), children: children ?? clampedValue }),
|
|
744
|
+
/* @__PURE__ */ jsx6(
|
|
745
|
+
"button",
|
|
746
|
+
{
|
|
747
|
+
...incrementButtonProps,
|
|
748
|
+
type: "button",
|
|
749
|
+
"aria-label": (incrementButtonProps == null ? void 0 : incrementButtonProps["aria-label"]) ?? "Increase",
|
|
750
|
+
"aria-disabled": incrementDisabled,
|
|
751
|
+
disabled: incrementDisabled,
|
|
752
|
+
className: cx5(
|
|
753
|
+
rightButtonStyles,
|
|
754
|
+
incrementDisabled && variant !== "disabled" ? variants.disabled.button : null,
|
|
755
|
+
incrementButtonProps == null ? void 0 : incrementButtonProps.className
|
|
756
|
+
),
|
|
757
|
+
onClick: (event) => {
|
|
758
|
+
var _a;
|
|
759
|
+
(_a = incrementButtonProps == null ? void 0 : incrementButtonProps.onClick) == null ? void 0 : _a.call(incrementButtonProps, event);
|
|
760
|
+
if (event.defaultPrevented) return;
|
|
761
|
+
commitValue(clampedValue + step);
|
|
762
|
+
},
|
|
763
|
+
children: effectiveIncrementIcon
|
|
764
|
+
}
|
|
765
|
+
)
|
|
766
|
+
]
|
|
767
|
+
}
|
|
768
|
+
);
|
|
769
|
+
});
|
|
498
770
|
export {
|
|
499
771
|
Button,
|
|
772
|
+
ButtonCounter,
|
|
500
773
|
ButtonGroup,
|
|
501
774
|
ButtonGroupItem,
|
|
502
775
|
IconButton,
|
package/package.json
CHANGED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ButtonCounter } from "./../index";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
title: "Components/Button",
|
|
6
|
+
component: ButtonCounter,
|
|
7
|
+
argTypes: {
|
|
8
|
+
size: {
|
|
9
|
+
control: { type: "select" },
|
|
10
|
+
options: ["small", "medium", "large"],
|
|
11
|
+
},
|
|
12
|
+
isSecondary: { control: "boolean" },
|
|
13
|
+
isDanger: { control: "boolean" },
|
|
14
|
+
isBare: { control: "boolean" },
|
|
15
|
+
isStretched: { control: "boolean" },
|
|
16
|
+
disabled: { control: "boolean" },
|
|
17
|
+
isEditable: { control: "boolean" },
|
|
18
|
+
inputProps: { control: false },
|
|
19
|
+
defaultValue: { control: "number" },
|
|
20
|
+
min: { control: "number" },
|
|
21
|
+
max: { control: "number" },
|
|
22
|
+
step: { control: "number" },
|
|
23
|
+
value: { control: false },
|
|
24
|
+
onChange: { control: false },
|
|
25
|
+
decrementIcon: { control: false },
|
|
26
|
+
incrementIcon: { control: false },
|
|
27
|
+
decrementButtonProps: { control: false },
|
|
28
|
+
incrementButtonProps: { control: false },
|
|
29
|
+
children: { control: false },
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const Template = (args) => <ButtonCounter {...args} />;
|
|
34
|
+
|
|
35
|
+
export const PrimaryCounter = Template.bind({});
|
|
36
|
+
PrimaryCounter.args = {
|
|
37
|
+
defaultValue: 1,
|
|
38
|
+
size: "medium",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const EditableCounter = Template.bind({});
|
|
42
|
+
EditableCounter.args = {
|
|
43
|
+
defaultValue: 1,
|
|
44
|
+
isEditable: true,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const SecondaryCounter = Template.bind({});
|
|
48
|
+
SecondaryCounter.args = {
|
|
49
|
+
defaultValue: 1,
|
|
50
|
+
isSecondary: true,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const DangerCounter = Template.bind({});
|
|
54
|
+
DangerCounter.args = {
|
|
55
|
+
defaultValue: 1,
|
|
56
|
+
isDanger: true,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const BareCounter = Template.bind({});
|
|
60
|
+
BareCounter.args = {
|
|
61
|
+
defaultValue: 1,
|
|
62
|
+
isBare: true,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const DangerBareCounter = Template.bind({});
|
|
66
|
+
DangerBareCounter.args = {
|
|
67
|
+
defaultValue: 1,
|
|
68
|
+
isBare: true,
|
|
69
|
+
isDanger: true,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export const DisabledCounter = Template.bind({});
|
|
73
|
+
DisabledCounter.args = {
|
|
74
|
+
defaultValue: 1,
|
|
75
|
+
disabled: true,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const SmallCounter = Template.bind({});
|
|
79
|
+
SmallCounter.args = {
|
|
80
|
+
defaultValue: 1,
|
|
81
|
+
size: "small",
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const LargeCounter = Template.bind({});
|
|
85
|
+
LargeCounter.args = {
|
|
86
|
+
defaultValue: 1,
|
|
87
|
+
size: "large",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const StretchedCounter = Template.bind({});
|
|
91
|
+
StretchedCounter.args = {
|
|
92
|
+
defaultValue: 1,
|
|
93
|
+
isStretched: true,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const WithBoundsCounter = Template.bind({});
|
|
97
|
+
WithBoundsCounter.args = {
|
|
98
|
+
defaultValue: 2,
|
|
99
|
+
min: 0,
|
|
100
|
+
max: 3,
|
|
101
|
+
};
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { cx } from "@edux-design/utils";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* ButtonCounter (stepper) component.
|
|
13
|
+
*
|
|
14
|
+
* Renders decrement/increment buttons with a central value display.
|
|
15
|
+
* Supports the same size + variant flags as the main Button.
|
|
16
|
+
*
|
|
17
|
+
* @typedef {React.HTMLAttributes<HTMLDivElement> & {
|
|
18
|
+
* value?: number,
|
|
19
|
+
* defaultValue?: number,
|
|
20
|
+
* onChange?: (value: number) => void,
|
|
21
|
+
* min?: number,
|
|
22
|
+
* max?: number,
|
|
23
|
+
* step?: number,
|
|
24
|
+
* children?: React.ReactNode,
|
|
25
|
+
* size?: "small"|"medium"|"large",
|
|
26
|
+
* isSecondary?: boolean,
|
|
27
|
+
* isDanger?: boolean,
|
|
28
|
+
* isBare?: boolean,
|
|
29
|
+
* isStretched?: boolean,
|
|
30
|
+
* disabled?: boolean,
|
|
31
|
+
* isEditable?: boolean,
|
|
32
|
+
* inputProps?: Omit<React.InputHTMLAttributes<HTMLInputElement>, "value"|"defaultValue"|"onChange"|"type">,
|
|
33
|
+
* decrementIcon?: React.ReactNode,
|
|
34
|
+
* incrementIcon?: React.ReactNode,
|
|
35
|
+
* startIcon?: React.ReactNode,
|
|
36
|
+
* endIcon?: React.ReactNode,
|
|
37
|
+
* decrementButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
38
|
+
* incrementButtonProps?: React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
39
|
+
* className?: string,
|
|
40
|
+
* }} ButtonCounterProps
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @type {React.ForwardRefExoticComponent<ButtonCounterProps & React.RefAttributes<HTMLDivElement>>}
|
|
45
|
+
*/
|
|
46
|
+
export const ButtonCounter = forwardRef(function ButtonCounter(
|
|
47
|
+
{
|
|
48
|
+
value,
|
|
49
|
+
defaultValue = 1,
|
|
50
|
+
onChange,
|
|
51
|
+
min = Number.NEGATIVE_INFINITY,
|
|
52
|
+
max = Number.POSITIVE_INFINITY,
|
|
53
|
+
step = 1,
|
|
54
|
+
children,
|
|
55
|
+
size = "medium",
|
|
56
|
+
isSecondary,
|
|
57
|
+
isDanger,
|
|
58
|
+
isBare,
|
|
59
|
+
isStretched,
|
|
60
|
+
disabled,
|
|
61
|
+
isEditable = false,
|
|
62
|
+
inputProps,
|
|
63
|
+
decrementIcon,
|
|
64
|
+
incrementIcon,
|
|
65
|
+
startIcon,
|
|
66
|
+
endIcon,
|
|
67
|
+
decrementButtonProps,
|
|
68
|
+
incrementButtonProps,
|
|
69
|
+
className,
|
|
70
|
+
...rest
|
|
71
|
+
},
|
|
72
|
+
ref,
|
|
73
|
+
) {
|
|
74
|
+
const isControlled = value !== undefined;
|
|
75
|
+
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
76
|
+
const currentValue = isControlled ? value : internalValue;
|
|
77
|
+
|
|
78
|
+
const clampedValue = useMemo(() => {
|
|
79
|
+
const safe = Number.isFinite(currentValue) ? currentValue : defaultValue;
|
|
80
|
+
return Math.min(max, Math.max(min, safe));
|
|
81
|
+
}, [currentValue, defaultValue, max, min]);
|
|
82
|
+
|
|
83
|
+
const commitValue = useCallback(
|
|
84
|
+
(next) => {
|
|
85
|
+
const clamped = Math.min(max, Math.max(min, next));
|
|
86
|
+
if (!isControlled) setInternalValue(clamped);
|
|
87
|
+
onChange?.(clamped);
|
|
88
|
+
},
|
|
89
|
+
[isControlled, max, min, onChange],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
const decrementDisabled =
|
|
93
|
+
!!disabled || !Number.isFinite(clampedValue) || clampedValue - step < min;
|
|
94
|
+
const incrementDisabled =
|
|
95
|
+
!!disabled || !Number.isFinite(clampedValue) || clampedValue + step > max;
|
|
96
|
+
|
|
97
|
+
let variant = "primary";
|
|
98
|
+
if (disabled) {
|
|
99
|
+
variant = "disabled";
|
|
100
|
+
} else if (isDanger && isBare) {
|
|
101
|
+
variant = "dangerBare";
|
|
102
|
+
} else if (isDanger) {
|
|
103
|
+
variant = "danger";
|
|
104
|
+
} else if (isSecondary) {
|
|
105
|
+
variant = "secondary";
|
|
106
|
+
} else if (isBare) {
|
|
107
|
+
variant = "bare";
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (process.env.NODE_ENV !== "production") {
|
|
111
|
+
const count = [isSecondary, isDanger, isBare].filter(Boolean).length;
|
|
112
|
+
const hasAllowedCombo = isDanger && isBare && count === 2;
|
|
113
|
+
if (count > 1 && !hasAllowedCombo) {
|
|
114
|
+
console.warn(
|
|
115
|
+
"[ButtonCounter]: Multiple variant props passed. Using the highest priority only.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (step <= 0) {
|
|
120
|
+
console.warn("[ButtonCounter]: step should be > 0.");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const CONTROL_SIZES = {
|
|
125
|
+
small: {
|
|
126
|
+
button: "h-[36px] w-[36px] text-sm",
|
|
127
|
+
display: "h-[36px] min-w-[48px] px-12 text-sm",
|
|
128
|
+
},
|
|
129
|
+
medium: {
|
|
130
|
+
button: "h-[44px] w-[44px] text-base",
|
|
131
|
+
display: "h-[44px] min-w-[56px] px-12 text-base",
|
|
132
|
+
},
|
|
133
|
+
large: {
|
|
134
|
+
button: "h-[56px] w-[56px] text-lg",
|
|
135
|
+
display: "h-[56px] min-w-[72px] px-16 text-lg",
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const defaultsButton =
|
|
140
|
+
"flex justify-center items-center focus:shadow-focus focus-visible:shadow-focus outline-none transition-all duration-300 ease-in-out cursor-pointer";
|
|
141
|
+
|
|
142
|
+
const defaultsDisplay =
|
|
143
|
+
"flex items-center justify-center text-bold tracking-normal";
|
|
144
|
+
|
|
145
|
+
const width = isStretched ? "w-full" : "w-fit-content";
|
|
146
|
+
|
|
147
|
+
const variants = {
|
|
148
|
+
primary: {
|
|
149
|
+
button:
|
|
150
|
+
"border-2 border-border-primary-emphasis bg-bg-primary-base text-white hover:bg-border-focus hover:text-fg-base hover:border-border-emphasis hover:shadow-md active:bg-border-focus active:bg-bg-focus-overlay active:text-fg-base",
|
|
151
|
+
display:
|
|
152
|
+
"border-y-2 border-x-2 border-border-primary-emphasis bg-bg-primary-base text-white",
|
|
153
|
+
},
|
|
154
|
+
secondary: {
|
|
155
|
+
button:
|
|
156
|
+
"border-2 border-border-primary-base bg-bg-base text-fg-primary-emphasis hover:shadow-md hover:bg-bg-primary-subtle active:bg-bg-primary-subtle-90",
|
|
157
|
+
display:
|
|
158
|
+
"border-y-2 border-x-2 border-border-primary-base bg-bg-base text-fg-primary-emphasis",
|
|
159
|
+
},
|
|
160
|
+
bare: {
|
|
161
|
+
button:
|
|
162
|
+
"bg-bg-base text-fg-primary-emphasis hover:bg-bg-minimal active:bg-bg-minimal-5",
|
|
163
|
+
display: "bg-bg-base text-fg-primary-emphasis",
|
|
164
|
+
},
|
|
165
|
+
dangerBare: {
|
|
166
|
+
button:
|
|
167
|
+
"bg-bg-base text-fg-danger-emphasis hover:bg-bg-danger-subtle active:bg-bg-danger-subtle",
|
|
168
|
+
display: "bg-bg-base text-fg-danger-emphasis",
|
|
169
|
+
},
|
|
170
|
+
danger: {
|
|
171
|
+
button:
|
|
172
|
+
"bg-bg-danger-base border-2 border-border-danger-emphasis text-white hover:bg-bg-danger-base-5 active:bg-bg-danger-base-10",
|
|
173
|
+
display:
|
|
174
|
+
"border-y-2 border-x-2 border-border-danger-emphasis bg-bg-danger-base text-white",
|
|
175
|
+
},
|
|
176
|
+
disabled: {
|
|
177
|
+
button:
|
|
178
|
+
"border-2 border-border-subtle bg-bg-inactive text-fg-inactive hover:no-underline cursor-not-allowed",
|
|
179
|
+
display:
|
|
180
|
+
"border-y-2 border-x-2 border-border-subtle bg-bg-inactive text-fg-inactive",
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const sizeStyles = CONTROL_SIZES[size] ?? CONTROL_SIZES.medium;
|
|
185
|
+
const variantStyles = variants[variant] ?? variants.primary;
|
|
186
|
+
|
|
187
|
+
const leftButtonStyles = cx(
|
|
188
|
+
defaultsButton,
|
|
189
|
+
sizeStyles.button,
|
|
190
|
+
"rounded-l-lg",
|
|
191
|
+
"relative z-10",
|
|
192
|
+
variantStyles.button,
|
|
193
|
+
variant !== "bare" && variant !== "dangerBare" ? "border-r-0" : null,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const rightButtonStyles = cx(
|
|
197
|
+
defaultsButton,
|
|
198
|
+
sizeStyles.button,
|
|
199
|
+
"rounded-r-lg",
|
|
200
|
+
variantStyles.button,
|
|
201
|
+
variant !== "bare" && variant !== "dangerBare" ? "border-l-0" : null,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const displayStyles = cx(
|
|
205
|
+
defaultsDisplay,
|
|
206
|
+
sizeStyles.display,
|
|
207
|
+
"relative z-0",
|
|
208
|
+
variantStyles.display,
|
|
209
|
+
isStretched ? "flex-1" : null,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const effectiveDecrementIcon = decrementIcon ?? startIcon ?? "-";
|
|
213
|
+
const effectiveIncrementIcon = incrementIcon ?? endIcon ?? "+";
|
|
214
|
+
|
|
215
|
+
const [draftValue, setDraftValue] = useState(String(clampedValue));
|
|
216
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
217
|
+
const inputRef = useRef(null);
|
|
218
|
+
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
if (isEditing) return;
|
|
221
|
+
setDraftValue(String(clampedValue));
|
|
222
|
+
}, [clampedValue, isEditing]);
|
|
223
|
+
|
|
224
|
+
const commitDraft = useCallback(() => {
|
|
225
|
+
const trimmed = String(draftValue).trim();
|
|
226
|
+
if (trimmed === "") {
|
|
227
|
+
setDraftValue(String(clampedValue));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const next = Number(trimmed);
|
|
232
|
+
if (!Number.isFinite(next)) {
|
|
233
|
+
setDraftValue(String(clampedValue));
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
commitValue(next);
|
|
238
|
+
}, [clampedValue, commitValue, draftValue]);
|
|
239
|
+
|
|
240
|
+
return (
|
|
241
|
+
<div
|
|
242
|
+
ref={ref}
|
|
243
|
+
className={cx("inline-flex items-stretch", width, className)}
|
|
244
|
+
{...rest}
|
|
245
|
+
>
|
|
246
|
+
<button
|
|
247
|
+
{...decrementButtonProps}
|
|
248
|
+
type="button"
|
|
249
|
+
aria-label={decrementButtonProps?.["aria-label"] ?? "Decrease"}
|
|
250
|
+
aria-disabled={decrementDisabled}
|
|
251
|
+
disabled={decrementDisabled}
|
|
252
|
+
className={cx(
|
|
253
|
+
leftButtonStyles,
|
|
254
|
+
decrementDisabled && variant !== "disabled"
|
|
255
|
+
? variants.disabled.button
|
|
256
|
+
: null,
|
|
257
|
+
decrementButtonProps?.className,
|
|
258
|
+
)}
|
|
259
|
+
onClick={(event) => {
|
|
260
|
+
decrementButtonProps?.onClick?.(event);
|
|
261
|
+
if (event.defaultPrevented) return;
|
|
262
|
+
commitValue(clampedValue - step);
|
|
263
|
+
}}
|
|
264
|
+
>
|
|
265
|
+
{effectiveDecrementIcon}
|
|
266
|
+
</button>
|
|
267
|
+
|
|
268
|
+
{isEditable && children == null ? (
|
|
269
|
+
<input
|
|
270
|
+
{...inputProps}
|
|
271
|
+
ref={inputRef}
|
|
272
|
+
type="number"
|
|
273
|
+
inputMode="numeric"
|
|
274
|
+
disabled={!!disabled}
|
|
275
|
+
aria-label={inputProps?.["aria-label"] ?? "Value"}
|
|
276
|
+
className={cx(
|
|
277
|
+
displayStyles,
|
|
278
|
+
"w-[44px] text-center outline-none bg-transparent text-fg-base",
|
|
279
|
+
"[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:m-0 [&::-webkit-inner-spin-button]:m-0",
|
|
280
|
+
"focus:shadow-focus focus-visible:shadow-focus focus:z-15 focus-within:z-15",
|
|
281
|
+
inputProps?.className,
|
|
282
|
+
)}
|
|
283
|
+
value={draftValue}
|
|
284
|
+
onChange={(event) => {
|
|
285
|
+
inputProps?.onChange?.(event);
|
|
286
|
+
if (event.defaultPrevented) return;
|
|
287
|
+
setDraftValue(event.target.value);
|
|
288
|
+
}}
|
|
289
|
+
onFocus={(event) => {
|
|
290
|
+
inputProps?.onFocus?.(event);
|
|
291
|
+
if (event.defaultPrevented) return;
|
|
292
|
+
setIsEditing(true);
|
|
293
|
+
}}
|
|
294
|
+
onBlur={(event) => {
|
|
295
|
+
inputProps?.onBlur?.(event);
|
|
296
|
+
if (event.defaultPrevented) return;
|
|
297
|
+
setIsEditing(false);
|
|
298
|
+
commitDraft();
|
|
299
|
+
}}
|
|
300
|
+
onKeyDown={(event) => {
|
|
301
|
+
inputProps?.onKeyDown?.(event);
|
|
302
|
+
if (event.defaultPrevented) return;
|
|
303
|
+
if (event.key === "Enter") {
|
|
304
|
+
event.currentTarget.blur();
|
|
305
|
+
}
|
|
306
|
+
if (event.key === "Escape") {
|
|
307
|
+
setDraftValue(String(clampedValue));
|
|
308
|
+
event.currentTarget.blur();
|
|
309
|
+
}
|
|
310
|
+
}}
|
|
311
|
+
/>
|
|
312
|
+
) : (
|
|
313
|
+
<div className={cx(displayStyles, "select-none")}>
|
|
314
|
+
{children ?? clampedValue}
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
|
|
318
|
+
<button
|
|
319
|
+
{...incrementButtonProps}
|
|
320
|
+
type="button"
|
|
321
|
+
aria-label={incrementButtonProps?.["aria-label"] ?? "Increase"}
|
|
322
|
+
aria-disabled={incrementDisabled}
|
|
323
|
+
disabled={incrementDisabled}
|
|
324
|
+
className={cx(
|
|
325
|
+
rightButtonStyles,
|
|
326
|
+
incrementDisabled && variant !== "disabled"
|
|
327
|
+
? variants.disabled.button
|
|
328
|
+
: null,
|
|
329
|
+
incrementButtonProps?.className,
|
|
330
|
+
)}
|
|
331
|
+
onClick={(event) => {
|
|
332
|
+
incrementButtonProps?.onClick?.(event);
|
|
333
|
+
if (event.defaultPrevented) return;
|
|
334
|
+
commitValue(clampedValue + step);
|
|
335
|
+
}}
|
|
336
|
+
>
|
|
337
|
+
{effectiveIncrementIcon}
|
|
338
|
+
</button>
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { ButtonCounter } from "./ButtonCounter";
|
|
4
|
+
|
|
5
|
+
describe("ButtonCounter", () => {
|
|
6
|
+
it("renders defaultValue", () => {
|
|
7
|
+
render(<ButtonCounter defaultValue={5} />);
|
|
8
|
+
expect(screen.getByText("5")).toBeInTheDocument();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("increments and decrements", () => {
|
|
12
|
+
render(<ButtonCounter defaultValue={1} />);
|
|
13
|
+
|
|
14
|
+
fireEvent.click(screen.getByRole("button", { name: "Increase" }));
|
|
15
|
+
expect(screen.getByText("2")).toBeInTheDocument();
|
|
16
|
+
|
|
17
|
+
fireEvent.click(screen.getByRole("button", { name: "Decrease" }));
|
|
18
|
+
expect(screen.getByText("1")).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("renders an editable input when isEditable=true", () => {
|
|
22
|
+
const onChange = vi.fn();
|
|
23
|
+
render(<ButtonCounter defaultValue={3} isEditable onChange={onChange} />);
|
|
24
|
+
|
|
25
|
+
const input = screen.getByRole("spinbutton", { name: "Value" });
|
|
26
|
+
expect(input).toHaveValue(3);
|
|
27
|
+
|
|
28
|
+
fireEvent.change(input, { target: { value: "7" } });
|
|
29
|
+
fireEvent.blur(input);
|
|
30
|
+
|
|
31
|
+
expect(onChange).toHaveBeenCalledWith(7);
|
|
32
|
+
expect(screen.getByRole("spinbutton", { name: "Value" })).toHaveValue(7);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("applies medium size by default", () => {
|
|
36
|
+
render(<ButtonCounter defaultValue={1} />);
|
|
37
|
+
const buttons = screen.getAllByRole("button");
|
|
38
|
+
expect(buttons[0].className).toMatch(/h-\[44px\]/);
|
|
39
|
+
expect(buttons[1].className).toMatch(/h-\[44px\]/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("applies small and large sizes", () => {
|
|
43
|
+
const { rerender } = render(<ButtonCounter size="small" defaultValue={1} />);
|
|
44
|
+
expect(screen.getAllByRole("button")[0].className).toMatch(/h-\[36px\]/);
|
|
45
|
+
|
|
46
|
+
rerender(<ButtonCounter size="large" defaultValue={1} />);
|
|
47
|
+
expect(screen.getAllByRole("button")[0].className).toMatch(/h-\[56px\]/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("applies variants", () => {
|
|
51
|
+
const { rerender } = render(<ButtonCounter defaultValue={1} />);
|
|
52
|
+
expect(screen.getAllByRole("button")[0].className).toMatch(/bg-bg-primary-base/);
|
|
53
|
+
|
|
54
|
+
rerender(<ButtonCounter isSecondary defaultValue={1} />);
|
|
55
|
+
expect(screen.getAllByRole("button")[0].className).toMatch(/bg-bg-base/);
|
|
56
|
+
|
|
57
|
+
rerender(<ButtonCounter isDanger defaultValue={1} />);
|
|
58
|
+
expect(screen.getAllByRole("button")[0].className).toMatch(/bg-bg-danger-base/);
|
|
59
|
+
|
|
60
|
+
rerender(<ButtonCounter isDanger isBare defaultValue={1} />);
|
|
61
|
+
expect(screen.getAllByRole("button")[0].className).toMatch(/text-fg-danger-emphasis/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("applies disabled state", () => {
|
|
65
|
+
render(<ButtonCounter disabled defaultValue={1} />);
|
|
66
|
+
const buttons = screen.getAllByRole("button");
|
|
67
|
+
expect(buttons[0]).toBeDisabled();
|
|
68
|
+
expect(buttons[1]).toBeDisabled();
|
|
69
|
+
expect(buttons[0].className).toMatch(/bg-bg-inactive/);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("uses stretched width when isStretched=true", () => {
|
|
73
|
+
const { container } = render(<ButtonCounter isStretched defaultValue={1} />);
|
|
74
|
+
expect(container.firstChild.className).toMatch(/w-full/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("logs a warning in dev mode if multiple variants are passed", () => {
|
|
78
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
79
|
+
process.env.NODE_ENV = "development";
|
|
80
|
+
|
|
81
|
+
render(<ButtonCounter isSecondary isDanger defaultValue={1} />);
|
|
82
|
+
|
|
83
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
84
|
+
"[ButtonCounter]: Multiple variant props passed. Using the highest priority only."
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
warnSpy.mockRestore();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does not warn for the danger bare combination", () => {
|
|
91
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
92
|
+
process.env.NODE_ENV = "development";
|
|
93
|
+
|
|
94
|
+
render(<ButtonCounter isDanger isBare defaultValue={1} />);
|
|
95
|
+
|
|
96
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
97
|
+
|
|
98
|
+
warnSpy.mockRestore();
|
|
99
|
+
});
|
|
100
|
+
});
|
package/src/index.js
CHANGED
|
@@ -3,3 +3,4 @@ export { Button } from "./elements/Button";
|
|
|
3
3
|
export { MenuButton } from "./elements/MenuButton";
|
|
4
4
|
export { ButtonGroup } from "./elements/ButtonGroup";
|
|
5
5
|
export { ButtonGroupItem } from "./elements/ButtonGroupItem";
|
|
6
|
+
export { ButtonCounter } from "./elements/ButtonCounter";
|