@flikk/ui 1.0.0-beta.7 → 1.0.0-beta.8
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/components/core/Alert/Alert.js +1 -0
- package/dist/components/core/Alert/Alert.theme.js +3 -7
- package/dist/components/core/Alert/Alert.types.d.ts +2 -1
- package/dist/components/core/Badge/Badge.theme.js +9 -4
- package/dist/components/core/Badge/Badge.types.d.ts +2 -1
- package/dist/components/core/Button/Button.theme.js +32 -26
- package/dist/components/core/Button/Button.types.d.ts +2 -1
- package/dist/components/core/Popover/PopoverContent.js +2 -0
- package/dist/components/core/Progress/Progress.theme.js +8 -5
- package/dist/components/core/Progress/Progress.types.d.ts +2 -1
- package/dist/components/core/Spinner/Spinner.theme.js +3 -5
- package/dist/components/core/Spinner/Spinner.types.d.ts +2 -1
- package/dist/components/data-display/KPI/KPI.theme.js +3 -4
- package/dist/components/data-display/KPI/KPI.types.d.ts +2 -1
- package/dist/components/data-display/Metric/Metric.theme.js +4 -0
- package/dist/components/data-display/Metric/Metric.types.d.ts +2 -1
- package/dist/components/effects/CustomCursor/CustomCursor.theme.js +2 -0
- package/dist/components/effects/CustomCursor/CustomCursor.types.d.ts +2 -1
- package/dist/components/forms/Checkbox/Checkbox.d.ts +3 -4
- package/dist/components/forms/Checkbox/Checkbox.js +33 -9
- package/dist/components/forms/Checkbox/Checkbox.theme.js +6 -0
- package/dist/components/forms/Checkbox/Checkbox.types.d.ts +28 -2
- package/dist/components/forms/Checkbox/CheckboxContext.d.ts +14 -0
- package/dist/components/forms/Checkbox/CheckboxContext.js +12 -0
- package/dist/components/forms/Checkbox/CheckboxGroup.d.ts +3 -0
- package/dist/components/forms/Checkbox/CheckboxGroup.js +40 -0
- package/dist/components/forms/Checkbox/index.d.ts +1 -1
- package/dist/components/forms/Radio/Radio.d.ts +3 -4
- package/dist/components/forms/Radio/Radio.js +21 -4
- package/dist/components/forms/Radio/Radio.theme.js +6 -0
- package/dist/components/forms/Radio/Radio.types.d.ts +30 -4
- package/dist/components/forms/Radio/RadioContext.d.ts +14 -0
- package/dist/components/forms/Radio/RadioContext.js +12 -0
- package/dist/components/forms/Radio/RadioGroup.d.ts +3 -0
- package/dist/components/forms/Radio/RadioGroup.js +36 -0
- package/dist/components/forms/Radio/index.d.ts +1 -1
- package/dist/components/forms/index.d.ts +2 -2
- package/dist/hooks/useSelectPortal.d.ts +3 -2
- package/dist/hooks/useSelectPortal.js +44 -19
- package/dist/styles.css +1 -1
- package/dist/utils/colorUtils.d.ts +23 -0
- package/dist/utils/colorUtils.js +45 -0
- package/dist/utils/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/styles/theme.css +19 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
2
|
+
import React__default, { useId, useState, useMemo } from 'react';
|
|
3
|
+
import { cn } from '../../../utils/cn.js';
|
|
4
|
+
import { checkboxTheme } from './Checkbox.theme.js';
|
|
5
|
+
import { formLabelTheme } from '../FormLabel/FormLabel.theme.js';
|
|
6
|
+
import { CheckboxGroupContext } from './CheckboxContext.js';
|
|
7
|
+
|
|
8
|
+
const CheckboxGroup = React__default.forwardRef(({ value: controlledValue, defaultValue, onChange, name, size = "md", state = "default", orientation = "vertical", label, description, error, required, className, children, ...props }, ref) => {
|
|
9
|
+
const autoId = useId();
|
|
10
|
+
const groupName = name !== null && name !== void 0 ? name : `checkbox-group-${autoId}`;
|
|
11
|
+
const labelId = `${autoId}-label`;
|
|
12
|
+
const descriptionId = `${autoId}-desc`;
|
|
13
|
+
const errorId = `${autoId}-error`;
|
|
14
|
+
// Controlled vs uncontrolled
|
|
15
|
+
const isControlled = controlledValue !== undefined;
|
|
16
|
+
const [internalValue, setInternalValue] = useState(defaultValue !== null && defaultValue !== void 0 ? defaultValue : []);
|
|
17
|
+
const currentValue = isControlled ? controlledValue : internalValue;
|
|
18
|
+
const handleChange = (itemValue, checked) => {
|
|
19
|
+
const next = checked
|
|
20
|
+
? [...currentValue, itemValue]
|
|
21
|
+
: currentValue.filter((v) => v !== itemValue);
|
|
22
|
+
if (!isControlled) {
|
|
23
|
+
setInternalValue(next);
|
|
24
|
+
}
|
|
25
|
+
onChange === null || onChange === void 0 ? void 0 : onChange(next);
|
|
26
|
+
};
|
|
27
|
+
const contextValue = useMemo(() => ({
|
|
28
|
+
name: groupName,
|
|
29
|
+
value: currentValue,
|
|
30
|
+
onChange: handleChange,
|
|
31
|
+
size,
|
|
32
|
+
state,
|
|
33
|
+
}), [groupName, currentValue, size, state]);
|
|
34
|
+
const labelState = error ? "invalid" : state === "disabled" ? "disabled" : "default";
|
|
35
|
+
return (jsx(CheckboxGroupContext.Provider, { value: contextValue, children: jsxs("fieldset", { ref: ref, role: "group", "aria-labelledby": label ? labelId : undefined, "aria-describedby": description ? descriptionId : undefined, "aria-required": required || undefined, "aria-invalid": error ? true : undefined, "aria-errormessage": error ? errorId : undefined, className: cn("border-0 p-0 m-0", checkboxTheme.groupStyle, className), ...props, children: [label && (jsxs("legend", { id: labelId, className: cn(formLabelTheme.baseStyle, formLabelTheme.stateStyles[labelState]), children: [label, required && (jsx("span", { className: formLabelTheme.requiredIndicatorStyle, children: "*" }))] })), description && (jsx("p", { id: descriptionId, className: checkboxTheme.groupDescriptionStyle, children: description })), jsx("div", { className: cn(checkboxTheme.groupItemsStyle, orientation === "horizontal" &&
|
|
36
|
+
checkboxTheme.groupHorizontalStyle), children: children }), error && (jsx("p", { id: errorId, role: "alert", className: checkboxTheme.groupErrorStyle, children: error }))] }) }));
|
|
37
|
+
});
|
|
38
|
+
CheckboxGroup.displayName = "Checkbox.Group";
|
|
39
|
+
|
|
40
|
+
export { CheckboxGroup };
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { Checkbox } from './Checkbox';
|
|
2
|
-
export type { CheckboxProps, CheckboxSize, CheckboxState } from './Checkbox.types';
|
|
2
|
+
export type { CheckboxProps, CheckboxSize, CheckboxState, CheckboxGroupProps, CheckboxGroupOrientation } from './Checkbox.types';
|
|
3
3
|
export { checkboxTheme } from './Checkbox.theme';
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { RadioProps } from "./Radio.types";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
export declare const Radio: React.ForwardRefExoticComponent<RadioProps & React.RefAttributes<HTMLInputElement>>;
|
|
3
|
+
export declare const Radio: React.ForwardRefExoticComponent<RadioProps & React.RefAttributes<HTMLInputElement>> & {
|
|
4
|
+
Group: React.ForwardRefExoticComponent<import("./Radio.types").RadioGroupProps & React.RefAttributes<HTMLFieldSetElement>>;
|
|
5
|
+
};
|
|
@@ -1,18 +1,34 @@
|
|
|
1
1
|
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
2
|
-
import React__default from 'react';
|
|
2
|
+
import React__default, { useId } from 'react';
|
|
3
3
|
import { radioTheme } from './Radio.theme.js';
|
|
4
4
|
import { formLabelTheme } from '../FormLabel/FormLabel.theme.js';
|
|
5
5
|
import { cn } from '../../../utils/cn.js';
|
|
6
|
+
import { useRadioGroupContext } from './RadioContext.js';
|
|
7
|
+
import { RadioGroup } from './RadioGroup.js';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Radio component for selecting one option from a group
|
|
9
11
|
*/
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
+
const RadioComponent = React__default.forwardRef(({ id: idProp, name: nameProp, value, checked: checkedProp, defaultChecked, onChange, label, description, state: stateProp = "default", size: sizeProp = "md", className, ...restProps }, ref) => {
|
|
13
|
+
var _a, _b, _c, _d;
|
|
14
|
+
const autoId = useId();
|
|
15
|
+
const groupContext = useRadioGroupContext();
|
|
16
|
+
// Resolve props from group context or individual props
|
|
17
|
+
const id = idProp !== null && idProp !== void 0 ? idProp : autoId;
|
|
18
|
+
const name = (_b = (_a = groupContext === null || groupContext === void 0 ? void 0 : groupContext.name) !== null && _a !== void 0 ? _a : nameProp) !== null && _b !== void 0 ? _b : autoId;
|
|
19
|
+
const size = (_c = groupContext === null || groupContext === void 0 ? void 0 : groupContext.size) !== null && _c !== void 0 ? _c : sizeProp;
|
|
20
|
+
const state = stateProp === "disabled" ? "disabled" : ((_d = groupContext === null || groupContext === void 0 ? void 0 : groupContext.state) !== null && _d !== void 0 ? _d : stateProp);
|
|
12
21
|
const isDisabled = state === "disabled";
|
|
22
|
+
// Determine checked state
|
|
23
|
+
const isInGroup = groupContext !== undefined;
|
|
24
|
+
const checked = isInGroup ? groupContext.value === value : checkedProp;
|
|
25
|
+
const isControlled = checked !== undefined;
|
|
13
26
|
const handleChange = (e) => {
|
|
14
27
|
if (isDisabled)
|
|
15
28
|
return;
|
|
29
|
+
if (isInGroup) {
|
|
30
|
+
groupContext.onChange(value);
|
|
31
|
+
}
|
|
16
32
|
onChange === null || onChange === void 0 ? void 0 : onChange(e.target.checked, value);
|
|
17
33
|
};
|
|
18
34
|
const inputProps = isControlled
|
|
@@ -20,6 +36,7 @@ const Radio = React__default.forwardRef(({ id, name, value, checked, defaultChec
|
|
|
20
36
|
: { defaultChecked };
|
|
21
37
|
return (jsxs("div", { className: cn(radioTheme.baseStyle, className), ...restProps, children: [jsxs("div", { className: cn(radioTheme.inputContainerStyle, radioTheme.sizes[size]), children: [jsx("input", { ref: ref, type: "radio", id: id, name: name, value: value, ...inputProps, onChange: handleChange, disabled: isDisabled, "aria-describedby": description ? `${id}-description` : undefined, "data-state": isDisabled ? "disabled" : "default", className: cn("peer", radioTheme.inputStyle, radioTheme.sizes[size]) }), jsx("div", { className: cn(radioTheme.dotStyle, size === "sm" ? "size-2.5 top-0.25" : "size-3") })] }), (label || description) && (jsxs("label", { htmlFor: id, className: cn("cursor-pointer", isDisabled && "cursor-not-allowed"), children: [label && (jsx("span", { className: cn(formLabelTheme.baseStyle, formLabelTheme.stateStyles[state], "mb-0"), children: label })), description && (jsx("div", { id: `${id}-description`, className: radioTheme.descriptionStyle, children: description }))] }))] }));
|
|
22
38
|
});
|
|
23
|
-
|
|
39
|
+
RadioComponent.displayName = "Radio";
|
|
40
|
+
const Radio = Object.assign(RadioComponent, { Group: RadioGroup });
|
|
24
41
|
|
|
25
42
|
export { Radio };
|
|
@@ -34,6 +34,12 @@ const radioTheme = {
|
|
|
34
34
|
sm: "size-5 mt-0.25",
|
|
35
35
|
md: "size-6",
|
|
36
36
|
},
|
|
37
|
+
// Group styles
|
|
38
|
+
groupStyle: "flex flex-col gap-1.5",
|
|
39
|
+
groupHorizontalStyle: "flex-row flex-wrap gap-x-6 gap-y-3",
|
|
40
|
+
groupDescriptionStyle: "text-sm text-[var(--color-text-secondary)]/80 -mt-0.5 mb-1",
|
|
41
|
+
groupErrorStyle: "text-sm text-[var(--color-danger)] mt-1.5",
|
|
42
|
+
groupItemsStyle: "flex flex-col gap-3",
|
|
37
43
|
};
|
|
38
44
|
|
|
39
45
|
export { radioTheme };
|
|
@@ -7,21 +7,42 @@ export type RadioState = 'default' | 'disabled';
|
|
|
7
7
|
* Size variants for the radio component
|
|
8
8
|
*/
|
|
9
9
|
export type RadioSize = 'sm' | 'md';
|
|
10
|
+
/**
|
|
11
|
+
* Orientation for Radio.Group layout
|
|
12
|
+
*/
|
|
13
|
+
export type RadioGroupOrientation = 'vertical' | 'horizontal';
|
|
10
14
|
/**
|
|
11
15
|
* Radio component props
|
|
12
16
|
*/
|
|
13
17
|
export interface RadioProps extends Omit<React.HTMLAttributes<HTMLInputElement>, 'onChange' | 'size'> {
|
|
14
|
-
id
|
|
15
|
-
name
|
|
16
|
-
value: string;
|
|
18
|
+
id?: string;
|
|
19
|
+
name?: string;
|
|
20
|
+
value: string | number;
|
|
17
21
|
checked?: boolean;
|
|
18
|
-
onChange?: (checked: boolean, value: string) => void;
|
|
22
|
+
onChange?: (checked: boolean, value: string | number) => void;
|
|
19
23
|
label?: string;
|
|
20
24
|
description?: string;
|
|
21
25
|
state?: RadioState;
|
|
22
26
|
size?: RadioSize;
|
|
23
27
|
className?: string;
|
|
24
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Radio.Group component props
|
|
31
|
+
*/
|
|
32
|
+
export interface RadioGroupProps extends Omit<React.HTMLAttributes<HTMLFieldSetElement>, 'onChange'> {
|
|
33
|
+
value?: string | number;
|
|
34
|
+
defaultValue?: string | number;
|
|
35
|
+
onChange?: (value: string | number) => void;
|
|
36
|
+
name?: string;
|
|
37
|
+
size?: RadioSize;
|
|
38
|
+
state?: RadioState;
|
|
39
|
+
orientation?: RadioGroupOrientation;
|
|
40
|
+
label?: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
error?: string;
|
|
43
|
+
required?: boolean;
|
|
44
|
+
className?: string;
|
|
45
|
+
}
|
|
25
46
|
/**
|
|
26
47
|
* Radio theme configuration
|
|
27
48
|
*/
|
|
@@ -32,4 +53,9 @@ export interface RadioTheme {
|
|
|
32
53
|
dotStyle: string;
|
|
33
54
|
descriptionStyle: string;
|
|
34
55
|
sizes: Record<RadioSize, string>;
|
|
56
|
+
groupStyle?: string;
|
|
57
|
+
groupHorizontalStyle?: string;
|
|
58
|
+
groupDescriptionStyle?: string;
|
|
59
|
+
groupErrorStyle?: string;
|
|
60
|
+
groupItemsStyle?: string;
|
|
35
61
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RadioSize, RadioState } from "./Radio.types";
|
|
2
|
+
export interface RadioGroupContextValue {
|
|
3
|
+
name: string;
|
|
4
|
+
value: string | number | undefined;
|
|
5
|
+
onChange: (value: string | number) => void;
|
|
6
|
+
size: RadioSize;
|
|
7
|
+
state: RadioState;
|
|
8
|
+
}
|
|
9
|
+
export declare const RadioGroupContext: import("react").Context<RadioGroupContextValue | undefined>;
|
|
10
|
+
/**
|
|
11
|
+
* Returns the group context, or undefined when used outside a Radio.Group.
|
|
12
|
+
* This allows standalone Radio usage without a Group wrapper.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useRadioGroupContext(): RadioGroupContextValue | undefined;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext, useContext } from 'react';
|
|
2
|
+
|
|
3
|
+
const RadioGroupContext = createContext(undefined);
|
|
4
|
+
/**
|
|
5
|
+
* Returns the group context, or undefined when used outside a Radio.Group.
|
|
6
|
+
* This allows standalone Radio usage without a Group wrapper.
|
|
7
|
+
*/
|
|
8
|
+
function useRadioGroupContext() {
|
|
9
|
+
return useContext(RadioGroupContext);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export { RadioGroupContext, useRadioGroupContext };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
2
|
+
import React__default, { useId, useState, useMemo } from 'react';
|
|
3
|
+
import { cn } from '../../../utils/cn.js';
|
|
4
|
+
import { radioTheme } from './Radio.theme.js';
|
|
5
|
+
import { formLabelTheme } from '../FormLabel/FormLabel.theme.js';
|
|
6
|
+
import { RadioGroupContext } from './RadioContext.js';
|
|
7
|
+
|
|
8
|
+
const RadioGroup = React__default.forwardRef(({ value: controlledValue, defaultValue, onChange, name, size = "md", state = "default", orientation = "vertical", label, description, error, required, className, children, ...props }, ref) => {
|
|
9
|
+
const autoId = useId();
|
|
10
|
+
const groupName = name !== null && name !== void 0 ? name : `radio-group-${autoId}`;
|
|
11
|
+
const labelId = `${autoId}-label`;
|
|
12
|
+
const descriptionId = `${autoId}-desc`;
|
|
13
|
+
const errorId = `${autoId}-error`;
|
|
14
|
+
// Controlled vs uncontrolled
|
|
15
|
+
const isControlled = controlledValue !== undefined;
|
|
16
|
+
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
17
|
+
const currentValue = isControlled ? controlledValue : internalValue;
|
|
18
|
+
const handleChange = (itemValue) => {
|
|
19
|
+
if (!isControlled) {
|
|
20
|
+
setInternalValue(itemValue);
|
|
21
|
+
}
|
|
22
|
+
onChange === null || onChange === void 0 ? void 0 : onChange(itemValue);
|
|
23
|
+
};
|
|
24
|
+
const contextValue = useMemo(() => ({
|
|
25
|
+
name: groupName,
|
|
26
|
+
value: currentValue,
|
|
27
|
+
onChange: handleChange,
|
|
28
|
+
size,
|
|
29
|
+
state,
|
|
30
|
+
}), [groupName, currentValue, size, state]);
|
|
31
|
+
const labelState = error ? "invalid" : state === "disabled" ? "disabled" : "default";
|
|
32
|
+
return (jsx(RadioGroupContext.Provider, { value: contextValue, children: jsxs("fieldset", { ref: ref, role: "radiogroup", "aria-labelledby": label ? labelId : undefined, "aria-describedby": description ? descriptionId : undefined, "aria-required": required || undefined, "aria-invalid": error ? true : undefined, "aria-errormessage": error ? errorId : undefined, className: cn("border-0 p-0 m-0", radioTheme.groupStyle, className), ...props, children: [label && (jsxs("legend", { id: labelId, className: cn(formLabelTheme.baseStyle, formLabelTheme.stateStyles[labelState]), children: [label, required && (jsx("span", { className: formLabelTheme.requiredIndicatorStyle, children: "*" }))] })), description && (jsx("p", { id: descriptionId, className: radioTheme.groupDescriptionStyle, children: description })), jsx("div", { className: cn(radioTheme.groupItemsStyle, orientation === "horizontal" && radioTheme.groupHorizontalStyle), children: children }), error && (jsx("p", { id: errorId, role: "alert", className: radioTheme.groupErrorStyle, children: error }))] }) }));
|
|
33
|
+
});
|
|
34
|
+
RadioGroup.displayName = "Radio.Group";
|
|
35
|
+
|
|
36
|
+
export { RadioGroup };
|
|
@@ -11,10 +11,10 @@ export { FormLabel } from "./FormLabel";
|
|
|
11
11
|
export type { FormLabelProps, FormLabelState, FormLabelTheme, } from "./FormLabel";
|
|
12
12
|
export { formLabelTheme } from "./FormLabel";
|
|
13
13
|
export { Radio } from "./Radio";
|
|
14
|
-
export type { RadioProps, RadioSize, RadioState } from "./Radio";
|
|
14
|
+
export type { RadioProps, RadioSize, RadioState, RadioGroupProps, RadioGroupOrientation, } from "./Radio";
|
|
15
15
|
export { radioTheme } from "./Radio";
|
|
16
16
|
export { Checkbox } from "./Checkbox";
|
|
17
|
-
export type { CheckboxProps, CheckboxSize, CheckboxState } from "./Checkbox";
|
|
17
|
+
export type { CheckboxProps, CheckboxSize, CheckboxState, CheckboxGroupProps, CheckboxGroupOrientation, } from "./Checkbox";
|
|
18
18
|
export { checkboxTheme } from "./Checkbox";
|
|
19
19
|
export { Switch } from "./Switch";
|
|
20
20
|
export type { SwitchProps, SwitchThemeOverrides } from "./Switch";
|
|
@@ -6,6 +6,7 @@ export interface UseSimpleDropdownOptions {
|
|
|
6
6
|
offset?: number;
|
|
7
7
|
estimatedWidth?: number;
|
|
8
8
|
autoWidth?: boolean;
|
|
9
|
+
onClose?: () => void;
|
|
9
10
|
}
|
|
10
11
|
export interface SimpleDropdownReturn {
|
|
11
12
|
position: {
|
|
@@ -24,5 +25,5 @@ export interface SimpleDropdownReturn {
|
|
|
24
25
|
* Uses position: fixed with pure viewport coordinates to properly follow scroll
|
|
25
26
|
* Fixes critical issues: dropdown not following scroll & multiple dropdowns not working
|
|
26
27
|
*/
|
|
27
|
-
export declare const useSimpleDropdown: ({ triggerRef, isOpen, placement, offset, estimatedWidth, autoWidth, }: UseSimpleDropdownOptions) => SimpleDropdownReturn;
|
|
28
|
-
export declare const useSelectPortal: ({ triggerRef, isOpen, placement, offset, estimatedWidth, autoWidth, }: UseSimpleDropdownOptions) => SimpleDropdownReturn;
|
|
28
|
+
export declare const useSimpleDropdown: ({ triggerRef, isOpen, placement, offset, estimatedWidth, autoWidth, onClose, }: UseSimpleDropdownOptions) => SimpleDropdownReturn;
|
|
29
|
+
export declare const useSelectPortal: ({ triggerRef, isOpen, placement, offset, estimatedWidth, autoWidth, onClose, }: UseSimpleDropdownOptions) => SimpleDropdownReturn;
|
|
@@ -1,19 +1,11 @@
|
|
|
1
|
-
import { useRef, useState, useCallback, useLayoutEffect
|
|
1
|
+
import { useRef, useState, useCallback, useLayoutEffect } from 'react';
|
|
2
2
|
|
|
3
|
-
// Simple debounce utility
|
|
4
|
-
const debounce = (func, wait) => {
|
|
5
|
-
let timeout;
|
|
6
|
-
return () => {
|
|
7
|
-
clearTimeout(timeout);
|
|
8
|
-
timeout = setTimeout(func, wait);
|
|
9
|
-
};
|
|
10
|
-
};
|
|
11
3
|
/**
|
|
12
4
|
* Fixed dropdown positioning hook
|
|
13
5
|
* Uses position: fixed with pure viewport coordinates to properly follow scroll
|
|
14
6
|
* Fixes critical issues: dropdown not following scroll & multiple dropdowns not working
|
|
15
7
|
*/
|
|
16
|
-
const useSimpleDropdown = ({ triggerRef, isOpen, placement = "bottom-start", offset = 4, estimatedWidth, autoWidth = false, }) => {
|
|
8
|
+
const useSimpleDropdown = ({ triggerRef, isOpen, placement = "bottom-start", offset = 4, estimatedWidth, autoWidth = false, onClose, }) => {
|
|
17
9
|
const contentRef = useRef(null);
|
|
18
10
|
const [position, setPosition] = useState({ top: -9999, left: -9999, width: 0 });
|
|
19
11
|
const [cssVariables, setCssVariables] = useState({});
|
|
@@ -280,20 +272,53 @@ const useSimpleDropdown = ({ triggerRef, isOpen, placement = "bottom-start", off
|
|
|
280
272
|
return;
|
|
281
273
|
calculatePosition();
|
|
282
274
|
}, [isOpen, calculatePosition]);
|
|
283
|
-
//
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
275
|
+
// rAF-synced scroll handler: skips internal scrolls, detects off-screen trigger
|
|
276
|
+
const scrollRafRef = useRef(null);
|
|
277
|
+
const handleScroll = useCallback((e) => {
|
|
278
|
+
// Skip if scroll originated inside the popover content
|
|
279
|
+
if (contentRef.current && contentRef.current.contains(e.target)) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// Close if trigger has scrolled off-screen
|
|
283
|
+
if (triggerRef.current) {
|
|
284
|
+
const rect = triggerRef.current.getBoundingClientRect();
|
|
285
|
+
const isOffScreen = rect.bottom < 0 || rect.top > window.innerHeight
|
|
286
|
+
|| rect.right < 0 || rect.left > window.innerWidth;
|
|
287
|
+
if (isOffScreen && onClose) {
|
|
288
|
+
onClose();
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (scrollRafRef.current)
|
|
293
|
+
cancelAnimationFrame(scrollRafRef.current);
|
|
294
|
+
scrollRafRef.current = requestAnimationFrame(() => {
|
|
295
|
+
scrollRafRef.current = null;
|
|
296
|
+
calculatePosition();
|
|
297
|
+
});
|
|
298
|
+
}, [calculatePosition, onClose, triggerRef]);
|
|
299
|
+
const handleResize = useCallback(() => {
|
|
300
|
+
if (scrollRafRef.current)
|
|
301
|
+
cancelAnimationFrame(scrollRafRef.current);
|
|
302
|
+
scrollRafRef.current = requestAnimationFrame(() => {
|
|
303
|
+
scrollRafRef.current = null;
|
|
304
|
+
calculatePosition();
|
|
305
|
+
});
|
|
306
|
+
}, [calculatePosition]);
|
|
307
|
+
// Update position on scroll and resize - rAF-synced for smooth tracking
|
|
287
308
|
useLayoutEffect(() => {
|
|
288
309
|
if (!isOpen)
|
|
289
310
|
return;
|
|
290
|
-
window.addEventListener("scroll",
|
|
291
|
-
window.addEventListener("resize",
|
|
311
|
+
window.addEventListener("scroll", handleScroll, { passive: true, capture: true });
|
|
312
|
+
window.addEventListener("resize", handleResize, { passive: true });
|
|
292
313
|
return () => {
|
|
293
|
-
window.removeEventListener("scroll",
|
|
294
|
-
window.removeEventListener("resize",
|
|
314
|
+
window.removeEventListener("scroll", handleScroll, true);
|
|
315
|
+
window.removeEventListener("resize", handleResize);
|
|
316
|
+
if (scrollRafRef.current) {
|
|
317
|
+
cancelAnimationFrame(scrollRafRef.current);
|
|
318
|
+
scrollRafRef.current = null;
|
|
319
|
+
}
|
|
295
320
|
};
|
|
296
|
-
}, [isOpen,
|
|
321
|
+
}, [isOpen, handleScroll, handleResize]);
|
|
297
322
|
// Recalculate when dropdown content resizes (e.g. search filtering)
|
|
298
323
|
useLayoutEffect(() => {
|
|
299
324
|
if (!isOpen)
|