@allxsmith/bestax-bulma 5.28.0 → 5.30.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/bestax.css +1 -1
- package/dist/bestax.css.map +1 -1
- package/dist/esm/components/ContextMenu.js +244 -0
- package/dist/esm/components/ContextMenu.js.map +1 -0
- package/dist/esm/components/Dialog.js +8 -4
- package/dist/esm/components/Dialog.js.map +1 -1
- package/dist/esm/components/HoverCard.js +3 -10
- package/dist/esm/components/HoverCard.js.map +1 -1
- package/dist/esm/components/Modal.js +19 -11
- package/dist/esm/components/Modal.js.map +1 -1
- package/dist/esm/components/Popover.js.map +1 -1
- package/dist/esm/components/Sidebar.js +12 -3
- package/dist/esm/components/Sidebar.js.map +1 -1
- package/dist/esm/components/Toast.js +5 -3
- package/dist/esm/components/Toast.js.map +1 -1
- package/dist/esm/components/Tooltip.js +36 -61
- package/dist/esm/components/Tooltip.js.map +1 -1
- package/dist/esm/elements/Button.js.map +1 -1
- package/dist/esm/elements/Notification.js +3 -1
- package/dist/esm/elements/Notification.js.map +1 -1
- package/dist/esm/elements/ToggleButton.js +60 -0
- package/dist/esm/elements/ToggleButton.js.map +1 -0
- package/dist/esm/form/DateInputBase.js.map +1 -1
- package/dist/esm/form/DateRangeInputBase.js.map +1 -1
- package/dist/esm/form/DateTimeInputBase.js.map +1 -1
- package/dist/esm/form/TimeInputBase.js.map +1 -1
- package/dist/esm/form/_pickerInternals/PickerPopover.js +3 -1
- package/dist/esm/form/_pickerInternals/PickerPopover.js.map +1 -1
- package/dist/esm/form/_pickerInternals/pickerTypes.js +14 -6
- package/dist/esm/form/_pickerInternals/pickerTypes.js.map +1 -1
- package/dist/esm/helpers/Config.js +5 -2
- package/dist/esm/helpers/Config.js.map +1 -1
- package/dist/esm/helpers/portal.js +30 -5
- package/dist/esm/helpers/portal.js.map +1 -1
- package/dist/esm/helpers/useContextMenuTrigger.js +166 -0
- package/dist/esm/helpers/useContextMenuTrigger.js.map +1 -0
- package/dist/esm/helpers/useHoverOpen.js +40 -6
- package/dist/esm/helpers/useHoverOpen.js.map +1 -1
- package/dist/esm/helpers/useMenuKeys.js +182 -0
- package/dist/esm/helpers/useMenuKeys.js.map +1 -0
- package/dist/extras.css +1 -1
- package/dist/extras.css.map +1 -1
- package/dist/index.cjs +2150 -1474
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +2 -0
- package/dist/index.esm.js.map +1 -1
- package/dist/types/components/ContextMenu.d.ts +66 -0
- package/dist/types/components/HoverCard.d.ts +14 -12
- package/dist/types/components/Modal.d.ts +2 -1
- package/dist/types/components/Popover.d.ts +8 -7
- package/dist/types/components/Tooltip.d.ts +45 -4
- package/dist/types/elements/Button.d.ts +1 -1
- package/dist/types/elements/ToggleButton.d.ts +90 -0
- package/dist/types/form/DateInputBase.d.ts +4 -1
- package/dist/types/form/DateRangeInputBase.d.ts +4 -1
- package/dist/types/form/DateTimeInputBase.d.ts +8 -2
- package/dist/types/form/TimeInputBase.d.ts +7 -3
- package/dist/types/form/_pickerInternals/pickerTypes.d.ts +16 -1
- package/dist/types/helpers/Config.d.ts +34 -3
- package/dist/types/helpers/portal.d.ts +27 -5
- package/dist/types/helpers/useContextMenuTrigger.d.ts +67 -0
- package/dist/types/helpers/useHoverOpen.d.ts +19 -3
- package/dist/types/helpers/useMenuKeys.d.ts +57 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types-cjs/components/ContextMenu.d.ts +66 -0
- package/dist/types-cjs/components/HoverCard.d.ts +14 -12
- package/dist/types-cjs/components/Modal.d.ts +2 -1
- package/dist/types-cjs/components/Popover.d.ts +8 -7
- package/dist/types-cjs/components/Tooltip.d.ts +45 -4
- package/dist/types-cjs/elements/Button.d.ts +1 -1
- package/dist/types-cjs/elements/ToggleButton.d.ts +90 -0
- package/dist/types-cjs/form/DateInputBase.d.ts +4 -1
- package/dist/types-cjs/form/DateRangeInputBase.d.ts +4 -1
- package/dist/types-cjs/form/DateTimeInputBase.d.ts +8 -2
- package/dist/types-cjs/form/TimeInputBase.d.ts +7 -3
- package/dist/types-cjs/form/_pickerInternals/pickerTypes.d.ts +16 -1
- package/dist/types-cjs/helpers/Config.d.ts +34 -3
- package/dist/types-cjs/helpers/portal.d.ts +27 -5
- package/dist/types-cjs/helpers/useContextMenuTrigger.d.ts +67 -0
- package/dist/types-cjs/helpers/useHoverOpen.d.ts +19 -3
- package/dist/types-cjs/helpers/useMenuKeys.d.ts +57 -0
- package/dist/types-cjs/index.d.ts +2 -0
- package/dist/versions/bestax-no-dark-mode.css +1 -1
- package/dist/versions/bestax-no-dark-mode.css.map +1 -1
- package/dist/versions/bestax-no-helpers-prefixed.css +1 -1
- package/dist/versions/bestax-no-helpers-prefixed.css.map +1 -1
- package/dist/versions/bestax-no-helpers.css +1 -1
- package/dist/versions/bestax-no-helpers.css.map +1 -1
- package/dist/versions/bestax-prefixed.css +1 -1
- package/dist/versions/bestax-prefixed.css.map +1 -1
- package/package.json +1 -1
- package/src/scss/components/_contextmenu.scss +50 -0
- package/src/scss/components/_index.scss +1 -0
- package/src/scss/components/_tooltip.scss +76 -1
package/dist/index.cjs
CHANGED
|
@@ -16,6 +16,9 @@ const useConfig = () => React.useContext(ConfigContext);
|
|
|
16
16
|
/**
|
|
17
17
|
* ConfigProvider injects configuration into all child components via React context.
|
|
18
18
|
*
|
|
19
|
+
* A nested provider replaces the outer one's settings rather than adding to
|
|
20
|
+
* them, so inside it any setting it leaves out is back to its default.
|
|
21
|
+
*
|
|
19
22
|
* @function
|
|
20
23
|
* @param {ConfigProviderProps} props - Provider props.
|
|
21
24
|
* @returns {JSX.Element} The context provider wrapping children.
|
|
@@ -25,8 +28,8 @@ const useConfig = () => React.useContext(ConfigContext);
|
|
|
25
28
|
* <App />
|
|
26
29
|
* </ConfigProvider>
|
|
27
30
|
*/
|
|
28
|
-
const ConfigProvider = ({ classPrefix, iconLibrary, children, }) => {
|
|
29
|
-
return (jsxRuntime.jsx(ConfigContext.Provider, { value: { classPrefix, iconLibrary }, children: children }));
|
|
31
|
+
const ConfigProvider = ({ classPrefix, iconLibrary, portalContainer, children, }) => {
|
|
32
|
+
return (jsxRuntime.jsx(ConfigContext.Provider, { value: { classPrefix, iconLibrary, portalContainer }, children: children }));
|
|
30
33
|
};
|
|
31
34
|
/**
|
|
32
35
|
* Utility hook to get the configured class prefix string.
|
|
@@ -2934,7 +2937,26 @@ function resolvePortalContainer(container) {
|
|
|
2934
2937
|
return container;
|
|
2935
2938
|
}
|
|
2936
2939
|
/**
|
|
2937
|
-
*
|
|
2940
|
+
* Where a component's portal goes, as a function to call when the portal is
|
|
2941
|
+
* created: the target the component was given, else the `portalContainer` a
|
|
2942
|
+
* `ConfigProvider` sets, else `document.body`, each read as
|
|
2943
|
+
* `resolvePortalContainer` reads it. A component that portals resolves its
|
|
2944
|
+
* target through this, so the provider's default reaches it.
|
|
2945
|
+
*
|
|
2946
|
+
* The hook only reads context, so it is safe to call on the server, ahead of
|
|
2947
|
+
* any early return. The function it returns reads the DOM, so call it only
|
|
2948
|
+
* where the portal is created, which the server never reaches.
|
|
2949
|
+
*
|
|
2950
|
+
* @function usePortalContainer
|
|
2951
|
+
* @returns A function that takes the component's own target, if any, and
|
|
2952
|
+
* returns the DOM node to portal into.
|
|
2953
|
+
*/
|
|
2954
|
+
function usePortalContainer() {
|
|
2955
|
+
const { portalContainer } = useConfig();
|
|
2956
|
+
return container => resolvePortalContainer(container || portalContainer);
|
|
2957
|
+
}
|
|
2958
|
+
/**
|
|
2959
|
+
* Renders its children into another part of the page, `document.body` unless `container` or a `ConfigProvider` says otherwise, so floating content escapes an ancestor's `overflow`, `transform` or stacking context.
|
|
2938
2960
|
*
|
|
2939
2961
|
* React's server renderer can't render a portal, so `Portal` renders nothing
|
|
2940
2962
|
* on the server and during hydration, and mounts its children in the commit
|
|
@@ -2945,8 +2967,12 @@ function resolvePortalContainer(container) {
|
|
|
2945
2967
|
* portaled children, and their React events bubble through the React tree.
|
|
2946
2968
|
* CSS inheritance follows the DOM instead: a scoped (non-`isRoot`) `Theme`
|
|
2947
2969
|
* sets its variables on a wrapper element the portaled content has left, so
|
|
2948
|
-
* that content sees the page-level theme
|
|
2949
|
-
*
|
|
2970
|
+
* that content sees the page-level theme, and styles scoped to a shadow root
|
|
2971
|
+
* don't reach `document.body` at all. To keep them, portal into an element
|
|
2972
|
+
* inside the themed area or the shadow root: pass it as `container`, or as
|
|
2973
|
+
* the `portalContainer` of a `ConfigProvider` around that area. The second
|
|
2974
|
+
* also reaches what the library's components portal, such as Popover's
|
|
2975
|
+
* `appendToBody` panel or a `Modal` with `portal`.
|
|
2950
2976
|
*
|
|
2951
2977
|
* Tab order and focus follow the DOM too. Content portaled from inside a
|
|
2952
2978
|
* focus trap is no longer inside the trapped element, so the trap neither
|
|
@@ -2972,11 +2998,12 @@ function resolvePortalContainer(container) {
|
|
|
2972
2998
|
*/
|
|
2973
2999
|
function Portal({ children, container, disabled = false }) {
|
|
2974
3000
|
const hydrated = useIsHydrated();
|
|
3001
|
+
const resolveContainer = usePortalContainer();
|
|
2975
3002
|
if (disabled)
|
|
2976
3003
|
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
|
|
2977
3004
|
if (!hydrated)
|
|
2978
3005
|
return null;
|
|
2979
|
-
return reactDom.createPortal(children,
|
|
3006
|
+
return reactDom.createPortal(children, resolveContainer(container));
|
|
2980
3007
|
}
|
|
2981
3008
|
|
|
2982
3009
|
/**
|
|
@@ -3131,7 +3158,7 @@ const ModalClose = ({ className, size = 'large', variant = 'delete', ...props })
|
|
|
3131
3158
|
* @see {@link https://bulma.io/documentation/components/modal/ | Bulma Modal documentation}
|
|
3132
3159
|
*/
|
|
3133
3160
|
const ModalRoot = React.forwardRef(function ModalRoot({ active, isActive, onClose, className, textColor, bgColor, modalCardTitle, modalCardFoot, type, children, closeOnEscape = true, lockScroll = true, portal = false, role, 'aria-modal': ariaModalProp, ...props }, ref) {
|
|
3134
|
-
const { classPrefix } = useConfig();
|
|
3161
|
+
const { classPrefix, portalContainer } = useConfig();
|
|
3135
3162
|
const { bulmaHelperClasses, rest } = useBulmaClasses({
|
|
3136
3163
|
color: textColor,
|
|
3137
3164
|
backgroundColor: bgColor,
|
|
@@ -3181,10 +3208,17 @@ const ModalRoot = React.forwardRef(function ModalRoot({ active, isActive, onClos
|
|
|
3181
3208
|
// hydrating render have to stay inline and only move into the portal once
|
|
3182
3209
|
// we're past hydration.
|
|
3183
3210
|
const onClient = useIsHydrated();
|
|
3184
|
-
|
|
3185
|
-
//
|
|
3186
|
-
//
|
|
3211
|
+
const resolveContainer = usePortalContainer();
|
|
3212
|
+
// Moving into the portal, or from one portal target to another, remounts
|
|
3213
|
+
// the modal's subtree, so any effect holding a DOM node from before the move
|
|
3214
|
+
// is holding a detached one. Effects that touch the modal's nodes key on
|
|
3215
|
+
// this, on `portal` and on `defaultTarget`, so they re-run against the new
|
|
3216
|
+
// tree.
|
|
3187
3217
|
const isPortaled = Boolean(portal) && onClient;
|
|
3218
|
+
// The configured `portalContainer` only places a modal portaled with
|
|
3219
|
+
// `portal` set to `true`. In place, or with a target of its own, a change
|
|
3220
|
+
// to it moves nothing, so it mustn't re-run the effects either.
|
|
3221
|
+
const defaultTarget = isPortaled && portal === true ? portalContainer : undefined;
|
|
3188
3222
|
useScrollLock(isModalActive && lockScroll);
|
|
3189
3223
|
// Record open order so keyboard handling only applies to the topmost modal.
|
|
3190
3224
|
React.useEffect(() => {
|
|
@@ -3264,11 +3298,12 @@ const ModalRoot = React.forwardRef(function ModalRoot({ active, isActive, onClos
|
|
|
3264
3298
|
previouslyFocusedRef.current?.focus?.();
|
|
3265
3299
|
};
|
|
3266
3300
|
// `isPortaled` flips once when a hydrated portal modal moves out of the
|
|
3267
|
-
// inline tree
|
|
3268
|
-
//
|
|
3269
|
-
//
|
|
3270
|
-
//
|
|
3271
|
-
|
|
3301
|
+
// inline tree, and `portal` or `defaultTarget` changing moves a portaled
|
|
3302
|
+
// one to another target; re-running rebinds focus onto the remounted
|
|
3303
|
+
// nodes. The cleanup above restores focus to the pre-open element first
|
|
3304
|
+
// (the detached subtree has left focus on <body>), so the re-run re-records
|
|
3305
|
+
// the same element and restore-on-close still lands in the right place.
|
|
3306
|
+
}, [isModalActive, isPortaled, portal, defaultTarget]);
|
|
3272
3307
|
// Check if children contain compound components
|
|
3273
3308
|
const hasCompoundComponents = React.Children.toArray(children).some(child => React.isValidElement(child) &&
|
|
3274
3309
|
(child.type === ModalBackground ||
|
|
@@ -3305,7 +3340,7 @@ const ModalRoot = React.forwardRef(function ModalRoot({ active, isActive, onClos
|
|
|
3305
3340
|
modalElement = (jsxRuntime.jsxs("div", { className: modalClasses, ref: combinedRef, role: resolvedRole, "aria-modal": resolvedAriaModal, "aria-labelledby": titleId, tabIndex: -1, ...rest, "data-testid": "modal", children: [jsxRuntime.jsx("div", { className: prefixedClassNames(classPrefix, 'modal-background'), onClick: onClose, "data-testid": "modal-background" }), isModalCard ? (jsxRuntime.jsxs("div", { className: prefixedClassNames(classPrefix, 'modal-card'), children: [modalCardTitle && (jsxRuntime.jsxs("header", { className: prefixedClassNames(classPrefix, 'modal-card-head'), children: [jsxRuntime.jsx("p", { id: titleId, className: prefixedClassNames(classPrefix, 'modal-card-title'), children: modalCardTitle }), onClose && (jsxRuntime.jsx("button", { className: deleteClass, "aria-label": "close", onClick: onClose, type: "button", "data-testid": "modal-close" }))] })), jsxRuntime.jsx("section", { className: prefixedClassNames(classPrefix, 'modal-card-body'), "data-testid": "modal-body", children: children }), modalCardFoot && (jsxRuntime.jsx("footer", { className: prefixedClassNames(classPrefix, 'modal-card-foot'), children: modalCardFoot }))] })) : (jsxRuntime.jsx("div", { className: prefixedClassNames(classPrefix, 'modal-content'), "data-testid": "modal-content", children: children })), (!isModalCard || (!modalCardTitle && onClose)) && onClose && (jsxRuntime.jsx("button", { className: prefixedClassNames(classPrefix, 'modal-close', 'is-large'), "aria-label": "close", onClick: onClose, type: "button", "data-testid": "modal-close-float" }))] }));
|
|
3306
3341
|
}
|
|
3307
3342
|
if (isPortaled) {
|
|
3308
|
-
const target =
|
|
3343
|
+
const target = resolveContainer(typeof portal === 'boolean' ? undefined : portal);
|
|
3309
3344
|
return reactDom.createPortal(modalElement, target);
|
|
3310
3345
|
}
|
|
3311
3346
|
return modalElement;
|
|
@@ -4860,1551 +4895,1552 @@ const CollapsesComponent = ({ multiple = false, value, defaultValue, onChange, s
|
|
|
4860
4895
|
};
|
|
4861
4896
|
const Collapses = withSubComponents(CollapsesComponent, { Collapse }, 'Collapses');
|
|
4862
4897
|
|
|
4898
|
+
// INTERNAL: deliberately not exported from src/index.ts.
|
|
4863
4899
|
/**
|
|
4864
|
-
*
|
|
4865
|
-
*
|
|
4866
|
-
*
|
|
4867
|
-
* @param {TooltipProps} props - Props for the Tooltip component.
|
|
4868
|
-
* @returns {JSX.Element} The rendered tooltip wrapper.
|
|
4869
|
-
*
|
|
4870
|
-
* @example
|
|
4871
|
-
* // Basic tooltip
|
|
4872
|
-
* <Tooltip label="This is helpful info">
|
|
4873
|
-
* <button>Hover me</button>
|
|
4874
|
-
* </Tooltip>
|
|
4875
|
-
*
|
|
4876
|
-
* @example
|
|
4877
|
-
* // Positioned tooltip with color
|
|
4878
|
-
* <Tooltip label="Delete item" position="right" color="danger">
|
|
4879
|
-
* <Icon icon="fas fa-trash" />
|
|
4880
|
-
* </Tooltip>
|
|
4881
|
-
*
|
|
4882
|
-
* @example
|
|
4883
|
-
* // Multiline tooltip
|
|
4884
|
-
* <Tooltip label="This is a longer tooltip that wraps to multiple lines" multiline>
|
|
4885
|
-
* <span>?</span>
|
|
4886
|
-
* </Tooltip>
|
|
4900
|
+
* `useLayoutEffect` in the browser, `useEffect` on the server. A server
|
|
4901
|
+
* render runs neither, and React 18 warns about a layout effect there, so the
|
|
4902
|
+
* server is handed the plain one.
|
|
4887
4903
|
*/
|
|
4888
|
-
const
|
|
4889
|
-
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
4890
|
-
const [isVisible, setIsVisible] = React.useState(false);
|
|
4891
|
-
const [isHovering, setIsHovering] = React.useState(false);
|
|
4892
|
-
const [resolvedPosition, setResolvedPosition] = React.useState(position === 'auto' ? 'top' : position);
|
|
4893
|
-
const [contentStyle, setContentStyle] = React.useState({});
|
|
4894
|
-
const openTimeoutRef = React.useRef(null);
|
|
4895
|
-
const closeTimeoutRef = React.useRef(null);
|
|
4896
|
-
const wrapperRef = React.useRef(null);
|
|
4897
|
-
const contentRef = React.useRef(null);
|
|
4898
|
-
// Clear timeouts on unmount
|
|
4899
|
-
React.useEffect(() => {
|
|
4900
|
-
return () => {
|
|
4901
|
-
if (openTimeoutRef.current) {
|
|
4902
|
-
clearTimeout(openTimeoutRef.current);
|
|
4903
|
-
}
|
|
4904
|
-
if (closeTimeoutRef.current) {
|
|
4905
|
-
clearTimeout(closeTimeoutRef.current);
|
|
4906
|
-
}
|
|
4907
|
-
};
|
|
4908
|
-
}, []);
|
|
4909
|
-
// Handle delayed visibility
|
|
4910
|
-
React.useEffect(() => {
|
|
4911
|
-
if (isHovering) {
|
|
4912
|
-
if (closeTimeoutRef.current) {
|
|
4913
|
-
clearTimeout(closeTimeoutRef.current);
|
|
4914
|
-
closeTimeoutRef.current = null;
|
|
4915
|
-
}
|
|
4916
|
-
if (delay > 0) {
|
|
4917
|
-
openTimeoutRef.current = setTimeout(() => {
|
|
4918
|
-
setIsVisible(true);
|
|
4919
|
-
}, delay);
|
|
4920
|
-
}
|
|
4921
|
-
else {
|
|
4922
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect -- show immediately on hover when no delay
|
|
4923
|
-
setIsVisible(true);
|
|
4924
|
-
}
|
|
4925
|
-
}
|
|
4926
|
-
else {
|
|
4927
|
-
if (openTimeoutRef.current) {
|
|
4928
|
-
clearTimeout(openTimeoutRef.current);
|
|
4929
|
-
openTimeoutRef.current = null;
|
|
4930
|
-
}
|
|
4931
|
-
if (closeDelay > 0) {
|
|
4932
|
-
closeTimeoutRef.current = setTimeout(() => {
|
|
4933
|
-
setIsVisible(false);
|
|
4934
|
-
}, closeDelay);
|
|
4935
|
-
}
|
|
4936
|
-
else {
|
|
4937
|
-
setIsVisible(false);
|
|
4938
|
-
}
|
|
4939
|
-
}
|
|
4940
|
-
}, [isHovering, delay, closeDelay]);
|
|
4941
|
-
// Auto-placement and overflow correction
|
|
4942
|
-
React.useLayoutEffect(() => {
|
|
4943
|
-
const isAuto = position === 'auto';
|
|
4944
|
-
if (!isAuto) {
|
|
4945
|
-
// eslint-disable-next-line react-hooks/set-state-in-effect -- layout measurement effect resolves placement
|
|
4946
|
-
setResolvedPosition(position);
|
|
4947
|
-
}
|
|
4948
|
-
const showing = isVisible || alwaysActive;
|
|
4949
|
-
if (!showing) {
|
|
4950
|
-
setContentStyle({});
|
|
4951
|
-
return;
|
|
4952
|
-
}
|
|
4953
|
-
const wrapper = wrapperRef.current;
|
|
4954
|
-
const content = contentRef.current;
|
|
4955
|
-
if (!wrapper || !content)
|
|
4956
|
-
return;
|
|
4957
|
-
const wrapperRect = wrapper.getBoundingClientRect();
|
|
4958
|
-
const margin = 40;
|
|
4959
|
-
const edgePadding = 8; // minimum distance from viewport edge
|
|
4960
|
-
// Step 1: determine position (auto mode only)
|
|
4961
|
-
if (isAuto) {
|
|
4962
|
-
const spaceTop = wrapperRect.top;
|
|
4963
|
-
const spaceBottom = window.innerHeight - wrapperRect.bottom;
|
|
4964
|
-
if (spaceTop >= margin) {
|
|
4965
|
-
setResolvedPosition('top');
|
|
4966
|
-
}
|
|
4967
|
-
else if (spaceBottom >= margin) {
|
|
4968
|
-
setResolvedPosition('bottom');
|
|
4969
|
-
}
|
|
4970
|
-
else if (window.innerWidth - wrapperRect.right >= wrapperRect.left) {
|
|
4971
|
-
setResolvedPosition('right');
|
|
4972
|
-
}
|
|
4973
|
-
else {
|
|
4974
|
-
setResolvedPosition('left');
|
|
4975
|
-
}
|
|
4976
|
-
}
|
|
4977
|
-
// Step 2: measure tooltip content and correct overflow.
|
|
4978
|
-
// Use requestAnimationFrame so the resolved position class is applied first.
|
|
4979
|
-
requestAnimationFrame(() => {
|
|
4980
|
-
const contentEl = contentRef.current;
|
|
4981
|
-
if (!contentEl)
|
|
4982
|
-
return;
|
|
4983
|
-
const contentRect = contentEl.getBoundingClientRect();
|
|
4984
|
-
const style = {};
|
|
4985
|
-
// For top/bottom positions, tooltip is centered horizontally via CSS
|
|
4986
|
-
// (left: 50%; transform: translateX(-50%)).
|
|
4987
|
-
// Check if it overflows left or right and apply a corrective shift.
|
|
4988
|
-
const pos = isAuto ? undefined : position; // read from state won't be updated yet in auto
|
|
4989
|
-
const effectivePos = pos || 'top'; // fallback, actual is set by state
|
|
4990
|
-
const isVertical = effectivePos === 'top' ||
|
|
4991
|
-
effectivePos === 'bottom' ||
|
|
4992
|
-
resolvedPosition === 'top' ||
|
|
4993
|
-
resolvedPosition === 'bottom';
|
|
4994
|
-
if (isVertical) {
|
|
4995
|
-
if (contentRect.left < edgePadding) {
|
|
4996
|
-
// Overflows left — shift right
|
|
4997
|
-
const shift = edgePadding - contentRect.left;
|
|
4998
|
-
style.left = `calc(50% + ${shift}px)`;
|
|
4999
|
-
style['--tooltip-arrow-offset'] =
|
|
5000
|
-
`${-shift}px`;
|
|
5001
|
-
}
|
|
5002
|
-
else if (contentRect.right > window.innerWidth - edgePadding) {
|
|
5003
|
-
// Overflows right — shift left
|
|
5004
|
-
const shift = contentRect.right - (window.innerWidth - edgePadding);
|
|
5005
|
-
style.left = `calc(50% - ${shift}px)`;
|
|
5006
|
-
style['--tooltip-arrow-offset'] =
|
|
5007
|
-
`${shift}px`;
|
|
5008
|
-
}
|
|
5009
|
-
}
|
|
5010
|
-
else {
|
|
5011
|
-
// For left/right positions, tooltip is centered vertically via CSS
|
|
5012
|
-
// (top: 50%; transform: translateY(-50%)).
|
|
5013
|
-
if (contentRect.top < edgePadding) {
|
|
5014
|
-
const shift = edgePadding - contentRect.top;
|
|
5015
|
-
style.top = `calc(50% + ${shift}px)`;
|
|
5016
|
-
style['--tooltip-arrow-offset'] =
|
|
5017
|
-
`${-shift}px`;
|
|
5018
|
-
}
|
|
5019
|
-
else if (contentRect.bottom > window.innerHeight - edgePadding) {
|
|
5020
|
-
const shift = contentRect.bottom - (window.innerHeight - edgePadding);
|
|
5021
|
-
style.top = `calc(50% - ${shift}px)`;
|
|
5022
|
-
style['--tooltip-arrow-offset'] =
|
|
5023
|
-
`${shift}px`;
|
|
5024
|
-
}
|
|
5025
|
-
}
|
|
5026
|
-
setContentStyle(style);
|
|
5027
|
-
});
|
|
5028
|
-
}, [position, resolvedPosition, isVisible, alwaysActive, label, content]);
|
|
5029
|
-
const handleMouseEnter = React.useCallback(() => {
|
|
5030
|
-
setIsHovering(true);
|
|
5031
|
-
}, []);
|
|
5032
|
-
const handleMouseLeave = React.useCallback(() => {
|
|
5033
|
-
setIsHovering(false);
|
|
5034
|
-
}, []);
|
|
5035
|
-
const handleFocus = React.useCallback(() => {
|
|
5036
|
-
setIsHovering(true);
|
|
5037
|
-
}, []);
|
|
5038
|
-
const handleBlur = React.useCallback(() => {
|
|
5039
|
-
setIsHovering(false);
|
|
5040
|
-
}, []);
|
|
5041
|
-
// Generate Bulma classes
|
|
5042
|
-
const tooltipClasses = usePrefixedClassNames('tooltip', {
|
|
5043
|
-
'is-active': alwaysActive || isVisible,
|
|
5044
|
-
[`is-${resolvedPosition}`]: resolvedPosition,
|
|
5045
|
-
[`is-${color}`]: color,
|
|
5046
|
-
[`is-${size}`]: size,
|
|
5047
|
-
'is-multiline': multiline,
|
|
5048
|
-
'is-animated': animated,
|
|
5049
|
-
'is-square': square,
|
|
5050
|
-
'is-dashed': dashed,
|
|
5051
|
-
});
|
|
5052
|
-
const combinedClasses = classNames(tooltipClasses, bulmaHelperClasses, className);
|
|
5053
|
-
const combinedTooltipClasses = usePrefixedClassNames('tooltip-content', tooltipClassName);
|
|
5054
|
-
return (jsxRuntime.jsxs("span", { ref: wrapperRef, className: combinedClasses, onMouseEnter: handleMouseEnter, onMouseLeave: handleMouseLeave, onFocus: handleFocus, onBlur: handleBlur, ...(label ? { 'data-tooltip': label } : {}), ...rest, children: [children, jsxRuntime.jsx("span", { ref: contentRef, className: combinedTooltipClasses, role: "tooltip", "aria-hidden": !isVisible && !alwaysActive, style: Object.keys(contentStyle).length > 0 ? contentStyle : undefined, children: content || label })] }));
|
|
5055
|
-
};
|
|
4904
|
+
const useClientLayoutEffect = typeof document === 'undefined' ? React.useEffect : React.useLayoutEffect;
|
|
5056
4905
|
|
|
4906
|
+
// INTERNAL: deliberately not exported from src/index.ts.
|
|
5057
4907
|
/**
|
|
5058
|
-
*
|
|
5059
|
-
*
|
|
5060
|
-
* turns editing off, and a missing or unknown value inherits the parent's.
|
|
4908
|
+
* Runs the caller's own handler on the element that holds the trigger, then
|
|
4909
|
+
* the hook's, so neither replaces the other.
|
|
5061
4910
|
*/
|
|
5062
|
-
function
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
return null;
|
|
5067
|
-
if (value === '' || value === 'true' || value === 'plaintext-only') {
|
|
5068
|
-
return true;
|
|
5069
|
-
}
|
|
5070
|
-
return value === 'false' ? false : null;
|
|
4911
|
+
function chain$1(own, ours) {
|
|
4912
|
+
return event => {
|
|
4913
|
+
own?.(event);
|
|
4914
|
+
ours(event);
|
|
5071
4915
|
};
|
|
5072
|
-
if (editable(el) !== true)
|
|
5073
|
-
return false;
|
|
5074
|
-
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
5075
|
-
const state = editable(node);
|
|
5076
|
-
if (state !== null)
|
|
5077
|
-
return !state;
|
|
5078
|
-
}
|
|
5079
|
-
return true;
|
|
5080
4916
|
}
|
|
5081
|
-
/** Whether `el` is the summary of its `<details>`: the first one, not a stray. */
|
|
5082
|
-
const isDetailsSummary = (el) => el.parentElement?.localName === 'details' &&
|
|
5083
|
-
Array.from(el.parentElement.children).find(child => child.localName === 'summary') === el;
|
|
5084
4917
|
/**
|
|
5085
|
-
* Whether
|
|
5086
|
-
*
|
|
5087
|
-
*
|
|
5088
|
-
*
|
|
5089
|
-
*
|
|
5090
|
-
* Internal like `firstTabStop`: `src/index.ts` names what it takes from here.
|
|
4918
|
+
* Whether focus that just arrived on `el` is keyboard focus, as the browser
|
|
4919
|
+
* decides it for `:focus-visible`: focus from a key or from the browser's
|
|
4920
|
+
* own controls is, focus a press brings isn't, and focus a script moves
|
|
4921
|
+
* follows whichever came last. A browser that can't match the selector
|
|
4922
|
+
* throws, and then every focus counts, so keyboard users can still open it.
|
|
5091
4923
|
*/
|
|
5092
|
-
function
|
|
5093
|
-
|
|
5094
|
-
|
|
5095
|
-
return el.tabIndex >= 0;
|
|
4924
|
+
function isKeyboardFocus(el) {
|
|
4925
|
+
try {
|
|
4926
|
+
return el.matches(':focus-visible');
|
|
5096
4927
|
}
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
case 'area':
|
|
5100
|
-
return el.hasAttribute('href');
|
|
5101
|
-
case 'input':
|
|
5102
|
-
return el.type !== 'hidden';
|
|
5103
|
-
case 'button':
|
|
5104
|
-
case 'select':
|
|
5105
|
-
case 'textarea':
|
|
5106
|
-
case 'iframe':
|
|
5107
|
-
case 'embed':
|
|
5108
|
-
case 'object':
|
|
5109
|
-
return true;
|
|
5110
|
-
case 'audio':
|
|
5111
|
-
case 'video':
|
|
5112
|
-
return el.hasAttribute('controls');
|
|
5113
|
-
case 'summary':
|
|
5114
|
-
return isDetailsSummary(el);
|
|
5115
|
-
default:
|
|
5116
|
-
return isEditingHost(el);
|
|
4928
|
+
catch {
|
|
4929
|
+
return true;
|
|
5117
4930
|
}
|
|
5118
4931
|
}
|
|
4932
|
+
/** Whether `node` is `container` or inside it, when there are both. */
|
|
4933
|
+
const within = (container, node) => Boolean(container && node && container.contains(node));
|
|
5119
4934
|
/**
|
|
5120
|
-
*
|
|
5121
|
-
*
|
|
5122
|
-
*
|
|
5123
|
-
* root hides its slots (`assignedSlot` reads `null` under one), so a child of
|
|
5124
|
-
* its host goes straight to the host, whether a slot takes it or not.
|
|
4935
|
+
* Per document, the hooks that want Escape and the one listener they share
|
|
4936
|
+
* while there are any, so a card inside another card doesn't add a second
|
|
4937
|
+
* listener that closes both on one keypress.
|
|
5125
4938
|
*/
|
|
5126
|
-
const
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
null;
|
|
4939
|
+
const escapeListeners = new Map();
|
|
4940
|
+
/** Counts openings across the page, so the most recent one is known. */
|
|
4941
|
+
let openings = 0;
|
|
5130
4942
|
/**
|
|
5131
|
-
*
|
|
5132
|
-
*
|
|
5133
|
-
*
|
|
5134
|
-
*
|
|
5135
|
-
*
|
|
4943
|
+
* Adds `entry` to the hooks that want Escape on `doc`, starting the shared
|
|
4944
|
+
* listener when it is the first.
|
|
4945
|
+
*
|
|
4946
|
+
* Escape takes back every open that is on its way, so nothing appears right
|
|
4947
|
+
* after it, and closes one open floating element, chosen by these rules in
|
|
4948
|
+
* turn:
|
|
4949
|
+
*
|
|
4950
|
+
* 1. One that focus is in, on its trigger or inside it, when there is one.
|
|
4951
|
+
* 2. The innermost: one whose floating element holds no other's trigger.
|
|
4952
|
+
* That follows nesting whether either one is portaled, since an inner
|
|
4953
|
+
* trigger sits in the outer floating element either way.
|
|
4954
|
+
* 3. The one that opened last.
|
|
4955
|
+
*
|
|
4956
|
+
* The keypress belongs to whatever has focus. When that is one of the open
|
|
4957
|
+
* floating elements or their triggers, or nothing, the listener stops it in
|
|
4958
|
+
* the capture phase, before anything around (a Popover, a Modal) sees it,
|
|
4959
|
+
* unless the one it closes was set not to consume Escape.
|
|
4960
|
+
* When focus is anywhere else, such as a combobox or a dialog of its own,
|
|
4961
|
+
* the floating element still closes, since hover put it there without
|
|
4962
|
+
* focus, and the key goes on to that owner.
|
|
4963
|
+
*
|
|
4964
|
+
* @returns What to call when the hook stops wanting Escape.
|
|
5136
4965
|
*/
|
|
5137
|
-
function
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
const
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
4966
|
+
function wantEscape(doc, entry) {
|
|
4967
|
+
let listener = escapeListeners.get(doc);
|
|
4968
|
+
if (!listener) {
|
|
4969
|
+
const entries = new Set();
|
|
4970
|
+
const handleKeyDown = (event) => {
|
|
4971
|
+
if (event.key !== 'Escape')
|
|
4972
|
+
return;
|
|
4973
|
+
const open = [];
|
|
4974
|
+
// A copy, since taking an open back can take its hook out of the set.
|
|
4975
|
+
for (const each of [...entries]) {
|
|
4976
|
+
if (each.openedAt)
|
|
4977
|
+
open.push(each);
|
|
4978
|
+
else
|
|
4979
|
+
each.cancel();
|
|
4980
|
+
}
|
|
4981
|
+
const focused = getDeepestActiveElement(doc);
|
|
4982
|
+
const holding = open.filter(each => within(each.trigger(), focused) || within(each.floating(), focused));
|
|
4983
|
+
const pool = holding.length > 0 ? holding : open;
|
|
4984
|
+
let top;
|
|
4985
|
+
for (const each of pool) {
|
|
4986
|
+
const holdsAnother = pool.some(other => other !== each && within(each.floating(), other.trigger()));
|
|
4987
|
+
if (!holdsAnother && (!top || each.openedAt > top.openedAt)) {
|
|
4988
|
+
top = each;
|
|
4989
|
+
}
|
|
4990
|
+
}
|
|
4991
|
+
if (!top)
|
|
4992
|
+
return;
|
|
4993
|
+
if (top.consumes() &&
|
|
4994
|
+
(holding.length > 0 || !focused || focused === doc.body)) {
|
|
4995
|
+
event.preventDefault();
|
|
4996
|
+
event.stopPropagation();
|
|
4997
|
+
}
|
|
4998
|
+
top.close();
|
|
4999
|
+
};
|
|
5000
|
+
doc.addEventListener('keydown', handleKeyDown, true);
|
|
5001
|
+
listener = {
|
|
5002
|
+
entries,
|
|
5003
|
+
stop: () => {
|
|
5004
|
+
doc.removeEventListener('keydown', handleKeyDown, true);
|
|
5005
|
+
escapeListeners.delete(doc);
|
|
5006
|
+
},
|
|
5007
|
+
};
|
|
5008
|
+
escapeListeners.set(doc, listener);
|
|
5145
5009
|
}
|
|
5146
|
-
|
|
5010
|
+
const shared = listener;
|
|
5011
|
+
shared.entries.add(entry);
|
|
5012
|
+
return () => {
|
|
5013
|
+
shared.entries.delete(entry);
|
|
5014
|
+
if (shared.entries.size === 0)
|
|
5015
|
+
shared.stop();
|
|
5016
|
+
};
|
|
5147
5017
|
}
|
|
5148
|
-
const isShown = (el) => !el.hasAttribute('inert') && getComputedStyle(el).display !== 'none';
|
|
5149
|
-
const isVisible = (el) => {
|
|
5150
|
-
const { visibility } = getComputedStyle(el);
|
|
5151
|
-
return visibility !== 'hidden' && visibility !== 'collapse';
|
|
5152
|
-
};
|
|
5153
5018
|
/**
|
|
5154
|
-
*
|
|
5155
|
-
*
|
|
5156
|
-
*
|
|
5157
|
-
*
|
|
5019
|
+
* Decides when a floating element that opens on hover or keyboard focus asks
|
|
5020
|
+
* to open and to close. It renders nothing, and leaves roles, ARIA and
|
|
5021
|
+
* placement to the component that calls it.
|
|
5022
|
+
*
|
|
5023
|
+
* - The pointer entering, or keyboard focus arriving on the trigger, asks to
|
|
5024
|
+
* open after `openDelay`. A touch pointer doesn't count. Neither does focus
|
|
5025
|
+
* that a press brings to the trigger: it counts when the browser shows it
|
|
5026
|
+
* as `:focus-visible`. The pointer arriving from outside a shadow root the
|
|
5027
|
+
* trigger renders into counts too, though React sends no enter for it.
|
|
5028
|
+
* - Focus in the floating element holds it open however it got there,
|
|
5029
|
+
* including focus in content it portals elsewhere, such as a nested card.
|
|
5030
|
+
* Whether a click focuses what it lands on is up to the browser: Safari
|
|
5031
|
+
* and Firefox on macOS don't focus a button on click, so there a clicked
|
|
5032
|
+
* button doesn't hold it once the pointer leaves.
|
|
5033
|
+
* - Once the pointer and focus have both left, it asks to close after
|
|
5034
|
+
* `closeDelay`, and either coming back before then keeps it open. Nothing
|
|
5035
|
+
* else closes it on a timer.
|
|
5036
|
+
* - Escape closes it at once, wherever focus is, and stops there when focus
|
|
5037
|
+
* is in it, on its trigger or nowhere, so a `Popover` or `Modal` around it
|
|
5038
|
+
* keeps its own. With focus somewhere else, or with `consumeEscape` off,
|
|
5039
|
+
* the key goes on after closing it. Inside another one, the inner one
|
|
5040
|
+
* closes first. Escape before it
|
|
5041
|
+
* opens takes the opening back and goes on its way. One key listener per
|
|
5042
|
+
* document serves every hook on it, there only while one of them is open
|
|
5043
|
+
* or on its way to open.
|
|
5044
|
+
* - With `openOnTap`, a tap on the trigger opens it at once and the next tap
|
|
5045
|
+
* on the trigger closes it. While a tap holds it open, a press outside the
|
|
5046
|
+
* trigger and the floating element closes it, from a listener on the
|
|
5047
|
+
* document that is there only then. Taps never count as hover, and mouse
|
|
5048
|
+
* and pen work the same with it or without.
|
|
5049
|
+
* - Once it closes, for any reason, whatever was holding it open has to
|
|
5050
|
+
* leave and come back to open it again.
|
|
5051
|
+
* - When the floating element closes with focus inside it, focus goes back
|
|
5052
|
+
* to the trigger, unless the same update put it somewhere else. Handing it
|
|
5053
|
+
* back doesn't count as focus arriving.
|
|
5054
|
+
*
|
|
5055
|
+
* Nothing it scheduled runs after the component unmounts.
|
|
5056
|
+
*
|
|
5057
|
+
* @function useHoverOpen
|
|
5058
|
+
* @param options - The open state, how to ask for another, the delays, and the two elements.
|
|
5059
|
+
* @returns Handlers for the element that holds the trigger.
|
|
5158
5060
|
*/
|
|
5159
|
-
function
|
|
5160
|
-
|
|
5161
|
-
|
|
5162
|
-
|
|
5163
|
-
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5061
|
+
function useHoverOpen(options) {
|
|
5062
|
+
const latestRef = React.useRef(options);
|
|
5063
|
+
const stateRef = React.useRef({
|
|
5064
|
+
pointerIn: false,
|
|
5065
|
+
focusIn: false,
|
|
5066
|
+
focusInFloating: false,
|
|
5067
|
+
held: false,
|
|
5068
|
+
opening: false,
|
|
5069
|
+
asked: false,
|
|
5070
|
+
restoring: false,
|
|
5071
|
+
});
|
|
5072
|
+
// In the hooks that want Escape from the moment an open is on its way until
|
|
5073
|
+
// the floating element closes, and out otherwise. Stamped with the order it
|
|
5074
|
+
// opened in.
|
|
5075
|
+
const listenForEscape = React.useCallback(function listen() {
|
|
5076
|
+
const s = stateRef.current;
|
|
5077
|
+
const entry = entryRef.current;
|
|
5078
|
+
const { open, triggerRef } = latestRef.current;
|
|
5079
|
+
if (!open)
|
|
5080
|
+
entry.openedAt = 0;
|
|
5081
|
+
else if (!entry.openedAt) {
|
|
5082
|
+
openings += 1;
|
|
5083
|
+
entry.openedAt = openings;
|
|
5169
5084
|
}
|
|
5170
|
-
|
|
5171
|
-
|
|
5172
|
-
|
|
5173
|
-
|
|
5174
|
-
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5085
|
+
const wanted = open || s.opening || s.asked;
|
|
5086
|
+
if (wanted === Boolean(s.releaseEscape))
|
|
5087
|
+
return;
|
|
5088
|
+
if (s.releaseEscape) {
|
|
5089
|
+
s.releaseEscape();
|
|
5090
|
+
s.releaseEscape = undefined;
|
|
5091
|
+
return;
|
|
5092
|
+
}
|
|
5093
|
+
const doc = triggerRef.current?.ownerDocument ?? document;
|
|
5094
|
+
s.releaseEscape = wantEscape(doc, entry);
|
|
5095
|
+
}, []);
|
|
5096
|
+
const entryRef = React.useRef({
|
|
5097
|
+
openedAt: 0,
|
|
5098
|
+
trigger: () => latestRef.current.triggerRef.current,
|
|
5099
|
+
floating: () => latestRef.current.floatingRef.current,
|
|
5100
|
+
// Only ever called while an open is on its way, so it is one or the
|
|
5101
|
+
// other: still waiting on its delay, or asked for already.
|
|
5102
|
+
cancel: () => {
|
|
5103
|
+
const s = stateRef.current;
|
|
5104
|
+
clearTimeout(s.timer);
|
|
5105
|
+
// An ask already made is taken back by asking the other way.
|
|
5106
|
+
if (s.asked)
|
|
5107
|
+
latestRef.current.onOpenChange(false);
|
|
5108
|
+
s.opening = false;
|
|
5109
|
+
s.asked = false;
|
|
5110
|
+
listenForEscape();
|
|
5111
|
+
},
|
|
5112
|
+
close: () => {
|
|
5113
|
+
clearTimeout(stateRef.current.timer);
|
|
5114
|
+
latestRef.current.onOpenChange(false);
|
|
5115
|
+
},
|
|
5116
|
+
consumes: () => latestRef.current.consumeEscape !== false,
|
|
5186
5117
|
});
|
|
5118
|
+
// Asks to open or to close. Escape can take an ask to open back until it is
|
|
5119
|
+
// answered: by the commit that follows it, or, when the caller keeps it
|
|
5120
|
+
// closed without rendering at all, once the event that asked is over, by
|
|
5121
|
+
// which time React has rendered any answer the caller set.
|
|
5122
|
+
const ask = React.useCallback((next) => {
|
|
5123
|
+
const s = stateRef.current;
|
|
5124
|
+
s.asked = next;
|
|
5125
|
+
latestRef.current.onOpenChange(next);
|
|
5126
|
+
listenForEscape();
|
|
5127
|
+
if (!next)
|
|
5128
|
+
return;
|
|
5129
|
+
clearTimeout(s.askTimer);
|
|
5130
|
+
s.askTimer = setTimeout(() => {
|
|
5131
|
+
// Still closed: the caller turned it down.
|
|
5132
|
+
if (latestRef.current.open)
|
|
5133
|
+
return;
|
|
5134
|
+
s.asked = false;
|
|
5135
|
+
s.tappedIn = undefined;
|
|
5136
|
+
listenForEscape();
|
|
5137
|
+
});
|
|
5138
|
+
}, [listenForEscape]);
|
|
5139
|
+
// Asks for what the pointer and focus call for, after its delay, when that
|
|
5140
|
+
// changed since the last time and isn't the state already.
|
|
5141
|
+
const settle = React.useCallback(() => {
|
|
5142
|
+
const s = stateRef.current;
|
|
5143
|
+
const held = s.pointerIn || s.focusIn;
|
|
5144
|
+
if (held !== s.held) {
|
|
5145
|
+
s.held = held;
|
|
5146
|
+
clearTimeout(s.timer);
|
|
5147
|
+
const { open, openDelay, closeDelay } = latestRef.current;
|
|
5148
|
+
s.opening = held && !open;
|
|
5149
|
+
if (held !== open) {
|
|
5150
|
+
s.timer = setTimeout(() => {
|
|
5151
|
+
s.opening = false;
|
|
5152
|
+
if (latestRef.current.open !== held)
|
|
5153
|
+
ask(held);
|
|
5154
|
+
else
|
|
5155
|
+
listenForEscape();
|
|
5156
|
+
}, held ? openDelay : closeDelay);
|
|
5157
|
+
}
|
|
5158
|
+
}
|
|
5159
|
+
listenForEscape();
|
|
5160
|
+
}, [listenForEscape, ask]);
|
|
5161
|
+
// While a tap holds the floating element open, a press outside the trigger
|
|
5162
|
+
// and the floating element closes it. Set up again each time it opens or
|
|
5163
|
+
// closes.
|
|
5164
|
+
const listenForPress = React.useCallback(() => {
|
|
5165
|
+
const s = stateRef.current;
|
|
5166
|
+
s.stopPress?.();
|
|
5167
|
+
s.stopPress = undefined;
|
|
5168
|
+
const doc = latestRef.current.open ? s.tappedIn : undefined;
|
|
5169
|
+
if (!doc)
|
|
5170
|
+
return;
|
|
5171
|
+
const handlePointerDown = (event) => {
|
|
5172
|
+
// Inside is a press React brought to the holder, which covers content
|
|
5173
|
+
// the floating element portals elsewhere, or one whose path runs
|
|
5174
|
+
// through the trigger or the floating element, which covers a press
|
|
5175
|
+
// that something inside stopped before React got it to the holder.
|
|
5176
|
+
const { triggerRef, floatingRef } = latestRef.current;
|
|
5177
|
+
if (event === s.pressedInside ||
|
|
5178
|
+
isEventInside(event, triggerRef.current) ||
|
|
5179
|
+
isEventInside(event, floatingRef.current)) {
|
|
5180
|
+
return;
|
|
5181
|
+
}
|
|
5182
|
+
latestRef.current.onOpenChange(false);
|
|
5183
|
+
};
|
|
5184
|
+
doc.addEventListener('pointerdown', handlePointerDown);
|
|
5185
|
+
s.stopPress = () => doc.removeEventListener('pointerdown', handlePointerDown);
|
|
5186
|
+
}, []);
|
|
5187
|
+
useClientLayoutEffect(() => {
|
|
5188
|
+
latestRef.current = options;
|
|
5189
|
+
// The commit after an ask to open answers it, open or still closed. A
|
|
5190
|
+
// tap that asked and still finds it closed was turned down.
|
|
5191
|
+
const s = stateRef.current;
|
|
5192
|
+
if (s.asked) {
|
|
5193
|
+
s.asked = false;
|
|
5194
|
+
if (!options.open)
|
|
5195
|
+
s.tappedIn = undefined;
|
|
5196
|
+
listenForEscape();
|
|
5197
|
+
}
|
|
5198
|
+
});
|
|
5199
|
+
useClientLayoutEffect(() => {
|
|
5200
|
+
const s = stateRef.current;
|
|
5201
|
+
return () => {
|
|
5202
|
+
clearTimeout(s.timer);
|
|
5203
|
+
clearTimeout(s.askTimer);
|
|
5204
|
+
s.opening = false;
|
|
5205
|
+
s.asked = false;
|
|
5206
|
+
s.releaseEscape?.();
|
|
5207
|
+
s.releaseEscape = undefined;
|
|
5208
|
+
s.stopPress?.();
|
|
5209
|
+
s.stopPress = undefined;
|
|
5210
|
+
};
|
|
5211
|
+
}, []);
|
|
5212
|
+
const { open } = options;
|
|
5213
|
+
// Before paint, so a pointer or a key arriving next finds this settled.
|
|
5214
|
+
useClientLayoutEffect(() => {
|
|
5215
|
+
const s = stateRef.current;
|
|
5216
|
+
if (!open) {
|
|
5217
|
+
// The floating element left from under the pointer, if it was there,
|
|
5218
|
+
// and no leave event says so. Focus resting on the trigger has done
|
|
5219
|
+
// its job too, so neither opens it again until it comes back.
|
|
5220
|
+
s.pointerIn = false;
|
|
5221
|
+
s.focusIn = false;
|
|
5222
|
+
s.tappedIn = undefined;
|
|
5223
|
+
if (s.focusInFloating) {
|
|
5224
|
+
s.focusInFloating = false;
|
|
5225
|
+
const trigger = latestRef.current.triggerRef.current;
|
|
5226
|
+
// Focus that went down with the floating element sits on <body>.
|
|
5227
|
+
// Focus somewhere else was put there by the same update, so it stays.
|
|
5228
|
+
if (trigger &&
|
|
5229
|
+
trigger.ownerDocument.activeElement === trigger.ownerDocument.body) {
|
|
5230
|
+
s.restoring = true;
|
|
5231
|
+
trigger.focus();
|
|
5232
|
+
s.restoring = false;
|
|
5233
|
+
}
|
|
5234
|
+
}
|
|
5235
|
+
}
|
|
5236
|
+
settle();
|
|
5237
|
+
listenForPress();
|
|
5238
|
+
}, [open, settle, listenForPress]);
|
|
5239
|
+
return React.useMemo(() => {
|
|
5240
|
+
const inTrigger = (node) => within(latestRef.current.triggerRef.current, node);
|
|
5241
|
+
return {
|
|
5242
|
+
onPointerEnter: event => {
|
|
5243
|
+
if (event.pointerType === 'touch')
|
|
5244
|
+
return;
|
|
5245
|
+
stateRef.current.pointerIn = true;
|
|
5246
|
+
settle();
|
|
5247
|
+
},
|
|
5248
|
+
onPointerOver: event => {
|
|
5249
|
+
// React makes the enter out of the pointerout on the element the
|
|
5250
|
+
// pointer left, and skips this pointerover when that element is its
|
|
5251
|
+
// own. Leaving page content for a trigger inside a shadow root, that
|
|
5252
|
+
// pointerout names only the shadow host, so no enter reaches here.
|
|
5253
|
+
// A pointerover from outside the holder and the floating element is
|
|
5254
|
+
// the pointer arriving, then. One from inside them is the pointer
|
|
5255
|
+
// moving about within, which doesn't bring back a closed one.
|
|
5256
|
+
const s = stateRef.current;
|
|
5257
|
+
if (s.pointerIn || event.pointerType === 'touch')
|
|
5258
|
+
return;
|
|
5259
|
+
const from = event.relatedTarget;
|
|
5260
|
+
if (within(event.currentTarget, from) ||
|
|
5261
|
+
within(latestRef.current.floatingRef.current, from)) {
|
|
5262
|
+
return;
|
|
5263
|
+
}
|
|
5264
|
+
s.pointerIn = true;
|
|
5265
|
+
settle();
|
|
5266
|
+
},
|
|
5267
|
+
onPointerLeave: event => {
|
|
5268
|
+
// A touch pointer never set pointerIn, so its leave has nothing to
|
|
5269
|
+
// clear, and on a device with a mouse too it would close a card the
|
|
5270
|
+
// mouse is still holding open.
|
|
5271
|
+
if (event.pointerType === 'touch')
|
|
5272
|
+
return;
|
|
5273
|
+
stateRef.current.pointerIn = false;
|
|
5274
|
+
settle();
|
|
5275
|
+
},
|
|
5276
|
+
onFocus: event => {
|
|
5277
|
+
const s = stateRef.current;
|
|
5278
|
+
// React brings focus here from the trigger and from the floating
|
|
5279
|
+
// element's whole tree, portals included, so focus not in the
|
|
5280
|
+
// trigger is in the floating element, wherever its DOM is.
|
|
5281
|
+
s.focusInFloating = !inTrigger(event.target);
|
|
5282
|
+
if (s.restoring)
|
|
5283
|
+
return;
|
|
5284
|
+
// Any focus in the floating element holds it. On the trigger, only
|
|
5285
|
+
// keyboard focus does, so a click there opens nothing.
|
|
5286
|
+
s.focusIn = s.focusInFloating || isKeyboardFocus(event.target);
|
|
5287
|
+
settle();
|
|
5288
|
+
},
|
|
5289
|
+
onBlur: event => {
|
|
5290
|
+
const s = stateRef.current;
|
|
5291
|
+
const next = event.relatedTarget;
|
|
5292
|
+
// Where focus goes next, as far as the DOM can say. Focus going into
|
|
5293
|
+
// content the floating element portals elsewhere reads as leaving,
|
|
5294
|
+
// and the focus event that follows straight after brings it back.
|
|
5295
|
+
s.focusInFloating = within(latestRef.current.floatingRef.current, next);
|
|
5296
|
+
// Moving between the trigger and the floating element isn't leaving.
|
|
5297
|
+
if (s.focusInFloating || within(event.currentTarget, next))
|
|
5298
|
+
return;
|
|
5299
|
+
s.focusIn = false;
|
|
5300
|
+
settle();
|
|
5301
|
+
},
|
|
5302
|
+
onPointerDown: event => {
|
|
5303
|
+
stateRef.current.pressedInside = event.nativeEvent;
|
|
5304
|
+
},
|
|
5305
|
+
onPointerUp: event => {
|
|
5306
|
+
const now = latestRef.current;
|
|
5307
|
+
// With openOnTap, a tap on the trigger opens it at once, and the
|
|
5308
|
+
// next one closes it. A tap anywhere else does nothing here.
|
|
5309
|
+
if (!now.openOnTap ||
|
|
5310
|
+
event.pointerType !== 'touch' ||
|
|
5311
|
+
!inTrigger(event.target)) {
|
|
5312
|
+
return;
|
|
5313
|
+
}
|
|
5314
|
+
stateRef.current.tappedIn = now.open
|
|
5315
|
+
? undefined
|
|
5316
|
+
: event.target.ownerDocument;
|
|
5317
|
+
ask(!now.open);
|
|
5318
|
+
},
|
|
5319
|
+
};
|
|
5320
|
+
}, [settle, ask]);
|
|
5187
5321
|
}
|
|
5322
|
+
|
|
5188
5323
|
/**
|
|
5189
|
-
*
|
|
5190
|
-
* HTML rendering rules give it `display: none`, though a browser may not
|
|
5191
|
-
* report that. So it counts where an image using its map is shown, as long
|
|
5192
|
-
* as nothing makes it inert.
|
|
5193
|
-
*/
|
|
5194
|
-
function isAreaShown(area, container) {
|
|
5195
|
-
for (let node = area; node && node !== container; node = flatParent(node)) {
|
|
5196
|
-
if (node.hasAttribute('inert'))
|
|
5197
|
-
return false;
|
|
5198
|
-
}
|
|
5199
|
-
return imagesUsingMapOf(area).some(img => isShownUpTo(img, null) && isVisible(img));
|
|
5200
|
-
}
|
|
5201
|
-
/**
|
|
5202
|
-
* Whether Tab can land on `el`, one of the kinds it visits. A disabled control
|
|
5203
|
-
* (including one inside a disabled `<fieldset>`), an inert subtree and
|
|
5204
|
-
* anything not rendered are skipped. `visibility` is inherited, so the
|
|
5205
|
-
* element's own computed value covers its ancestors.
|
|
5206
|
-
*/
|
|
5207
|
-
function isRenderedAndEnabled(el, container) {
|
|
5208
|
-
if (el.matches(':disabled'))
|
|
5209
|
-
return false;
|
|
5210
|
-
if (el.localName === 'area')
|
|
5211
|
-
return isAreaShown(el, container);
|
|
5212
|
-
return isShownUpTo(el, container) && isVisible(el);
|
|
5213
|
-
}
|
|
5214
|
-
/** A radio button with a name, which shares one tab stop with its group. */
|
|
5215
|
-
const isGroupedRadio = (el) => el.localName === 'input' &&
|
|
5216
|
-
el.type === 'radio' &&
|
|
5217
|
-
el.name !== '';
|
|
5218
|
-
/** Whether two radio buttons are in one group: same name, form owner and tree. */
|
|
5219
|
-
const sameGroup = (a, b) => isGroupedRadio(b) &&
|
|
5220
|
-
b.name === a.name &&
|
|
5221
|
-
b.form === a.form &&
|
|
5222
|
-
b.getRootNode() === a.getRootNode();
|
|
5223
|
-
/**
|
|
5224
|
-
* The checked button of `radio`'s group. The group isn't bounded by the trap,
|
|
5225
|
-
* so this looks through the radio's whole tree.
|
|
5226
|
-
*/
|
|
5227
|
-
const checkedInGroup = (radio) => Array.from(radio.getRootNode().querySelectorAll('input:checked')).find(el => sameGroup(radio, el));
|
|
5228
|
-
/** The flattened-tree ancestors of `node` below the container, outermost first, then `node`. */
|
|
5229
|
-
function flatChain(node, container) {
|
|
5230
|
-
const chain = [];
|
|
5231
|
-
for (let n = node; n && n !== container; n = flatParent(n)) {
|
|
5232
|
-
chain.unshift(n);
|
|
5233
|
-
}
|
|
5234
|
-
return chain;
|
|
5235
|
-
}
|
|
5236
|
-
/**
|
|
5237
|
-
* Whether `node` is inside the container in the flattened tree: in it, in a
|
|
5238
|
-
* shadow root it holds, or slotted into it. A child of a closed-root host
|
|
5239
|
-
* counts as inside wherever the host is, as its slot can't be seen.
|
|
5240
|
-
*/
|
|
5241
|
-
function inside(container, node) {
|
|
5242
|
-
for (let n = node; n; n = flatParent(n)) {
|
|
5243
|
-
if (n === container)
|
|
5244
|
-
return true;
|
|
5245
|
-
}
|
|
5246
|
-
return false;
|
|
5247
|
-
}
|
|
5248
|
-
/**
|
|
5249
|
-
* `true` when Tab reaches `b` after `a`, which is their order in the
|
|
5250
|
-
* flattened tree: a shadow host comes before its shadow root's content, and
|
|
5251
|
-
* slotted content comes where its slot is. A closed root's slots can't be
|
|
5252
|
-
* seen, so its host's children keep their place in the light DOM. Two
|
|
5253
|
-
* flattened-tree siblings are always in one DOM tree, so their document
|
|
5254
|
-
* position decides between them.
|
|
5255
|
-
*/
|
|
5256
|
-
function follows(container, a, b) {
|
|
5257
|
-
const chainA = flatChain(a, container);
|
|
5258
|
-
const chainB = flatChain(b, container);
|
|
5259
|
-
for (let i = 0; i < Math.max(chainA.length, chainB.length); i++) {
|
|
5260
|
-
const [x, y] = [chainA[i], chainB[i]];
|
|
5261
|
-
if (x === y)
|
|
5262
|
-
continue;
|
|
5263
|
-
if (!x)
|
|
5264
|
-
return true;
|
|
5265
|
-
if (!y)
|
|
5266
|
-
return false;
|
|
5267
|
-
return Boolean(x.compareDocumentPosition(y) & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
5268
|
-
}
|
|
5269
|
-
return false;
|
|
5270
|
-
}
|
|
5271
|
-
/**
|
|
5272
|
-
* Every element of a tabbable kind under `el` in the flattened tree, depth
|
|
5273
|
-
* first, which is the order Tab visits them. Under a host whose shadow root
|
|
5274
|
-
* is closed, that takes in the host's own children instead, rendered or not.
|
|
5275
|
-
*/
|
|
5276
|
-
function collectCandidates(el, out = []) {
|
|
5277
|
-
for (const child of flatChildren(el)) {
|
|
5278
|
-
if (isTabbableKind(child))
|
|
5279
|
-
out.push(child);
|
|
5280
|
-
collectCandidates(child, out);
|
|
5281
|
-
}
|
|
5282
|
-
return out;
|
|
5283
|
-
}
|
|
5284
|
-
/**
|
|
5285
|
-
* The first and last tab stops inside `container`, or `null` when there are
|
|
5286
|
-
* none. Only the ends matter for wrapping, so the style checks run from each
|
|
5287
|
-
* end until they find a stop rather than over every candidate.
|
|
5324
|
+
* The `Tooltip` component displays helpful information when users hover over or focus on an element.
|
|
5288
5325
|
*
|
|
5289
|
-
*
|
|
5290
|
-
*
|
|
5291
|
-
*
|
|
5292
|
-
*
|
|
5293
|
-
*
|
|
5294
|
-
*
|
|
5326
|
+
* Hovering the trigger or giving it keyboard focus shows it. Touch doesn't,
|
|
5327
|
+
* unless `openOnTap` is on, and neither does focus a click or a tap brings,
|
|
5328
|
+
* so tapping the trigger just does what the trigger does. The pointer can
|
|
5329
|
+
* move onto a shown tooltip. Escape hides it wherever focus is, without
|
|
5330
|
+
* moving the pointer or focus, and it stays hidden until one of them leaves
|
|
5331
|
+
* the trigger and comes back, or, with `openOnTap`, until the next tap on
|
|
5332
|
+
* the trigger. The key always goes on too, to whatever has focus or a
|
|
5333
|
+
* `Modal` or `Popover` around it, since a tooltip only adds to what's
|
|
5334
|
+
* there.
|
|
5335
|
+
*
|
|
5336
|
+
* @function
|
|
5337
|
+
* @param {TooltipProps} props - Props for the Tooltip component.
|
|
5338
|
+
* @returns {JSX.Element} The rendered tooltip wrapper.
|
|
5339
|
+
*
|
|
5340
|
+
* @example
|
|
5341
|
+
* // Basic tooltip
|
|
5342
|
+
* <Tooltip label="This is helpful info">
|
|
5343
|
+
* <button>Hover me</button>
|
|
5344
|
+
* </Tooltip>
|
|
5345
|
+
*
|
|
5346
|
+
* @example
|
|
5347
|
+
* // Positioned tooltip with color
|
|
5348
|
+
* <Tooltip label="Delete item" position="right" color="danger">
|
|
5349
|
+
* <Icon icon="fas fa-trash" />
|
|
5350
|
+
* </Tooltip>
|
|
5351
|
+
*
|
|
5352
|
+
* @example
|
|
5353
|
+
* // Multiline tooltip
|
|
5354
|
+
* <Tooltip label="This is a longer tooltip that wraps to multiple lines" multiline>
|
|
5355
|
+
* <span>?</span>
|
|
5356
|
+
* </Tooltip>
|
|
5357
|
+
*/
|
|
5358
|
+
const Tooltip = ({ label, content, position = 'top', color, size, active: alwaysActive, multiline = false, animated = true, square = false, dashed = false, delay = 0, closeDelay = 0, openOnTap = false, children, className, tooltipClassName, ...props }) => {
|
|
5359
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
5360
|
+
const { onPointerEnter, onPointerOver, onPointerLeave, onPointerDown, onPointerUp, onFocus, onBlur, ...attrs } = rest;
|
|
5361
|
+
const [isVisible, setIsVisible] = React.useState(false);
|
|
5362
|
+
const [resolvedPosition, setResolvedPosition] = React.useState(position === 'auto' ? 'top' : position);
|
|
5363
|
+
const [contentStyle, setContentStyle] = React.useState({});
|
|
5364
|
+
const wrapperRef = React.useRef(null);
|
|
5365
|
+
const contentRef = React.useRef(null);
|
|
5366
|
+
const hover = useHoverOpen({
|
|
5367
|
+
// The pointer and focus as they are. `active` shows it on top of that, so
|
|
5368
|
+
// dropping `active` leaves it shown while the pointer or focus holds it.
|
|
5369
|
+
// An ask to close can come while it's already hidden, which is no change.
|
|
5370
|
+
open: isVisible,
|
|
5371
|
+
onOpenChange: setIsVisible,
|
|
5372
|
+
openDelay: delay,
|
|
5373
|
+
closeDelay,
|
|
5374
|
+
// The wrapper stands in for the trigger. It holds only the children and
|
|
5375
|
+
// the tooltip, and nothing in the tooltip takes focus, so focus anywhere
|
|
5376
|
+
// in it is focus on the trigger.
|
|
5377
|
+
triggerRef: wrapperRef,
|
|
5378
|
+
floatingRef: contentRef,
|
|
5379
|
+
// Escape hides it and carries on, so a control in the trigger, or a
|
|
5380
|
+
// Modal around it, still gets the key.
|
|
5381
|
+
consumeEscape: false,
|
|
5382
|
+
openOnTap,
|
|
5383
|
+
});
|
|
5384
|
+
// Auto-placement and overflow correction
|
|
5385
|
+
React.useLayoutEffect(() => {
|
|
5386
|
+
const isAuto = position === 'auto';
|
|
5387
|
+
if (!isAuto) {
|
|
5388
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- layout measurement effect resolves placement
|
|
5389
|
+
setResolvedPosition(position);
|
|
5390
|
+
}
|
|
5391
|
+
const showing = isVisible || alwaysActive;
|
|
5392
|
+
if (!showing) {
|
|
5393
|
+
setContentStyle({});
|
|
5394
|
+
return;
|
|
5395
|
+
}
|
|
5396
|
+
const wrapper = wrapperRef.current;
|
|
5397
|
+
const content = contentRef.current;
|
|
5398
|
+
if (!wrapper || !content)
|
|
5399
|
+
return;
|
|
5400
|
+
const wrapperRect = wrapper.getBoundingClientRect();
|
|
5401
|
+
const margin = 40;
|
|
5402
|
+
const edgePadding = 8; // minimum distance from viewport edge
|
|
5403
|
+
// Step 1: determine position (auto mode only)
|
|
5404
|
+
if (isAuto) {
|
|
5405
|
+
const spaceTop = wrapperRect.top;
|
|
5406
|
+
const spaceBottom = window.innerHeight - wrapperRect.bottom;
|
|
5407
|
+
if (spaceTop >= margin) {
|
|
5408
|
+
setResolvedPosition('top');
|
|
5409
|
+
}
|
|
5410
|
+
else if (spaceBottom >= margin) {
|
|
5411
|
+
setResolvedPosition('bottom');
|
|
5412
|
+
}
|
|
5413
|
+
else if (window.innerWidth - wrapperRect.right >= wrapperRect.left) {
|
|
5414
|
+
setResolvedPosition('right');
|
|
5415
|
+
}
|
|
5416
|
+
else {
|
|
5417
|
+
setResolvedPosition('left');
|
|
5418
|
+
}
|
|
5419
|
+
}
|
|
5420
|
+
// Step 2: measure tooltip content and correct overflow.
|
|
5421
|
+
// Use requestAnimationFrame so the resolved position class is applied first.
|
|
5422
|
+
requestAnimationFrame(() => {
|
|
5423
|
+
const contentEl = contentRef.current;
|
|
5424
|
+
if (!contentEl)
|
|
5425
|
+
return;
|
|
5426
|
+
const contentRect = contentEl.getBoundingClientRect();
|
|
5427
|
+
const style = {};
|
|
5428
|
+
// For top/bottom positions, tooltip is centered horizontally via CSS
|
|
5429
|
+
// (left: 50%; transform: translateX(-50%)).
|
|
5430
|
+
// Check if it overflows left or right and apply a corrective shift.
|
|
5431
|
+
const pos = isAuto ? undefined : position; // read from state won't be updated yet in auto
|
|
5432
|
+
const effectivePos = pos || 'top'; // fallback, actual is set by state
|
|
5433
|
+
const isVertical = effectivePos === 'top' ||
|
|
5434
|
+
effectivePos === 'bottom' ||
|
|
5435
|
+
resolvedPosition === 'top' ||
|
|
5436
|
+
resolvedPosition === 'bottom';
|
|
5437
|
+
if (isVertical) {
|
|
5438
|
+
if (contentRect.left < edgePadding) {
|
|
5439
|
+
// Overflows left — shift right
|
|
5440
|
+
const shift = edgePadding - contentRect.left;
|
|
5441
|
+
style.left = `calc(50% + ${shift}px)`;
|
|
5442
|
+
style['--tooltip-arrow-offset'] =
|
|
5443
|
+
`${-shift}px`;
|
|
5444
|
+
}
|
|
5445
|
+
else if (contentRect.right > window.innerWidth - edgePadding) {
|
|
5446
|
+
// Overflows right — shift left
|
|
5447
|
+
const shift = contentRect.right - (window.innerWidth - edgePadding);
|
|
5448
|
+
style.left = `calc(50% - ${shift}px)`;
|
|
5449
|
+
style['--tooltip-arrow-offset'] =
|
|
5450
|
+
`${shift}px`;
|
|
5451
|
+
}
|
|
5452
|
+
}
|
|
5453
|
+
else {
|
|
5454
|
+
// For left/right positions, tooltip is centered vertically via CSS
|
|
5455
|
+
// (top: 50%; transform: translateY(-50%)).
|
|
5456
|
+
if (contentRect.top < edgePadding) {
|
|
5457
|
+
const shift = edgePadding - contentRect.top;
|
|
5458
|
+
style.top = `calc(50% + ${shift}px)`;
|
|
5459
|
+
style['--tooltip-arrow-offset'] =
|
|
5460
|
+
`${-shift}px`;
|
|
5461
|
+
}
|
|
5462
|
+
else if (contentRect.bottom > window.innerHeight - edgePadding) {
|
|
5463
|
+
const shift = contentRect.bottom - (window.innerHeight - edgePadding);
|
|
5464
|
+
style.top = `calc(50% - ${shift}px)`;
|
|
5465
|
+
style['--tooltip-arrow-offset'] =
|
|
5466
|
+
`${shift}px`;
|
|
5467
|
+
}
|
|
5468
|
+
}
|
|
5469
|
+
setContentStyle(style);
|
|
5470
|
+
});
|
|
5471
|
+
}, [position, resolvedPosition, isVisible, alwaysActive, label, content]);
|
|
5472
|
+
// Generate Bulma classes
|
|
5473
|
+
const tooltipClasses = usePrefixedClassNames('tooltip', {
|
|
5474
|
+
'is-active': alwaysActive || isVisible,
|
|
5475
|
+
// Open by the pointer, focus or a tap, rather than shown by `active`
|
|
5476
|
+
// alone: only then does the tooltip, and the gap to it, take the pointer.
|
|
5477
|
+
'is-open': isVisible,
|
|
5478
|
+
[`is-${resolvedPosition}`]: resolvedPosition,
|
|
5479
|
+
[`is-${color}`]: color,
|
|
5480
|
+
[`is-${size}`]: size,
|
|
5481
|
+
'is-multiline': multiline,
|
|
5482
|
+
'is-animated': animated,
|
|
5483
|
+
'is-square': square,
|
|
5484
|
+
'is-dashed': dashed,
|
|
5485
|
+
});
|
|
5486
|
+
const combinedClasses = classNames(tooltipClasses, bulmaHelperClasses, className);
|
|
5487
|
+
const combinedTooltipClasses = usePrefixedClassNames('tooltip-content', tooltipClassName);
|
|
5488
|
+
return (jsxRuntime.jsxs("span", { ref: wrapperRef, className: combinedClasses, onPointerEnter: chain$1(onPointerEnter, hover.onPointerEnter), onPointerOver: chain$1(onPointerOver, hover.onPointerOver), onPointerLeave: chain$1(onPointerLeave, hover.onPointerLeave), onFocus: chain$1(onFocus, hover.onFocus), onBlur: chain$1(onBlur, hover.onBlur), onPointerDown: chain$1(onPointerDown, hover.onPointerDown), onPointerUp: chain$1(onPointerUp, hover.onPointerUp), ...(label ? { 'data-tooltip': label } : {}), ...attrs, children: [children, jsxRuntime.jsx("span", { ref: contentRef, className: combinedTooltipClasses, role: "tooltip", "aria-hidden": !isVisible && !alwaysActive, style: Object.keys(contentStyle).length > 0 ? contentStyle : undefined, children: content || label })] }));
|
|
5489
|
+
};
|
|
5490
|
+
|
|
5491
|
+
/**
|
|
5492
|
+
* Whether `el` is editable while its parent is not, which makes it an editing
|
|
5493
|
+
* host: the one tab stop an editable region gets. `contenteditable="false"`
|
|
5494
|
+
* turns editing off, and a missing or unknown value inherits the parent's.
|
|
5295
5495
|
*/
|
|
5296
|
-
function
|
|
5297
|
-
const
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
if (
|
|
5302
|
-
|
|
5303
|
-
checked.set(el, ok);
|
|
5496
|
+
function isEditingHost(el) {
|
|
5497
|
+
const editable = (node) => {
|
|
5498
|
+
const value = node.getAttribute('contenteditable')?.toLowerCase();
|
|
5499
|
+
if (value === undefined)
|
|
5500
|
+
return null;
|
|
5501
|
+
if (value === '' || value === 'true' || value === 'plaintext-only') {
|
|
5502
|
+
return true;
|
|
5304
5503
|
}
|
|
5305
|
-
return
|
|
5306
|
-
};
|
|
5307
|
-
// Resolved once per group, filed under each of its buttons, since finding
|
|
5308
|
-
// the checked one scans the radio's whole tree.
|
|
5309
|
-
const groupStops = new Map();
|
|
5310
|
-
const groupStop = (radio) => {
|
|
5311
|
-
if (groupStops.has(radio))
|
|
5312
|
-
return groupStops.get(radio);
|
|
5313
|
-
const group = candidates.filter(el => sameGroup(radio, el));
|
|
5314
|
-
const on = checkedInGroup(radio);
|
|
5315
|
-
const stop = on && isTabbableKind(on) && usable(on)
|
|
5316
|
-
? group.find(el => el === on)
|
|
5317
|
-
: group.find(usable);
|
|
5318
|
-
for (const el of [radio, ...group])
|
|
5319
|
-
groupStops.set(el, stop);
|
|
5320
|
-
return stop;
|
|
5504
|
+
return value === 'false' ? false : null;
|
|
5321
5505
|
};
|
|
5322
|
-
|
|
5323
|
-
|
|
5324
|
-
|
|
5325
|
-
|
|
5326
|
-
|
|
5327
|
-
|
|
5328
|
-
if (isStop(candidates[i])) {
|
|
5329
|
-
last = candidates[i];
|
|
5330
|
-
break;
|
|
5331
|
-
}
|
|
5506
|
+
if (editable(el) !== true)
|
|
5507
|
+
return false;
|
|
5508
|
+
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
5509
|
+
const state = editable(node);
|
|
5510
|
+
if (state !== null)
|
|
5511
|
+
return !state;
|
|
5332
5512
|
}
|
|
5333
|
-
return
|
|
5334
|
-
first,
|
|
5335
|
-
last,
|
|
5336
|
-
stopFor: node => (isGroupedRadio(node) && groupStop(node)) || node,
|
|
5337
|
-
};
|
|
5338
|
-
}
|
|
5339
|
-
/**
|
|
5340
|
-
* The first tab stop inside `container`, found the way the trap finds it, or
|
|
5341
|
-
* `null` when there is none. For a component that moves focus into a panel
|
|
5342
|
-
* without trapping it there. `src/index.ts` names its exports from this
|
|
5343
|
-
* module, so this one stays internal.
|
|
5344
|
-
*/
|
|
5345
|
-
function firstTabStop(container) {
|
|
5346
|
-
return findTabStops(container)?.first ?? null;
|
|
5513
|
+
return true;
|
|
5347
5514
|
}
|
|
5515
|
+
/** Whether `el` is the summary of its `<details>`: the first one, not a stray. */
|
|
5516
|
+
const isDetailsSummary = (el) => el.parentElement?.localName === 'details' &&
|
|
5517
|
+
Array.from(el.parentElement.children).find(child => child.localName === 'summary') === el;
|
|
5348
5518
|
/**
|
|
5349
|
-
*
|
|
5350
|
-
*
|
|
5351
|
-
*
|
|
5352
|
-
*
|
|
5353
|
-
*
|
|
5354
|
-
*
|
|
5355
|
-
*/
|
|
5356
|
-
const focusedElement = (doc) => getDeepestActiveElement(doc) ?? doc.body;
|
|
5357
|
-
/**
|
|
5358
|
-
* Keeps keyboard focus inside a container while it is active: focus moves in
|
|
5359
|
-
* when the trap turns on, Tab and Shift+Tab wrap between the first and last
|
|
5360
|
-
* tab stops, and focus goes back where it came from when the trap turns off.
|
|
5361
|
-
*
|
|
5362
|
-
* Tab stops are what the browser's Tab visits, in document order: links,
|
|
5363
|
-
* form controls, frames, media with controls, a `<details>` summary, editable
|
|
5364
|
-
* regions, anything with `tabIndex={0}`, and the content of open shadow
|
|
5365
|
-
* roots. Hidden, disabled, inert and `tabIndex={-1}` elements are skipped,
|
|
5366
|
-
* and a radio group counts once. Give the container `tabIndex={-1}` so it can
|
|
5367
|
-
* hold focus itself when nothing inside can. The trap listens on the
|
|
5368
|
-
* container, so a trap nested inside another handles Tab first and the outer
|
|
5369
|
-
* one stands aside.
|
|
5370
|
-
*
|
|
5371
|
-
* The trap is about Tab. If a Tab it let through still takes focus out (a
|
|
5372
|
-
* browser can leave out a stop the page has, such as links when its settings
|
|
5373
|
-
* say to), it sends focus back to the other end. A pointer or a script moving
|
|
5374
|
-
* focus out is left alone, because pulling focus back from a click is modal
|
|
5375
|
-
* behaviour, and a modal also has to block the page behind it, which `Modal`
|
|
5376
|
-
* does. Content the container renders through a portal lives elsewhere in
|
|
5377
|
-
* the DOM, so it is outside the trap: render nested overlays inside the
|
|
5378
|
-
* container, or give them a trap of their own. To trap content a `Portal`
|
|
5379
|
-
* renders, pass a ref to the element inside the `Portal` as `containerRef`.
|
|
5380
|
-
*
|
|
5381
|
-
* The trap looks at `containerRef` again each time the component calling it
|
|
5382
|
-
* renders. A container that mounts after the trap turns on, such as a panel
|
|
5383
|
-
* waiting for a `Portal` target held in state, has it attach once that
|
|
5384
|
-
* component renders with the container in the DOM. When the container leaves
|
|
5385
|
-
* or is replaced, the trap lets go of it as it would turning off, handing
|
|
5386
|
-
* focus back, and attaches to the next one.
|
|
5387
|
-
* A child that mounts the container in a render of its own (from its own
|
|
5388
|
-
* state, or as a `Suspense` boundary resolves) goes unseen until the
|
|
5389
|
-
* component calling the hook renders again. The trap waits for hydration, so
|
|
5390
|
-
* that component renders again when the page hydrates, and portaled content,
|
|
5391
|
-
* which first appears then, is found.
|
|
5392
|
-
*
|
|
5393
|
-
* @function useFocusTrap
|
|
5394
|
-
* @param containerRef - Ref to the element focus stays inside.
|
|
5395
|
-
* @param options - When the trap is on, and where focus starts and ends.
|
|
5396
|
-
*
|
|
5397
|
-
* @example
|
|
5398
|
-
* const panelRef = useRef<HTMLDivElement>(null);
|
|
5399
|
-
* useFocusTrap(panelRef, { active: isOpen });
|
|
5400
|
-
* return isOpen ? (
|
|
5401
|
-
* <div ref={panelRef} role="dialog" aria-label="Filters" tabIndex={-1}>
|
|
5402
|
-
* …
|
|
5403
|
-
* </div>
|
|
5404
|
-
* ) : null;
|
|
5519
|
+
* Whether the browser puts `el` in the tab order, before anything that hides
|
|
5520
|
+
* or disables it. The `tabIndex` property can't answer this on its own: it
|
|
5521
|
+
* can read `-1` for an editable region or an `<embed>` that Tab still visits,
|
|
5522
|
+
* and `0` for a `<video>` without controls that it doesn't. So a valid
|
|
5523
|
+
* `tabindex` attribute decides, and otherwise the element's kind does.
|
|
5524
|
+
* Internal like `firstTabStop`: `src/index.ts` names what it takes from here.
|
|
5405
5525
|
*/
|
|
5406
|
-
function
|
|
5407
|
-
const
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
5455
|
-
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
5463
|
-
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
// `null` is an object too, and a caller without types can pass it.
|
|
5484
|
-
if (restoreFocus && typeof restoreFocus === 'object') {
|
|
5485
|
-
// Read now rather than when the trap turned on: a trigger that
|
|
5486
|
-
// re-mounted in between has a new node.
|
|
5487
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5488
|
-
target = restoreFocus.current;
|
|
5489
|
-
}
|
|
5490
|
-
// Hand focus back only while the trap still holds it: inside the
|
|
5491
|
-
// container, or dropped to <body> because the container left the page.
|
|
5492
|
-
const now = focusedElement(doc);
|
|
5493
|
-
if (target && (now === doc.body || inside(container, now))) {
|
|
5494
|
-
target.focus();
|
|
5495
|
-
}
|
|
5496
|
-
};
|
|
5497
|
-
}, [
|
|
5498
|
-
active,
|
|
5499
|
-
hydrated,
|
|
5500
|
-
containerRef,
|
|
5501
|
-
initialFocusRef,
|
|
5502
|
-
restoreFocus,
|
|
5503
|
-
refChanges,
|
|
5504
|
-
]);
|
|
5505
|
-
// Nothing says when a ref is set, so after every render, a ref that no
|
|
5506
|
-
// longer matches what the trap holds (a container that mounted, left or was
|
|
5507
|
-
// replaced) runs it again. Effects run in order, so whenever the effect
|
|
5508
|
-
// above ran in this commit, it read the ref just before this does, and a
|
|
5509
|
-
// mismatch means the ref changed in a render it didn't run for. The render
|
|
5510
|
-
// a bump asks for runs it, so the two match and the trap settles. An empty
|
|
5511
|
-
// ref matches a trap holding nothing, so a container that never mounts
|
|
5512
|
-
// costs no render.
|
|
5513
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- after every render on purpose
|
|
5514
|
-
React.useEffect(() => {
|
|
5515
|
-
if (active && hydrated && containerRef.current !== heldRef.current) {
|
|
5516
|
-
setRefChanges(n => n + 1);
|
|
5526
|
+
function isTabbableKind(el) {
|
|
5527
|
+
const tabindex = el.getAttribute('tabindex');
|
|
5528
|
+
if (tabindex !== null && /^\s*[+-]?\d/.test(tabindex)) {
|
|
5529
|
+
return el.tabIndex >= 0;
|
|
5530
|
+
}
|
|
5531
|
+
switch (el.localName) {
|
|
5532
|
+
case 'a':
|
|
5533
|
+
case 'area':
|
|
5534
|
+
return el.hasAttribute('href');
|
|
5535
|
+
case 'input':
|
|
5536
|
+
return el.type !== 'hidden';
|
|
5537
|
+
case 'button':
|
|
5538
|
+
case 'select':
|
|
5539
|
+
case 'textarea':
|
|
5540
|
+
case 'iframe':
|
|
5541
|
+
case 'embed':
|
|
5542
|
+
case 'object':
|
|
5543
|
+
return true;
|
|
5544
|
+
case 'audio':
|
|
5545
|
+
case 'video':
|
|
5546
|
+
return el.hasAttribute('controls');
|
|
5547
|
+
case 'summary':
|
|
5548
|
+
return isDetailsSummary(el);
|
|
5549
|
+
default:
|
|
5550
|
+
return isEditingHost(el);
|
|
5551
|
+
}
|
|
5552
|
+
}
|
|
5553
|
+
/**
|
|
5554
|
+
* The node's parent in the flattened tree, the one the browser renders and
|
|
5555
|
+
* tabs through: the slot it is assigned to, else its parent, else the host of
|
|
5556
|
+
* the shadow root it tops, and nothing above the document. A closed shadow
|
|
5557
|
+
* root hides its slots (`assignedSlot` reads `null` under one), so a child of
|
|
5558
|
+
* its host goes straight to the host, whether a slot takes it or not.
|
|
5559
|
+
*/
|
|
5560
|
+
const flatParent = (node) => node.assignedSlot ??
|
|
5561
|
+
node.parentElement ??
|
|
5562
|
+
node.parentNode?.host ??
|
|
5563
|
+
null;
|
|
5564
|
+
/**
|
|
5565
|
+
* The element's children in the flattened tree, in the order they render: a
|
|
5566
|
+
* host's shadow root content, a slot's assigned elements (or its fallback
|
|
5567
|
+
* content when nothing is assigned), anything else's own children. A host
|
|
5568
|
+
* whose shadow root is closed has no root to read, so its own children stand
|
|
5569
|
+
* in, including any that no slot takes and that aren't rendered.
|
|
5570
|
+
*/
|
|
5571
|
+
function flatChildren(el) {
|
|
5572
|
+
if (el.shadowRoot)
|
|
5573
|
+
return Array.from(el.shadowRoot.children);
|
|
5574
|
+
if (el.localName === 'slot') {
|
|
5575
|
+
const assigned = el.assignedNodes();
|
|
5576
|
+
if (assigned.length > 0) {
|
|
5577
|
+
return assigned.filter((node) => node.nodeType === Node.ELEMENT_NODE);
|
|
5578
|
+
}
|
|
5579
|
+
}
|
|
5580
|
+
return Array.from(el.children);
|
|
5581
|
+
}
|
|
5582
|
+
const isShown = (el) => !el.hasAttribute('inert') && getComputedStyle(el).display !== 'none';
|
|
5583
|
+
const isVisible = (el) => {
|
|
5584
|
+
const { visibility } = getComputedStyle(el);
|
|
5585
|
+
return visibility !== 'hidden' && visibility !== 'collapse';
|
|
5586
|
+
};
|
|
5587
|
+
/**
|
|
5588
|
+
* Whether nothing from `el` up to `top` hides it: `inert`, `display: none`
|
|
5589
|
+
* (not inherited, so each element is checked) or a closed `<details>` it
|
|
5590
|
+
* isn't the summary of. `top` itself isn't checked, and `null` walks to the
|
|
5591
|
+
* top of the page.
|
|
5592
|
+
*/
|
|
5593
|
+
function isShownUpTo(el, top) {
|
|
5594
|
+
let below = null;
|
|
5595
|
+
for (let node = el; node && node !== top; node = flatParent(node)) {
|
|
5596
|
+
if (!isShown(node))
|
|
5597
|
+
return false;
|
|
5598
|
+
if (below &&
|
|
5599
|
+
node.localName === 'details' &&
|
|
5600
|
+
!node.hasAttribute('open') &&
|
|
5601
|
+
!isDetailsSummary(below)) {
|
|
5602
|
+
return false;
|
|
5517
5603
|
}
|
|
5604
|
+
below = node;
|
|
5605
|
+
}
|
|
5606
|
+
return true;
|
|
5607
|
+
}
|
|
5608
|
+
/**
|
|
5609
|
+
* The images that use the image map `area` belongs to. An image names its
|
|
5610
|
+
* map with `usemap="#…"`, matching the map's `name` or `id`.
|
|
5611
|
+
*/
|
|
5612
|
+
function imagesUsingMapOf(area) {
|
|
5613
|
+
const map = area.closest('map');
|
|
5614
|
+
if (!map)
|
|
5615
|
+
return [];
|
|
5616
|
+
const names = [map.getAttribute('name'), map.id].filter(name => name);
|
|
5617
|
+
return Array.from(area.getRootNode().querySelectorAll('img[usemap]')).filter(img => {
|
|
5618
|
+
const ref = img.getAttribute('usemap');
|
|
5619
|
+
return ref.startsWith('#') && names.includes(ref.slice(1));
|
|
5518
5620
|
});
|
|
5519
5621
|
}
|
|
5520
|
-
|
|
5521
|
-
// INTERNAL: deliberately not exported from src/index.ts.
|
|
5522
5622
|
/**
|
|
5523
|
-
*
|
|
5524
|
-
*
|
|
5525
|
-
*
|
|
5623
|
+
* Whether an image map area is shown. An area has no box of its own: the
|
|
5624
|
+
* HTML rendering rules give it `display: none`, though a browser may not
|
|
5625
|
+
* report that. So it counts where an image using its map is shown, as long
|
|
5626
|
+
* as nothing makes it inert.
|
|
5526
5627
|
*/
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5628
|
+
function isAreaShown(area, container) {
|
|
5629
|
+
for (let node = area; node && node !== container; node = flatParent(node)) {
|
|
5630
|
+
if (node.hasAttribute('inert'))
|
|
5631
|
+
return false;
|
|
5632
|
+
}
|
|
5633
|
+
return imagesUsingMapOf(area).some(img => isShownUpTo(img, null) && isVisible(img));
|
|
5634
|
+
}
|
|
5530
5635
|
/**
|
|
5531
|
-
*
|
|
5532
|
-
*
|
|
5533
|
-
*
|
|
5636
|
+
* Whether Tab can land on `el`, one of the kinds it visits. A disabled control
|
|
5637
|
+
* (including one inside a disabled `<fieldset>`), an inert subtree and
|
|
5638
|
+
* anything not rendered are skipped. `visibility` is inherited, so the
|
|
5639
|
+
* element's own computed value covers its ancestors.
|
|
5534
5640
|
*/
|
|
5535
|
-
function
|
|
5536
|
-
|
|
5537
|
-
|
|
5641
|
+
function isRenderedAndEnabled(el, container) {
|
|
5642
|
+
if (el.matches(':disabled'))
|
|
5643
|
+
return false;
|
|
5644
|
+
if (el.localName === 'area')
|
|
5645
|
+
return isAreaShown(el, container);
|
|
5646
|
+
return isShownUpTo(el, container) && isVisible(el);
|
|
5538
5647
|
}
|
|
5648
|
+
/** A radio button with a name, which shares one tab stop with its group. */
|
|
5649
|
+
const isGroupedRadio = (el) => el.localName === 'input' &&
|
|
5650
|
+
el.type === 'radio' &&
|
|
5651
|
+
el.name !== '';
|
|
5652
|
+
/** Whether two radio buttons are in one group: same name, form owner and tree. */
|
|
5653
|
+
const sameGroup = (a, b) => isGroupedRadio(b) &&
|
|
5654
|
+
b.name === a.name &&
|
|
5655
|
+
b.form === a.form &&
|
|
5656
|
+
b.getRootNode() === a.getRootNode();
|
|
5539
5657
|
/**
|
|
5540
|
-
* The
|
|
5541
|
-
*
|
|
5658
|
+
* The checked button of `radio`'s group. The group isn't bounded by the trap,
|
|
5659
|
+
* so this looks through the radio's whole tree.
|
|
5542
5660
|
*/
|
|
5543
|
-
|
|
5544
|
-
|
|
5545
|
-
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5661
|
+
const checkedInGroup = (radio) => Array.from(radio.getRootNode().querySelectorAll('input:checked')).find(el => sameGroup(radio, el));
|
|
5662
|
+
/** The flattened-tree ancestors of `node` below the container, outermost first, then `node`. */
|
|
5663
|
+
function flatChain(node, container) {
|
|
5664
|
+
const chain = [];
|
|
5665
|
+
for (let n = node; n && n !== container; n = flatParent(n)) {
|
|
5666
|
+
chain.unshift(n);
|
|
5667
|
+
}
|
|
5668
|
+
return chain;
|
|
5549
5669
|
}
|
|
5550
|
-
const samePosition = (a, b) => a.position === b.position && a.top === b.top && a.left === b.left;
|
|
5551
5670
|
/**
|
|
5552
|
-
*
|
|
5553
|
-
*
|
|
5554
|
-
*
|
|
5555
|
-
* iframe's, when the anchor renders into one), so a fixed panel follows its
|
|
5556
|
-
* anchor. A measurement that changes nothing doesn't render again.
|
|
5557
|
-
*
|
|
5558
|
-
* Measuring happens in a layout effect, so the panel is placed before it
|
|
5559
|
-
* paints. It needs both elements on the page while `active` is on, and
|
|
5560
|
-
* keeps the last placement when either is missing.
|
|
5561
|
-
*
|
|
5562
|
-
* @function useAnchoredPosition
|
|
5563
|
-
* @param anchorRef - The element the panel opens from.
|
|
5564
|
-
* @param panelRef - The floating panel.
|
|
5565
|
-
* @param options - Whether to measure, the corner asked for, and how the panel is placed.
|
|
5566
|
-
* @returns The corner the panel opens from, with coordinates when it is fixed.
|
|
5671
|
+
* Whether `node` is inside the container in the flattened tree: in it, in a
|
|
5672
|
+
* shadow root it holds, or slotted into it. A child of a closed-root host
|
|
5673
|
+
* counts as inside wherever the host is, as its slot can't be seen.
|
|
5567
5674
|
*/
|
|
5568
|
-
function
|
|
5569
|
-
|
|
5570
|
-
|
|
5571
|
-
|
|
5572
|
-
|
|
5573
|
-
|
|
5574
|
-
return undefined;
|
|
5575
|
-
const view = (anchorRef.current ?? panelRef.current)?.ownerDocument.defaultView ??
|
|
5576
|
-
window;
|
|
5577
|
-
const update = () => {
|
|
5578
|
-
const anchor = anchorRef.current;
|
|
5579
|
-
const panel = panelRef.current;
|
|
5580
|
-
if (!anchor || !panel)
|
|
5581
|
-
return;
|
|
5582
|
-
const rect = anchor.getBoundingClientRect();
|
|
5583
|
-
const { width, height } = panel.getBoundingClientRect();
|
|
5584
|
-
const corner = position === 'auto'
|
|
5585
|
-
? resolveAuto(view, rect, width, height, offset + (styledGap?.(panel) ?? 0))
|
|
5586
|
-
: position;
|
|
5587
|
-
const next = fixed
|
|
5588
|
-
? {
|
|
5589
|
-
position: corner,
|
|
5590
|
-
top: corner.startsWith('bottom')
|
|
5591
|
-
? rect.bottom + offset
|
|
5592
|
-
: rect.top - height - offset,
|
|
5593
|
-
left: corner.endsWith('left') ? rect.left : rect.right - width,
|
|
5594
|
-
}
|
|
5595
|
-
: { position: corner };
|
|
5596
|
-
setResolved(prev => (samePosition(prev, next) ? prev : next));
|
|
5597
|
-
};
|
|
5598
|
-
update();
|
|
5599
|
-
view.addEventListener('resize', update, { passive: true });
|
|
5600
|
-
view.addEventListener('scroll', update, { passive: true, capture: true });
|
|
5601
|
-
return () => {
|
|
5602
|
-
view.removeEventListener('resize', update);
|
|
5603
|
-
view.removeEventListener('scroll', update, { capture: true });
|
|
5604
|
-
};
|
|
5605
|
-
}, [active, anchorRef, panelRef, position, fixed, offset, styledGap]);
|
|
5606
|
-
return resolved;
|
|
5675
|
+
function inside(container, node) {
|
|
5676
|
+
for (let n = node; n; n = flatParent(n)) {
|
|
5677
|
+
if (n === container)
|
|
5678
|
+
return true;
|
|
5679
|
+
}
|
|
5680
|
+
return false;
|
|
5607
5681
|
}
|
|
5608
|
-
|
|
5609
|
-
const validButtonColors = [...validColors, 'text', 'ghost'];
|
|
5610
5682
|
/**
|
|
5611
|
-
*
|
|
5612
|
-
*
|
|
5613
|
-
*
|
|
5614
|
-
*
|
|
5615
|
-
*
|
|
5616
|
-
*
|
|
5617
|
-
|
|
5683
|
+
* `true` when Tab reaches `b` after `a`, which is their order in the
|
|
5684
|
+
* flattened tree: a shadow host comes before its shadow root's content, and
|
|
5685
|
+
* slotted content comes where its slot is. A closed root's slots can't be
|
|
5686
|
+
* seen, so its host's children keep their place in the light DOM. Two
|
|
5687
|
+
* flattened-tree siblings are always in one DOM tree, so their document
|
|
5688
|
+
* position decides between them.
|
|
5689
|
+
*/
|
|
5690
|
+
function follows(container, a, b) {
|
|
5691
|
+
const chainA = flatChain(a, container);
|
|
5692
|
+
const chainB = flatChain(b, container);
|
|
5693
|
+
for (let i = 0; i < Math.max(chainA.length, chainB.length); i++) {
|
|
5694
|
+
const [x, y] = [chainA[i], chainB[i]];
|
|
5695
|
+
if (x === y)
|
|
5696
|
+
continue;
|
|
5697
|
+
if (!x)
|
|
5698
|
+
return true;
|
|
5699
|
+
if (!y)
|
|
5700
|
+
return false;
|
|
5701
|
+
return Boolean(x.compareDocumentPosition(y) & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
5702
|
+
}
|
|
5703
|
+
return false;
|
|
5704
|
+
}
|
|
5705
|
+
/**
|
|
5706
|
+
* Every element of a tabbable kind under `el` in the flattened tree, depth
|
|
5707
|
+
* first, which is the order Tab visits them. Under a host whose shadow root
|
|
5708
|
+
* is closed, that takes in the host's own children instead, rendered or not.
|
|
5709
|
+
*/
|
|
5710
|
+
function collectCandidates(el, out = []) {
|
|
5711
|
+
for (const child of flatChildren(el)) {
|
|
5712
|
+
if (isTabbableKind(child))
|
|
5713
|
+
out.push(child);
|
|
5714
|
+
collectCandidates(child, out);
|
|
5715
|
+
}
|
|
5716
|
+
return out;
|
|
5717
|
+
}
|
|
5718
|
+
/**
|
|
5719
|
+
* The first and last tab stops inside `container`, or `null` when there are
|
|
5720
|
+
* none. Only the ends matter for wrapping, so the style checks run from each
|
|
5721
|
+
* end until they find a stop rather than over every candidate.
|
|
5722
|
+
*
|
|
5723
|
+
* A radio group is one stop, the way the browser treats it: its checked
|
|
5724
|
+
* button, or its first when none is checked. Counting every button would make
|
|
5725
|
+
* the group's last one an end, and Tab from the checked one would leave. The
|
|
5726
|
+
* group goes by name, form owner and tree, not by the trap, so a checked
|
|
5727
|
+
* button outside the container that Tab can land on leaves the group no stop
|
|
5728
|
+
* inside. One that Tab can't land on leaves the group as if none were checked.
|
|
5618
5729
|
*/
|
|
5619
|
-
|
|
5620
|
-
const
|
|
5621
|
-
const
|
|
5622
|
-
|
|
5623
|
-
|
|
5624
|
-
|
|
5625
|
-
|
|
5626
|
-
|
|
5627
|
-
|
|
5628
|
-
|
|
5629
|
-
|
|
5630
|
-
|
|
5631
|
-
|
|
5632
|
-
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
// `Custom` never received the `name` it requires.
|
|
5655
|
-
//
|
|
5656
|
-
// `autoFocus` and `type` are deliberately NOT stripped: both are valid on
|
|
5657
|
-
// targets this list was filtering them from. `autoFocus` is a global
|
|
5658
|
-
// attribute (React decides per element whether to honour it, which is not
|
|
5659
|
-
// ours to pre-empt), and `type` on an anchor is the MIME hint that
|
|
5660
|
-
// `ButtonProps<'a'>` now types it as.
|
|
5661
|
-
//
|
|
5662
|
-
// The types already answer this for a TypeScript caller: `ButtonProps<T>`
|
|
5663
|
-
// derives from `ComponentPropsWithoutRef<T>`, so only what the target
|
|
5664
|
-
// accepts can be passed. What is left is a runtime backstop for JavaScript
|
|
5665
|
-
// consumers and spread objects.
|
|
5666
|
-
//
|
|
5667
|
-
// Keep it to the two cases where a stray attribute does something. The
|
|
5668
|
-
// submit-overrides belong to a submit control and never make sense on the
|
|
5669
|
-
// anchor path. `disabled` is the one that is VISIBLE: Bulma styles
|
|
5670
|
-
// `.button[disabled]` (background, border, shadow, opacity), so letting it
|
|
5671
|
-
// through to a `<span>` greys the element out — main stripped it, and not
|
|
5672
|
-
// stripping it would be a silent visual change on a types-only release.
|
|
5673
|
-
//
|
|
5674
|
-
// Everything else (`name`, `value`, `form`) is inert where it does not
|
|
5675
|
-
// belong, and enumerating which tags own it is what kept going wrong:
|
|
5676
|
-
// first all nine names on every non-button tag (so `<Button as="input"
|
|
5677
|
-
// name="query" value="Search">` submitted nothing), then a form-control
|
|
5678
|
-
// allowlist that still lost `formAction` on an input and `value` on an
|
|
5679
|
-
// option. Those now ride on the types instead.
|
|
5680
|
-
//
|
|
5681
|
-
// A custom component is exempt entirely — it owns its prop contract, and
|
|
5682
|
-
// `ComponentPropsWithoutRef<T>` promises the caller its props arrive.
|
|
5683
|
-
//
|
|
5684
|
-
// The elements that own `disabled`, per the HTML spec. A closed set, unlike
|
|
5685
|
-
// the open-ended question of which tag owns which attribute.
|
|
5686
|
-
const SUBMIT_OVERRIDES = [
|
|
5687
|
-
'formAction',
|
|
5688
|
-
'formEncType',
|
|
5689
|
-
'formMethod',
|
|
5690
|
-
'formNoValidate',
|
|
5691
|
-
'formTarget',
|
|
5692
|
-
];
|
|
5693
|
-
const OWNS_DISABLED = [
|
|
5694
|
-
'button',
|
|
5695
|
-
'fieldset',
|
|
5696
|
-
'input',
|
|
5697
|
-
'optgroup',
|
|
5698
|
-
'option',
|
|
5699
|
-
'select',
|
|
5700
|
-
'textarea',
|
|
5701
|
-
];
|
|
5702
|
-
// Two independent filters. They are not exclusive — an `<a>` owns neither
|
|
5703
|
-
// the submit-overrides nor `disabled`, so it takes both — and deriving them
|
|
5704
|
-
// from one destructure, or from one if/else, is what broke each of them in
|
|
5705
|
-
// turn: first the overrides came off every intrinsic (an `as="input"` lost
|
|
5706
|
-
// `formAction`), then scoping them to the anchor let `disabled` back onto
|
|
5707
|
-
// it.
|
|
5708
|
-
const forwardedRest = { ...rest };
|
|
5709
|
-
if (typeof Component === 'string' && !isCustomElement(Component)) {
|
|
5710
|
-
// Meaningless anywhere but a submit control, and an anchor is not one.
|
|
5711
|
-
if (Component === 'a') {
|
|
5712
|
-
for (const key of SUBMIT_OVERRIDES)
|
|
5713
|
-
delete forwardedRest[key];
|
|
5714
|
-
}
|
|
5715
|
-
// Visible via Bulma's `.button[disabled]`, so withheld from any element
|
|
5716
|
-
// that does not own it.
|
|
5717
|
-
if (!OWNS_DISABLED.includes(Component))
|
|
5718
|
-
delete forwardedRest.disabled;
|
|
5730
|
+
function findTabStops(container) {
|
|
5731
|
+
const candidates = collectCandidates(container);
|
|
5732
|
+
const checked = new Map();
|
|
5733
|
+
const usable = (el) => {
|
|
5734
|
+
let ok = checked.get(el);
|
|
5735
|
+
if (ok === undefined) {
|
|
5736
|
+
ok = isRenderedAndEnabled(el, container);
|
|
5737
|
+
checked.set(el, ok);
|
|
5738
|
+
}
|
|
5739
|
+
return ok;
|
|
5740
|
+
};
|
|
5741
|
+
// Resolved once per group, filed under each of its buttons, since finding
|
|
5742
|
+
// the checked one scans the radio's whole tree.
|
|
5743
|
+
const groupStops = new Map();
|
|
5744
|
+
const groupStop = (radio) => {
|
|
5745
|
+
if (groupStops.has(radio))
|
|
5746
|
+
return groupStops.get(radio);
|
|
5747
|
+
const group = candidates.filter(el => sameGroup(radio, el));
|
|
5748
|
+
const on = checkedInGroup(radio);
|
|
5749
|
+
const stop = on && isTabbableKind(on) && usable(on)
|
|
5750
|
+
? group.find(el => el === on)
|
|
5751
|
+
: group.find(usable);
|
|
5752
|
+
for (const el of [radio, ...group])
|
|
5753
|
+
groupStops.set(el, stop);
|
|
5754
|
+
return stop;
|
|
5755
|
+
};
|
|
5756
|
+
const isStop = (el) => usable(el) && (!isGroupedRadio(el) || groupStop(el) === el);
|
|
5757
|
+
const first = candidates.find(isStop);
|
|
5758
|
+
if (!first)
|
|
5759
|
+
return null;
|
|
5760
|
+
let last = first;
|
|
5761
|
+
for (let i = candidates.length - 1; candidates[i] !== first; i--) {
|
|
5762
|
+
if (isStop(candidates[i])) {
|
|
5763
|
+
last = candidates[i];
|
|
5764
|
+
break;
|
|
5719
5765
|
}
|
|
5720
|
-
return (jsxRuntime.jsx(Component, { ref: ref, className: buttonClasses, href: href, target: target, rel: rel, "aria-disabled": isDisabled, tabIndex: isDisabled ? -1 : undefined, onClick: isDisabled
|
|
5721
|
-
? // Guarded, because this handler is installed into whatever `as`
|
|
5722
|
-
// renders and a custom component's `onClick` need not be a DOM
|
|
5723
|
-
// event handler at all. One declaring `onClick: (v: string) =>
|
|
5724
|
-
// void` calls this with a string, and an unguarded
|
|
5725
|
-
// `e.preventDefault()` throws `is not a function`. Blocking the
|
|
5726
|
-
// real event is the point; a foreign payload is simply not ours
|
|
5727
|
-
// to act on.
|
|
5728
|
-
(e) => {
|
|
5729
|
-
if (typeof e?.preventDefault === 'function')
|
|
5730
|
-
e.preventDefault();
|
|
5731
|
-
}
|
|
5732
|
-
: onClick, ...forwardedRest, children: children }));
|
|
5733
5766
|
}
|
|
5734
|
-
return
|
|
5735
|
-
|
|
5736
|
-
|
|
5737
|
-
|
|
5738
|
-
|
|
5739
|
-
|
|
5740
|
-
const NATIVE_CONTROLS = new Set([
|
|
5741
|
-
'button',
|
|
5742
|
-
'input',
|
|
5743
|
-
'select',
|
|
5744
|
-
'textarea',
|
|
5745
|
-
'summary',
|
|
5746
|
-
]);
|
|
5767
|
+
return {
|
|
5768
|
+
first,
|
|
5769
|
+
last,
|
|
5770
|
+
stopFor: node => (isGroupedRadio(node) && groupStop(node)) || node,
|
|
5771
|
+
};
|
|
5772
|
+
}
|
|
5747
5773
|
/**
|
|
5748
|
-
*
|
|
5749
|
-
*
|
|
5774
|
+
* The first tab stop inside `container`, found the way the trap finds it, or
|
|
5775
|
+
* `null` when there is none. For a component that moves focus into a panel
|
|
5776
|
+
* without trapping it there. `src/index.ts` names its exports from this
|
|
5777
|
+
* module, so this one stays internal.
|
|
5750
5778
|
*/
|
|
5751
|
-
function
|
|
5752
|
-
|
|
5753
|
-
return true;
|
|
5754
|
-
return el.localName === 'a' && key === 'Enter' && el.hasAttribute('href');
|
|
5755
|
-
}
|
|
5756
|
-
/** The innermost dialog the event passed through, which is the one its Escape belongs to. */
|
|
5757
|
-
function nearestDialog(event) {
|
|
5758
|
-
return (event.composedPath().find(node => {
|
|
5759
|
-
const role = node.getAttribute?.('role');
|
|
5760
|
-
return role === 'dialog' || role === 'alertdialog';
|
|
5761
|
-
}) ?? null);
|
|
5779
|
+
function firstTabStop(container) {
|
|
5780
|
+
return findTabStops(container)?.first ?? null;
|
|
5762
5781
|
}
|
|
5763
5782
|
/**
|
|
5764
|
-
* The
|
|
5783
|
+
* The element that has focus in the container's own document (an iframe's,
|
|
5784
|
+
* when it renders into one), inside open shadow roots too. Focus inside a
|
|
5785
|
+
* closed one reads as its host, which the comparisons above place like any
|
|
5786
|
+
* other element. Browsers fall back to <body> when nothing has
|
|
5787
|
+
* focus. jsdom, where consumers run their tests, reports nothing once focus
|
|
5788
|
+
* was inside a shadow root that was removed, so this falls back for it.
|
|
5789
|
+
*/
|
|
5790
|
+
const focusedElement = (doc) => getDeepestActiveElement(doc) ?? doc.body;
|
|
5791
|
+
/**
|
|
5792
|
+
* Keeps keyboard focus inside a container while it is active: focus moves in
|
|
5793
|
+
* when the trap turns on, Tab and Shift+Tab wrap between the first and last
|
|
5794
|
+
* tab stops, and focus goes back where it came from when the trap turns off.
|
|
5765
5795
|
*
|
|
5766
|
-
*
|
|
5767
|
-
*
|
|
5768
|
-
*
|
|
5769
|
-
*
|
|
5796
|
+
* Tab stops are what the browser's Tab visits, in document order: links,
|
|
5797
|
+
* form controls, frames, media with controls, a `<details>` summary, editable
|
|
5798
|
+
* regions, anything with `tabIndex={0}`, and the content of open shadow
|
|
5799
|
+
* roots. Hidden, disabled, inert and `tabIndex={-1}` elements are skipped,
|
|
5800
|
+
* and a radio group counts once. Give the container `tabIndex={-1}` so it can
|
|
5801
|
+
* hold focus itself when nothing inside can. The trap listens on the
|
|
5802
|
+
* container, so a trap nested inside another handles Tab first and the outer
|
|
5803
|
+
* one stands aside.
|
|
5770
5804
|
*
|
|
5771
|
-
*
|
|
5772
|
-
*
|
|
5773
|
-
*
|
|
5805
|
+
* The trap is about Tab. If a Tab it let through still takes focus out (a
|
|
5806
|
+
* browser can leave out a stop the page has, such as links when its settings
|
|
5807
|
+
* say to), it sends focus back to the other end. A pointer or a script moving
|
|
5808
|
+
* focus out is left alone, because pulling focus back from a click is modal
|
|
5809
|
+
* behaviour, and a modal also has to block the page behind it, which `Modal`
|
|
5810
|
+
* does. Content the container renders through a portal lives elsewhere in
|
|
5811
|
+
* the DOM, so it is outside the trap: render nested overlays inside the
|
|
5812
|
+
* container, or give them a trap of their own. To trap content a `Portal`
|
|
5813
|
+
* renders, pass a ref to the element inside the `Portal` as `containerRef`.
|
|
5814
|
+
*
|
|
5815
|
+
* The trap looks at `containerRef` again each time the component calling it
|
|
5816
|
+
* renders. A container that mounts after the trap turns on, such as a panel
|
|
5817
|
+
* waiting for a `Portal` target held in state, has it attach once that
|
|
5818
|
+
* component renders with the container in the DOM. When the container leaves
|
|
5819
|
+
* or is replaced, the trap lets go of it as it would turning off, handing
|
|
5820
|
+
* focus back, and attaches to the next one.
|
|
5821
|
+
* A child that mounts the container in a render of its own (from its own
|
|
5822
|
+
* state, or as a `Suspense` boundary resolves) goes unseen until the
|
|
5823
|
+
* component calling the hook renders again. The trap waits for hydration, so
|
|
5824
|
+
* that component renders again when the page hydrates, and portaled content,
|
|
5825
|
+
* which first appears then, is found.
|
|
5826
|
+
*
|
|
5827
|
+
* @function useFocusTrap
|
|
5828
|
+
* @param containerRef - Ref to the element focus stays inside.
|
|
5829
|
+
* @param options - When the trap is on, and where focus starts and ends.
|
|
5774
5830
|
*
|
|
5775
5831
|
* @example
|
|
5776
|
-
*
|
|
5777
|
-
*
|
|
5778
|
-
*
|
|
5779
|
-
* <
|
|
5780
|
-
*
|
|
5781
|
-
* </
|
|
5782
|
-
*
|
|
5832
|
+
* const panelRef = useRef<HTMLDivElement>(null);
|
|
5833
|
+
* useFocusTrap(panelRef, { active: isOpen });
|
|
5834
|
+
* return isOpen ? (
|
|
5835
|
+
* <div ref={panelRef} role="dialog" aria-label="Filters" tabIndex={-1}>
|
|
5836
|
+
* …
|
|
5837
|
+
* </div>
|
|
5838
|
+
* ) : null;
|
|
5783
5839
|
*/
|
|
5784
|
-
|
|
5785
|
-
const {
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
// The last pointerdown React saw inside the panel. React's tree includes
|
|
5789
|
-
// content the panel portals elsewhere, which the DOM path doesn't.
|
|
5790
|
-
const insidePointerRef = React.useRef(null);
|
|
5791
|
-
const baseId = React.useId();
|
|
5792
|
-
const panelId = `${baseId}-panel`;
|
|
5793
|
-
const titleId = `${baseId}-title`;
|
|
5794
|
-
const isControlled = controlledOpen !== undefined;
|
|
5795
|
-
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
|
5796
|
-
const isOpen = controlledOpen ?? internalOpen;
|
|
5797
|
-
const requestOpen = React.useCallback((next) => {
|
|
5798
|
-
onOpenChange?.(next);
|
|
5799
|
-
if (!isControlled)
|
|
5800
|
-
setInternalOpen(next);
|
|
5801
|
-
}, [isControlled, onOpenChange]);
|
|
5802
|
-
const close = React.useCallback(() => requestOpen(false), [requestOpen]);
|
|
5803
|
-
// A portaled panel has no server markup, so it only exists from the commit
|
|
5804
|
-
// after hydration. Everything that measures or focuses it waits for that.
|
|
5840
|
+
function useFocusTrap(containerRef, options = {}) {
|
|
5841
|
+
const { active = true, initialFocusRef, restoreFocus = true } = options;
|
|
5842
|
+
// Portaled content has no server-rendered counterpart, so a portaled
|
|
5843
|
+
// container only exists from the commit after hydration.
|
|
5805
5844
|
const hydrated = useIsHydrated();
|
|
5806
|
-
|
|
5807
|
-
const
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
fixed: appendToBody,
|
|
5811
|
-
// The gap comes from `--bulma-popover-offset` in the stylesheet, so the
|
|
5812
|
-
// coordinates leave it out and `auto` reads it off the panel.
|
|
5813
|
-
offset: 0,
|
|
5814
|
-
styledGap: marginGap,
|
|
5815
|
-
});
|
|
5816
|
-
// The trigger renders first inside the wrapper. Read when needed rather
|
|
5817
|
-
// than held, so a trigger that remounted is still the one found.
|
|
5818
|
-
const triggerNode = React.useCallback(() => {
|
|
5819
|
-
const first = rootRef.current?.firstElementChild;
|
|
5820
|
-
return first && first !== panelRef.current ? first : null;
|
|
5821
|
-
}, []);
|
|
5822
|
-
const restoreFocusRef = React.useMemo(() => ({
|
|
5823
|
-
get current() {
|
|
5824
|
-
return triggerNode();
|
|
5825
|
-
},
|
|
5826
|
-
}), [triggerNode]);
|
|
5827
|
-
useFocusTrap(panelRef, {
|
|
5828
|
-
active: panelMounted && trapFocus,
|
|
5829
|
-
restoreFocus: restoreFocusRef,
|
|
5830
|
-
});
|
|
5831
|
-
// Without the trap, focus still arrives in the panel and goes back to the
|
|
5832
|
-
// trigger, on the trap's terms: only while the panel still holds it.
|
|
5845
|
+
// The container the trap is attached to, or `null` while it has none.
|
|
5846
|
+
const heldRef = React.useRef(null);
|
|
5847
|
+
// Bumped to run the trap again when the container in the ref changes.
|
|
5848
|
+
const [refChanges, setRefChanges] = React.useState(0);
|
|
5833
5849
|
React.useEffect(() => {
|
|
5834
|
-
|
|
5835
|
-
if (!
|
|
5850
|
+
heldRef.current = null;
|
|
5851
|
+
if (!active || !hydrated)
|
|
5836
5852
|
return undefined;
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
const doc = panel.ownerDocument;
|
|
5840
|
-
const now = getDeepestActiveElement(doc);
|
|
5841
|
-
if (!now || now === doc.body || panel.contains(now)) {
|
|
5842
|
-
triggerNode()?.focus();
|
|
5843
|
-
}
|
|
5844
|
-
};
|
|
5845
|
-
}, [panelMounted, trapFocus, triggerNode]);
|
|
5846
|
-
React.useEffect(() => {
|
|
5847
|
-
if (!panelMounted || !closeOnClickOutside)
|
|
5853
|
+
const container = containerRef.current;
|
|
5854
|
+
if (!container)
|
|
5848
5855
|
return undefined;
|
|
5849
|
-
|
|
5850
|
-
|
|
5856
|
+
heldRef.current = container;
|
|
5857
|
+
const doc = container.ownerDocument;
|
|
5858
|
+
const opener = focusedElement(doc);
|
|
5859
|
+
// `initialFocusRef` counts only when focusing it puts focus inside. Focus
|
|
5860
|
+
// put outside would stay out, as Tab there never reaches the container's
|
|
5861
|
+
// listener, and focusing something that can't take focus (a heading
|
|
5862
|
+
// without `tabIndex`, a disabled control) leaves focus where it was.
|
|
5863
|
+
const initial = initialFocusRef?.current;
|
|
5864
|
+
const inTrap = initial && inside(container, initial) ? initial : null;
|
|
5865
|
+
inTrap?.focus();
|
|
5866
|
+
if (!inTrap || !inside(container, focusedElement(doc))) {
|
|
5867
|
+
(findTabStops(container)?.first ?? container).focus();
|
|
5868
|
+
}
|
|
5869
|
+
// Where focus goes if a Tab the trap let the browser handle takes it out,
|
|
5870
|
+
// held until that Tab has finished moving focus.
|
|
5871
|
+
let wrapTo = null;
|
|
5872
|
+
let wrapTimer;
|
|
5873
|
+
const handleKeyDown = (e) => {
|
|
5874
|
+
// A trap nested inside this one has already wrapped focus.
|
|
5875
|
+
if (e.key !== 'Tab' || e.defaultPrevented)
|
|
5876
|
+
return;
|
|
5877
|
+
const stops = findTabStops(container);
|
|
5878
|
+
if (!stops) {
|
|
5879
|
+
e.preventDefault();
|
|
5880
|
+
container.focus();
|
|
5851
5881
|
return;
|
|
5852
5882
|
}
|
|
5853
|
-
|
|
5854
|
-
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
5861
|
-
const handlePanelKeyDown = (e) => {
|
|
5862
|
-
if (e.key !== 'Escape' || !closeOnEscape || e.defaultPrevented)
|
|
5863
|
-
return;
|
|
5864
|
-
// React bubbles the key here from portaled content as well, so a dialog
|
|
5865
|
-
// of its own on the way (a nested popover, a picker's calendar) owns it.
|
|
5866
|
-
const owner = nearestDialog(e.nativeEvent);
|
|
5867
|
-
if (owner && owner !== panelRef.current)
|
|
5868
|
-
return;
|
|
5869
|
-
e.preventDefault();
|
|
5870
|
-
close();
|
|
5871
|
-
};
|
|
5872
|
-
const triggerProps = trigger.props;
|
|
5873
|
-
const handleTriggerClick = (e) => {
|
|
5874
|
-
triggerProps.onClick?.(e);
|
|
5875
|
-
if (!e.defaultPrevented)
|
|
5876
|
-
requestOpen(!isOpen);
|
|
5877
|
-
};
|
|
5878
|
-
const handleTriggerKeyDown = (e) => {
|
|
5879
|
-
triggerProps.onKeyDown?.(e);
|
|
5880
|
-
// A key on something inside the trigger is that element's to answer.
|
|
5881
|
-
if (e.defaultPrevented || e.target !== e.currentTarget)
|
|
5882
|
-
return;
|
|
5883
|
-
if (e.key === 'Escape') {
|
|
5884
|
-
if (isOpen && closeOnEscape) {
|
|
5883
|
+
const current = stops.stopFor(focusedElement(doc));
|
|
5884
|
+
// Wrap whenever the browser has no stop left in that direction inside
|
|
5885
|
+
// the container. Comparing positions rather than testing `=== first`
|
|
5886
|
+
// also covers focus resting on the container or on an element outside
|
|
5887
|
+
// the tab order, which the browser would otherwise walk straight out of.
|
|
5888
|
+
if (e.shiftKey
|
|
5889
|
+
? !follows(container, stops.first, current)
|
|
5890
|
+
: !follows(container, current, stops.last)) {
|
|
5885
5891
|
e.preventDefault();
|
|
5886
|
-
|
|
5892
|
+
(e.shiftKey ? stops.last : stops.first).focus();
|
|
5893
|
+
return;
|
|
5887
5894
|
}
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
5892
|
-
|
|
5893
|
-
|
|
5894
|
-
//
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5895
|
+
wrapTo = e.shiftKey ? stops.last : stops.first;
|
|
5896
|
+
clearTimeout(wrapTimer);
|
|
5897
|
+
wrapTimer = setTimeout(() => {
|
|
5898
|
+
wrapTo = null;
|
|
5899
|
+
});
|
|
5900
|
+
};
|
|
5901
|
+
// The browser moves focus as soon as the keydown is over, so focus that
|
|
5902
|
+
// lands outside while `wrapTo` is set got there by that Tab.
|
|
5903
|
+
const handleFocusIn = (e) => {
|
|
5904
|
+
if (!wrapTo || inside(container, e.composedPath()[0]))
|
|
5905
|
+
return;
|
|
5906
|
+
const target = wrapTo;
|
|
5907
|
+
wrapTo = null;
|
|
5908
|
+
target.focus();
|
|
5909
|
+
};
|
|
5910
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
5911
|
+
doc.addEventListener('focusin', handleFocusIn, true);
|
|
5912
|
+
return () => {
|
|
5913
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
5914
|
+
doc.removeEventListener('focusin', handleFocusIn, true);
|
|
5915
|
+
clearTimeout(wrapTimer);
|
|
5916
|
+
let target = restoreFocus ? opener : null;
|
|
5917
|
+
// `null` is an object too, and a caller without types can pass it.
|
|
5918
|
+
if (restoreFocus && typeof restoreFocus === 'object') {
|
|
5919
|
+
// Read now rather than when the trap turned on: a trigger that
|
|
5920
|
+
// re-mounted in between has a new node.
|
|
5921
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5922
|
+
target = restoreFocus.current;
|
|
5907
5923
|
}
|
|
5908
|
-
:
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
* `Popover.Header`s inside it afresh every time.
|
|
5933
|
-
*/
|
|
5934
|
-
function PopoverPanel({ panelRef, titleId, ariaLabel, close, children, ...attrs }) {
|
|
5935
|
-
// Headers register in a layout effect, after this first render, and this
|
|
5936
|
-
// panel's own layout effect runs after theirs. Until then the panel assumes
|
|
5937
|
-
// it has one, so a panel that does is named from its first commit, before
|
|
5938
|
-
// focus moves in, and one that doesn't drops the reference before it paints.
|
|
5939
|
-
const [titlesCounted, setTitlesCounted] = React.useState(false);
|
|
5940
|
-
const [titleCount, setTitleCount] = React.useState(0);
|
|
5941
|
-
const registerTitle = React.useCallback(() => {
|
|
5942
|
-
setTitleCount(n => n + 1);
|
|
5943
|
-
return () => setTitleCount(n => n - 1);
|
|
5944
|
-
}, []);
|
|
5945
|
-
useClientLayoutEffect(() => {
|
|
5946
|
-
setTitlesCounted(true);
|
|
5947
|
-
}, []);
|
|
5948
|
-
const named = Boolean(ariaLabel) || titleCount > 0;
|
|
5924
|
+
// Hand focus back only while the trap still holds it: inside the
|
|
5925
|
+
// container, or dropped to <body> because the container left the page.
|
|
5926
|
+
const now = focusedElement(doc);
|
|
5927
|
+
if (target && (now === doc.body || inside(container, now))) {
|
|
5928
|
+
target.focus();
|
|
5929
|
+
}
|
|
5930
|
+
};
|
|
5931
|
+
}, [
|
|
5932
|
+
active,
|
|
5933
|
+
hydrated,
|
|
5934
|
+
containerRef,
|
|
5935
|
+
initialFocusRef,
|
|
5936
|
+
restoreFocus,
|
|
5937
|
+
refChanges,
|
|
5938
|
+
]);
|
|
5939
|
+
// Nothing says when a ref is set, so after every render, a ref that no
|
|
5940
|
+
// longer matches what the trap holds (a container that mounted, left or was
|
|
5941
|
+
// replaced) runs it again. Effects run in order, so whenever the effect
|
|
5942
|
+
// above ran in this commit, it read the ref just before this does, and a
|
|
5943
|
+
// mismatch means the ref changed in a render it didn't run for. The render
|
|
5944
|
+
// a bump asks for runs it, so the two match and the trap settles. An empty
|
|
5945
|
+
// ref matches a trap holding nothing, so a container that never mounts
|
|
5946
|
+
// costs no render.
|
|
5947
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- after every render on purpose
|
|
5949
5948
|
React.useEffect(() => {
|
|
5950
|
-
if (
|
|
5951
|
-
|
|
5952
|
-
'Pass ariaLabel, or render a Popover.Header inside it.');
|
|
5949
|
+
if (active && hydrated && containerRef.current !== heldRef.current) {
|
|
5950
|
+
setRefChanges(n => n + 1);
|
|
5953
5951
|
}
|
|
5954
|
-
}
|
|
5955
|
-
const context = React.useMemo(() => ({ titleId, registerTitle, close }), [titleId, registerTitle, close]);
|
|
5956
|
-
return (jsxRuntime.jsx(PopoverContext.Provider, { value: context, children: jsxRuntime.jsx("div", { ...attrs, ref: panelRef, role: "dialog", "aria-label": ariaLabel, "aria-labelledby": !ariaLabel && (!titlesCounted || titleCount > 0) ? titleId : undefined, tabIndex: -1, children: children }) }));
|
|
5952
|
+
});
|
|
5957
5953
|
}
|
|
5954
|
+
|
|
5955
|
+
// INTERNAL: deliberately not exported from src/index.ts.
|
|
5958
5956
|
/**
|
|
5959
|
-
*
|
|
5960
|
-
*
|
|
5961
|
-
*
|
|
5962
|
-
* @param {PopoverHeaderProps} props - Props for the Popover.Header component.
|
|
5963
|
-
* @returns {JSX.Element} The header element.
|
|
5957
|
+
* A `styledGap` for a panel whose stylesheet puts the gap in a margin on the
|
|
5958
|
+
* side facing the anchor, negative on a portaled panel above it, so the
|
|
5959
|
+
* larger magnitude is the gap. In pixels.
|
|
5964
5960
|
*/
|
|
5965
|
-
|
|
5966
|
-
const
|
|
5967
|
-
|
|
5968
|
-
|
|
5969
|
-
useClientLayoutEffect(() => registerTitle?.(), [registerTitle]);
|
|
5970
|
-
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
5971
|
-
return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-header'), bulmaHelperClasses, className), ...rest, id: context?.titleId }));
|
|
5972
|
-
};
|
|
5961
|
+
function marginGap(panel) {
|
|
5962
|
+
const { marginTop, marginBottom } = getComputedStyle(panel);
|
|
5963
|
+
return Math.max(Math.abs(parseFloat(marginTop)) || 0, Math.abs(parseFloat(marginBottom)) || 0);
|
|
5964
|
+
}
|
|
5973
5965
|
/**
|
|
5974
|
-
* The
|
|
5975
|
-
*
|
|
5976
|
-
* @function
|
|
5977
|
-
* @param {PopoverBodyProps} props - Props for the Popover.Body component.
|
|
5978
|
-
* @returns {JSX.Element} The body element.
|
|
5966
|
+
* The corner that keeps a panel of this size in the viewport, preferring
|
|
5967
|
+
* below the anchor and lined up with its left edge.
|
|
5979
5968
|
*/
|
|
5980
|
-
|
|
5981
|
-
const
|
|
5982
|
-
|
|
5983
|
-
|
|
5969
|
+
function resolveAuto(view, rect, panelWidth, panelHeight, gap) {
|
|
5970
|
+
const fitsBelow = rect.bottom + panelHeight + gap <= view.innerHeight;
|
|
5971
|
+
const fitsRight = rect.left + panelWidth <= view.innerWidth;
|
|
5972
|
+
if (fitsBelow)
|
|
5973
|
+
return fitsRight ? 'bottom-left' : 'bottom-right';
|
|
5974
|
+
return fitsRight ? 'top-left' : 'top-right';
|
|
5975
|
+
}
|
|
5976
|
+
const samePosition = (a, b) => a.position === b.position && a.top === b.top && a.left === b.left;
|
|
5984
5977
|
/**
|
|
5985
|
-
*
|
|
5978
|
+
* Places a floating panel against its anchor: resolves `auto` to a corner
|
|
5979
|
+
* and, for a panel fixed to the viewport, works out its coordinates. It
|
|
5980
|
+
* measures again on resize and on any scroll of the anchor's window (an
|
|
5981
|
+
* iframe's, when the anchor renders into one), so a fixed panel follows its
|
|
5982
|
+
* anchor. A measurement that changes nothing doesn't render again.
|
|
5986
5983
|
*
|
|
5987
|
-
*
|
|
5988
|
-
*
|
|
5989
|
-
*
|
|
5984
|
+
* Measuring happens in a layout effect, so the panel is placed before it
|
|
5985
|
+
* paints. It needs both elements on the page while `active` is on, and
|
|
5986
|
+
* keeps the last placement when either is missing.
|
|
5987
|
+
*
|
|
5988
|
+
* @function useAnchoredPosition
|
|
5989
|
+
* @param anchorRef - The element the panel opens from.
|
|
5990
|
+
* @param panelRef - The floating panel.
|
|
5991
|
+
* @param options - Whether to measure, the corner asked for, and how the panel is placed.
|
|
5992
|
+
* @returns The corner the panel opens from, with coordinates when it is fixed.
|
|
5990
5993
|
*/
|
|
5991
|
-
|
|
5992
|
-
const
|
|
5993
|
-
|
|
5994
|
-
};
|
|
5994
|
+
function useAnchoredPosition(anchorRef, panelRef, { active, position, fixed, offset = 4, styledGap }) {
|
|
5995
|
+
const [resolved, setResolved] = React.useState({
|
|
5996
|
+
position: position === 'auto' ? 'bottom-left' : position,
|
|
5997
|
+
});
|
|
5998
|
+
useClientLayoutEffect(() => {
|
|
5999
|
+
if (!active)
|
|
6000
|
+
return undefined;
|
|
6001
|
+
const view = (anchorRef.current ?? panelRef.current)?.ownerDocument.defaultView ??
|
|
6002
|
+
window;
|
|
6003
|
+
const update = () => {
|
|
6004
|
+
const anchor = anchorRef.current;
|
|
6005
|
+
const panel = panelRef.current;
|
|
6006
|
+
if (!anchor || !panel)
|
|
6007
|
+
return;
|
|
6008
|
+
const rect = anchor.getBoundingClientRect();
|
|
6009
|
+
const { width, height } = panel.getBoundingClientRect();
|
|
6010
|
+
const corner = position === 'auto'
|
|
6011
|
+
? resolveAuto(view, rect, width, height, offset + (styledGap?.(panel) ?? 0))
|
|
6012
|
+
: position;
|
|
6013
|
+
const next = fixed
|
|
6014
|
+
? {
|
|
6015
|
+
position: corner,
|
|
6016
|
+
top: corner.startsWith('bottom')
|
|
6017
|
+
? rect.bottom + offset
|
|
6018
|
+
: rect.top - height - offset,
|
|
6019
|
+
left: corner.endsWith('left') ? rect.left : rect.right - width,
|
|
6020
|
+
}
|
|
6021
|
+
: { position: corner };
|
|
6022
|
+
setResolved(prev => (samePosition(prev, next) ? prev : next));
|
|
6023
|
+
};
|
|
6024
|
+
update();
|
|
6025
|
+
view.addEventListener('resize', update, { passive: true });
|
|
6026
|
+
view.addEventListener('scroll', update, { passive: true, capture: true });
|
|
6027
|
+
return () => {
|
|
6028
|
+
view.removeEventListener('resize', update);
|
|
6029
|
+
view.removeEventListener('scroll', update, { capture: true });
|
|
6030
|
+
};
|
|
6031
|
+
}, [active, anchorRef, panelRef, position, fixed, offset, styledGap]);
|
|
6032
|
+
return resolved;
|
|
6033
|
+
}
|
|
6034
|
+
|
|
6035
|
+
const validButtonColors = [...validColors, 'text', 'ghost'];
|
|
5995
6036
|
/**
|
|
5996
|
-
*
|
|
6037
|
+
* The `Button` component provides a flexible and highly customizable button for your Bulma React UI.
|
|
5997
6038
|
*
|
|
5998
6039
|
* @function
|
|
5999
|
-
* @param {
|
|
6000
|
-
* @
|
|
6040
|
+
* @param {ButtonProps} props - Props for the Button component.
|
|
6041
|
+
* @param {React.Ref} ref - Forwarded ref to the element `as` renders.
|
|
6042
|
+
* @returns {JSX.Element} The rendered button, anchor, or custom element.
|
|
6043
|
+
* @see {@link https://bulma.io/documentation/elements/button/ | Bulma Button documentation}
|
|
6001
6044
|
*/
|
|
6002
|
-
const
|
|
6003
|
-
const
|
|
6004
|
-
|
|
6005
|
-
|
|
6006
|
-
|
|
6007
|
-
|
|
6008
|
-
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
6014
|
-
|
|
6015
|
-
|
|
6016
|
-
|
|
6045
|
+
const Button = React.forwardRef(function Button(props, ref) {
|
|
6046
|
+
const { color, size, isLight, isRounded, isLoading, isStatic, isFullwidth, isFullWidth, isOutlined, isInverted, isFocused, isActive, isHovered, isDisabled, className, children, textColor, bgColor, as: Component = 'button', href, onClick, target, rel, ...bulmaProps } = props;
|
|
6047
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses({
|
|
6048
|
+
color: textColor,
|
|
6049
|
+
backgroundColor: bgColor,
|
|
6050
|
+
...bulmaProps,
|
|
6051
|
+
});
|
|
6052
|
+
// Generate Bulma classes with prefix
|
|
6053
|
+
const bulmaClasses = usePrefixedClassNames('button', {
|
|
6054
|
+
[`is-${color}`]: color &&
|
|
6055
|
+
validButtonColors.includes(color),
|
|
6056
|
+
[`is-${size}`]: size,
|
|
6057
|
+
'is-outlined': isOutlined,
|
|
6058
|
+
'is-light': isLight,
|
|
6059
|
+
'is-loading': isLoading,
|
|
6060
|
+
'is-static': isStatic,
|
|
6061
|
+
'is-disabled': isDisabled,
|
|
6062
|
+
'is-rounded': isRounded,
|
|
6063
|
+
'is-hovered': isHovered,
|
|
6064
|
+
'is-focused': isFocused,
|
|
6065
|
+
'is-active': isActive,
|
|
6066
|
+
'is-inverted': isInverted,
|
|
6067
|
+
'is-fullwidth': isFullwidth ?? isFullWidth,
|
|
6068
|
+
});
|
|
6069
|
+
// Combine prefixed Bulma classes with unprefixed user className and prefixed helper classes
|
|
6070
|
+
const buttonClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
|
|
6071
|
+
if (Component !== 'button') {
|
|
6072
|
+
// Strip the form-control attributes a link-like element cannot carry, so a
|
|
6073
|
+
// JavaScript consumer or a spread object doesn't put them on the DOM. The
|
|
6074
|
+
// types no longer allow them through; this is the runtime backstop.
|
|
6075
|
+
//
|
|
6076
|
+
// Only for INTRINSIC tags. A custom component (a router Link, ...) owns its
|
|
6077
|
+
// prop contract, and `ComponentPropsWithoutRef<T>` promises the caller that
|
|
6078
|
+
// its props reach it. Stripping names by their spelling alone broke that
|
|
6079
|
+
// promise silently: `<Button as={Custom} name="x" />` type-checked while
|
|
6080
|
+
// `Custom` never received the `name` it requires.
|
|
6081
|
+
//
|
|
6082
|
+
// `autoFocus` and `type` are deliberately NOT stripped: both are valid on
|
|
6083
|
+
// targets this list was filtering them from. `autoFocus` is a global
|
|
6084
|
+
// attribute (React decides per element whether to honour it, which is not
|
|
6085
|
+
// ours to pre-empt), and `type` on an anchor is the MIME hint that
|
|
6086
|
+
// `ButtonProps<'a'>` now types it as.
|
|
6087
|
+
//
|
|
6088
|
+
// The types already answer this for a TypeScript caller: `ButtonProps<T>`
|
|
6089
|
+
// derives from `ComponentPropsWithoutRef<T>`, so only what the target
|
|
6090
|
+
// accepts can be passed. What is left is a runtime backstop for JavaScript
|
|
6091
|
+
// consumers and spread objects.
|
|
6092
|
+
//
|
|
6093
|
+
// Keep it to the two cases where a stray attribute does something. The
|
|
6094
|
+
// submit-overrides belong to a submit control and never make sense on the
|
|
6095
|
+
// anchor path. `disabled` is the one that is VISIBLE: Bulma styles
|
|
6096
|
+
// `.button[disabled]` (background, border, shadow, opacity), so letting it
|
|
6097
|
+
// through to a `<span>` greys the element out — main stripped it, and not
|
|
6098
|
+
// stripping it would be a silent visual change on a types-only release.
|
|
6099
|
+
//
|
|
6100
|
+
// Everything else (`name`, `value`, `form`) is inert where it does not
|
|
6101
|
+
// belong, and enumerating which tags own it is what kept going wrong:
|
|
6102
|
+
// first all nine names on every non-button tag (so `<Button as="input"
|
|
6103
|
+
// name="query" value="Search">` submitted nothing), then a form-control
|
|
6104
|
+
// allowlist that still lost `formAction` on an input and `value` on an
|
|
6105
|
+
// option. Those now ride on the types instead.
|
|
6106
|
+
//
|
|
6107
|
+
// A custom component is exempt entirely — it owns its prop contract, and
|
|
6108
|
+
// `ComponentPropsWithoutRef<T>` promises the caller its props arrive.
|
|
6109
|
+
//
|
|
6110
|
+
// The elements that own `disabled`, per the HTML spec. A closed set, unlike
|
|
6111
|
+
// the open-ended question of which tag owns which attribute.
|
|
6112
|
+
const SUBMIT_OVERRIDES = [
|
|
6113
|
+
'formAction',
|
|
6114
|
+
'formEncType',
|
|
6115
|
+
'formMethod',
|
|
6116
|
+
'formNoValidate',
|
|
6117
|
+
'formTarget',
|
|
6118
|
+
];
|
|
6119
|
+
const OWNS_DISABLED = [
|
|
6120
|
+
'button',
|
|
6121
|
+
'fieldset',
|
|
6122
|
+
'input',
|
|
6123
|
+
'optgroup',
|
|
6124
|
+
'option',
|
|
6125
|
+
'select',
|
|
6126
|
+
'textarea',
|
|
6127
|
+
];
|
|
6128
|
+
// Two independent filters. They are not exclusive — an `<a>` owns neither
|
|
6129
|
+
// the submit-overrides nor `disabled`, so it takes both — and deriving them
|
|
6130
|
+
// from one destructure, or from one if/else, is what broke each of them in
|
|
6131
|
+
// turn: first the overrides came off every intrinsic (an `as="input"` lost
|
|
6132
|
+
// `formAction`), then scoping them to the anchor let `disabled` back onto
|
|
6133
|
+
// it.
|
|
6134
|
+
const forwardedRest = { ...rest };
|
|
6135
|
+
if (typeof Component === 'string' && !isCustomElement(Component)) {
|
|
6136
|
+
// Meaningless anywhere but a submit control, and an anchor is not one.
|
|
6137
|
+
if (Component === 'a') {
|
|
6138
|
+
for (const key of SUBMIT_OVERRIDES)
|
|
6139
|
+
delete forwardedRest[key];
|
|
6140
|
+
}
|
|
6141
|
+
// Visible via Bulma's `.button[disabled]`, so withheld from any element
|
|
6142
|
+
// that does not own it.
|
|
6143
|
+
if (!OWNS_DISABLED.includes(Component))
|
|
6144
|
+
delete forwardedRest.disabled;
|
|
6145
|
+
}
|
|
6146
|
+
return (jsxRuntime.jsx(Component, { ref: ref, className: buttonClasses, href: href, target: target, rel: rel, "aria-disabled": isDisabled, tabIndex: isDisabled ? -1 : undefined, onClick: isDisabled
|
|
6147
|
+
? // Guarded, because this handler is installed into whatever `as`
|
|
6148
|
+
// renders and a custom component's `onClick` need not be a DOM
|
|
6149
|
+
// event handler at all. One declaring `onClick: (v: string) =>
|
|
6150
|
+
// void` calls this with a string, and an unguarded
|
|
6151
|
+
// `e.preventDefault()` throws `is not a function`. Blocking the
|
|
6152
|
+
// real event is the point; a foreign payload is simply not ours
|
|
6153
|
+
// to act on.
|
|
6154
|
+
(e) => {
|
|
6155
|
+
if (typeof e?.preventDefault === 'function')
|
|
6156
|
+
e.preventDefault();
|
|
6157
|
+
}
|
|
6158
|
+
: onClick, ...forwardedRest, children: children }));
|
|
6159
|
+
}
|
|
6160
|
+
return (jsxRuntime.jsx("button", { ref: ref, className: buttonClasses, disabled: isDisabled, onClick: onClick, ...rest, children: children }));
|
|
6161
|
+
});
|
|
6162
|
+
Button.displayName = 'Button';
|
|
6017
6163
|
|
|
6018
|
-
|
|
6164
|
+
const PopoverContext = React.createContext(null);
|
|
6165
|
+
/** Intrinsic elements that are controls already, and keep their own role. */
|
|
6166
|
+
const NATIVE_CONTROLS = new Set([
|
|
6167
|
+
'button',
|
|
6168
|
+
'input',
|
|
6169
|
+
'select',
|
|
6170
|
+
'textarea',
|
|
6171
|
+
'summary',
|
|
6172
|
+
]);
|
|
6019
6173
|
/**
|
|
6020
|
-
* Whether
|
|
6021
|
-
*
|
|
6022
|
-
* own controls is, focus a press brings isn't, and focus a script moves
|
|
6023
|
-
* follows whichever came last. A browser that can't match the selector
|
|
6024
|
-
* throws, and then every focus counts, so keyboard users can still open it.
|
|
6174
|
+
* Whether the browser answers this key on `el` itself, by clicking it or by
|
|
6175
|
+
* using the key as input, so the trigger's click handler covers it.
|
|
6025
6176
|
*/
|
|
6026
|
-
function
|
|
6027
|
-
|
|
6028
|
-
return el.matches(':focus-visible');
|
|
6029
|
-
}
|
|
6030
|
-
catch {
|
|
6177
|
+
function handlesKeyItself(el, key) {
|
|
6178
|
+
if (NATIVE_CONTROLS.has(el.localName))
|
|
6031
6179
|
return true;
|
|
6032
|
-
|
|
6180
|
+
return el.localName === 'a' && key === 'Enter' && el.hasAttribute('href');
|
|
6033
6181
|
}
|
|
6034
|
-
/**
|
|
6035
|
-
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
*/
|
|
6041
|
-
const escapeListeners = new Map();
|
|
6042
|
-
/** Counts openings across the page, so the most recent one is known. */
|
|
6043
|
-
let openings = 0;
|
|
6044
|
-
/**
|
|
6045
|
-
* Adds `entry` to the hooks that want Escape on `doc`, starting the shared
|
|
6046
|
-
* listener when it is the first.
|
|
6047
|
-
*
|
|
6048
|
-
* Escape takes back every open that is on its way, so nothing appears right
|
|
6049
|
-
* after it, and closes one open floating element, chosen by these rules in
|
|
6050
|
-
* turn:
|
|
6051
|
-
*
|
|
6052
|
-
* 1. One that focus is in, on its trigger or inside it, when there is one.
|
|
6053
|
-
* 2. The innermost: one whose floating element holds no other's trigger.
|
|
6054
|
-
* That follows nesting whether either one is portaled, since an inner
|
|
6055
|
-
* trigger sits in the outer floating element either way.
|
|
6056
|
-
* 3. The one that opened last.
|
|
6057
|
-
*
|
|
6058
|
-
* The keypress belongs to whatever has focus. When that is one of the open
|
|
6059
|
-
* floating elements or their triggers, or nothing, the listener stops it in
|
|
6060
|
-
* the capture phase, before anything around (a Popover, a Modal) sees it.
|
|
6061
|
-
* When focus is anywhere else, such as a combobox or a dialog of its own,
|
|
6062
|
-
* the floating element still closes, since hover put it there without
|
|
6063
|
-
* focus, and the key goes on to that owner.
|
|
6064
|
-
*
|
|
6065
|
-
* @returns What to call when the hook stops wanting Escape.
|
|
6066
|
-
*/
|
|
6067
|
-
function wantEscape(doc, entry) {
|
|
6068
|
-
let listener = escapeListeners.get(doc);
|
|
6069
|
-
if (!listener) {
|
|
6070
|
-
const entries = new Set();
|
|
6071
|
-
const handleKeyDown = (event) => {
|
|
6072
|
-
if (event.key !== 'Escape')
|
|
6073
|
-
return;
|
|
6074
|
-
const open = [];
|
|
6075
|
-
// A copy, since taking an open back can take its hook out of the set.
|
|
6076
|
-
for (const each of [...entries]) {
|
|
6077
|
-
if (each.openedAt)
|
|
6078
|
-
open.push(each);
|
|
6079
|
-
else
|
|
6080
|
-
each.cancel();
|
|
6081
|
-
}
|
|
6082
|
-
const focused = getDeepestActiveElement(doc);
|
|
6083
|
-
const holding = open.filter(each => within(each.trigger(), focused) || within(each.floating(), focused));
|
|
6084
|
-
const pool = holding.length > 0 ? holding : open;
|
|
6085
|
-
let top;
|
|
6086
|
-
for (const each of pool) {
|
|
6087
|
-
const holdsAnother = pool.some(other => other !== each && within(each.floating(), other.trigger()));
|
|
6088
|
-
if (!holdsAnother && (!top || each.openedAt > top.openedAt)) {
|
|
6089
|
-
top = each;
|
|
6090
|
-
}
|
|
6091
|
-
}
|
|
6092
|
-
if (!top)
|
|
6093
|
-
return;
|
|
6094
|
-
if (holding.length > 0 || !focused || focused === doc.body) {
|
|
6095
|
-
event.preventDefault();
|
|
6096
|
-
event.stopPropagation();
|
|
6097
|
-
}
|
|
6098
|
-
top.close();
|
|
6099
|
-
};
|
|
6100
|
-
doc.addEventListener('keydown', handleKeyDown, true);
|
|
6101
|
-
listener = {
|
|
6102
|
-
entries,
|
|
6103
|
-
stop: () => {
|
|
6104
|
-
doc.removeEventListener('keydown', handleKeyDown, true);
|
|
6105
|
-
escapeListeners.delete(doc);
|
|
6106
|
-
},
|
|
6107
|
-
};
|
|
6108
|
-
escapeListeners.set(doc, listener);
|
|
6109
|
-
}
|
|
6110
|
-
const shared = listener;
|
|
6111
|
-
shared.entries.add(entry);
|
|
6112
|
-
return () => {
|
|
6113
|
-
shared.entries.delete(entry);
|
|
6114
|
-
if (shared.entries.size === 0)
|
|
6115
|
-
shared.stop();
|
|
6116
|
-
};
|
|
6182
|
+
/** The innermost dialog the event passed through, which is the one its Escape belongs to. */
|
|
6183
|
+
function nearestDialog(event) {
|
|
6184
|
+
return (event.composedPath().find(node => {
|
|
6185
|
+
const role = node.getAttribute?.('role');
|
|
6186
|
+
return role === 'dialog' || role === 'alertdialog';
|
|
6187
|
+
}) ?? null);
|
|
6117
6188
|
}
|
|
6118
6189
|
/**
|
|
6119
|
-
*
|
|
6120
|
-
* to open and to close. It renders nothing, and leaves roles, ARIA and
|
|
6121
|
-
* placement to the component that calls it.
|
|
6190
|
+
* The `Popover` component opens a panel of interactive content, such as a filter form or a set of share options, anchored to the element that toggles it.
|
|
6122
6191
|
*
|
|
6123
|
-
*
|
|
6124
|
-
*
|
|
6125
|
-
*
|
|
6126
|
-
*
|
|
6127
|
-
* - Focus in the floating element holds it open however it got there,
|
|
6128
|
-
* including focus in content it portals elsewhere, such as a nested card.
|
|
6129
|
-
* Whether a click focuses what it lands on is up to the browser: Safari
|
|
6130
|
-
* and Firefox on macOS don't focus a button on click, so there a clicked
|
|
6131
|
-
* button doesn't hold it once the pointer leaves.
|
|
6132
|
-
* - Once the pointer and focus have both left, it asks to close after
|
|
6133
|
-
* `closeDelay`, and either coming back before then keeps it open. Nothing
|
|
6134
|
-
* else closes it on a timer.
|
|
6135
|
-
* - Escape closes it at once, wherever focus is, and stops there when focus
|
|
6136
|
-
* is in it, on its trigger or nowhere, so a `Popover` or `Modal` around it
|
|
6137
|
-
* keeps its own. With focus somewhere else, the key goes on after closing
|
|
6138
|
-
* it. Inside another one, the inner one closes first. Escape before it
|
|
6139
|
-
* opens takes the opening back and goes on its way. One key listener per
|
|
6140
|
-
* document serves every hook on it, there only while one of them is open
|
|
6141
|
-
* or on its way to open.
|
|
6142
|
-
* - With `openOnTap`, a tap on the trigger opens it at once and the next tap
|
|
6143
|
-
* on the trigger closes it. While a tap holds it open, a press outside the
|
|
6144
|
-
* trigger and the floating element closes it, from a listener on the
|
|
6145
|
-
* document that is there only then. Taps never count as hover, and mouse
|
|
6146
|
-
* and pen work the same with it or without.
|
|
6147
|
-
* - Once it closes, for any reason, whatever was holding it open has to
|
|
6148
|
-
* leave and come back to open it again.
|
|
6149
|
-
* - When the floating element closes with focus inside it, focus goes back
|
|
6150
|
-
* to the trigger, unless the same update put it somewhere else. Handing it
|
|
6151
|
-
* back doesn't count as focus arriving.
|
|
6192
|
+
* The panel is a non-modal `role="dialog"`: focus moves into it on open
|
|
6193
|
+
* and returns to the trigger on close, while the rest of the page stays
|
|
6194
|
+
* reachable by pointer and assistive technology. For hover-only hints use
|
|
6195
|
+
* `Tooltip`, and for a list of actions use `Dropdown`.
|
|
6152
6196
|
*
|
|
6153
|
-
*
|
|
6197
|
+
* @function
|
|
6198
|
+
* @param {PopoverProps} props - Props for the Popover component.
|
|
6199
|
+
* @returns {JSX.Element} The trigger in its wrapper, and the panel while open.
|
|
6154
6200
|
*
|
|
6155
|
-
* @
|
|
6156
|
-
*
|
|
6157
|
-
*
|
|
6201
|
+
* @example
|
|
6202
|
+
* <Popover trigger={<Button>Filters</Button>}>
|
|
6203
|
+
* <Popover.Header>Filter rows</Popover.Header>
|
|
6204
|
+
* <Popover.Body>…</Popover.Body>
|
|
6205
|
+
* <Popover.Footer>
|
|
6206
|
+
* <Popover.Close>Done</Popover.Close>
|
|
6207
|
+
* </Popover.Footer>
|
|
6208
|
+
* </Popover>
|
|
6158
6209
|
*/
|
|
6159
|
-
|
|
6160
|
-
const
|
|
6161
|
-
const
|
|
6162
|
-
|
|
6163
|
-
|
|
6164
|
-
|
|
6165
|
-
|
|
6166
|
-
|
|
6167
|
-
|
|
6168
|
-
|
|
6210
|
+
const PopoverComponent = ({ trigger, children, open: controlledOpen, defaultOpen = false, onOpenChange, position = 'bottom-left', appendToBody = false, trapFocus = true, closeOnClickOutside = true, closeOnEscape = true, ariaLabel, className, contentClassName, ...props }) => {
|
|
6211
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
6212
|
+
const rootRef = React.useRef(null);
|
|
6213
|
+
const panelRef = React.useRef(null);
|
|
6214
|
+
// The last pointerdown React saw inside the panel. React's tree includes
|
|
6215
|
+
// content the panel portals elsewhere, which the DOM path doesn't.
|
|
6216
|
+
const insidePointerRef = React.useRef(null);
|
|
6217
|
+
const baseId = React.useId();
|
|
6218
|
+
const panelId = `${baseId}-panel`;
|
|
6219
|
+
const titleId = `${baseId}-title`;
|
|
6220
|
+
const isControlled = controlledOpen !== undefined;
|
|
6221
|
+
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
|
6222
|
+
const isOpen = controlledOpen ?? internalOpen;
|
|
6223
|
+
const requestOpen = React.useCallback((next) => {
|
|
6224
|
+
onOpenChange?.(next);
|
|
6225
|
+
if (!isControlled)
|
|
6226
|
+
setInternalOpen(next);
|
|
6227
|
+
}, [isControlled, onOpenChange]);
|
|
6228
|
+
const close = React.useCallback(() => requestOpen(false), [requestOpen]);
|
|
6229
|
+
// A portaled panel has no server markup, so it only exists from the commit
|
|
6230
|
+
// after hydration. Everything that measures or focuses it waits for that.
|
|
6231
|
+
const hydrated = useIsHydrated();
|
|
6232
|
+
const panelMounted = isOpen && (hydrated || !appendToBody);
|
|
6233
|
+
const resolved = useAnchoredPosition(rootRef, panelRef, {
|
|
6234
|
+
active: panelMounted,
|
|
6235
|
+
position,
|
|
6236
|
+
fixed: appendToBody,
|
|
6237
|
+
// The gap comes from `--bulma-popover-offset` in the stylesheet, so the
|
|
6238
|
+
// coordinates leave it out and `auto` reads it off the panel.
|
|
6239
|
+
offset: 0,
|
|
6240
|
+
styledGap: marginGap,
|
|
6169
6241
|
});
|
|
6170
|
-
//
|
|
6171
|
-
//
|
|
6172
|
-
|
|
6173
|
-
|
|
6174
|
-
|
|
6175
|
-
const entry = entryRef.current;
|
|
6176
|
-
const { open, triggerRef } = latestRef.current;
|
|
6177
|
-
if (!open)
|
|
6178
|
-
entry.openedAt = 0;
|
|
6179
|
-
else if (!entry.openedAt) {
|
|
6180
|
-
openings += 1;
|
|
6181
|
-
entry.openedAt = openings;
|
|
6182
|
-
}
|
|
6183
|
-
const wanted = open || s.opening || s.asked;
|
|
6184
|
-
if (wanted === Boolean(s.releaseEscape))
|
|
6185
|
-
return;
|
|
6186
|
-
if (s.releaseEscape) {
|
|
6187
|
-
s.releaseEscape();
|
|
6188
|
-
s.releaseEscape = undefined;
|
|
6189
|
-
return;
|
|
6190
|
-
}
|
|
6191
|
-
const doc = triggerRef.current?.ownerDocument ?? document;
|
|
6192
|
-
s.releaseEscape = wantEscape(doc, entry);
|
|
6242
|
+
// The trigger renders first inside the wrapper. Read when needed rather
|
|
6243
|
+
// than held, so a trigger that remounted is still the one found.
|
|
6244
|
+
const triggerNode = React.useCallback(() => {
|
|
6245
|
+
const first = rootRef.current?.firstElementChild;
|
|
6246
|
+
return first && first !== panelRef.current ? first : null;
|
|
6193
6247
|
}, []);
|
|
6194
|
-
const
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
floating: () => latestRef.current.floatingRef.current,
|
|
6198
|
-
// Only ever called while an open is on its way, so it is one or the
|
|
6199
|
-
// other: still waiting on its delay, or asked for already.
|
|
6200
|
-
cancel: () => {
|
|
6201
|
-
const s = stateRef.current;
|
|
6202
|
-
clearTimeout(s.timer);
|
|
6203
|
-
// An ask already made is taken back by asking the other way.
|
|
6204
|
-
if (s.asked)
|
|
6205
|
-
latestRef.current.onOpenChange(false);
|
|
6206
|
-
s.opening = false;
|
|
6207
|
-
s.asked = false;
|
|
6208
|
-
listenForEscape();
|
|
6209
|
-
},
|
|
6210
|
-
close: () => {
|
|
6211
|
-
clearTimeout(stateRef.current.timer);
|
|
6212
|
-
latestRef.current.onOpenChange(false);
|
|
6248
|
+
const restoreFocusRef = React.useMemo(() => ({
|
|
6249
|
+
get current() {
|
|
6250
|
+
return triggerNode();
|
|
6213
6251
|
},
|
|
6252
|
+
}), [triggerNode]);
|
|
6253
|
+
useFocusTrap(panelRef, {
|
|
6254
|
+
active: panelMounted && trapFocus,
|
|
6255
|
+
restoreFocus: restoreFocusRef,
|
|
6214
6256
|
});
|
|
6215
|
-
//
|
|
6216
|
-
//
|
|
6217
|
-
|
|
6218
|
-
|
|
6219
|
-
|
|
6220
|
-
|
|
6221
|
-
|
|
6222
|
-
|
|
6223
|
-
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
s.askTimer = setTimeout(() => {
|
|
6228
|
-
// Still closed: the caller turned it down.
|
|
6229
|
-
if (latestRef.current.open)
|
|
6230
|
-
return;
|
|
6231
|
-
s.asked = false;
|
|
6232
|
-
s.tappedIn = undefined;
|
|
6233
|
-
listenForEscape();
|
|
6234
|
-
});
|
|
6235
|
-
}, [listenForEscape]);
|
|
6236
|
-
// Asks for what the pointer and focus call for, after its delay, when that
|
|
6237
|
-
// changed since the last time and isn't the state already.
|
|
6238
|
-
const settle = React.useCallback(() => {
|
|
6239
|
-
const s = stateRef.current;
|
|
6240
|
-
const held = s.pointerIn || s.focusIn;
|
|
6241
|
-
if (held !== s.held) {
|
|
6242
|
-
s.held = held;
|
|
6243
|
-
clearTimeout(s.timer);
|
|
6244
|
-
const { open, openDelay, closeDelay } = latestRef.current;
|
|
6245
|
-
s.opening = held && !open;
|
|
6246
|
-
if (held !== open) {
|
|
6247
|
-
s.timer = setTimeout(() => {
|
|
6248
|
-
s.opening = false;
|
|
6249
|
-
if (latestRef.current.open !== held)
|
|
6250
|
-
ask(held);
|
|
6251
|
-
else
|
|
6252
|
-
listenForEscape();
|
|
6253
|
-
}, held ? openDelay : closeDelay);
|
|
6257
|
+
// Without the trap, focus still arrives in the panel and goes back to the
|
|
6258
|
+
// trigger, on the trap's terms: only while the panel still holds it.
|
|
6259
|
+
React.useEffect(() => {
|
|
6260
|
+
const panel = panelRef.current;
|
|
6261
|
+
if (!panelMounted || trapFocus || !panel)
|
|
6262
|
+
return undefined;
|
|
6263
|
+
(firstTabStop(panel) ?? panel).focus();
|
|
6264
|
+
return () => {
|
|
6265
|
+
const doc = panel.ownerDocument;
|
|
6266
|
+
const now = getDeepestActiveElement(doc);
|
|
6267
|
+
if (!now || now === doc.body || panel.contains(now)) {
|
|
6268
|
+
triggerNode()?.focus();
|
|
6254
6269
|
}
|
|
6255
|
-
}
|
|
6256
|
-
|
|
6257
|
-
|
|
6258
|
-
|
|
6259
|
-
|
|
6260
|
-
|
|
6261
|
-
|
|
6262
|
-
const s = stateRef.current;
|
|
6263
|
-
s.stopPress?.();
|
|
6264
|
-
s.stopPress = undefined;
|
|
6265
|
-
const doc = latestRef.current.open ? s.tappedIn : undefined;
|
|
6266
|
-
if (!doc)
|
|
6267
|
-
return;
|
|
6268
|
-
const handlePointerDown = (event) => {
|
|
6269
|
-
// Inside is a press React brought to the holder, which covers content
|
|
6270
|
-
// the floating element portals elsewhere, or one whose path runs
|
|
6271
|
-
// through the trigger or the floating element, which covers a press
|
|
6272
|
-
// that something inside stopped before React got it to the holder.
|
|
6273
|
-
const { triggerRef, floatingRef } = latestRef.current;
|
|
6274
|
-
if (event === s.pressedInside ||
|
|
6275
|
-
isEventInside(event, triggerRef.current) ||
|
|
6276
|
-
isEventInside(event, floatingRef.current)) {
|
|
6270
|
+
};
|
|
6271
|
+
}, [panelMounted, trapFocus, triggerNode]);
|
|
6272
|
+
React.useEffect(() => {
|
|
6273
|
+
if (!panelMounted || !closeOnClickOutside)
|
|
6274
|
+
return undefined;
|
|
6275
|
+
const handlePointerDown = (e) => {
|
|
6276
|
+
if (e === insidePointerRef.current || isEventInside(e, rootRef.current)) {
|
|
6277
6277
|
return;
|
|
6278
6278
|
}
|
|
6279
|
-
|
|
6279
|
+
close();
|
|
6280
6280
|
};
|
|
6281
|
+
// The wrapper is on the page whenever the panel is. Its document, an
|
|
6282
|
+
// iframe's when the tree renders into one, is where presses land.
|
|
6283
|
+
const doc = rootRef.current.ownerDocument;
|
|
6281
6284
|
doc.addEventListener('pointerdown', handlePointerDown);
|
|
6282
|
-
|
|
6283
|
-
}, []);
|
|
6284
|
-
|
|
6285
|
-
|
|
6286
|
-
|
|
6287
|
-
//
|
|
6288
|
-
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6293
|
-
|
|
6294
|
-
|
|
6295
|
-
|
|
6296
|
-
|
|
6297
|
-
|
|
6298
|
-
|
|
6299
|
-
|
|
6300
|
-
|
|
6301
|
-
|
|
6302
|
-
|
|
6303
|
-
|
|
6304
|
-
|
|
6305
|
-
|
|
6306
|
-
|
|
6307
|
-
|
|
6308
|
-
|
|
6309
|
-
|
|
6310
|
-
// Before paint, so a pointer or a key arriving next finds this settled.
|
|
6311
|
-
useClientLayoutEffect(() => {
|
|
6312
|
-
const s = stateRef.current;
|
|
6313
|
-
if (!open) {
|
|
6314
|
-
// The floating element left from under the pointer, if it was there,
|
|
6315
|
-
// and no leave event says so. Focus resting on the trigger has done
|
|
6316
|
-
// its job too, so neither opens it again until it comes back.
|
|
6317
|
-
s.pointerIn = false;
|
|
6318
|
-
s.focusIn = false;
|
|
6319
|
-
s.tappedIn = undefined;
|
|
6320
|
-
if (s.focusInFloating) {
|
|
6321
|
-
s.focusInFloating = false;
|
|
6322
|
-
const trigger = latestRef.current.triggerRef.current;
|
|
6323
|
-
// Focus that went down with the floating element sits on <body>.
|
|
6324
|
-
// Focus somewhere else was put there by the same update, so it stays.
|
|
6325
|
-
if (trigger &&
|
|
6326
|
-
trigger.ownerDocument.activeElement === trigger.ownerDocument.body) {
|
|
6327
|
-
s.restoring = true;
|
|
6328
|
-
trigger.focus();
|
|
6329
|
-
s.restoring = false;
|
|
6330
|
-
}
|
|
6285
|
+
return () => doc.removeEventListener('pointerdown', handlePointerDown);
|
|
6286
|
+
}, [panelMounted, closeOnClickOutside, close]);
|
|
6287
|
+
const handlePanelKeyDown = (e) => {
|
|
6288
|
+
if (e.key !== 'Escape' || !closeOnEscape || e.defaultPrevented)
|
|
6289
|
+
return;
|
|
6290
|
+
// React bubbles the key here from portaled content as well, so a dialog
|
|
6291
|
+
// of its own on the way (a nested popover, a picker's calendar) owns it.
|
|
6292
|
+
const owner = nearestDialog(e.nativeEvent);
|
|
6293
|
+
if (owner && owner !== panelRef.current)
|
|
6294
|
+
return;
|
|
6295
|
+
e.preventDefault();
|
|
6296
|
+
close();
|
|
6297
|
+
};
|
|
6298
|
+
const triggerProps = trigger.props;
|
|
6299
|
+
const handleTriggerClick = (e) => {
|
|
6300
|
+
triggerProps.onClick?.(e);
|
|
6301
|
+
if (!e.defaultPrevented)
|
|
6302
|
+
requestOpen(!isOpen);
|
|
6303
|
+
};
|
|
6304
|
+
const handleTriggerKeyDown = (e) => {
|
|
6305
|
+
triggerProps.onKeyDown?.(e);
|
|
6306
|
+
// A key on something inside the trigger is that element's to answer.
|
|
6307
|
+
if (e.defaultPrevented || e.target !== e.currentTarget)
|
|
6308
|
+
return;
|
|
6309
|
+
if (e.key === 'Escape') {
|
|
6310
|
+
if (isOpen && closeOnEscape) {
|
|
6311
|
+
e.preventDefault();
|
|
6312
|
+
close();
|
|
6331
6313
|
}
|
|
6314
|
+
return;
|
|
6332
6315
|
}
|
|
6333
|
-
|
|
6334
|
-
|
|
6335
|
-
|
|
6336
|
-
|
|
6337
|
-
|
|
6338
|
-
|
|
6339
|
-
|
|
6340
|
-
|
|
6341
|
-
|
|
6342
|
-
|
|
6343
|
-
|
|
6344
|
-
|
|
6345
|
-
|
|
6346
|
-
|
|
6347
|
-
|
|
6348
|
-
|
|
6349
|
-
|
|
6350
|
-
|
|
6351
|
-
|
|
6352
|
-
|
|
6353
|
-
|
|
6354
|
-
|
|
6355
|
-
|
|
6356
|
-
|
|
6357
|
-
|
|
6358
|
-
|
|
6359
|
-
|
|
6360
|
-
|
|
6361
|
-
|
|
6362
|
-
|
|
6363
|
-
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
|
|
6370
|
-
|
|
6371
|
-
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
|
|
6376
|
-
|
|
6377
|
-
|
|
6378
|
-
|
|
6379
|
-
|
|
6380
|
-
|
|
6381
|
-
|
|
6382
|
-
|
|
6383
|
-
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
|
|
6387
|
-
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
6391
|
-
|
|
6392
|
-
|
|
6393
|
-
|
|
6394
|
-
|
|
6395
|
-
|
|
6396
|
-
|
|
6397
|
-
|
|
6398
|
-
}, [
|
|
6316
|
+
if (e.key !== 'Enter' && e.key !== ' ')
|
|
6317
|
+
return;
|
|
6318
|
+
if (handlesKeyItself(e.currentTarget, e.key))
|
|
6319
|
+
return;
|
|
6320
|
+
// Space would scroll the page; a held key repeats.
|
|
6321
|
+
e.preventDefault();
|
|
6322
|
+
if (!e.repeat)
|
|
6323
|
+
requestOpen(!isOpen);
|
|
6324
|
+
};
|
|
6325
|
+
const needsButtonRole = typeof trigger.type === 'string' &&
|
|
6326
|
+
!NATIVE_CONTROLS.has(trigger.type) &&
|
|
6327
|
+
!(trigger.type === 'a' && triggerProps.href != null);
|
|
6328
|
+
const clonedTrigger = React.cloneElement(trigger, {
|
|
6329
|
+
...(needsButtonRole
|
|
6330
|
+
? {
|
|
6331
|
+
role: triggerProps.role ?? 'button',
|
|
6332
|
+
tabIndex: triggerProps.tabIndex ?? 0,
|
|
6333
|
+
}
|
|
6334
|
+
: {}),
|
|
6335
|
+
'aria-haspopup': 'dialog',
|
|
6336
|
+
'aria-expanded': isOpen,
|
|
6337
|
+
'aria-controls': panelMounted ? panelId : undefined,
|
|
6338
|
+
onClick: handleTriggerClick,
|
|
6339
|
+
onKeyDown: handleTriggerKeyDown,
|
|
6340
|
+
});
|
|
6341
|
+
const rootClasses = classNames(usePrefixedClassNames('popover', { 'is-active': isOpen }), bulmaHelperClasses, className);
|
|
6342
|
+
const panelClasses = classNames(usePrefixedClassNames('popover-content', {
|
|
6343
|
+
[`is-${resolved.position}`]: true,
|
|
6344
|
+
'is-portal': appendToBody,
|
|
6345
|
+
}), contentClassName);
|
|
6346
|
+
return (jsxRuntime.jsxs("div", { className: rootClasses, ...rest, ref: rootRef, children: [clonedTrigger, isOpen && (jsxRuntime.jsx(Portal, { disabled: !appendToBody, children: jsxRuntime.jsx(PopoverPanel, { panelRef: panelRef, titleId: titleId, ariaLabel: ariaLabel, close: close, id: panelId, className: panelClasses, style: appendToBody
|
|
6347
|
+
? {
|
|
6348
|
+
position: 'fixed',
|
|
6349
|
+
top: resolved.top,
|
|
6350
|
+
left: resolved.left,
|
|
6351
|
+
}
|
|
6352
|
+
: undefined, onKeyDown: handlePanelKeyDown, onPointerDownCapture: e => {
|
|
6353
|
+
insidePointerRef.current = e.nativeEvent;
|
|
6354
|
+
}, children: children }) }))] }));
|
|
6355
|
+
};
|
|
6356
|
+
/**
|
|
6357
|
+
* The open panel. It mounts with each opening, so it counts the
|
|
6358
|
+
* `Popover.Header`s inside it afresh every time.
|
|
6359
|
+
*/
|
|
6360
|
+
function PopoverPanel({ panelRef, titleId, ariaLabel, close, children, ...attrs }) {
|
|
6361
|
+
// Headers register in a layout effect, after this first render, and this
|
|
6362
|
+
// panel's own layout effect runs after theirs. Until then the panel assumes
|
|
6363
|
+
// it has one, so a panel that does is named from its first commit, before
|
|
6364
|
+
// focus moves in, and one that doesn't drops the reference before it paints.
|
|
6365
|
+
const [titlesCounted, setTitlesCounted] = React.useState(false);
|
|
6366
|
+
const [titleCount, setTitleCount] = React.useState(0);
|
|
6367
|
+
const registerTitle = React.useCallback(() => {
|
|
6368
|
+
setTitleCount(n => n + 1);
|
|
6369
|
+
return () => setTitleCount(n => n - 1);
|
|
6370
|
+
}, []);
|
|
6371
|
+
useClientLayoutEffect(() => {
|
|
6372
|
+
setTitlesCounted(true);
|
|
6373
|
+
}, []);
|
|
6374
|
+
const named = Boolean(ariaLabel) || titleCount > 0;
|
|
6375
|
+
React.useEffect(() => {
|
|
6376
|
+
if (titlesCounted && !named) {
|
|
6377
|
+
warnOnce('Popover:unnamed', '[bestax-bulma] <Popover> opened a panel with no accessible name. ' +
|
|
6378
|
+
'Pass ariaLabel, or render a Popover.Header inside it.');
|
|
6379
|
+
}
|
|
6380
|
+
}, [titlesCounted, named]);
|
|
6381
|
+
const context = React.useMemo(() => ({ titleId, registerTitle, close }), [titleId, registerTitle, close]);
|
|
6382
|
+
return (jsxRuntime.jsx(PopoverContext.Provider, { value: context, children: jsxRuntime.jsx("div", { ...attrs, ref: panelRef, role: "dialog", "aria-label": ariaLabel, "aria-labelledby": !ariaLabel && (!titlesCounted || titleCount > 0) ? titleId : undefined, tabIndex: -1, children: children }) }));
|
|
6399
6383
|
}
|
|
6384
|
+
/**
|
|
6385
|
+
* The popover's title, which names the panel for assistive technology.
|
|
6386
|
+
*
|
|
6387
|
+
* @function
|
|
6388
|
+
* @param {PopoverHeaderProps} props - Props for the Popover.Header component.
|
|
6389
|
+
* @returns {JSX.Element} The header element.
|
|
6390
|
+
*/
|
|
6391
|
+
const PopoverHeader = ({ className, ...props }) => {
|
|
6392
|
+
const context = React.useContext(PopoverContext);
|
|
6393
|
+
const registerTitle = context?.registerTitle;
|
|
6394
|
+
// Before paint, so the panel is named by the time it shows.
|
|
6395
|
+
useClientLayoutEffect(() => registerTitle?.(), [registerTitle]);
|
|
6396
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
6397
|
+
return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-header'), bulmaHelperClasses, className), ...rest, id: context?.titleId }));
|
|
6398
|
+
};
|
|
6399
|
+
/**
|
|
6400
|
+
* The popover's main content area.
|
|
6401
|
+
*
|
|
6402
|
+
* @function
|
|
6403
|
+
* @param {PopoverBodyProps} props - Props for the Popover.Body component.
|
|
6404
|
+
* @returns {JSX.Element} The body element.
|
|
6405
|
+
*/
|
|
6406
|
+
const PopoverBody = ({ className, ...props }) => {
|
|
6407
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
6408
|
+
return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-body'), bulmaHelperClasses, className), ...rest }));
|
|
6409
|
+
};
|
|
6410
|
+
/**
|
|
6411
|
+
* A row of actions along the bottom of the popover, aligned to the end.
|
|
6412
|
+
*
|
|
6413
|
+
* @function
|
|
6414
|
+
* @param {PopoverFooterProps} props - Props for the Popover.Footer component.
|
|
6415
|
+
* @returns {JSX.Element} The footer element.
|
|
6416
|
+
*/
|
|
6417
|
+
const PopoverFooter = ({ className, ...props }) => {
|
|
6418
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
6419
|
+
return (jsxRuntime.jsx("div", { className: classNames(usePrefixedClassNames('popover-footer'), bulmaHelperClasses, className), ...rest }));
|
|
6420
|
+
};
|
|
6421
|
+
/**
|
|
6422
|
+
* A `Button` that closes the popover, handing focus back to the trigger.
|
|
6423
|
+
*
|
|
6424
|
+
* @function
|
|
6425
|
+
* @param {PopoverCloseProps} props - Props for the Popover.Close component.
|
|
6426
|
+
* @returns {JSX.Element} The close button.
|
|
6427
|
+
*/
|
|
6428
|
+
const PopoverClose = ({ onClick, ...props }) => {
|
|
6429
|
+
const context = React.useContext(PopoverContext);
|
|
6430
|
+
return (jsxRuntime.jsx(Button, { ...props, type: buttonType(props.type), onClick: (e) => {
|
|
6431
|
+
onClick?.(e);
|
|
6432
|
+
if (!e.defaultPrevented)
|
|
6433
|
+
context?.close();
|
|
6434
|
+
} }));
|
|
6435
|
+
};
|
|
6436
|
+
/** Popover with Header, Body, Footer and Close sub-components. */
|
|
6437
|
+
const Popover = withSubComponents(PopoverComponent, {
|
|
6438
|
+
Header: PopoverHeader,
|
|
6439
|
+
Body: PopoverBody,
|
|
6440
|
+
Footer: PopoverFooter,
|
|
6441
|
+
Close: PopoverClose,
|
|
6442
|
+
}, 'Popover');
|
|
6400
6443
|
|
|
6401
|
-
/** Runs the caller's handler on the wrapper, then the hover card's. */
|
|
6402
|
-
function chain(own, ours) {
|
|
6403
|
-
return event => {
|
|
6404
|
-
own?.(event);
|
|
6405
|
-
ours(event);
|
|
6406
|
-
};
|
|
6407
|
-
}
|
|
6408
6444
|
/**
|
|
6409
6445
|
* The `HoverCard` component shows a card previewing what's behind a link or a button while the pointer rests on it or keyboard focus is on it.
|
|
6410
6446
|
*
|
|
@@ -6429,7 +6465,7 @@ function chain(own, ours) {
|
|
|
6429
6465
|
*/
|
|
6430
6466
|
const HoverCard = ({ trigger, children, open: controlledOpen, defaultOpen = false, onOpenChange, openDelay = 600, closeDelay = 300, position = 'bottom-left', appendToBody = false, openOnTap = false, className, contentClassName, ...props }) => {
|
|
6431
6467
|
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
6432
|
-
const { onPointerEnter, onPointerLeave, onPointerDown, onPointerUp, onFocus, onBlur, ...attrs } = rest;
|
|
6468
|
+
const { onPointerEnter, onPointerOver, onPointerLeave, onPointerDown, onPointerUp, onFocus, onBlur, ...attrs } = rest;
|
|
6433
6469
|
const rootRef = React.useRef(null);
|
|
6434
6470
|
const cardRef = React.useRef(null);
|
|
6435
6471
|
const cardId = `${React.useId()}-card`;
|
|
@@ -6502,7 +6538,7 @@ const HoverCard = ({ trigger, children, open: controlledOpen, defaultOpen = fals
|
|
|
6502
6538
|
[`is-${resolved.position}`]: true,
|
|
6503
6539
|
'is-portal': appendToBody,
|
|
6504
6540
|
}), contentClassName);
|
|
6505
|
-
return (jsxRuntime.jsxs("span", { className: rootClasses, ...attrs, onPointerEnter: chain(onPointerEnter, hover.onPointerEnter), onPointerLeave: chain(onPointerLeave, hover.onPointerLeave), onFocus: chain(onFocus, hover.onFocus), onBlur: chain(onBlur, hover.onBlur), onPointerDown: chain(onPointerDown, hover.onPointerDown), onPointerUp: chain(onPointerUp, hover.onPointerUp), ref: rootRef, children: [clonedTrigger, isOpen && (jsxRuntime.jsx(Portal, { disabled: !appendToBody, children: jsxRuntime.jsx("div", { ref: cardRef, id: cardId, className: cardClasses, style: appendToBody
|
|
6541
|
+
return (jsxRuntime.jsxs("span", { className: rootClasses, ...attrs, onPointerEnter: chain$1(onPointerEnter, hover.onPointerEnter), onPointerOver: chain$1(onPointerOver, hover.onPointerOver), onPointerLeave: chain$1(onPointerLeave, hover.onPointerLeave), onFocus: chain$1(onFocus, hover.onFocus), onBlur: chain$1(onBlur, hover.onBlur), onPointerDown: chain$1(onPointerDown, hover.onPointerDown), onPointerUp: chain$1(onPointerUp, hover.onPointerUp), ref: rootRef, children: [clonedTrigger, isOpen && (jsxRuntime.jsx(Portal, { disabled: !appendToBody, children: jsxRuntime.jsx("div", { ref: cardRef, id: cardId, className: cardClasses, style: appendToBody
|
|
6506
6542
|
? {
|
|
6507
6543
|
position: 'fixed',
|
|
6508
6544
|
top: resolved.top,
|
|
@@ -6511,6 +6547,570 @@ const HoverCard = ({ trigger, children, open: controlledOpen, defaultOpen = fals
|
|
|
6511
6547
|
: undefined, children: children }) }))] }));
|
|
6512
6548
|
};
|
|
6513
6549
|
|
|
6550
|
+
// INTERNAL: deliberately not exported from src/index.ts.
|
|
6551
|
+
/** How long a touch or a pen has to stay down to open the menu, in milliseconds. */
|
|
6552
|
+
const LONG_PRESS_DELAY = 500;
|
|
6553
|
+
/** How far a long press can move before it counts as something else, in CSS pixels. */
|
|
6554
|
+
const LONG_PRESS_TOLERANCE = 10;
|
|
6555
|
+
/**
|
|
6556
|
+
* The last press no trigger is to start a long press for: one a trigger
|
|
6557
|
+
* took, or another pointer coming down while a press is held. A press
|
|
6558
|
+
* reaches every trigger it bubbles through, the innermost first and all
|
|
6559
|
+
* before the next press, so this is how a trigger around another one knows
|
|
6560
|
+
* the inner one took it, and how a trigger a second finger lands on knows to
|
|
6561
|
+
* leave it.
|
|
6562
|
+
*/
|
|
6563
|
+
let claimed = null;
|
|
6564
|
+
/** Whether the event happened in the element its handler is on, rather than in content portaled out of it. */
|
|
6565
|
+
const inTrigger = (event) => event.currentTarget.contains(event.target);
|
|
6566
|
+
/**
|
|
6567
|
+
* Where a `contextmenu` asks to open: at its point when that is on the
|
|
6568
|
+
* element it was fired at, which it is for the pointer, and at that
|
|
6569
|
+
* element's bottom-left corner otherwise, which is where the browser puts
|
|
6570
|
+
* its own menu for a key.
|
|
6571
|
+
*/
|
|
6572
|
+
function contextMenuPoint(event) {
|
|
6573
|
+
const { clientX: x, clientY: y } = event;
|
|
6574
|
+
const rect = event.target.getBoundingClientRect();
|
|
6575
|
+
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
|
|
6576
|
+
? { x, y }
|
|
6577
|
+
: { x: rect.left, y: rect.bottom };
|
|
6578
|
+
}
|
|
6579
|
+
/**
|
|
6580
|
+
* Decides when a context menu asks to open, and where. It renders nothing,
|
|
6581
|
+
* and knows nothing about ARIA or the menu.
|
|
6582
|
+
*
|
|
6583
|
+
* - **`contextmenu`:** a right-click, and Shift+F10 or the context menu key
|
|
6584
|
+
* where the browser fires the event for them, asks to open at the point
|
|
6585
|
+
* `contextMenuPoint` works out, and prevents the browser's own menu. Each
|
|
6586
|
+
* one asks again, so a right-click elsewhere on the trigger moves it.
|
|
6587
|
+
* - **Long press:** a touch or a pen held still on the trigger for
|
|
6588
|
+
* `LONG_PRESS_DELAY` fires a `contextmenu` at what it landed on, so it
|
|
6589
|
+
* takes the same path, handlers on the way included. Moving more than
|
|
6590
|
+
* `LONG_PRESS_TOLERANCE`, lifting, another pointer coming down anywhere in
|
|
6591
|
+
* the document, or `pointercancel`, which is the browser taking the
|
|
6592
|
+
* gesture for a scroll or a zoom, cancels it, and a mouse never starts
|
|
6593
|
+
* one. A pointer that comes down while another is held starts no long
|
|
6594
|
+
* press of its own, on this trigger or another, so two fingers on two rows
|
|
6595
|
+
* open nothing. While the finger is down, and only then, a listener on the
|
|
6596
|
+
* document hears other pointers coming down. Where the
|
|
6597
|
+
* browser fires `contextmenu` for a long press itself, the first of the two
|
|
6598
|
+
* opens it, and the end of a touch that opened it is prevented, so lifting
|
|
6599
|
+
* doesn't click what is under the finger.
|
|
6600
|
+
* - **Who opens:** a handler that calls `preventDefault()` on the
|
|
6601
|
+
* `contextmenu` before this one keeps it from asking, which is how a
|
|
6602
|
+
* trigger's own handler opts out and how the innermost of nested triggers
|
|
6603
|
+
* wins. A long press goes to the innermost trigger that takes it. Events
|
|
6604
|
+
* from content portaled out of the trigger, which React brings here
|
|
6605
|
+
* anyway, are ignored.
|
|
6606
|
+
* - **`disabled`:** nothing asks, and the browser's menu shows.
|
|
6607
|
+
*
|
|
6608
|
+
* Nothing it scheduled runs after the component unmounts.
|
|
6609
|
+
*
|
|
6610
|
+
* @function useContextMenuTrigger
|
|
6611
|
+
* @param options - How to ask to open, and whether to.
|
|
6612
|
+
* @returns Handlers for the trigger element.
|
|
6613
|
+
*/
|
|
6614
|
+
function useContextMenuTrigger({ onOpen, disabled, }) {
|
|
6615
|
+
const pressRef = React.useRef(null);
|
|
6616
|
+
/** A long press opened the menu, and the touch that made it hasn't ended. */
|
|
6617
|
+
const heldRef = React.useRef(false);
|
|
6618
|
+
const cancel = () => {
|
|
6619
|
+
clearTimeout(pressRef.current?.timer);
|
|
6620
|
+
pressRef.current?.stop();
|
|
6621
|
+
pressRef.current = null;
|
|
6622
|
+
};
|
|
6623
|
+
React.useEffect(() => () => {
|
|
6624
|
+
clearTimeout(pressRef.current?.timer);
|
|
6625
|
+
pressRef.current?.stop();
|
|
6626
|
+
pressRef.current = null;
|
|
6627
|
+
}, []);
|
|
6628
|
+
const isPress = (event) => pressRef.current?.pointerId === event.pointerId;
|
|
6629
|
+
return {
|
|
6630
|
+
onContextMenu: event => {
|
|
6631
|
+
if (disabled || event.defaultPrevented || !inTrigger(event))
|
|
6632
|
+
return;
|
|
6633
|
+
event.preventDefault();
|
|
6634
|
+
const press = pressRef.current;
|
|
6635
|
+
if (press) {
|
|
6636
|
+
if (press.opened)
|
|
6637
|
+
return;
|
|
6638
|
+
clearTimeout(press.timer);
|
|
6639
|
+
press.opened = true;
|
|
6640
|
+
heldRef.current = true;
|
|
6641
|
+
}
|
|
6642
|
+
onOpen(contextMenuPoint(event), event.currentTarget);
|
|
6643
|
+
},
|
|
6644
|
+
onPointerDown: event => {
|
|
6645
|
+
const native = event.nativeEvent;
|
|
6646
|
+
if (disabled ||
|
|
6647
|
+
(event.pointerType !== 'touch' && event.pointerType !== 'pen') ||
|
|
6648
|
+
claimed === native ||
|
|
6649
|
+
!inTrigger(event)) {
|
|
6650
|
+
return;
|
|
6651
|
+
}
|
|
6652
|
+
claimed = native;
|
|
6653
|
+
heldRef.current = false;
|
|
6654
|
+
const target = event.target;
|
|
6655
|
+
const doc = target.ownerDocument;
|
|
6656
|
+
// Another pointer coming down anywhere makes it a pinch or a second
|
|
6657
|
+
// touch, not a long press. In the capture phase, so it's heard before
|
|
6658
|
+
// any trigger it lands on, which then leaves it. The press's own
|
|
6659
|
+
// pointerdown has passed the document already, so it isn't heard.
|
|
6660
|
+
const another = (other) => {
|
|
6661
|
+
claimed = other;
|
|
6662
|
+
cancel();
|
|
6663
|
+
};
|
|
6664
|
+
doc.addEventListener('pointerdown', another, true);
|
|
6665
|
+
const press = {
|
|
6666
|
+
pointerId: event.pointerId,
|
|
6667
|
+
target,
|
|
6668
|
+
x: event.clientX,
|
|
6669
|
+
y: event.clientY,
|
|
6670
|
+
opened: false,
|
|
6671
|
+
stop: () => doc.removeEventListener('pointerdown', another, true),
|
|
6672
|
+
};
|
|
6673
|
+
press.timer = setTimeout(() => {
|
|
6674
|
+
const view = press.target.ownerDocument.defaultView;
|
|
6675
|
+
press.target.dispatchEvent(new view.MouseEvent('contextmenu', {
|
|
6676
|
+
bubbles: true,
|
|
6677
|
+
cancelable: true,
|
|
6678
|
+
composed: true,
|
|
6679
|
+
clientX: press.x,
|
|
6680
|
+
clientY: press.y,
|
|
6681
|
+
button: 2,
|
|
6682
|
+
}));
|
|
6683
|
+
}, LONG_PRESS_DELAY);
|
|
6684
|
+
pressRef.current = press;
|
|
6685
|
+
},
|
|
6686
|
+
onPointerMove: event => {
|
|
6687
|
+
const press = pressRef.current;
|
|
6688
|
+
if (!press || !isPress(event) || press.opened)
|
|
6689
|
+
return;
|
|
6690
|
+
const moved = Math.hypot(event.clientX - press.x, event.clientY - press.y);
|
|
6691
|
+
if (moved > LONG_PRESS_TOLERANCE)
|
|
6692
|
+
cancel();
|
|
6693
|
+
},
|
|
6694
|
+
onPointerUp: event => {
|
|
6695
|
+
if (isPress(event))
|
|
6696
|
+
cancel();
|
|
6697
|
+
},
|
|
6698
|
+
onPointerCancel: event => {
|
|
6699
|
+
if (isPress(event))
|
|
6700
|
+
cancel();
|
|
6701
|
+
},
|
|
6702
|
+
onTouchEnd: event => {
|
|
6703
|
+
if (!heldRef.current)
|
|
6704
|
+
return;
|
|
6705
|
+
heldRef.current = false;
|
|
6706
|
+
event.preventDefault();
|
|
6707
|
+
},
|
|
6708
|
+
};
|
|
6709
|
+
}
|
|
6710
|
+
|
|
6711
|
+
// INTERNAL: deliberately not exported from src/index.ts.
|
|
6712
|
+
/**
|
|
6713
|
+
* Every menu-item role, so an item a caller gives `menuitemcheckbox` or
|
|
6714
|
+
* `menuitemradio` (a filter or sort menu) stays reachable.
|
|
6715
|
+
*/
|
|
6716
|
+
const MENU_ITEM_SELECTOR = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"]';
|
|
6717
|
+
/**
|
|
6718
|
+
* Whether a menu item can be reached and activated: it isn't disabled, as a
|
|
6719
|
+
* native control or through `aria-disabled="true"`.
|
|
6720
|
+
*/
|
|
6721
|
+
function isEnabledMenuItem(item) {
|
|
6722
|
+
return (!item.hasAttribute('disabled') &&
|
|
6723
|
+
item.getAttribute('aria-disabled') !== 'true');
|
|
6724
|
+
}
|
|
6725
|
+
/** Whether `node` is one of `menu`'s items, enabled or not. */
|
|
6726
|
+
const isItemOf = (menu, node) => Boolean(node && menu.contains(node) && node.matches(MENU_ITEM_SELECTOR));
|
|
6727
|
+
/** The items in `menu` the keys reach, in document order. */
|
|
6728
|
+
function enabledMenuItems(menu) {
|
|
6729
|
+
return Array.from(menu.querySelectorAll(MENU_ITEM_SELECTOR)).filter(isEnabledMenuItem);
|
|
6730
|
+
}
|
|
6731
|
+
/** How long typed characters keep adding up to one search, in milliseconds. */
|
|
6732
|
+
const TYPEAHEAD_TIMEOUT = 500;
|
|
6733
|
+
/**
|
|
6734
|
+
* Whether the browser activates `item` on this key by itself: a `<button>`
|
|
6735
|
+
* answers Enter and Space, and a link with an `href` answers Enter.
|
|
6736
|
+
*/
|
|
6737
|
+
function activatesItself(item, key) {
|
|
6738
|
+
if (item.tagName === 'BUTTON')
|
|
6739
|
+
return true;
|
|
6740
|
+
return key === 'Enter' && item.tagName === 'A' && item.hasAttribute('href');
|
|
6741
|
+
}
|
|
6742
|
+
/**
|
|
6743
|
+
* The text typing finds an item by: what it says, without what's hidden from
|
|
6744
|
+
* assistive technology, such as an icon whose glyph is a ligature's name.
|
|
6745
|
+
*/
|
|
6746
|
+
function label(item) {
|
|
6747
|
+
let text = '';
|
|
6748
|
+
const read = (node) => {
|
|
6749
|
+
node.childNodes.forEach(child => {
|
|
6750
|
+
if (child.nodeType === 3)
|
|
6751
|
+
text += child.textContent;
|
|
6752
|
+
else if (child.nodeType === 1 &&
|
|
6753
|
+
child.getAttribute('aria-hidden') !== 'true') {
|
|
6754
|
+
read(child);
|
|
6755
|
+
}
|
|
6756
|
+
});
|
|
6757
|
+
};
|
|
6758
|
+
read(item);
|
|
6759
|
+
return text.trim().toLowerCase();
|
|
6760
|
+
}
|
|
6761
|
+
/**
|
|
6762
|
+
* The item typing `search` moves to, or none. A single character looks from
|
|
6763
|
+
* the item after the focused one, so typing the same letter again cycles
|
|
6764
|
+
* through the items that start with it, and so does a run of one letter
|
|
6765
|
+
* ("dd"). A longer search looks from the focused item, so typing more of
|
|
6766
|
+
* its own text keeps it.
|
|
6767
|
+
*/
|
|
6768
|
+
function typeaheadMatch(items, search, current) {
|
|
6769
|
+
const repeated = Array.from(search).every(char => char === search[0]);
|
|
6770
|
+
const wanted = repeated ? search[0] : search;
|
|
6771
|
+
const start = current < 0 ? 0 : current + (wanted.length === 1 ? 1 : 0);
|
|
6772
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
6773
|
+
const item = items[(start + i) % items.length];
|
|
6774
|
+
if (label(item).startsWith(wanted))
|
|
6775
|
+
return item;
|
|
6776
|
+
}
|
|
6777
|
+
return undefined;
|
|
6778
|
+
}
|
|
6779
|
+
/**
|
|
6780
|
+
* Moves through and activates the items of a `role="menu"` element from the
|
|
6781
|
+
* keyboard. Put the `onKeyDown` it returns on that element. It reads the
|
|
6782
|
+
* items from the element each time, so items that come and go are found,
|
|
6783
|
+
* and it renders nothing.
|
|
6784
|
+
*
|
|
6785
|
+
* It acts only while focus is on the menu element itself or on one of its
|
|
6786
|
+
* items, so anything else focusable inside the menu, such as a filter
|
|
6787
|
+
* field, keeps its own keys.
|
|
6788
|
+
*
|
|
6789
|
+
* - Down and Up move to the next and previous enabled item, wrapping, and
|
|
6790
|
+
* Home and End go to the first and last. From no item, Down goes to the
|
|
6791
|
+
* first and Up to the last.
|
|
6792
|
+
* - Enter and Space activate the focused item. A `<button>`, and a link with
|
|
6793
|
+
* an `href` for Enter, get the browser's own click, and anything else gets
|
|
6794
|
+
* one from here, so its `onClick` runs either way. A key held down
|
|
6795
|
+
* activates it once. A key handler on the item that called
|
|
6796
|
+
* `preventDefault()` has claimed the key, and nothing else runs it.
|
|
6797
|
+
* - A printable character moves to the next enabled item whose text starts
|
|
6798
|
+
* with what has been typed. Characters typed within `TYPEAHEAD_TIMEOUT` of
|
|
6799
|
+
* each other add up to one search, until `reset` starts a new one.
|
|
6800
|
+
*
|
|
6801
|
+
* Items are `MENU_ITEM_SELECTOR` matches, and disabled ones are skipped by
|
|
6802
|
+
* all of the above. Escape and Tab are left to the menu's owner, since what
|
|
6803
|
+
* closing means is up to it, and so is any other key.
|
|
6804
|
+
*
|
|
6805
|
+
* @function useMenuKeys
|
|
6806
|
+
* @returns The `keydown` handler for the menu element, and a way to forget
|
|
6807
|
+
* what has been typed.
|
|
6808
|
+
*/
|
|
6809
|
+
function useMenuKeys() {
|
|
6810
|
+
const typedRef = React.useRef({ text: '' });
|
|
6811
|
+
React.useEffect(() => {
|
|
6812
|
+
const typed = typedRef.current;
|
|
6813
|
+
return () => clearTimeout(typed.timer);
|
|
6814
|
+
}, []);
|
|
6815
|
+
return React.useMemo(() => {
|
|
6816
|
+
const reset = () => {
|
|
6817
|
+
const typed = typedRef.current;
|
|
6818
|
+
clearTimeout(typed.timer);
|
|
6819
|
+
typed.text = '';
|
|
6820
|
+
};
|
|
6821
|
+
const onKeyDown = (event) => {
|
|
6822
|
+
const menu = event.currentTarget;
|
|
6823
|
+
const focused = getActiveElementInTree(menu);
|
|
6824
|
+
if (focused !== menu && !isItemOf(menu, focused))
|
|
6825
|
+
return;
|
|
6826
|
+
const items = enabledMenuItems(menu);
|
|
6827
|
+
if (!items.length)
|
|
6828
|
+
return;
|
|
6829
|
+
const current = items.indexOf(focused);
|
|
6830
|
+
switch (event.key) {
|
|
6831
|
+
case 'ArrowDown':
|
|
6832
|
+
event.preventDefault();
|
|
6833
|
+
items[current >= 0 ? (current + 1) % items.length : 0].focus();
|
|
6834
|
+
return;
|
|
6835
|
+
case 'ArrowUp':
|
|
6836
|
+
event.preventDefault();
|
|
6837
|
+
items[current >= 0
|
|
6838
|
+
? (current - 1 + items.length) % items.length
|
|
6839
|
+
: items.length - 1].focus();
|
|
6840
|
+
return;
|
|
6841
|
+
case 'Home':
|
|
6842
|
+
event.preventDefault();
|
|
6843
|
+
items[0].focus();
|
|
6844
|
+
return;
|
|
6845
|
+
case 'End':
|
|
6846
|
+
event.preventDefault();
|
|
6847
|
+
items[items.length - 1].focus();
|
|
6848
|
+
return;
|
|
6849
|
+
case 'Enter':
|
|
6850
|
+
case ' ': {
|
|
6851
|
+
if (current < 0 || event.defaultPrevented)
|
|
6852
|
+
return;
|
|
6853
|
+
const item = items[current];
|
|
6854
|
+
if (activatesItself(item, event.key)) {
|
|
6855
|
+
// The browser clicks on Enter's keydown, and a held key sends one
|
|
6856
|
+
// per auto-repeat. Space clicks on keyup, once however long it's
|
|
6857
|
+
// held.
|
|
6858
|
+
if (event.repeat && event.key === 'Enter')
|
|
6859
|
+
event.preventDefault();
|
|
6860
|
+
return;
|
|
6861
|
+
}
|
|
6862
|
+
// Space would scroll the page, and a held key repeats.
|
|
6863
|
+
event.preventDefault();
|
|
6864
|
+
if (!event.repeat)
|
|
6865
|
+
item.click();
|
|
6866
|
+
return;
|
|
6867
|
+
}
|
|
6868
|
+
}
|
|
6869
|
+
if (event.key.length !== 1 ||
|
|
6870
|
+
event.ctrlKey ||
|
|
6871
|
+
event.metaKey ||
|
|
6872
|
+
event.altKey ||
|
|
6873
|
+
event.defaultPrevented) {
|
|
6874
|
+
return;
|
|
6875
|
+
}
|
|
6876
|
+
event.preventDefault();
|
|
6877
|
+
const typed = typedRef.current;
|
|
6878
|
+
typed.text += event.key.toLowerCase();
|
|
6879
|
+
clearTimeout(typed.timer);
|
|
6880
|
+
typed.timer = setTimeout(reset, TYPEAHEAD_TIMEOUT);
|
|
6881
|
+
typeaheadMatch(items, typed.text, current)?.focus();
|
|
6882
|
+
};
|
|
6883
|
+
return { onKeyDown, reset };
|
|
6884
|
+
}, []);
|
|
6885
|
+
}
|
|
6886
|
+
|
|
6887
|
+
/**
|
|
6888
|
+
* Where the menu goes along one axis, from the point at `at`: after it when
|
|
6889
|
+
* there's room, before it when there's room there instead, and otherwise
|
|
6890
|
+
* moved in until it fits in `room`.
|
|
6891
|
+
*/
|
|
6892
|
+
function placeAlong(at, size, room) {
|
|
6893
|
+
if (at + size <= room)
|
|
6894
|
+
return { start: at, flip: false };
|
|
6895
|
+
if (at - size >= 0)
|
|
6896
|
+
return { start: at, flip: true };
|
|
6897
|
+
return { start: Math.max(0, room - size), flip: false };
|
|
6898
|
+
}
|
|
6899
|
+
/** Runs the trigger's own handler, then the context menu's. */
|
|
6900
|
+
function chain(own, ours) {
|
|
6901
|
+
return event => {
|
|
6902
|
+
own?.(event);
|
|
6903
|
+
ours(event);
|
|
6904
|
+
};
|
|
6905
|
+
}
|
|
6906
|
+
/**
|
|
6907
|
+
* The `ContextMenu` component opens a menu of actions where you right-click an element, such as a table row or a card, or long press it on a touch screen.
|
|
6908
|
+
*
|
|
6909
|
+
* Shift+F10 and the context menu key open it too, where the browser fires
|
|
6910
|
+
* `contextmenu` for them, which Windows and Linux do. The menu is Dropdown's
|
|
6911
|
+
* markup, portaled into the `portalContainer` a `ConfigProvider` sets, or
|
|
6912
|
+
* the end of the trigger's own document's `<body>`, so it never has server
|
|
6913
|
+
* markup. It opens below and to the right of the point, flips above or to
|
|
6914
|
+
* the left where there's no room, moves in where it fits neither way, and
|
|
6915
|
+
* scrolls inside when it's taller than the viewport. It doesn't follow the
|
|
6916
|
+
* trigger, since scrolling closes it. Inside a `Modal` or a shadow root,
|
|
6917
|
+
* point `portalContainer` inside it, so screen readers that honor
|
|
6918
|
+
* `aria-modal` reach the menu and the shadow root's styles apply.
|
|
6919
|
+
*
|
|
6920
|
+
* Opening moves focus to the first enabled item. The arrow keys, Home, End
|
|
6921
|
+
* and typing move between items, and Enter and Space activate one. Escape,
|
|
6922
|
+
* Tab, an item, a press outside, scrolling, resizing and the window losing
|
|
6923
|
+
* focus close it, handing focus back to what had it unless an item moved it
|
|
6924
|
+
* somewhere else. For a menu opened from a button use `Dropdown`.
|
|
6925
|
+
*
|
|
6926
|
+
* @function
|
|
6927
|
+
* @param {ContextMenuProps} props - Props for the ContextMenu component.
|
|
6928
|
+
* @returns {JSX.Element} The trigger, and the menu while open.
|
|
6929
|
+
*
|
|
6930
|
+
* @example
|
|
6931
|
+
* <ContextMenu
|
|
6932
|
+
* aria-label="Actions for report.pdf"
|
|
6933
|
+
* trigger={<Box>report.pdf</Box>}
|
|
6934
|
+
* >
|
|
6935
|
+
* <ContextMenu.Item as="button">Rename</ContextMenu.Item>
|
|
6936
|
+
* <ContextMenu.Divider />
|
|
6937
|
+
* <ContextMenu.Item as="button">Delete</ContextMenu.Item>
|
|
6938
|
+
* </ContextMenu>
|
|
6939
|
+
*/
|
|
6940
|
+
const ContextMenuComponent = ({ trigger, children, onOpenChange, closeOnClick = true, disabled = false, className, onKeyDown, onClick, onContextMenu, ...props }) => {
|
|
6941
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
6942
|
+
const { portalContainer } = useConfig();
|
|
6943
|
+
const [menu, setMenu] = React.useState(null);
|
|
6944
|
+
// Turning `disabled` on closes it.
|
|
6945
|
+
if (disabled && menu)
|
|
6946
|
+
setMenu(null);
|
|
6947
|
+
const open = menu !== null;
|
|
6948
|
+
const doc = menu?.doc;
|
|
6949
|
+
const placed = menu?.placed;
|
|
6950
|
+
const boxRef = React.useRef(null);
|
|
6951
|
+
const menuRef = React.useRef(null);
|
|
6952
|
+
const openerRef = React.useRef(null);
|
|
6953
|
+
const reportedRef = React.useRef(false);
|
|
6954
|
+
const menuKeys = useMenuKeys();
|
|
6955
|
+
const named = Boolean(props['aria-label'] || props['aria-labelledby']);
|
|
6956
|
+
const handlers = useContextMenuTrigger({
|
|
6957
|
+
disabled,
|
|
6958
|
+
onOpen: (point, triggerNode) => {
|
|
6959
|
+
const ownerDoc = triggerNode.ownerDocument;
|
|
6960
|
+
openerRef.current = getDeepestActiveElement(ownerDoc);
|
|
6961
|
+
setMenu({ point, doc: ownerDoc });
|
|
6962
|
+
},
|
|
6963
|
+
});
|
|
6964
|
+
// Measured once per opening, before it paints, against what a fixed box
|
|
6965
|
+
// resolves against: the root element's client area, which leaves out a
|
|
6966
|
+
// classic scrollbar. `useAnchoredPosition` and Tooltip read innerWidth and
|
|
6967
|
+
// innerHeight, which count it. A menu pinned to the viewport's edge would
|
|
6968
|
+
// sit under the scrollbar by that much, so this reads the narrower box.
|
|
6969
|
+
useClientLayoutEffect(() => {
|
|
6970
|
+
const menuNode = menuRef.current;
|
|
6971
|
+
if (!menu || menu.placed || !menuNode)
|
|
6972
|
+
return;
|
|
6973
|
+
const root = menuNode.ownerDocument.documentElement;
|
|
6974
|
+
const { width, height } = menuNode.getBoundingClientRect();
|
|
6975
|
+
const x = placeAlong(menu.point.x, width, root.clientWidth);
|
|
6976
|
+
const y = placeAlong(menu.point.y, height, root.clientHeight);
|
|
6977
|
+
setMenu({
|
|
6978
|
+
...menu,
|
|
6979
|
+
placed: { left: x.start, top: y.start, right: x.flip, up: y.flip },
|
|
6980
|
+
});
|
|
6981
|
+
}, [menu]);
|
|
6982
|
+
const isPlaced = Boolean(placed);
|
|
6983
|
+
// What closes it while it's open, and focus back on closing. Nothing
|
|
6984
|
+
// listens while it's closed.
|
|
6985
|
+
React.useEffect(() => {
|
|
6986
|
+
const box = boxRef.current;
|
|
6987
|
+
const menuNode = menuRef.current;
|
|
6988
|
+
if (!doc || !box || !menuNode)
|
|
6989
|
+
return undefined;
|
|
6990
|
+
const view = doc.defaultView;
|
|
6991
|
+
// What had focus when it opened. This runs once per opening, so opening
|
|
6992
|
+
// again at a new point while it's open keeps it.
|
|
6993
|
+
const opener = openerRef.current;
|
|
6994
|
+
const close = () => setMenu(null);
|
|
6995
|
+
const closeOutside = (event) => {
|
|
6996
|
+
if (!isEventInside(event, box))
|
|
6997
|
+
close();
|
|
6998
|
+
};
|
|
6999
|
+
doc.addEventListener('pointerdown', closeOutside, true);
|
|
7000
|
+
view.addEventListener('resize', close);
|
|
7001
|
+
view.addEventListener('blur', close);
|
|
7002
|
+
return () => {
|
|
7003
|
+
doc.removeEventListener('pointerdown', closeOutside, true);
|
|
7004
|
+
view.removeEventListener('resize', close);
|
|
7005
|
+
view.removeEventListener('blur', close);
|
|
7006
|
+
// Back to what had focus while the menu still has it, or it dropped to
|
|
7007
|
+
// <body> when the menu left. An item that put it somewhere else, such
|
|
7008
|
+
// as a Modal or a rename field, keeps it there.
|
|
7009
|
+
const now = getDeepestActiveElement(doc);
|
|
7010
|
+
if (!now || now === doc.body || menuNode.contains(now))
|
|
7011
|
+
opener?.focus();
|
|
7012
|
+
};
|
|
7013
|
+
}, [doc]);
|
|
7014
|
+
// Focus in, and closing on scroll, once it's placed. Placed, it's inside
|
|
7015
|
+
// the viewport, so its first item has nothing to scroll into view, and
|
|
7016
|
+
// `preventScroll` makes sure: a scroll the focus caused would close it as
|
|
7017
|
+
// it opened. Opening again at a new point places it again, and leaves
|
|
7018
|
+
// focus where it is in the menu.
|
|
7019
|
+
React.useEffect(() => {
|
|
7020
|
+
const box = boxRef.current;
|
|
7021
|
+
const menuNode = menuRef.current;
|
|
7022
|
+
if (!doc || !isPlaced || !box || !menuNode)
|
|
7023
|
+
return undefined;
|
|
7024
|
+
if (!menuNode.contains(getDeepestActiveElement(doc))) {
|
|
7025
|
+
menuKeys.reset();
|
|
7026
|
+
(enabledMenuItems(menuNode)[0] ?? menuNode).focus({
|
|
7027
|
+
preventScroll: true,
|
|
7028
|
+
});
|
|
7029
|
+
}
|
|
7030
|
+
const closeOutside = (event) => {
|
|
7031
|
+
if (!isEventInside(event, box))
|
|
7032
|
+
setMenu(null);
|
|
7033
|
+
};
|
|
7034
|
+
doc.addEventListener('scroll', closeOutside, true);
|
|
7035
|
+
return () => doc.removeEventListener('scroll', closeOutside, true);
|
|
7036
|
+
}, [doc, isPlaced, menuKeys]);
|
|
7037
|
+
// Before paint, so what the caller does with it, such as highlighting the
|
|
7038
|
+
// row, shows with the menu rather than a frame behind it.
|
|
7039
|
+
useClientLayoutEffect(() => {
|
|
7040
|
+
if (open === reportedRef.current)
|
|
7041
|
+
return;
|
|
7042
|
+
reportedRef.current = open;
|
|
7043
|
+
onOpenChange?.(open);
|
|
7044
|
+
});
|
|
7045
|
+
React.useEffect(() => {
|
|
7046
|
+
if (open && !named) {
|
|
7047
|
+
warnOnce('ContextMenu:unnamed', '[bestax-bulma] <ContextMenu> opened a menu with no accessible ' +
|
|
7048
|
+
'name. Pass aria-label or aria-labelledby.');
|
|
7049
|
+
}
|
|
7050
|
+
}, [open, named]);
|
|
7051
|
+
const handleKeyDown = (event) => {
|
|
7052
|
+
onKeyDown?.(event);
|
|
7053
|
+
// The menu has the keyboard while focus is in it. React brings its keys
|
|
7054
|
+
// up the tree to wherever the ContextMenu sits, where a table that moves
|
|
7055
|
+
// between rows on the arrow keys, or a Popover or a Modal waiting for
|
|
7056
|
+
// Escape, would take them too.
|
|
7057
|
+
event.stopPropagation();
|
|
7058
|
+
if (event.key === 'Escape' || event.key === 'Tab') {
|
|
7059
|
+
// Tab would go on from the end of <body>, past the rest of the page.
|
|
7060
|
+
event.preventDefault();
|
|
7061
|
+
setMenu(null);
|
|
7062
|
+
return;
|
|
7063
|
+
}
|
|
7064
|
+
menuKeys.onKeyDown(event);
|
|
7065
|
+
};
|
|
7066
|
+
const handleClick = (event) => {
|
|
7067
|
+
onClick?.(event);
|
|
7068
|
+
if (!closeOnClick)
|
|
7069
|
+
return;
|
|
7070
|
+
const item = event.target.closest(MENU_ITEM_SELECTOR);
|
|
7071
|
+
if (item && isEnabledMenuItem(item))
|
|
7072
|
+
setMenu(null);
|
|
7073
|
+
};
|
|
7074
|
+
const handleContextMenu = (event) => {
|
|
7075
|
+
onContextMenu?.(event);
|
|
7076
|
+
// No other menu over this one: not the browser's, and not a trigger's
|
|
7077
|
+
// that React brings the event up to.
|
|
7078
|
+
event.preventDefault();
|
|
7079
|
+
event.stopPropagation();
|
|
7080
|
+
};
|
|
7081
|
+
const own = trigger.props;
|
|
7082
|
+
const triggerClass = usePrefixedClassNames('context-menu');
|
|
7083
|
+
const clonedTrigger = React.cloneElement(trigger, {
|
|
7084
|
+
className: classNames(own.className, !disabled && triggerClass) || undefined,
|
|
7085
|
+
onContextMenu: chain(own.onContextMenu, handlers.onContextMenu),
|
|
7086
|
+
onPointerDown: chain(own.onPointerDown, handlers.onPointerDown),
|
|
7087
|
+
onPointerMove: chain(own.onPointerMove, handlers.onPointerMove),
|
|
7088
|
+
onPointerUp: chain(own.onPointerUp, handlers.onPointerUp),
|
|
7089
|
+
onPointerCancel: chain(own.onPointerCancel, handlers.onPointerCancel),
|
|
7090
|
+
onTouchEnd: chain(own.onTouchEnd, handlers.onTouchEnd),
|
|
7091
|
+
});
|
|
7092
|
+
const boxClasses = usePrefixedClassNames('context-menu-content', 'dropdown', {
|
|
7093
|
+
'is-active': true,
|
|
7094
|
+
'is-up': placed?.up,
|
|
7095
|
+
'is-right': placed?.right,
|
|
7096
|
+
});
|
|
7097
|
+
const menuClasses = classNames(usePrefixedClassNames('dropdown-menu'), bulmaHelperClasses, className);
|
|
7098
|
+
const contentClasses = usePrefixedClassNames('dropdown-content');
|
|
7099
|
+
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [clonedTrigger, menu && (jsxRuntime.jsx(Portal, { container: portalContainer || menu.doc.body, children: jsxRuntime.jsx("div", { ref: boxRef, className: boxClasses, style: {
|
|
7100
|
+
top: placed?.top ?? menu.point.y,
|
|
7101
|
+
left: placed?.left ?? menu.point.x,
|
|
7102
|
+
}, children: jsxRuntime.jsx("div", { ...rest, ref: menuRef, role: "menu", tabIndex: -1, className: menuClasses, onKeyDown: handleKeyDown, onClick: handleClick, onContextMenu: handleContextMenu, children: jsxRuntime.jsx("div", { className: contentClasses, children: children }) }) }) }))] }));
|
|
7103
|
+
};
|
|
7104
|
+
/**
|
|
7105
|
+
* ContextMenu with Item and Divider sub-components, which are `Dropdown.Item`
|
|
7106
|
+
* and `Dropdown.Divider`, so items written for a Dropdown work here
|
|
7107
|
+
* unchanged.
|
|
7108
|
+
*/
|
|
7109
|
+
const ContextMenu = withSubComponents(ContextMenuComponent, {
|
|
7110
|
+
Item: DropdownItem,
|
|
7111
|
+
Divider: DropdownDivider,
|
|
7112
|
+
}, 'ContextMenu');
|
|
7113
|
+
|
|
6514
7114
|
/**
|
|
6515
7115
|
* Individual Step component for use inside Steps.
|
|
6516
7116
|
*
|
|
@@ -6658,6 +7258,11 @@ const Steps = withSubComponents(StepsComponent, { Step }, 'Steps');
|
|
|
6658
7258
|
*/
|
|
6659
7259
|
const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left', width = '260px', isFullwidth, fullWidth, overlay = true, overlayClose = true, escapeClose = true, canCancel = true, children, inline = false, className, style, ...props }, ref) => {
|
|
6660
7260
|
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
7261
|
+
const resolveContainer = usePortalContainer();
|
|
7262
|
+
const { portalContainer } = useConfig();
|
|
7263
|
+
// Where the configured default puts the sidebar. An `inline` one doesn't
|
|
7264
|
+
// portal, so a change to the default moves nothing.
|
|
7265
|
+
const defaultTarget = inline ? undefined : portalContainer;
|
|
6661
7266
|
const sidebarRef = React.useRef(null);
|
|
6662
7267
|
// Cleanup returned by a consumer's callback ref on attach, held until detach.
|
|
6663
7268
|
const consumerCleanupRef = React.useRef(null);
|
|
@@ -6689,7 +7294,9 @@ const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left',
|
|
|
6689
7294
|
// Prevent body scroll when sidebar is open. Ref-counted through the shared
|
|
6690
7295
|
// helper so an overlapping Modal/Dialog/Loading doesn't unlock underneath.
|
|
6691
7296
|
useScrollLock(isOpen && overlay);
|
|
6692
|
-
// Focus trap (basic - focus sidebar when opened)
|
|
7297
|
+
// Focus trap (basic - focus sidebar when opened). A different
|
|
7298
|
+
// `defaultTarget` remounts the portaled sidebar, so it focuses the new
|
|
7299
|
+
// nodes then too.
|
|
6693
7300
|
React.useEffect(() => {
|
|
6694
7301
|
if (isOpen) {
|
|
6695
7302
|
const element = sidebarRef.current;
|
|
@@ -6704,7 +7311,7 @@ const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left',
|
|
|
6704
7311
|
}
|
|
6705
7312
|
}
|
|
6706
7313
|
}
|
|
6707
|
-
}, [isOpen]);
|
|
7314
|
+
}, [isOpen, defaultTarget]);
|
|
6708
7315
|
// Use combined ref
|
|
6709
7316
|
const combinedRef = React.useCallback((node) => {
|
|
6710
7317
|
sidebarRef.current =
|
|
@@ -6759,7 +7366,7 @@ const SidebarComponent = React.forwardRef(({ isOpen, onClose, position = 'left',
|
|
|
6759
7366
|
}
|
|
6760
7367
|
// Render to portal for proper stacking
|
|
6761
7368
|
if (typeof document !== 'undefined') {
|
|
6762
|
-
return reactDom.createPortal(sidebarContent,
|
|
7369
|
+
return reactDom.createPortal(sidebarContent, resolveContainer());
|
|
6763
7370
|
}
|
|
6764
7371
|
return null;
|
|
6765
7372
|
});
|
|
@@ -6887,6 +7494,7 @@ function groupIntoPositionStacks(items, positionOf, containerPosition, order) {
|
|
|
6887
7494
|
*/
|
|
6888
7495
|
const Toast = React.forwardRef(({ message, type = 'default', actionType, position = 'top-right', duration = 2000, indefinite = false, dismissible = true, closable = false, rounded = false, pauseOnHover = false, cancelable = true, actionText, cancelText, onAction, onClose, container, inline = false, className, ...props }, ref) => {
|
|
6889
7496
|
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
7497
|
+
const resolveContainer = usePortalContainer();
|
|
6890
7498
|
const [isVisible, setIsVisible] = React.useState(true);
|
|
6891
7499
|
const [isPaused, setIsPaused] = React.useState(false);
|
|
6892
7500
|
const toastRef = React.useRef(null);
|
|
@@ -7012,7 +7620,7 @@ const Toast = React.forwardRef(({ message, type = 'default', actionType, positio
|
|
|
7012
7620
|
}
|
|
7013
7621
|
const toastContent = jsxRuntime.jsx("div", { className: containerClasses, children: toastElement });
|
|
7014
7622
|
if (typeof document !== 'undefined') {
|
|
7015
|
-
return reactDom.createPortal(toastContent,
|
|
7623
|
+
return reactDom.createPortal(toastContent, resolveContainer(container));
|
|
7016
7624
|
}
|
|
7017
7625
|
return null;
|
|
7018
7626
|
});
|
|
@@ -7169,6 +7777,7 @@ const ToastContainer = ({ position = 'top-right' }) => {
|
|
|
7169
7777
|
// Starts from the toasts already showing instead of an empty list, then
|
|
7170
7778
|
// follows changes.
|
|
7171
7779
|
const toastList = React.useSyncExternalStore(toast.subscribe, getVisibleToasts, getServerToasts);
|
|
7780
|
+
const resolveContainer = usePortalContainer();
|
|
7172
7781
|
if (typeof document === 'undefined' || toastList.length === 0) {
|
|
7173
7782
|
return null;
|
|
7174
7783
|
}
|
|
@@ -7176,7 +7785,7 @@ const ToastContainer = ({ position = 'top-right' }) => {
|
|
|
7176
7785
|
return reactDom.createPortal(stacks.map(stack => (jsxRuntime.jsx("div", { className: `toast-container is-${stack.position}`, children: stack.items.map(t => {
|
|
7177
7786
|
const { queue: _queue, ...toastProps } = t.props;
|
|
7178
7787
|
return (jsxRuntime.jsx(Toast, { ...toastProps, position: stack.position, onClose: () => toast.close(t.id) }, t.id));
|
|
7179
|
-
}) }, stack.key))),
|
|
7788
|
+
}) }, stack.key))), resolveContainer());
|
|
7180
7789
|
};
|
|
7181
7790
|
|
|
7182
7791
|
/**
|
|
@@ -7233,11 +7842,14 @@ const Dialog = React.forwardRef(({ isOpen, title, message, type = 'default', con
|
|
|
7233
7842
|
handleCancel();
|
|
7234
7843
|
}
|
|
7235
7844
|
}, [canCancel, handleCancel]);
|
|
7236
|
-
// Moving into the portal
|
|
7237
|
-
// included
|
|
7238
|
-
// nodes, exactly as the Modal's own focus
|
|
7845
|
+
// Moving into the portal, or from one portal target to another, remounts
|
|
7846
|
+
// the Modal's subtree, these buttons included, so the focus effect below
|
|
7847
|
+
// has to re-run against the new nodes, exactly as the Modal's own focus
|
|
7848
|
+
// effect does, on the same inputs.
|
|
7239
7849
|
const isHydrated = useIsHydrated();
|
|
7240
7850
|
const isPortaled = Boolean(portal) && isHydrated;
|
|
7851
|
+
const { portalContainer } = useConfig();
|
|
7852
|
+
const defaultTarget = isPortaled && portal === true ? portalContainer : undefined;
|
|
7241
7853
|
// Focus management — Dialog picks a specific button rather than the
|
|
7242
7854
|
// Modal's default (first focusable/root); this effect runs after the
|
|
7243
7855
|
// inner Modal's own focus-on-open effect, so it wins.
|
|
@@ -7246,7 +7858,7 @@ const Dialog = React.forwardRef(({ isOpen, title, message, type = 'default', con
|
|
|
7246
7858
|
const buttonToFocus = focusCancel && showCancel ? cancelRef.current : confirmRef.current;
|
|
7247
7859
|
buttonToFocus?.focus();
|
|
7248
7860
|
}
|
|
7249
|
-
}, [isOpen, focusCancel, showCancel, isPortaled]);
|
|
7861
|
+
}, [isOpen, focusCancel, showCancel, isPortaled, portal, defaultTarget]);
|
|
7250
7862
|
// Use combined ref
|
|
7251
7863
|
const combinedRef = React.useCallback((node) => {
|
|
7252
7864
|
dialogRef.current =
|
|
@@ -7993,6 +8605,58 @@ const Buttons = withSubComponents(ButtonsComponent, {
|
|
|
7993
8605
|
LinkButton,
|
|
7994
8606
|
}, 'Buttons');
|
|
7995
8607
|
|
|
8608
|
+
/**
|
|
8609
|
+
* The `ToggleButton` component renders a button that stays pressed until it's pressed again, and says whether it's on through `aria-pressed`.
|
|
8610
|
+
*
|
|
8611
|
+
* It's always a `<button type="button">`, so it never submits a form and
|
|
8612
|
+
* carries no form value: a setting saved with a form is a `Checkbox` or a
|
|
8613
|
+
* `Switch`. A set of toggles that each turn on and off on their own goes in
|
|
8614
|
+
* `Buttons hasAddons` with `role="group"` and an `aria-label`. A set where only
|
|
8615
|
+
* one can be on is a radio group rather than a row of these: use `Radios`.
|
|
8616
|
+
*
|
|
8617
|
+
* @function
|
|
8618
|
+
* @param {ToggleButtonProps} props - Props for the ToggleButton component.
|
|
8619
|
+
* @param {React.Ref<HTMLButtonElement>} ref - Forwarded ref to the `<button>`.
|
|
8620
|
+
* @returns {JSX.Element} The rendered toggle button.
|
|
8621
|
+
* @see {@link https://www.w3.org/WAI/ARIA/apg/patterns/button/ | APG button pattern}
|
|
8622
|
+
*
|
|
8623
|
+
* @example
|
|
8624
|
+
* <ToggleButton onPressedChange={setMuted}>Mute</ToggleButton>
|
|
8625
|
+
*
|
|
8626
|
+
* @example
|
|
8627
|
+
* <Buttons hasAddons role="group" aria-label="Text style">
|
|
8628
|
+
* <ToggleButton aria-label="Bold" pressed={bold} onPressedChange={setBold}>
|
|
8629
|
+
* <Icon name="bold" />
|
|
8630
|
+
* </ToggleButton>
|
|
8631
|
+
* </Buttons>
|
|
8632
|
+
*/
|
|
8633
|
+
const ToggleButton = React.forwardRef(function ToggleButton({ pressed: controlledPressed, defaultPressed = false, onPressedChange, color = 'link', className, onClick, ...props }, ref) {
|
|
8634
|
+
const isControlled = controlledPressed !== undefined;
|
|
8635
|
+
const [internalPressed, setInternalPressed] = React.useState(defaultPressed);
|
|
8636
|
+
const pressed = controlledPressed ?? internalPressed;
|
|
8637
|
+
// Prefixed like Button's own classes, which a hand-written
|
|
8638
|
+
// `className="is-selected"` could not be under ConfigProvider classPrefix.
|
|
8639
|
+
// Bulma lifts `is-selected` above its neighbours in `.buttons.has-addons`.
|
|
8640
|
+
const selectedClass = usePrefixedClassNames({ 'is-selected': pressed });
|
|
8641
|
+
// The caller's onClick runs first, so a `preventDefault()` there vetoes
|
|
8642
|
+
// the toggle, as it does in Radix, MUI and Zag. Enter and Space reach here
|
|
8643
|
+
// through the button's own click, and a disabled button gets no click.
|
|
8644
|
+
const handleClick = (event) => {
|
|
8645
|
+
onClick?.(event);
|
|
8646
|
+
if (event.defaultPrevented)
|
|
8647
|
+
return;
|
|
8648
|
+
const next = !pressed;
|
|
8649
|
+
onPressedChange?.(next);
|
|
8650
|
+
if (!isControlled)
|
|
8651
|
+
setInternalPressed(next);
|
|
8652
|
+
};
|
|
8653
|
+
// The props are spread first, so the four things a toggle depends on win
|
|
8654
|
+
// over anything a JavaScript caller or a spread object passes along: it
|
|
8655
|
+
// stays a button, never submits, and reports the state it's in.
|
|
8656
|
+
return (jsxRuntime.jsx(Button, { ...props, ref: ref, as: "button", type: "button", "aria-pressed": pressed, color: pressed ? color : undefined, className: classNames(selectedClass, className), onClick: handleClick }));
|
|
8657
|
+
});
|
|
8658
|
+
ToggleButton.displayName = 'ToggleButton';
|
|
8659
|
+
|
|
7996
8660
|
/**
|
|
7997
8661
|
* The `Code` component renders a styled `<code>` element with Bulma helper class integration.
|
|
7998
8662
|
*
|
|
@@ -8881,6 +9545,7 @@ const NotificationContainer = ({ position = 'top-right' }) => {
|
|
|
8881
9545
|
// the server render and the hydrating one render nothing, and the container
|
|
8882
9546
|
// portals in from the render after hydration.
|
|
8883
9547
|
const isHydrated = useIsHydrated();
|
|
9548
|
+
const resolveContainer = usePortalContainer();
|
|
8884
9549
|
// The notifications the status region announces. Danger and warning
|
|
8885
9550
|
// announce themselves.
|
|
8886
9551
|
const politeItems = React.useMemo(() => items.filter(item => !isUrgentColor(item.options.color)), [items]);
|
|
@@ -8913,7 +9578,7 @@ const NotificationContainer = ({ position = 'top-right' }) => {
|
|
|
8913
9578
|
return null;
|
|
8914
9579
|
}
|
|
8915
9580
|
const stacks = groupIntoPositionStacks(items, item => item.options.position, position, notificationStackOrder);
|
|
8916
|
-
return reactDom.createPortal(jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [stacks.map(stack => (jsxRuntime.jsx("div", { style: notificationStackStyle(stack.position), children: stack.items.map(item => (jsxRuntime.jsx(NotificationItem, { instance: item, onClose: notification.close, registerMessage: registerMessage }, item.id))) }, stack.key))), jsxRuntime.jsx(StatusRegion, { items: politeItems, describe: describe })] }),
|
|
9581
|
+
return reactDom.createPortal(jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [stacks.map(stack => (jsxRuntime.jsx("div", { style: notificationStackStyle(stack.position), children: stack.items.map(item => (jsxRuntime.jsx(NotificationItem, { instance: item, onClose: notification.close, registerMessage: registerMessage }, item.id))) }, stack.key))), jsxRuntime.jsx(StatusRegion, { items: politeItems, describe: describe })] }), resolveContainer());
|
|
8917
9582
|
};
|
|
8918
9583
|
|
|
8919
9584
|
/**
|
|
@@ -12484,7 +13149,7 @@ const DEFAULT_PICKER_LABELS = {
|
|
|
12484
13149
|
hours: 'hours',
|
|
12485
13150
|
minutes: 'minutes',
|
|
12486
13151
|
seconds: 'seconds',
|
|
12487
|
-
ampm: '
|
|
13152
|
+
ampm: 'AM/PM',
|
|
12488
13153
|
increaseHours: 'Increase hours',
|
|
12489
13154
|
decreaseHours: 'Decrease hours',
|
|
12490
13155
|
increaseMinutes: 'Increase minutes',
|
|
@@ -12503,11 +13168,19 @@ const DEFAULT_PICKER_LABELS = {
|
|
|
12503
13168
|
done: 'Done',
|
|
12504
13169
|
time: 'Time',
|
|
12505
13170
|
};
|
|
12506
|
-
/**
|
|
12507
|
-
|
|
12508
|
-
|
|
12509
|
-
|
|
12510
|
-
|
|
13171
|
+
/**
|
|
13172
|
+
* Merge user-supplied label overrides with the defaults. A key set to
|
|
13173
|
+
* `undefined` keeps its default, so a locale table that is missing a string
|
|
13174
|
+
* still names its control instead of leaving it with no accessible name.
|
|
13175
|
+
*/
|
|
13176
|
+
const mergeLabels = (overrides) => {
|
|
13177
|
+
const merged = { ...DEFAULT_PICKER_LABELS };
|
|
13178
|
+
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
13179
|
+
if (value !== undefined)
|
|
13180
|
+
merged[key] = value;
|
|
13181
|
+
}
|
|
13182
|
+
return merged;
|
|
13183
|
+
};
|
|
12511
13184
|
|
|
12512
13185
|
function isSameDay(a, b) {
|
|
12513
13186
|
return (a.getFullYear() === b.getFullYear() &&
|
|
@@ -13840,6 +14513,7 @@ Calendar.displayName = 'Calendar';
|
|
|
13840
14513
|
const isBrowser = typeof window !== 'undefined';
|
|
13841
14514
|
const PickerPopover = ({ isOpen, onClose, anchorRef, children, position = 'bottom-left', appendToBody = false, className, trapFocus = true, closeOnClickOutside = true, closeOnEscape = true, ariaLabel, ariaLabelledBy, role = 'dialog', id, restoreFocusRef, initialFocusSelector, }) => {
|
|
13842
14515
|
const panelRef = React.useRef(null);
|
|
14516
|
+
const resolveContainer = usePortalContainer();
|
|
13843
14517
|
// Read as the trap turns on, once the panel's content has rendered.
|
|
13844
14518
|
const initialFocusRef = React.useMemo(() => initialFocusSelector === undefined
|
|
13845
14519
|
? undefined
|
|
@@ -13916,7 +14590,7 @@ const PickerPopover = ({ isOpen, onClose, anchorRef, children, position = 'botto
|
|
|
13916
14590
|
: {};
|
|
13917
14591
|
const panel = (jsxRuntime.jsx("div", { ref: panelRef, id: id, role: role, "aria-modal": role === 'dialog' ? 'false' : undefined, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, tabIndex: -1, className: classNames(panelClass, className), style: style, children: children }));
|
|
13918
14592
|
if (appendToBody && isBrowser) {
|
|
13919
|
-
return reactDom.createPortal(panel,
|
|
14593
|
+
return reactDom.createPortal(panel, resolveContainer());
|
|
13920
14594
|
}
|
|
13921
14595
|
return panel;
|
|
13922
14596
|
};
|
|
@@ -18428,6 +19102,7 @@ exports.Columns = Columns;
|
|
|
18428
19102
|
exports.ConfigProvider = ConfigProvider;
|
|
18429
19103
|
exports.Container = Container;
|
|
18430
19104
|
exports.Content = Content;
|
|
19105
|
+
exports.ContextMenu = ContextMenu;
|
|
18431
19106
|
exports.Control = Control;
|
|
18432
19107
|
exports.DateInput = DateInput;
|
|
18433
19108
|
exports.DateInputBase = DateInputBase;
|
|
@@ -18558,6 +19233,7 @@ exports.TimeInputBase = TimeInputBase;
|
|
|
18558
19233
|
exports.Title = Title;
|
|
18559
19234
|
exports.Toast = Toast;
|
|
18560
19235
|
exports.ToastContainer = ToastContainer;
|
|
19236
|
+
exports.ToggleButton = ToggleButton;
|
|
18561
19237
|
exports.Tooltip = Tooltip;
|
|
18562
19238
|
exports.Tr = Tr;
|
|
18563
19239
|
exports.UnorderedList = UnorderedList;
|