@allxsmith/bestax-bulma 5.22.0 → 5.24.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/extras.css +1 -1
- package/dist/extras.css.map +1 -1
- package/dist/index.cjs +1769 -562
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +1764 -564
- package/dist/index.esm.js.map +1 -1
- package/dist/types/components/Popover.d.ts +166 -0
- package/dist/types/form/DateInput.d.ts +1 -1
- package/dist/types/form/DateInputBase.d.ts +33 -3
- package/dist/types/form/_pickerInternals/Calendar.d.ts +11 -1
- package/dist/types/form/_pickerInternals/dateUtils.d.ts +36 -1
- package/dist/types/form/_pickerInternals/formatters.d.ts +2 -0
- package/dist/types/form/_pickerInternals/nativeInputSupport.d.ts +11 -0
- package/dist/types/form/_pickerInternals/pickerTypes.d.ts +10 -0
- package/dist/types/form/_pickerInternals/useSegmentedEntry.d.ts +9 -1
- package/dist/types/helpers/portal.d.ts +62 -0
- package/dist/types/helpers/shadowDom.d.ts +3 -1
- package/dist/types/helpers/useAnchoredPosition.d.ts +59 -0
- package/dist/types/helpers/useClientLayoutEffect.d.ts +7 -0
- package/dist/types/helpers/useFocusTrap.d.ts +84 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types-cjs/components/Popover.d.ts +166 -0
- package/dist/types-cjs/form/DateInput.d.ts +1 -1
- package/dist/types-cjs/form/DateInputBase.d.ts +33 -3
- package/dist/types-cjs/form/_pickerInternals/Calendar.d.ts +11 -1
- package/dist/types-cjs/form/_pickerInternals/dateUtils.d.ts +36 -1
- package/dist/types-cjs/form/_pickerInternals/formatters.d.ts +2 -0
- package/dist/types-cjs/form/_pickerInternals/nativeInputSupport.d.ts +11 -0
- package/dist/types-cjs/form/_pickerInternals/pickerTypes.d.ts +10 -0
- package/dist/types-cjs/form/_pickerInternals/useSegmentedEntry.d.ts +9 -1
- package/dist/types-cjs/helpers/portal.d.ts +62 -0
- package/dist/types-cjs/helpers/shadowDom.d.ts +3 -1
- package/dist/types-cjs/helpers/useAnchoredPosition.d.ts +59 -0
- package/dist/types-cjs/helpers/useClientLayoutEffect.d.ts +7 -0
- package/dist/types-cjs/helpers/useFocusTrap.d.ts +84 -0
- package/dist/types-cjs/index.d.ts +5 -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/_index.scss +1 -0
- package/src/scss/components/_popover.scss +144 -0
- package/src/scss/form/_dateinput.scss +20 -3
- package/dist/types/form/_pickerInternals/useFocusTrap.d.ts +0 -7
- package/dist/types-cjs/form/_pickerInternals/useFocusTrap.d.ts +0 -7
package/dist/index.esm.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* @allxsmith/bestax-bulma — AI agents: see AGENTS.md in the package root, or https://bestax.io/llms.txt */
|
|
2
2
|
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
3
|
-
import React, { createContext, useContext, useMemo, forwardRef, useState, useRef, useEffect, useCallback, useSyncExternalStore, useId, useLayoutEffect, Children, isValidElement
|
|
3
|
+
import React, { createContext, useContext, useMemo, forwardRef, useState, useRef, useEffect, useCallback, useSyncExternalStore, useId, useLayoutEffect, cloneElement, Children, isValidElement } from 'react';
|
|
4
4
|
import { createPortal } from 'react-dom';
|
|
5
5
|
|
|
6
6
|
const ConfigContext = createContext({});
|
|
@@ -2254,10 +2254,12 @@ function getActiveElementInTree(node) {
|
|
|
2254
2254
|
* opener sits in. A closed shadow root cannot be entered, so focus inside one
|
|
2255
2255
|
* reads as its host.
|
|
2256
2256
|
*
|
|
2257
|
+
* @param doc - The document to start from: the component's own, when it may
|
|
2258
|
+
* render into one other than the page's (an iframe's). Defaults to `document`.
|
|
2257
2259
|
* @returns The focused element, or `null` when the document has none.
|
|
2258
2260
|
*/
|
|
2259
|
-
function getDeepestActiveElement() {
|
|
2260
|
-
let active =
|
|
2261
|
+
function getDeepestActiveElement(doc = document) {
|
|
2262
|
+
let active = doc.activeElement;
|
|
2261
2263
|
while (active?.shadowRoot?.activeElement) {
|
|
2262
2264
|
active = active.shadowRoot.activeElement;
|
|
2263
2265
|
}
|
|
@@ -2794,6 +2796,32 @@ function useScrollLock(active) {
|
|
|
2794
2796
|
}, [active]);
|
|
2795
2797
|
}
|
|
2796
2798
|
|
|
2799
|
+
// "Are we past hydration?" as a store that never changes: React reads the
|
|
2800
|
+
// server snapshot both while server-rendering and while hydrating, and the
|
|
2801
|
+
// client snapshot from the first post-hydration render onward.
|
|
2802
|
+
const subscribeToNothing = () => () => { };
|
|
2803
|
+
const getClientSnapshot = () => true;
|
|
2804
|
+
const getServerSnapshot = () => false;
|
|
2805
|
+
/**
|
|
2806
|
+
* Reports whether the component has passed hydration.
|
|
2807
|
+
*
|
|
2808
|
+
* Returns `false` during server rendering and during the hydrating client
|
|
2809
|
+
* render, then `true` from the commit that follows. Use it to defer
|
|
2810
|
+
* DOM-only behaviour (such as portalling) past hydration so the first client
|
|
2811
|
+
* render matches the server markup instead of tripping hydration recovery.
|
|
2812
|
+
*
|
|
2813
|
+
* Only a component that hydrates ever sees `false` on the client. One that
|
|
2814
|
+
* mounts later (after a route change, or in an app with no server rendering)
|
|
2815
|
+
* gets `true` on its first render, so it pays for no extra render. It
|
|
2816
|
+
* subscribes to nothing, so no later render flips it back.
|
|
2817
|
+
*
|
|
2818
|
+
* @function useIsHydrated
|
|
2819
|
+
* @returns `true` once the hydrating render has completed, otherwise `false`.
|
|
2820
|
+
*/
|
|
2821
|
+
function useIsHydrated() {
|
|
2822
|
+
return useSyncExternalStore(subscribeToNothing, getClientSnapshot, getServerSnapshot);
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2797
2825
|
/**
|
|
2798
2826
|
* Resolves a portal target: an `HTMLElement` is used directly, a non-empty
|
|
2799
2827
|
* `string` is treated as a `document.querySelector` selector (falling back to
|
|
@@ -2817,31 +2845,49 @@ function resolvePortalContainer(container) {
|
|
|
2817
2845
|
}
|
|
2818
2846
|
return container;
|
|
2819
2847
|
}
|
|
2820
|
-
|
|
2821
|
-
// "Are we past hydration?" as a store that never changes: React reads the
|
|
2822
|
-
// server snapshot both while server-rendering and while hydrating, and the
|
|
2823
|
-
// client snapshot from the first post-hydration render onward.
|
|
2824
|
-
const subscribeToNothing = () => () => { };
|
|
2825
|
-
const getClientSnapshot = () => true;
|
|
2826
|
-
const getServerSnapshot = () => false;
|
|
2827
2848
|
/**
|
|
2828
|
-
*
|
|
2849
|
+
* Renders its children into another part of the page, `document.body` unless `container` says otherwise, so floating content escapes an ancestor's `overflow`, `transform` or stacking context.
|
|
2829
2850
|
*
|
|
2830
|
-
*
|
|
2831
|
-
*
|
|
2832
|
-
*
|
|
2833
|
-
*
|
|
2851
|
+
* React's server renderer can't render a portal, so `Portal` renders nothing
|
|
2852
|
+
* on the server and during hydration, and mounts its children in the commit
|
|
2853
|
+
* that follows. The first client render then matches the server markup. In an app
|
|
2854
|
+
* without server rendering it portals on the first render.
|
|
2834
2855
|
*
|
|
2835
|
-
*
|
|
2836
|
-
*
|
|
2837
|
-
*
|
|
2838
|
-
*
|
|
2856
|
+
* React context, such as `ConfigProvider`'s class prefix, reaches the
|
|
2857
|
+
* portaled children, and their React events bubble through the React tree.
|
|
2858
|
+
* CSS inheritance follows the DOM instead: a scoped (non-`isRoot`) `Theme`
|
|
2859
|
+
* sets its variables on a wrapper element the portaled content has left, so
|
|
2860
|
+
* that content sees the page-level theme. Point `container` at an element
|
|
2861
|
+
* inside the themed area to keep it.
|
|
2839
2862
|
*
|
|
2840
|
-
*
|
|
2841
|
-
*
|
|
2863
|
+
* Tab order and focus follow the DOM too. Content portaled from inside a
|
|
2864
|
+
* focus trap is no longer inside the trapped element, so the trap neither
|
|
2865
|
+
* reaches it nor holds focus there: render nested overlays inside the
|
|
2866
|
+
* trapped element, or give them a trap of their own.
|
|
2867
|
+
*
|
|
2868
|
+
* @function
|
|
2869
|
+
* @param {PortalProps} props - Props for the Portal component.
|
|
2870
|
+
* @returns The portal, the children in place when `disabled`, or nothing
|
|
2871
|
+
* before hydration.
|
|
2872
|
+
*
|
|
2873
|
+
* @example
|
|
2874
|
+
* <Portal>
|
|
2875
|
+
* <div className="box">Rendered at the end of document.body</div>
|
|
2876
|
+
* </Portal>
|
|
2877
|
+
*
|
|
2878
|
+
* @example
|
|
2879
|
+
* // Into a dedicated overlay root
|
|
2880
|
+
* <Portal container="#overlays">
|
|
2881
|
+
* <CommandPalette />
|
|
2882
|
+
* </Portal>
|
|
2842
2883
|
*/
|
|
2843
|
-
function
|
|
2844
|
-
|
|
2884
|
+
function Portal({ children, container, disabled = false }) {
|
|
2885
|
+
const hydrated = useIsHydrated();
|
|
2886
|
+
if (disabled)
|
|
2887
|
+
return jsx(Fragment, { children: children });
|
|
2888
|
+
if (!hydrated)
|
|
2889
|
+
return null;
|
|
2890
|
+
return createPortal(children, resolvePortalContainer(container));
|
|
2845
2891
|
}
|
|
2846
2892
|
|
|
2847
2893
|
/**
|
|
@@ -2855,7 +2901,7 @@ const activeModalStack = [];
|
|
|
2855
2901
|
* excluded because `focus()` on them is a no-op, which would leave focus
|
|
2856
2902
|
* outside the modal.
|
|
2857
2903
|
*/
|
|
2858
|
-
const FOCUSABLE_SELECTOR
|
|
2904
|
+
const FOCUSABLE_SELECTOR = 'button:not(:disabled), [href], input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), [tabindex]';
|
|
2859
2905
|
/**
|
|
2860
2906
|
* The modal's tab stops, in document order. The selector alone is not the
|
|
2861
2907
|
* tabbable set — `[href]` and `button` match regardless of `tabindex`, so an
|
|
@@ -2864,7 +2910,7 @@ const FOCUSABLE_SELECTOR$1 = 'button:not(:disabled), [href], input:not(:disabled
|
|
|
2864
2910
|
* resolved value, so filtering on it drops every negative index (not just
|
|
2865
2911
|
* `-1`) without a second selector to keep in sync.
|
|
2866
2912
|
*/
|
|
2867
|
-
const getTabbable = (node) => Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR
|
|
2913
|
+
const getTabbable = (node) => Array.from(node.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => el.tabIndex >= 0);
|
|
2868
2914
|
/**
|
|
2869
2915
|
* Modal.Background - Renders the modal background overlay.
|
|
2870
2916
|
*
|
|
@@ -4754,160 +4800,1085 @@ const Tooltip = ({ label, content, position = 'top', color, size, active: always
|
|
|
4754
4800
|
};
|
|
4755
4801
|
|
|
4756
4802
|
/**
|
|
4757
|
-
*
|
|
4758
|
-
*
|
|
4759
|
-
*
|
|
4760
|
-
* @param {StepProps} props - Props for the Step component.
|
|
4761
|
-
* @returns {JSX.Element} The rendered step segment.
|
|
4803
|
+
* Whether `el` is editable while its parent is not, which makes it an editing
|
|
4804
|
+
* host: the one tab stop an editable region gets. `contenteditable="false"`
|
|
4805
|
+
* turns editing off, and a missing or unknown value inherits the parent's.
|
|
4762
4806
|
*/
|
|
4763
|
-
|
|
4764
|
-
const
|
|
4765
|
-
|
|
4766
|
-
|
|
4767
|
-
|
|
4768
|
-
|
|
4769
|
-
|
|
4770
|
-
const stepsMarkerClass = usePrefixedClassNames('steps-marker');
|
|
4771
|
-
const stepsContentClass = usePrefixedClassNames('steps-content');
|
|
4772
|
-
const stepsTitleClass = usePrefixedClassNames('steps-title');
|
|
4773
|
-
const handleClick = () => {
|
|
4774
|
-
if (clickable && onClick) {
|
|
4775
|
-
onClick();
|
|
4776
|
-
}
|
|
4777
|
-
};
|
|
4778
|
-
const handleKeyDown = (e) => {
|
|
4779
|
-
if (clickable && onClick && (e.key === 'Enter' || e.key === ' ')) {
|
|
4780
|
-
e.preventDefault();
|
|
4781
|
-
onClick();
|
|
4807
|
+
function isEditingHost(el) {
|
|
4808
|
+
const editable = (node) => {
|
|
4809
|
+
const value = node.getAttribute('contenteditable')?.toLowerCase();
|
|
4810
|
+
if (value === undefined)
|
|
4811
|
+
return null;
|
|
4812
|
+
if (value === '' || value === 'true' || value === 'plaintext-only') {
|
|
4813
|
+
return true;
|
|
4782
4814
|
}
|
|
4815
|
+
return value === 'false' ? false : null;
|
|
4783
4816
|
};
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4817
|
+
if (editable(el) !== true)
|
|
4818
|
+
return false;
|
|
4819
|
+
for (let node = el.parentElement; node; node = node.parentElement) {
|
|
4820
|
+
const state = editable(node);
|
|
4821
|
+
if (state !== null)
|
|
4822
|
+
return !state;
|
|
4823
|
+
}
|
|
4824
|
+
return true;
|
|
4825
|
+
}
|
|
4826
|
+
/** Whether `el` is the summary of its `<details>`: the first one, not a stray. */
|
|
4827
|
+
const isDetailsSummary = (el) => el.parentElement?.localName === 'details' &&
|
|
4828
|
+
Array.from(el.parentElement.children).find(child => child.localName === 'summary') === el;
|
|
4829
|
+
/**
|
|
4830
|
+
* Whether the browser puts `el` in the tab order, before anything that hides
|
|
4831
|
+
* or disables it. The `tabIndex` property can't answer this on its own: it
|
|
4832
|
+
* can read `-1` for an editable region or an `<embed>` that Tab still visits,
|
|
4833
|
+
* and `0` for a `<video>` without controls that it doesn't. So a valid
|
|
4834
|
+
* `tabindex` attribute decides, and otherwise the element's kind does.
|
|
4835
|
+
*/
|
|
4836
|
+
function isTabbableKind(el) {
|
|
4837
|
+
const tabindex = el.getAttribute('tabindex');
|
|
4838
|
+
if (tabindex !== null && /^\s*[+-]?\d/.test(tabindex)) {
|
|
4839
|
+
return el.tabIndex >= 0;
|
|
4840
|
+
}
|
|
4841
|
+
switch (el.localName) {
|
|
4842
|
+
case 'a':
|
|
4843
|
+
case 'area':
|
|
4844
|
+
return el.hasAttribute('href');
|
|
4845
|
+
case 'input':
|
|
4846
|
+
return el.type !== 'hidden';
|
|
4847
|
+
case 'button':
|
|
4848
|
+
case 'select':
|
|
4849
|
+
case 'textarea':
|
|
4850
|
+
case 'iframe':
|
|
4851
|
+
case 'embed':
|
|
4852
|
+
case 'object':
|
|
4853
|
+
return true;
|
|
4854
|
+
case 'audio':
|
|
4855
|
+
case 'video':
|
|
4856
|
+
return el.hasAttribute('controls');
|
|
4857
|
+
case 'summary':
|
|
4858
|
+
return isDetailsSummary(el);
|
|
4859
|
+
default:
|
|
4860
|
+
return isEditingHost(el);
|
|
4861
|
+
}
|
|
4862
|
+
}
|
|
4863
|
+
/**
|
|
4864
|
+
* The node's parent in the flattened tree, the one the browser renders and
|
|
4865
|
+
* tabs through: the slot it is assigned to, else its parent, else the host of
|
|
4866
|
+
* the shadow root it tops, and nothing above the document. A closed shadow
|
|
4867
|
+
* root hides its slots (`assignedSlot` reads `null` under one), so a child of
|
|
4868
|
+
* its host goes straight to the host, whether a slot takes it or not.
|
|
4869
|
+
*/
|
|
4870
|
+
const flatParent = (node) => node.assignedSlot ??
|
|
4871
|
+
node.parentElement ??
|
|
4872
|
+
node.parentNode?.host ??
|
|
4873
|
+
null;
|
|
4874
|
+
/**
|
|
4875
|
+
* The element's children in the flattened tree, in the order they render: a
|
|
4876
|
+
* host's shadow root content, a slot's assigned elements (or its fallback
|
|
4877
|
+
* content when nothing is assigned), anything else's own children. A host
|
|
4878
|
+
* whose shadow root is closed has no root to read, so its own children stand
|
|
4879
|
+
* in, including any that no slot takes and that aren't rendered.
|
|
4880
|
+
*/
|
|
4881
|
+
function flatChildren(el) {
|
|
4882
|
+
if (el.shadowRoot)
|
|
4883
|
+
return Array.from(el.shadowRoot.children);
|
|
4884
|
+
if (el.localName === 'slot') {
|
|
4885
|
+
const assigned = el.assignedNodes();
|
|
4886
|
+
if (assigned.length > 0) {
|
|
4887
|
+
return assigned.filter((node) => node.nodeType === Node.ELEMENT_NODE);
|
|
4888
|
+
}
|
|
4889
|
+
}
|
|
4890
|
+
return Array.from(el.children);
|
|
4891
|
+
}
|
|
4892
|
+
const isShown = (el) => !el.hasAttribute('inert') && getComputedStyle(el).display !== 'none';
|
|
4893
|
+
const isVisible = (el) => {
|
|
4894
|
+
const { visibility } = getComputedStyle(el);
|
|
4895
|
+
return visibility !== 'hidden' && visibility !== 'collapse';
|
|
4790
4896
|
};
|
|
4791
4897
|
/**
|
|
4792
|
-
*
|
|
4793
|
-
*
|
|
4794
|
-
*
|
|
4795
|
-
*
|
|
4796
|
-
* @returns {JSX.Element} The rendered steps component.
|
|
4797
|
-
*
|
|
4798
|
-
* @example
|
|
4799
|
-
* // Basic steps with items
|
|
4800
|
-
* <Steps
|
|
4801
|
-
* value={1}
|
|
4802
|
-
* items={[
|
|
4803
|
-
* { label: 'Account' },
|
|
4804
|
-
* { label: 'Profile' },
|
|
4805
|
-
* { label: 'Complete' },
|
|
4806
|
-
* ]}
|
|
4807
|
-
* />
|
|
4808
|
-
*
|
|
4809
|
-
* @example
|
|
4810
|
-
* // Clickable steps with navigation
|
|
4811
|
-
* <Steps
|
|
4812
|
-
* value={currentStep}
|
|
4813
|
-
* onStepClick={(step) => setCurrentStep(step)}
|
|
4814
|
-
* hasNavigation
|
|
4815
|
-
* items={[
|
|
4816
|
-
* { label: 'Step 1', clickable: true },
|
|
4817
|
-
* { label: 'Step 2', clickable: true },
|
|
4818
|
-
* { label: 'Step 3', clickable: true },
|
|
4819
|
-
* ]}
|
|
4820
|
-
* />
|
|
4898
|
+
* Whether nothing from `el` up to `top` hides it: `inert`, `display: none`
|
|
4899
|
+
* (not inherited, so each element is checked) or a closed `<details>` it
|
|
4900
|
+
* isn't the summary of. `top` itself isn't checked, and `null` walks to the
|
|
4901
|
+
* top of the page.
|
|
4821
4902
|
*/
|
|
4822
|
-
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
[`has-label-${labelPosition}`]: labelPosition && (labelPosition !== 'bottom' || vertical),
|
|
4833
|
-
[`is-${mobileMode}`]: mobileMode,
|
|
4834
|
-
});
|
|
4835
|
-
const combinedClasses = classNames(stepsClasses, bulmaHelperClasses, className);
|
|
4836
|
-
// Generate classes for the ul (list)
|
|
4837
|
-
const listClasses = usePrefixedClassNames('steps-list', {
|
|
4838
|
-
'is-vertical': vertical,
|
|
4839
|
-
});
|
|
4840
|
-
const stepsNavigationClass = usePrefixedClassNames('steps-navigation');
|
|
4841
|
-
const prevButtonClass = usePrefixedClassNames('button');
|
|
4842
|
-
const nextButtonClass = usePrefixedClassNames('button', {
|
|
4843
|
-
'is-primary': true,
|
|
4844
|
-
});
|
|
4845
|
-
// Count total steps for navigation
|
|
4846
|
-
const totalSteps = items ? items.length : React.Children.count(children);
|
|
4847
|
-
// Render items if provided
|
|
4848
|
-
const renderSteps = () => {
|
|
4849
|
-
if (items && items.length > 0) {
|
|
4850
|
-
return items.map((item, index) => (jsx(Step, { isActive: index === value, isCompleted: index < value, label: item.label, icon: item.icon, completedIcon: item.completedIcon, clickable: item.clickable, stepNumber: showStepNumbers ? index + 1 : undefined, onClick: item.clickable && onStepClick ? () => onStepClick(index) : undefined, className: item.className }, index)));
|
|
4851
|
-
}
|
|
4852
|
-
// If children are provided, clone them with active/completed states
|
|
4853
|
-
if (children) {
|
|
4854
|
-
return React.Children.map(children, (child, index) => {
|
|
4855
|
-
if (React.isValidElement(child)) {
|
|
4856
|
-
return React.cloneElement(child, {
|
|
4857
|
-
isActive: index === value,
|
|
4858
|
-
isCompleted: index < value,
|
|
4859
|
-
stepNumber: showStepNumbers ? index + 1 : undefined,
|
|
4860
|
-
onClick: child.props.clickable && onStepClick
|
|
4861
|
-
? () => onStepClick(index)
|
|
4862
|
-
: child.props.onClick,
|
|
4863
|
-
});
|
|
4864
|
-
}
|
|
4865
|
-
return child;
|
|
4866
|
-
});
|
|
4903
|
+
function isShownUpTo(el, top) {
|
|
4904
|
+
let below = null;
|
|
4905
|
+
for (let node = el; node && node !== top; node = flatParent(node)) {
|
|
4906
|
+
if (!isShown(node))
|
|
4907
|
+
return false;
|
|
4908
|
+
if (below &&
|
|
4909
|
+
node.localName === 'details' &&
|
|
4910
|
+
!node.hasAttribute('open') &&
|
|
4911
|
+
!isDetailsSummary(below)) {
|
|
4912
|
+
return false;
|
|
4867
4913
|
}
|
|
4914
|
+
below = node;
|
|
4915
|
+
}
|
|
4916
|
+
return true;
|
|
4917
|
+
}
|
|
4918
|
+
/**
|
|
4919
|
+
* The images that use the image map `area` belongs to. An image names its
|
|
4920
|
+
* map with `usemap="#…"`, matching the map's `name` or `id`.
|
|
4921
|
+
*/
|
|
4922
|
+
function imagesUsingMapOf(area) {
|
|
4923
|
+
const map = area.closest('map');
|
|
4924
|
+
if (!map)
|
|
4925
|
+
return [];
|
|
4926
|
+
const names = [map.getAttribute('name'), map.id].filter(name => name);
|
|
4927
|
+
return Array.from(area.getRootNode().querySelectorAll('img[usemap]')).filter(img => {
|
|
4928
|
+
const ref = img.getAttribute('usemap');
|
|
4929
|
+
return ref.startsWith('#') && names.includes(ref.slice(1));
|
|
4930
|
+
});
|
|
4931
|
+
}
|
|
4932
|
+
/**
|
|
4933
|
+
* Whether an image map area is shown. An area has no box of its own: the
|
|
4934
|
+
* HTML rendering rules give it `display: none`, though a browser may not
|
|
4935
|
+
* report that. So it counts where an image using its map is shown, as long
|
|
4936
|
+
* as nothing makes it inert.
|
|
4937
|
+
*/
|
|
4938
|
+
function isAreaShown(area, container) {
|
|
4939
|
+
for (let node = area; node && node !== container; node = flatParent(node)) {
|
|
4940
|
+
if (node.hasAttribute('inert'))
|
|
4941
|
+
return false;
|
|
4942
|
+
}
|
|
4943
|
+
return imagesUsingMapOf(area).some(img => isShownUpTo(img, null) && isVisible(img));
|
|
4944
|
+
}
|
|
4945
|
+
/**
|
|
4946
|
+
* Whether Tab can land on `el`, one of the kinds it visits. A disabled control
|
|
4947
|
+
* (including one inside a disabled `<fieldset>`), an inert subtree and
|
|
4948
|
+
* anything not rendered are skipped. `visibility` is inherited, so the
|
|
4949
|
+
* element's own computed value covers its ancestors.
|
|
4950
|
+
*/
|
|
4951
|
+
function isRenderedAndEnabled(el, container) {
|
|
4952
|
+
if (el.matches(':disabled'))
|
|
4953
|
+
return false;
|
|
4954
|
+
if (el.localName === 'area')
|
|
4955
|
+
return isAreaShown(el, container);
|
|
4956
|
+
return isShownUpTo(el, container) && isVisible(el);
|
|
4957
|
+
}
|
|
4958
|
+
/** A radio button with a name, which shares one tab stop with its group. */
|
|
4959
|
+
const isGroupedRadio = (el) => el.localName === 'input' &&
|
|
4960
|
+
el.type === 'radio' &&
|
|
4961
|
+
el.name !== '';
|
|
4962
|
+
/** Whether two radio buttons are in one group: same name, form owner and tree. */
|
|
4963
|
+
const sameGroup = (a, b) => isGroupedRadio(b) &&
|
|
4964
|
+
b.name === a.name &&
|
|
4965
|
+
b.form === a.form &&
|
|
4966
|
+
b.getRootNode() === a.getRootNode();
|
|
4967
|
+
/**
|
|
4968
|
+
* The checked button of `radio`'s group. The group isn't bounded by the trap,
|
|
4969
|
+
* so this looks through the radio's whole tree.
|
|
4970
|
+
*/
|
|
4971
|
+
const checkedInGroup = (radio) => Array.from(radio.getRootNode().querySelectorAll('input:checked')).find(el => sameGroup(radio, el));
|
|
4972
|
+
/** The flattened-tree ancestors of `node` below the container, outermost first, then `node`. */
|
|
4973
|
+
function flatChain(node, container) {
|
|
4974
|
+
const chain = [];
|
|
4975
|
+
for (let n = node; n && n !== container; n = flatParent(n)) {
|
|
4976
|
+
chain.unshift(n);
|
|
4977
|
+
}
|
|
4978
|
+
return chain;
|
|
4979
|
+
}
|
|
4980
|
+
/**
|
|
4981
|
+
* Whether `node` is inside the container in the flattened tree: in it, in a
|
|
4982
|
+
* shadow root it holds, or slotted into it. A child of a closed-root host
|
|
4983
|
+
* counts as inside wherever the host is, as its slot can't be seen.
|
|
4984
|
+
*/
|
|
4985
|
+
function inside(container, node) {
|
|
4986
|
+
for (let n = node; n; n = flatParent(n)) {
|
|
4987
|
+
if (n === container)
|
|
4988
|
+
return true;
|
|
4989
|
+
}
|
|
4990
|
+
return false;
|
|
4991
|
+
}
|
|
4992
|
+
/**
|
|
4993
|
+
* `true` when Tab reaches `b` after `a`, which is their order in the
|
|
4994
|
+
* flattened tree: a shadow host comes before its shadow root's content, and
|
|
4995
|
+
* slotted content comes where its slot is. A closed root's slots can't be
|
|
4996
|
+
* seen, so its host's children keep their place in the light DOM. Two
|
|
4997
|
+
* flattened-tree siblings are always in one DOM tree, so their document
|
|
4998
|
+
* position decides between them.
|
|
4999
|
+
*/
|
|
5000
|
+
function follows(container, a, b) {
|
|
5001
|
+
const chainA = flatChain(a, container);
|
|
5002
|
+
const chainB = flatChain(b, container);
|
|
5003
|
+
for (let i = 0; i < Math.max(chainA.length, chainB.length); i++) {
|
|
5004
|
+
const [x, y] = [chainA[i], chainB[i]];
|
|
5005
|
+
if (x === y)
|
|
5006
|
+
continue;
|
|
5007
|
+
if (!x)
|
|
5008
|
+
return true;
|
|
5009
|
+
if (!y)
|
|
5010
|
+
return false;
|
|
5011
|
+
return Boolean(x.compareDocumentPosition(y) & Node.DOCUMENT_POSITION_FOLLOWING);
|
|
5012
|
+
}
|
|
5013
|
+
return false;
|
|
5014
|
+
}
|
|
5015
|
+
/**
|
|
5016
|
+
* Every element of a tabbable kind under `el` in the flattened tree, depth
|
|
5017
|
+
* first, which is the order Tab visits them. Under a host whose shadow root
|
|
5018
|
+
* is closed, that takes in the host's own children instead, rendered or not.
|
|
5019
|
+
*/
|
|
5020
|
+
function collectCandidates(el, out = []) {
|
|
5021
|
+
for (const child of flatChildren(el)) {
|
|
5022
|
+
if (isTabbableKind(child))
|
|
5023
|
+
out.push(child);
|
|
5024
|
+
collectCandidates(child, out);
|
|
5025
|
+
}
|
|
5026
|
+
return out;
|
|
5027
|
+
}
|
|
5028
|
+
/**
|
|
5029
|
+
* The first and last tab stops inside `container`, or `null` when there are
|
|
5030
|
+
* none. Only the ends matter for wrapping, so the style checks run from each
|
|
5031
|
+
* end until they find a stop rather than over every candidate.
|
|
5032
|
+
*
|
|
5033
|
+
* A radio group is one stop, the way the browser treats it: its checked
|
|
5034
|
+
* button, or its first when none is checked. Counting every button would make
|
|
5035
|
+
* the group's last one an end, and Tab from the checked one would leave. The
|
|
5036
|
+
* group goes by name, form owner and tree, not by the trap, so a checked
|
|
5037
|
+
* button outside the container that Tab can land on leaves the group no stop
|
|
5038
|
+
* inside. One that Tab can't land on leaves the group as if none were checked.
|
|
5039
|
+
*/
|
|
5040
|
+
function findTabStops(container) {
|
|
5041
|
+
const candidates = collectCandidates(container);
|
|
5042
|
+
const checked = new Map();
|
|
5043
|
+
const usable = (el) => {
|
|
5044
|
+
let ok = checked.get(el);
|
|
5045
|
+
if (ok === undefined) {
|
|
5046
|
+
ok = isRenderedAndEnabled(el, container);
|
|
5047
|
+
checked.set(el, ok);
|
|
5048
|
+
}
|
|
5049
|
+
return ok;
|
|
5050
|
+
};
|
|
5051
|
+
// Resolved once per group, filed under each of its buttons, since finding
|
|
5052
|
+
// the checked one scans the radio's whole tree.
|
|
5053
|
+
const groupStops = new Map();
|
|
5054
|
+
const groupStop = (radio) => {
|
|
5055
|
+
if (groupStops.has(radio))
|
|
5056
|
+
return groupStops.get(radio);
|
|
5057
|
+
const group = candidates.filter(el => sameGroup(radio, el));
|
|
5058
|
+
const on = checkedInGroup(radio);
|
|
5059
|
+
const stop = on && isTabbableKind(on) && usable(on)
|
|
5060
|
+
? group.find(el => el === on)
|
|
5061
|
+
: group.find(usable);
|
|
5062
|
+
for (const el of [radio, ...group])
|
|
5063
|
+
groupStops.set(el, stop);
|
|
5064
|
+
return stop;
|
|
5065
|
+
};
|
|
5066
|
+
const isStop = (el) => usable(el) && (!isGroupedRadio(el) || groupStop(el) === el);
|
|
5067
|
+
const first = candidates.find(isStop);
|
|
5068
|
+
if (!first)
|
|
4868
5069
|
return null;
|
|
5070
|
+
let last = first;
|
|
5071
|
+
for (let i = candidates.length - 1; candidates[i] !== first; i--) {
|
|
5072
|
+
if (isStop(candidates[i])) {
|
|
5073
|
+
last = candidates[i];
|
|
5074
|
+
break;
|
|
5075
|
+
}
|
|
5076
|
+
}
|
|
5077
|
+
return {
|
|
5078
|
+
first,
|
|
5079
|
+
last,
|
|
5080
|
+
stopFor: node => (isGroupedRadio(node) && groupStop(node)) || node,
|
|
4869
5081
|
};
|
|
4870
|
-
|
|
4871
|
-
};
|
|
4872
|
-
// Attach Step as static property
|
|
4873
|
-
const Steps = withSubComponents(StepsComponent, { Step }, 'Steps');
|
|
4874
|
-
|
|
5082
|
+
}
|
|
4875
5083
|
/**
|
|
4876
|
-
* The
|
|
4877
|
-
*
|
|
4878
|
-
*
|
|
4879
|
-
*
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
*
|
|
4886
|
-
*
|
|
4887
|
-
*
|
|
4888
|
-
*
|
|
5084
|
+
* The first tab stop inside `container`, found the way the trap finds it, or
|
|
5085
|
+
* `null` when there is none. For a component that moves focus into a panel
|
|
5086
|
+
* without trapping it there. `src/index.ts` names its exports from this
|
|
5087
|
+
* module, so this one stays internal.
|
|
5088
|
+
*/
|
|
5089
|
+
function firstTabStop(container) {
|
|
5090
|
+
return findTabStops(container)?.first ?? null;
|
|
5091
|
+
}
|
|
5092
|
+
/**
|
|
5093
|
+
* The element that has focus in the container's own document (an iframe's,
|
|
5094
|
+
* when it renders into one), inside open shadow roots too. Focus inside a
|
|
5095
|
+
* closed one reads as its host, which the comparisons above place like any
|
|
5096
|
+
* other element. Browsers fall back to <body> when nothing has
|
|
5097
|
+
* focus. jsdom, where consumers run their tests, reports nothing once focus
|
|
5098
|
+
* was inside a shadow root that was removed, so this falls back for it.
|
|
5099
|
+
*/
|
|
5100
|
+
const focusedElement = (doc) => getDeepestActiveElement(doc) ?? doc.body;
|
|
5101
|
+
/**
|
|
5102
|
+
* Keeps keyboard focus inside a container while it is active: focus moves in
|
|
5103
|
+
* when the trap turns on, Tab and Shift+Tab wrap between the first and last
|
|
5104
|
+
* tab stops, and focus goes back where it came from when the trap turns off.
|
|
5105
|
+
*
|
|
5106
|
+
* Tab stops are what the browser's Tab visits, in document order: links,
|
|
5107
|
+
* form controls, frames, media with controls, a `<details>` summary, editable
|
|
5108
|
+
* regions, anything with `tabIndex={0}`, and the content of open shadow
|
|
5109
|
+
* roots. Hidden, disabled, inert and `tabIndex={-1}` elements are skipped,
|
|
5110
|
+
* and a radio group counts once. Give the container `tabIndex={-1}` so it can
|
|
5111
|
+
* hold focus itself when nothing inside can. The trap listens on the
|
|
5112
|
+
* container, so a trap nested inside another handles Tab first and the outer
|
|
5113
|
+
* one stands aside.
|
|
5114
|
+
*
|
|
5115
|
+
* The trap is about Tab. If a Tab it let through still takes focus out (a
|
|
5116
|
+
* browser can leave out a stop the page has, such as links when its settings
|
|
5117
|
+
* say to), it sends focus back to the other end. A pointer or a script moving
|
|
5118
|
+
* focus out is left alone, because pulling focus back from a click is modal
|
|
5119
|
+
* behaviour, and a modal also has to block the page behind it, which `Modal`
|
|
5120
|
+
* does. Content the container renders through a portal lives elsewhere in
|
|
5121
|
+
* the DOM, so it is outside the trap: render nested overlays inside the
|
|
5122
|
+
* container, or give them a trap of their own.
|
|
5123
|
+
*
|
|
5124
|
+
* The container has to be in the DOM when the trap turns on. The trap waits
|
|
5125
|
+
* for hydration, so a container that only appears once the page has
|
|
5126
|
+
* hydrated, the way portaled content does, still has it attach in the commit
|
|
5127
|
+
* where the container appears.
|
|
5128
|
+
*
|
|
5129
|
+
* @function useFocusTrap
|
|
5130
|
+
* @param containerRef - Ref to the element focus stays inside.
|
|
5131
|
+
* @param options - When the trap is on, and where focus starts and ends.
|
|
4889
5132
|
*
|
|
4890
5133
|
* @example
|
|
4891
|
-
*
|
|
4892
|
-
*
|
|
4893
|
-
*
|
|
4894
|
-
*
|
|
4895
|
-
*
|
|
4896
|
-
*
|
|
4897
|
-
*
|
|
4898
|
-
|
|
4899
|
-
|
|
4900
|
-
|
|
4901
|
-
|
|
4902
|
-
|
|
4903
|
-
const
|
|
4904
|
-
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
5134
|
+
* const panelRef = useRef<HTMLDivElement>(null);
|
|
5135
|
+
* useFocusTrap(panelRef, { active: isOpen });
|
|
5136
|
+
* return isOpen ? (
|
|
5137
|
+
* <div ref={panelRef} role="dialog" aria-label="Filters" tabIndex={-1}>
|
|
5138
|
+
* …
|
|
5139
|
+
* </div>
|
|
5140
|
+
* ) : null;
|
|
5141
|
+
*/
|
|
5142
|
+
function useFocusTrap(containerRef, options = {}) {
|
|
5143
|
+
const { active = true, initialFocusRef, restoreFocus = true } = options;
|
|
5144
|
+
// Portaled content has no server-rendered counterpart, so a portaled
|
|
5145
|
+
// container only exists from the commit after hydration.
|
|
5146
|
+
const hydrated = useIsHydrated();
|
|
5147
|
+
useEffect(() => {
|
|
5148
|
+
if (!active || !hydrated)
|
|
5149
|
+
return undefined;
|
|
5150
|
+
const container = containerRef.current;
|
|
5151
|
+
if (!container)
|
|
5152
|
+
return undefined;
|
|
5153
|
+
const doc = container.ownerDocument;
|
|
5154
|
+
const opener = focusedElement(doc);
|
|
5155
|
+
// `initialFocusRef` counts only when focusing it puts focus inside. Focus
|
|
5156
|
+
// put outside would stay out, as Tab there never reaches the container's
|
|
5157
|
+
// listener, and focusing something that can't take focus (a heading
|
|
5158
|
+
// without `tabIndex`, a disabled control) leaves focus where it was.
|
|
5159
|
+
const initial = initialFocusRef?.current;
|
|
5160
|
+
const inTrap = initial && inside(container, initial) ? initial : null;
|
|
5161
|
+
inTrap?.focus();
|
|
5162
|
+
if (!inTrap || !inside(container, focusedElement(doc))) {
|
|
5163
|
+
(findTabStops(container)?.first ?? container).focus();
|
|
5164
|
+
}
|
|
5165
|
+
// Where focus goes if a Tab the trap let the browser handle takes it out,
|
|
5166
|
+
// held until that Tab has finished moving focus.
|
|
5167
|
+
let wrapTo = null;
|
|
5168
|
+
let wrapTimer;
|
|
5169
|
+
const handleKeyDown = (e) => {
|
|
5170
|
+
// A trap nested inside this one has already wrapped focus.
|
|
5171
|
+
if (e.key !== 'Tab' || e.defaultPrevented)
|
|
5172
|
+
return;
|
|
5173
|
+
const stops = findTabStops(container);
|
|
5174
|
+
if (!stops) {
|
|
5175
|
+
e.preventDefault();
|
|
5176
|
+
container.focus();
|
|
5177
|
+
return;
|
|
5178
|
+
}
|
|
5179
|
+
const current = stops.stopFor(focusedElement(doc));
|
|
5180
|
+
// Wrap whenever the browser has no stop left in that direction inside
|
|
5181
|
+
// the container. Comparing positions rather than testing `=== first`
|
|
5182
|
+
// also covers focus resting on the container or on an element outside
|
|
5183
|
+
// the tab order, which the browser would otherwise walk straight out of.
|
|
5184
|
+
if (e.shiftKey
|
|
5185
|
+
? !follows(container, stops.first, current)
|
|
5186
|
+
: !follows(container, current, stops.last)) {
|
|
5187
|
+
e.preventDefault();
|
|
5188
|
+
(e.shiftKey ? stops.last : stops.first).focus();
|
|
5189
|
+
return;
|
|
5190
|
+
}
|
|
5191
|
+
wrapTo = e.shiftKey ? stops.last : stops.first;
|
|
5192
|
+
clearTimeout(wrapTimer);
|
|
5193
|
+
wrapTimer = setTimeout(() => {
|
|
5194
|
+
wrapTo = null;
|
|
5195
|
+
});
|
|
5196
|
+
};
|
|
5197
|
+
// The browser moves focus as soon as the keydown is over, so focus that
|
|
5198
|
+
// lands outside while `wrapTo` is set got there by that Tab.
|
|
5199
|
+
const handleFocusIn = (e) => {
|
|
5200
|
+
if (!wrapTo || inside(container, e.composedPath()[0]))
|
|
5201
|
+
return;
|
|
5202
|
+
const target = wrapTo;
|
|
5203
|
+
wrapTo = null;
|
|
5204
|
+
target.focus();
|
|
5205
|
+
};
|
|
5206
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
5207
|
+
doc.addEventListener('focusin', handleFocusIn, true);
|
|
5208
|
+
return () => {
|
|
5209
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
5210
|
+
doc.removeEventListener('focusin', handleFocusIn, true);
|
|
5211
|
+
clearTimeout(wrapTimer);
|
|
5212
|
+
let target = restoreFocus ? opener : null;
|
|
5213
|
+
// `null` is an object too, and a caller without types can pass it.
|
|
5214
|
+
if (restoreFocus && typeof restoreFocus === 'object') {
|
|
5215
|
+
// Read now rather than when the trap turned on: a trigger that
|
|
5216
|
+
// re-mounted in between has a new node.
|
|
5217
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5218
|
+
target = restoreFocus.current;
|
|
5219
|
+
}
|
|
5220
|
+
// Hand focus back only while the trap still holds it: inside the
|
|
5221
|
+
// container, or dropped to <body> because the container left the page.
|
|
5222
|
+
const now = focusedElement(doc);
|
|
5223
|
+
if (target && (now === doc.body || inside(container, now))) {
|
|
5224
|
+
target.focus();
|
|
5225
|
+
}
|
|
5226
|
+
};
|
|
5227
|
+
}, [active, hydrated, containerRef, initialFocusRef, restoreFocus]);
|
|
5228
|
+
}
|
|
5229
|
+
|
|
5230
|
+
// INTERNAL: deliberately not exported from src/index.ts.
|
|
5231
|
+
/**
|
|
5232
|
+
* `useLayoutEffect` in the browser, `useEffect` on the server. A server
|
|
5233
|
+
* render runs neither, and React 18 warns about a layout effect there, so the
|
|
5234
|
+
* server is handed the plain one.
|
|
5235
|
+
*/
|
|
5236
|
+
const useClientLayoutEffect = typeof document === 'undefined' ? useEffect : useLayoutEffect;
|
|
5237
|
+
|
|
5238
|
+
// INTERNAL: deliberately not exported from src/index.ts.
|
|
5239
|
+
/**
|
|
5240
|
+
* The corner that keeps a panel of this size in the viewport, preferring
|
|
5241
|
+
* below the anchor and lined up with its left edge.
|
|
5242
|
+
*/
|
|
5243
|
+
function resolveAuto(view, rect, panelWidth, panelHeight, gap) {
|
|
5244
|
+
const fitsBelow = rect.bottom + panelHeight + gap <= view.innerHeight;
|
|
5245
|
+
const fitsRight = rect.left + panelWidth <= view.innerWidth;
|
|
5246
|
+
if (fitsBelow)
|
|
5247
|
+
return fitsRight ? 'bottom-left' : 'bottom-right';
|
|
5248
|
+
return fitsRight ? 'top-left' : 'top-right';
|
|
5249
|
+
}
|
|
5250
|
+
const samePosition = (a, b) => a.position === b.position && a.top === b.top && a.left === b.left;
|
|
5251
|
+
/**
|
|
5252
|
+
* Places a floating panel against its anchor: resolves `auto` to a corner
|
|
5253
|
+
* and, for a panel fixed to the viewport, works out its coordinates. It
|
|
5254
|
+
* measures again on resize and on any scroll of the anchor's window (an
|
|
5255
|
+
* iframe's, when the anchor renders into one), so a fixed panel follows its
|
|
5256
|
+
* anchor. A measurement that changes nothing doesn't render again.
|
|
5257
|
+
*
|
|
5258
|
+
* Measuring happens in a layout effect, so the panel is placed before it
|
|
5259
|
+
* paints. It needs both elements on the page while `active` is on, and
|
|
5260
|
+
* keeps the last placement when either is missing.
|
|
5261
|
+
*
|
|
5262
|
+
* @function useAnchoredPosition
|
|
5263
|
+
* @param anchorRef - The element the panel opens from.
|
|
5264
|
+
* @param panelRef - The floating panel.
|
|
5265
|
+
* @param options - Whether to measure, the corner asked for, and how the panel is placed.
|
|
5266
|
+
* @returns The corner the panel opens from, with coordinates when it is fixed.
|
|
5267
|
+
*/
|
|
5268
|
+
function useAnchoredPosition(anchorRef, panelRef, { active, position, fixed, offset = 4, styledGap }) {
|
|
5269
|
+
const [resolved, setResolved] = useState({
|
|
5270
|
+
position: position === 'auto' ? 'bottom-left' : position,
|
|
5271
|
+
});
|
|
5272
|
+
useClientLayoutEffect(() => {
|
|
5273
|
+
if (!active)
|
|
5274
|
+
return undefined;
|
|
5275
|
+
const view = (anchorRef.current ?? panelRef.current)?.ownerDocument.defaultView ??
|
|
5276
|
+
window;
|
|
5277
|
+
const update = () => {
|
|
5278
|
+
const anchor = anchorRef.current;
|
|
5279
|
+
const panel = panelRef.current;
|
|
5280
|
+
if (!anchor || !panel)
|
|
5281
|
+
return;
|
|
5282
|
+
const rect = anchor.getBoundingClientRect();
|
|
5283
|
+
const { width, height } = panel.getBoundingClientRect();
|
|
5284
|
+
const corner = position === 'auto'
|
|
5285
|
+
? resolveAuto(view, rect, width, height, offset + (styledGap?.(panel) ?? 0))
|
|
5286
|
+
: position;
|
|
5287
|
+
const next = fixed
|
|
5288
|
+
? {
|
|
5289
|
+
position: corner,
|
|
5290
|
+
top: corner.startsWith('bottom')
|
|
5291
|
+
? rect.bottom + offset
|
|
5292
|
+
: rect.top - height - offset,
|
|
5293
|
+
left: corner.endsWith('left') ? rect.left : rect.right - width,
|
|
5294
|
+
}
|
|
5295
|
+
: { position: corner };
|
|
5296
|
+
setResolved(prev => (samePosition(prev, next) ? prev : next));
|
|
5297
|
+
};
|
|
5298
|
+
update();
|
|
5299
|
+
view.addEventListener('resize', update, { passive: true });
|
|
5300
|
+
view.addEventListener('scroll', update, { passive: true, capture: true });
|
|
5301
|
+
return () => {
|
|
5302
|
+
view.removeEventListener('resize', update);
|
|
5303
|
+
view.removeEventListener('scroll', update, { capture: true });
|
|
5304
|
+
};
|
|
5305
|
+
}, [active, anchorRef, panelRef, position, fixed, offset, styledGap]);
|
|
5306
|
+
return resolved;
|
|
5307
|
+
}
|
|
5308
|
+
|
|
5309
|
+
const validButtonColors = [...validColors, 'text', 'ghost'];
|
|
5310
|
+
/**
|
|
5311
|
+
* The `Button` component provides a flexible and highly customizable button for your Bulma React UI.
|
|
5312
|
+
*
|
|
5313
|
+
* @function
|
|
5314
|
+
* @param {ButtonProps} props - Props for the Button component.
|
|
5315
|
+
* @param {React.Ref} ref - Forwarded ref to the element `as` renders.
|
|
5316
|
+
* @returns {JSX.Element} The rendered button, anchor, or custom element.
|
|
5317
|
+
* @see {@link https://bulma.io/documentation/elements/button/ | Bulma Button documentation}
|
|
5318
|
+
*/
|
|
5319
|
+
const Button = forwardRef(function Button(props, ref) {
|
|
5320
|
+
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;
|
|
5321
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses({
|
|
5322
|
+
color: textColor,
|
|
5323
|
+
backgroundColor: bgColor,
|
|
5324
|
+
...bulmaProps,
|
|
5325
|
+
});
|
|
5326
|
+
// Generate Bulma classes with prefix
|
|
5327
|
+
const bulmaClasses = usePrefixedClassNames('button', {
|
|
5328
|
+
[`is-${color}`]: color &&
|
|
5329
|
+
validButtonColors.includes(color),
|
|
5330
|
+
[`is-${size}`]: size,
|
|
5331
|
+
'is-outlined': isOutlined,
|
|
5332
|
+
'is-light': isLight,
|
|
5333
|
+
'is-loading': isLoading,
|
|
5334
|
+
'is-static': isStatic,
|
|
5335
|
+
'is-disabled': isDisabled,
|
|
5336
|
+
'is-rounded': isRounded,
|
|
5337
|
+
'is-hovered': isHovered,
|
|
5338
|
+
'is-focused': isFocused,
|
|
5339
|
+
'is-active': isActive,
|
|
5340
|
+
'is-inverted': isInverted,
|
|
5341
|
+
'is-fullwidth': isFullwidth ?? isFullWidth,
|
|
5342
|
+
});
|
|
5343
|
+
// Combine prefixed Bulma classes with unprefixed user className and prefixed helper classes
|
|
5344
|
+
const buttonClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
|
|
5345
|
+
if (Component !== 'button') {
|
|
5346
|
+
// Strip the form-control attributes a link-like element cannot carry, so a
|
|
5347
|
+
// JavaScript consumer or a spread object doesn't put them on the DOM. The
|
|
5348
|
+
// types no longer allow them through; this is the runtime backstop.
|
|
5349
|
+
//
|
|
5350
|
+
// Only for INTRINSIC tags. A custom component (a router Link, ...) owns its
|
|
5351
|
+
// prop contract, and `ComponentPropsWithoutRef<T>` promises the caller that
|
|
5352
|
+
// its props reach it. Stripping names by their spelling alone broke that
|
|
5353
|
+
// promise silently: `<Button as={Custom} name="x" />` type-checked while
|
|
5354
|
+
// `Custom` never received the `name` it requires.
|
|
5355
|
+
//
|
|
5356
|
+
// `autoFocus` and `type` are deliberately NOT stripped: both are valid on
|
|
5357
|
+
// targets this list was filtering them from. `autoFocus` is a global
|
|
5358
|
+
// attribute (React decides per element whether to honour it, which is not
|
|
5359
|
+
// ours to pre-empt), and `type` on an anchor is the MIME hint that
|
|
5360
|
+
// `ButtonProps<'a'>` now types it as.
|
|
5361
|
+
//
|
|
5362
|
+
// The types already answer this for a TypeScript caller: `ButtonProps<T>`
|
|
5363
|
+
// derives from `ComponentPropsWithoutRef<T>`, so only what the target
|
|
5364
|
+
// accepts can be passed. What is left is a runtime backstop for JavaScript
|
|
5365
|
+
// consumers and spread objects.
|
|
5366
|
+
//
|
|
5367
|
+
// Keep it to the two cases where a stray attribute does something. The
|
|
5368
|
+
// submit-overrides belong to a submit control and never make sense on the
|
|
5369
|
+
// anchor path. `disabled` is the one that is VISIBLE: Bulma styles
|
|
5370
|
+
// `.button[disabled]` (background, border, shadow, opacity), so letting it
|
|
5371
|
+
// through to a `<span>` greys the element out — main stripped it, and not
|
|
5372
|
+
// stripping it would be a silent visual change on a types-only release.
|
|
5373
|
+
//
|
|
5374
|
+
// Everything else (`name`, `value`, `form`) is inert where it does not
|
|
5375
|
+
// belong, and enumerating which tags own it is what kept going wrong:
|
|
5376
|
+
// first all nine names on every non-button tag (so `<Button as="input"
|
|
5377
|
+
// name="query" value="Search">` submitted nothing), then a form-control
|
|
5378
|
+
// allowlist that still lost `formAction` on an input and `value` on an
|
|
5379
|
+
// option. Those now ride on the types instead.
|
|
5380
|
+
//
|
|
5381
|
+
// A custom component is exempt entirely — it owns its prop contract, and
|
|
5382
|
+
// `ComponentPropsWithoutRef<T>` promises the caller its props arrive.
|
|
5383
|
+
//
|
|
5384
|
+
// The elements that own `disabled`, per the HTML spec. A closed set, unlike
|
|
5385
|
+
// the open-ended question of which tag owns which attribute.
|
|
5386
|
+
const SUBMIT_OVERRIDES = [
|
|
5387
|
+
'formAction',
|
|
5388
|
+
'formEncType',
|
|
5389
|
+
'formMethod',
|
|
5390
|
+
'formNoValidate',
|
|
5391
|
+
'formTarget',
|
|
5392
|
+
];
|
|
5393
|
+
const OWNS_DISABLED = [
|
|
5394
|
+
'button',
|
|
5395
|
+
'fieldset',
|
|
5396
|
+
'input',
|
|
5397
|
+
'optgroup',
|
|
5398
|
+
'option',
|
|
5399
|
+
'select',
|
|
5400
|
+
'textarea',
|
|
5401
|
+
];
|
|
5402
|
+
// Two independent filters. They are not exclusive — an `<a>` owns neither
|
|
5403
|
+
// the submit-overrides nor `disabled`, so it takes both — and deriving them
|
|
5404
|
+
// from one destructure, or from one if/else, is what broke each of them in
|
|
5405
|
+
// turn: first the overrides came off every intrinsic (an `as="input"` lost
|
|
5406
|
+
// `formAction`), then scoping them to the anchor let `disabled` back onto
|
|
5407
|
+
// it.
|
|
5408
|
+
const forwardedRest = { ...rest };
|
|
5409
|
+
if (typeof Component === 'string' && !isCustomElement(Component)) {
|
|
5410
|
+
// Meaningless anywhere but a submit control, and an anchor is not one.
|
|
5411
|
+
if (Component === 'a') {
|
|
5412
|
+
for (const key of SUBMIT_OVERRIDES)
|
|
5413
|
+
delete forwardedRest[key];
|
|
5414
|
+
}
|
|
5415
|
+
// Visible via Bulma's `.button[disabled]`, so withheld from any element
|
|
5416
|
+
// that does not own it.
|
|
5417
|
+
if (!OWNS_DISABLED.includes(Component))
|
|
5418
|
+
delete forwardedRest.disabled;
|
|
5419
|
+
}
|
|
5420
|
+
return (jsx(Component, { ref: ref, className: buttonClasses, href: href, target: target, rel: rel, "aria-disabled": isDisabled, tabIndex: isDisabled ? -1 : undefined, onClick: isDisabled
|
|
5421
|
+
? // Guarded, because this handler is installed into whatever `as`
|
|
5422
|
+
// renders and a custom component's `onClick` need not be a DOM
|
|
5423
|
+
// event handler at all. One declaring `onClick: (v: string) =>
|
|
5424
|
+
// void` calls this with a string, and an unguarded
|
|
5425
|
+
// `e.preventDefault()` throws `is not a function`. Blocking the
|
|
5426
|
+
// real event is the point; a foreign payload is simply not ours
|
|
5427
|
+
// to act on.
|
|
5428
|
+
(e) => {
|
|
5429
|
+
if (typeof e?.preventDefault === 'function')
|
|
5430
|
+
e.preventDefault();
|
|
5431
|
+
}
|
|
5432
|
+
: onClick, ...forwardedRest, children: children }));
|
|
5433
|
+
}
|
|
5434
|
+
return (jsx("button", { ref: ref, className: buttonClasses, disabled: isDisabled, onClick: onClick, ...rest, children: children }));
|
|
5435
|
+
});
|
|
5436
|
+
Button.displayName = 'Button';
|
|
5437
|
+
|
|
5438
|
+
const PopoverContext = createContext(null);
|
|
5439
|
+
/** Intrinsic elements that are controls already, and keep their own role. */
|
|
5440
|
+
const NATIVE_CONTROLS = new Set([
|
|
5441
|
+
'button',
|
|
5442
|
+
'input',
|
|
5443
|
+
'select',
|
|
5444
|
+
'textarea',
|
|
5445
|
+
'summary',
|
|
5446
|
+
]);
|
|
5447
|
+
/**
|
|
5448
|
+
* Whether the browser answers this key on `el` itself, by clicking it or by
|
|
5449
|
+
* using the key as input, so the trigger's click handler covers it.
|
|
5450
|
+
*/
|
|
5451
|
+
function handlesKeyItself(el, key) {
|
|
5452
|
+
if (NATIVE_CONTROLS.has(el.localName))
|
|
5453
|
+
return true;
|
|
5454
|
+
return el.localName === 'a' && key === 'Enter' && el.hasAttribute('href');
|
|
5455
|
+
}
|
|
5456
|
+
/** The innermost dialog the event passed through, which is the one its Escape belongs to. */
|
|
5457
|
+
function nearestDialog(event) {
|
|
5458
|
+
return (event.composedPath().find(node => {
|
|
5459
|
+
const role = node.getAttribute?.('role');
|
|
5460
|
+
return role === 'dialog' || role === 'alertdialog';
|
|
5461
|
+
}) ?? null);
|
|
5462
|
+
}
|
|
5463
|
+
/**
|
|
5464
|
+
* The gap the stylesheet puts between the panel and the trigger, in pixels.
|
|
5465
|
+
* It is a margin on whichever side faces the trigger, and a negative one on
|
|
5466
|
+
* a portaled panel above it, so the larger magnitude is the gap.
|
|
5467
|
+
*/
|
|
5468
|
+
function styledGap(panel) {
|
|
5469
|
+
const { marginTop, marginBottom } = getComputedStyle(panel);
|
|
5470
|
+
return Math.max(Math.abs(parseFloat(marginTop)) || 0, Math.abs(parseFloat(marginBottom)) || 0);
|
|
5471
|
+
}
|
|
5472
|
+
/**
|
|
5473
|
+
* 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.
|
|
5474
|
+
*
|
|
5475
|
+
* The panel is a non-modal `role="dialog"`: focus moves into it on open
|
|
5476
|
+
* and returns to the trigger on close, while the rest of the page stays
|
|
5477
|
+
* reachable by pointer and assistive technology. For hover-only hints use
|
|
5478
|
+
* `Tooltip`, and for a list of actions use `Dropdown`.
|
|
5479
|
+
*
|
|
5480
|
+
* @function
|
|
5481
|
+
* @param {PopoverProps} props - Props for the Popover component.
|
|
5482
|
+
* @returns {JSX.Element} The trigger in its wrapper, and the panel while open.
|
|
5483
|
+
*
|
|
5484
|
+
* @example
|
|
5485
|
+
* <Popover trigger={<Button>Filters</Button>}>
|
|
5486
|
+
* <Popover.Header>Filter rows</Popover.Header>
|
|
5487
|
+
* <Popover.Body>…</Popover.Body>
|
|
5488
|
+
* <Popover.Footer>
|
|
5489
|
+
* <Popover.Close>Done</Popover.Close>
|
|
5490
|
+
* </Popover.Footer>
|
|
5491
|
+
* </Popover>
|
|
5492
|
+
*/
|
|
5493
|
+
const PopoverComponent = ({ trigger, children, open: controlledOpen, defaultOpen = false, onOpenChange, position = 'bottom-left', appendToBody = false, trapFocus = true, closeOnClickOutside = true, closeOnEscape = true, ariaLabel, className, contentClassName, ...props }) => {
|
|
5494
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
5495
|
+
const rootRef = useRef(null);
|
|
5496
|
+
const panelRef = useRef(null);
|
|
5497
|
+
// The last pointerdown React saw inside the panel. React's tree includes
|
|
5498
|
+
// content the panel portals elsewhere, which the DOM path doesn't.
|
|
5499
|
+
const insidePointerRef = useRef(null);
|
|
5500
|
+
const baseId = useId();
|
|
5501
|
+
const panelId = `${baseId}-panel`;
|
|
5502
|
+
const titleId = `${baseId}-title`;
|
|
5503
|
+
const isControlled = controlledOpen !== undefined;
|
|
5504
|
+
const [internalOpen, setInternalOpen] = useState(defaultOpen);
|
|
5505
|
+
const isOpen = controlledOpen ?? internalOpen;
|
|
5506
|
+
const requestOpen = useCallback((next) => {
|
|
5507
|
+
onOpenChange?.(next);
|
|
5508
|
+
if (!isControlled)
|
|
5509
|
+
setInternalOpen(next);
|
|
5510
|
+
}, [isControlled, onOpenChange]);
|
|
5511
|
+
const close = useCallback(() => requestOpen(false), [requestOpen]);
|
|
5512
|
+
// A portaled panel has no server markup, so it only exists from the commit
|
|
5513
|
+
// after hydration. Everything that measures or focuses it waits for that.
|
|
5514
|
+
const hydrated = useIsHydrated();
|
|
5515
|
+
const panelMounted = isOpen && (hydrated || !appendToBody);
|
|
5516
|
+
const resolved = useAnchoredPosition(rootRef, panelRef, {
|
|
5517
|
+
active: panelMounted,
|
|
5518
|
+
position,
|
|
5519
|
+
fixed: appendToBody,
|
|
5520
|
+
// The gap comes from `--bulma-popover-offset` in the stylesheet, so the
|
|
5521
|
+
// coordinates leave it out and `auto` reads it off the panel.
|
|
5522
|
+
offset: 0,
|
|
5523
|
+
styledGap,
|
|
5524
|
+
});
|
|
5525
|
+
// The trigger renders first inside the wrapper. Read when needed rather
|
|
5526
|
+
// than held, so a trigger that remounted is still the one found.
|
|
5527
|
+
const triggerNode = useCallback(() => {
|
|
5528
|
+
const first = rootRef.current?.firstElementChild;
|
|
5529
|
+
return first && first !== panelRef.current ? first : null;
|
|
5530
|
+
}, []);
|
|
5531
|
+
const restoreFocusRef = useMemo(() => ({
|
|
5532
|
+
get current() {
|
|
5533
|
+
return triggerNode();
|
|
5534
|
+
},
|
|
5535
|
+
}), [triggerNode]);
|
|
5536
|
+
useFocusTrap(panelRef, {
|
|
5537
|
+
active: panelMounted && trapFocus,
|
|
5538
|
+
restoreFocus: restoreFocusRef,
|
|
5539
|
+
});
|
|
5540
|
+
// Without the trap, focus still arrives in the panel and goes back to the
|
|
5541
|
+
// trigger, on the trap's terms: only while the panel still holds it.
|
|
5542
|
+
useEffect(() => {
|
|
5543
|
+
const panel = panelRef.current;
|
|
5544
|
+
if (!panelMounted || trapFocus || !panel)
|
|
5545
|
+
return undefined;
|
|
5546
|
+
(firstTabStop(panel) ?? panel).focus();
|
|
5547
|
+
return () => {
|
|
5548
|
+
const doc = panel.ownerDocument;
|
|
5549
|
+
const now = getDeepestActiveElement(doc);
|
|
5550
|
+
if (!now || now === doc.body || panel.contains(now)) {
|
|
5551
|
+
triggerNode()?.focus();
|
|
5552
|
+
}
|
|
5553
|
+
};
|
|
5554
|
+
}, [panelMounted, trapFocus, triggerNode]);
|
|
5555
|
+
useEffect(() => {
|
|
5556
|
+
if (!panelMounted || !closeOnClickOutside)
|
|
5557
|
+
return undefined;
|
|
5558
|
+
const handlePointerDown = (e) => {
|
|
5559
|
+
if (e === insidePointerRef.current || isEventInside(e, rootRef.current)) {
|
|
5560
|
+
return;
|
|
5561
|
+
}
|
|
5562
|
+
close();
|
|
5563
|
+
};
|
|
5564
|
+
// The wrapper is on the page whenever the panel is. Its document, an
|
|
5565
|
+
// iframe's when the tree renders into one, is where presses land.
|
|
5566
|
+
const doc = rootRef.current.ownerDocument;
|
|
5567
|
+
doc.addEventListener('pointerdown', handlePointerDown);
|
|
5568
|
+
return () => doc.removeEventListener('pointerdown', handlePointerDown);
|
|
5569
|
+
}, [panelMounted, closeOnClickOutside, close]);
|
|
5570
|
+
const handlePanelKeyDown = (e) => {
|
|
5571
|
+
if (e.key !== 'Escape' || !closeOnEscape || e.defaultPrevented)
|
|
5572
|
+
return;
|
|
5573
|
+
// React bubbles the key here from portaled content as well, so a dialog
|
|
5574
|
+
// of its own on the way (a nested popover, a picker's calendar) owns it.
|
|
5575
|
+
const owner = nearestDialog(e.nativeEvent);
|
|
5576
|
+
if (owner && owner !== panelRef.current)
|
|
5577
|
+
return;
|
|
5578
|
+
e.preventDefault();
|
|
5579
|
+
close();
|
|
5580
|
+
};
|
|
5581
|
+
const triggerProps = trigger.props;
|
|
5582
|
+
const handleTriggerClick = (e) => {
|
|
5583
|
+
triggerProps.onClick?.(e);
|
|
5584
|
+
if (!e.defaultPrevented)
|
|
5585
|
+
requestOpen(!isOpen);
|
|
5586
|
+
};
|
|
5587
|
+
const handleTriggerKeyDown = (e) => {
|
|
5588
|
+
triggerProps.onKeyDown?.(e);
|
|
5589
|
+
// A key on something inside the trigger is that element's to answer.
|
|
5590
|
+
if (e.defaultPrevented || e.target !== e.currentTarget)
|
|
5591
|
+
return;
|
|
5592
|
+
if (e.key === 'Escape') {
|
|
5593
|
+
if (isOpen && closeOnEscape) {
|
|
5594
|
+
e.preventDefault();
|
|
5595
|
+
close();
|
|
5596
|
+
}
|
|
5597
|
+
return;
|
|
5598
|
+
}
|
|
5599
|
+
if (e.key !== 'Enter' && e.key !== ' ')
|
|
5600
|
+
return;
|
|
5601
|
+
if (handlesKeyItself(e.currentTarget, e.key))
|
|
5602
|
+
return;
|
|
5603
|
+
// Space would scroll the page; a held key repeats.
|
|
5604
|
+
e.preventDefault();
|
|
5605
|
+
if (!e.repeat)
|
|
5606
|
+
requestOpen(!isOpen);
|
|
5607
|
+
};
|
|
5608
|
+
const needsButtonRole = typeof trigger.type === 'string' &&
|
|
5609
|
+
!NATIVE_CONTROLS.has(trigger.type) &&
|
|
5610
|
+
!(trigger.type === 'a' && triggerProps.href != null);
|
|
5611
|
+
const clonedTrigger = cloneElement(trigger, {
|
|
5612
|
+
...(needsButtonRole
|
|
5613
|
+
? {
|
|
5614
|
+
role: triggerProps.role ?? 'button',
|
|
5615
|
+
tabIndex: triggerProps.tabIndex ?? 0,
|
|
5616
|
+
}
|
|
5617
|
+
: {}),
|
|
5618
|
+
'aria-haspopup': 'dialog',
|
|
5619
|
+
'aria-expanded': isOpen,
|
|
5620
|
+
'aria-controls': panelMounted ? panelId : undefined,
|
|
5621
|
+
onClick: handleTriggerClick,
|
|
5622
|
+
onKeyDown: handleTriggerKeyDown,
|
|
5623
|
+
});
|
|
5624
|
+
const rootClasses = classNames(usePrefixedClassNames('popover', { 'is-active': isOpen }), bulmaHelperClasses, className);
|
|
5625
|
+
const panelClasses = classNames(usePrefixedClassNames('popover-content', {
|
|
5626
|
+
[`is-${resolved.position}`]: true,
|
|
5627
|
+
'is-portal': appendToBody,
|
|
5628
|
+
}), contentClassName);
|
|
5629
|
+
return (jsxs("div", { className: rootClasses, ...rest, ref: rootRef, children: [clonedTrigger, isOpen && (jsx(Portal, { disabled: !appendToBody, children: jsx(PopoverPanel, { panelRef: panelRef, titleId: titleId, ariaLabel: ariaLabel, close: close, id: panelId, className: panelClasses, style: appendToBody
|
|
5630
|
+
? {
|
|
5631
|
+
position: 'fixed',
|
|
5632
|
+
top: resolved.top,
|
|
5633
|
+
left: resolved.left,
|
|
5634
|
+
}
|
|
5635
|
+
: undefined, onKeyDown: handlePanelKeyDown, onPointerDownCapture: e => {
|
|
5636
|
+
insidePointerRef.current = e.nativeEvent;
|
|
5637
|
+
}, children: children }) }))] }));
|
|
5638
|
+
};
|
|
5639
|
+
/**
|
|
5640
|
+
* The open panel. It mounts with each opening, so it counts the
|
|
5641
|
+
* `Popover.Header`s inside it afresh every time.
|
|
5642
|
+
*/
|
|
5643
|
+
function PopoverPanel({ panelRef, titleId, ariaLabel, close, children, ...attrs }) {
|
|
5644
|
+
// Headers register in a layout effect, after this first render, and this
|
|
5645
|
+
// panel's own layout effect runs after theirs. Until then the panel assumes
|
|
5646
|
+
// it has one, so a panel that does is named from its first commit, before
|
|
5647
|
+
// focus moves in, and one that doesn't drops the reference before it paints.
|
|
5648
|
+
const [titlesCounted, setTitlesCounted] = useState(false);
|
|
5649
|
+
const [titleCount, setTitleCount] = useState(0);
|
|
5650
|
+
const registerTitle = useCallback(() => {
|
|
5651
|
+
setTitleCount(n => n + 1);
|
|
5652
|
+
return () => setTitleCount(n => n - 1);
|
|
5653
|
+
}, []);
|
|
5654
|
+
useClientLayoutEffect(() => {
|
|
5655
|
+
setTitlesCounted(true);
|
|
5656
|
+
}, []);
|
|
5657
|
+
const named = Boolean(ariaLabel) || titleCount > 0;
|
|
5658
|
+
useEffect(() => {
|
|
5659
|
+
if (titlesCounted && !named) {
|
|
5660
|
+
warnOnce('Popover:unnamed', '[bestax-bulma] <Popover> opened a panel with no accessible name. ' +
|
|
5661
|
+
'Pass ariaLabel, or render a Popover.Header inside it.');
|
|
5662
|
+
}
|
|
5663
|
+
}, [titlesCounted, named]);
|
|
5664
|
+
const context = useMemo(() => ({ titleId, registerTitle, close }), [titleId, registerTitle, close]);
|
|
5665
|
+
return (jsx(PopoverContext.Provider, { value: context, children: jsx("div", { ...attrs, ref: panelRef, role: "dialog", "aria-label": ariaLabel, "aria-labelledby": !ariaLabel && (!titlesCounted || titleCount > 0) ? titleId : undefined, tabIndex: -1, children: children }) }));
|
|
5666
|
+
}
|
|
5667
|
+
/**
|
|
5668
|
+
* The popover's title, which names the panel for assistive technology.
|
|
5669
|
+
*
|
|
5670
|
+
* @function
|
|
5671
|
+
* @param {PopoverHeaderProps} props - Props for the Popover.Header component.
|
|
5672
|
+
* @returns {JSX.Element} The header element.
|
|
5673
|
+
*/
|
|
5674
|
+
const PopoverHeader = ({ className, ...props }) => {
|
|
5675
|
+
const context = useContext(PopoverContext);
|
|
5676
|
+
const registerTitle = context?.registerTitle;
|
|
5677
|
+
// Before paint, so the panel is named by the time it shows.
|
|
5678
|
+
useClientLayoutEffect(() => registerTitle?.(), [registerTitle]);
|
|
5679
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
5680
|
+
return (jsx("div", { className: classNames(usePrefixedClassNames('popover-header'), bulmaHelperClasses, className), ...rest, id: context?.titleId }));
|
|
5681
|
+
};
|
|
5682
|
+
/**
|
|
5683
|
+
* The popover's main content area.
|
|
5684
|
+
*
|
|
5685
|
+
* @function
|
|
5686
|
+
* @param {PopoverBodyProps} props - Props for the Popover.Body component.
|
|
5687
|
+
* @returns {JSX.Element} The body element.
|
|
5688
|
+
*/
|
|
5689
|
+
const PopoverBody = ({ className, ...props }) => {
|
|
5690
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
5691
|
+
return (jsx("div", { className: classNames(usePrefixedClassNames('popover-body'), bulmaHelperClasses, className), ...rest }));
|
|
5692
|
+
};
|
|
5693
|
+
/**
|
|
5694
|
+
* A row of actions along the bottom of the popover, aligned to the end.
|
|
5695
|
+
*
|
|
5696
|
+
* @function
|
|
5697
|
+
* @param {PopoverFooterProps} props - Props for the Popover.Footer component.
|
|
5698
|
+
* @returns {JSX.Element} The footer element.
|
|
5699
|
+
*/
|
|
5700
|
+
const PopoverFooter = ({ className, ...props }) => {
|
|
5701
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
5702
|
+
return (jsx("div", { className: classNames(usePrefixedClassNames('popover-footer'), bulmaHelperClasses, className), ...rest }));
|
|
5703
|
+
};
|
|
5704
|
+
/**
|
|
5705
|
+
* A `Button` that closes the popover, handing focus back to the trigger.
|
|
5706
|
+
*
|
|
5707
|
+
* @function
|
|
5708
|
+
* @param {PopoverCloseProps} props - Props for the Popover.Close component.
|
|
5709
|
+
* @returns {JSX.Element} The close button.
|
|
5710
|
+
*/
|
|
5711
|
+
const PopoverClose = ({ onClick, ...props }) => {
|
|
5712
|
+
const context = useContext(PopoverContext);
|
|
5713
|
+
return (jsx(Button, { ...props, type: buttonType(props.type), onClick: (e) => {
|
|
5714
|
+
onClick?.(e);
|
|
5715
|
+
if (!e.defaultPrevented)
|
|
5716
|
+
context?.close();
|
|
5717
|
+
} }));
|
|
5718
|
+
};
|
|
5719
|
+
/** Popover with Header, Body, Footer and Close sub-components. */
|
|
5720
|
+
const Popover = withSubComponents(PopoverComponent, {
|
|
5721
|
+
Header: PopoverHeader,
|
|
5722
|
+
Body: PopoverBody,
|
|
5723
|
+
Footer: PopoverFooter,
|
|
5724
|
+
Close: PopoverClose,
|
|
5725
|
+
}, 'Popover');
|
|
5726
|
+
|
|
5727
|
+
/**
|
|
5728
|
+
* Individual Step component for use inside Steps.
|
|
5729
|
+
*
|
|
5730
|
+
* @function
|
|
5731
|
+
* @param {StepProps} props - Props for the Step component.
|
|
5732
|
+
* @returns {JSX.Element} The rendered step segment.
|
|
5733
|
+
*/
|
|
5734
|
+
const Step = ({ isActive = false, isCompleted = false, label, icon, clickable = false, onClick, stepNumber, completedIcon, className, children, ...props }) => {
|
|
5735
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
5736
|
+
const stepClasses = classNames(usePrefixedClassNames('steps-segment', {
|
|
5737
|
+
'is-active': isActive,
|
|
5738
|
+
'is-completed': isCompleted,
|
|
5739
|
+
}), bulmaHelperClasses, className);
|
|
5740
|
+
const stepsLinkClass = usePrefixedClassNames('steps-link');
|
|
5741
|
+
const stepsMarkerClass = usePrefixedClassNames('steps-marker');
|
|
5742
|
+
const stepsContentClass = usePrefixedClassNames('steps-content');
|
|
5743
|
+
const stepsTitleClass = usePrefixedClassNames('steps-title');
|
|
5744
|
+
const handleClick = () => {
|
|
5745
|
+
if (clickable && onClick) {
|
|
5746
|
+
onClick();
|
|
5747
|
+
}
|
|
5748
|
+
};
|
|
5749
|
+
const handleKeyDown = (e) => {
|
|
5750
|
+
if (clickable && onClick && (e.key === 'Enter' || e.key === ' ')) {
|
|
5751
|
+
e.preventDefault();
|
|
5752
|
+
onClick();
|
|
5753
|
+
}
|
|
5754
|
+
};
|
|
5755
|
+
const resolvedCompletedIcon = completedIcon === undefined ? '\u2713' : completedIcon;
|
|
5756
|
+
const markerContent = icon ||
|
|
5757
|
+
(isCompleted && resolvedCompletedIcon
|
|
5758
|
+
? resolvedCompletedIcon
|
|
5759
|
+
: (stepNumber ?? null));
|
|
5760
|
+
return (jsx("li", { className: stepClasses, onClick: handleClick, onKeyDown: handleKeyDown, tabIndex: clickable ? 0 : undefined, role: clickable ? 'button' : undefined, "aria-current": isActive ? 'step' : undefined, ...rest, children: jsxs("div", { className: stepsLinkClass, children: [jsx("span", { className: stepsMarkerClass, children: markerContent }), (label || children) && (jsx("div", { className: stepsContentClass, children: jsx("p", { className: stepsTitleClass, children: label || children }) }))] }) }));
|
|
5761
|
+
};
|
|
5762
|
+
/**
|
|
5763
|
+
* The `Steps` component provides a multi-step progress indicator for wizard flows, checkout processes, or any multi-step workflow.
|
|
5764
|
+
*
|
|
5765
|
+
* @function
|
|
5766
|
+
* @param {StepsProps} props - Props for the Steps component.
|
|
5767
|
+
* @returns {JSX.Element} The rendered steps component.
|
|
5768
|
+
*
|
|
5769
|
+
* @example
|
|
5770
|
+
* // Basic steps with items
|
|
5771
|
+
* <Steps
|
|
5772
|
+
* value={1}
|
|
5773
|
+
* items={[
|
|
5774
|
+
* { label: 'Account' },
|
|
5775
|
+
* { label: 'Profile' },
|
|
5776
|
+
* { label: 'Complete' },
|
|
5777
|
+
* ]}
|
|
5778
|
+
* />
|
|
5779
|
+
*
|
|
5780
|
+
* @example
|
|
5781
|
+
* // Clickable steps with navigation
|
|
5782
|
+
* <Steps
|
|
5783
|
+
* value={currentStep}
|
|
5784
|
+
* onStepClick={(step) => setCurrentStep(step)}
|
|
5785
|
+
* hasNavigation
|
|
5786
|
+
* items={[
|
|
5787
|
+
* { label: 'Step 1', clickable: true },
|
|
5788
|
+
* { label: 'Step 2', clickable: true },
|
|
5789
|
+
* { label: 'Step 3', clickable: true },
|
|
5790
|
+
* ]}
|
|
5791
|
+
* />
|
|
5792
|
+
*/
|
|
5793
|
+
const StepsComponent = ({ value = 0, items, size, color, hasMarker = true, animated = true, rounded = true, vertical = false, labelPosition = 'bottom', mobileMode, showStepNumbers = true, hasNavigation = false, prevLabel, nextLabel, onPrev, onNext, onStepClick, className, children, ...props }) => {
|
|
5794
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
5795
|
+
// Generate classes for the wrapper div
|
|
5796
|
+
const stepsClasses = usePrefixedClassNames('steps', {
|
|
5797
|
+
[`is-${size}`]: size,
|
|
5798
|
+
[`is-${color}`]: color,
|
|
5799
|
+
'has-marker': hasMarker,
|
|
5800
|
+
'is-animated': animated,
|
|
5801
|
+
'is-rounded': rounded,
|
|
5802
|
+
'is-vertical': vertical,
|
|
5803
|
+
[`has-label-${labelPosition}`]: labelPosition && (labelPosition !== 'bottom' || vertical),
|
|
5804
|
+
[`is-${mobileMode}`]: mobileMode,
|
|
5805
|
+
});
|
|
5806
|
+
const combinedClasses = classNames(stepsClasses, bulmaHelperClasses, className);
|
|
5807
|
+
// Generate classes for the ul (list)
|
|
5808
|
+
const listClasses = usePrefixedClassNames('steps-list', {
|
|
5809
|
+
'is-vertical': vertical,
|
|
5810
|
+
});
|
|
5811
|
+
const stepsNavigationClass = usePrefixedClassNames('steps-navigation');
|
|
5812
|
+
const prevButtonClass = usePrefixedClassNames('button');
|
|
5813
|
+
const nextButtonClass = usePrefixedClassNames('button', {
|
|
5814
|
+
'is-primary': true,
|
|
5815
|
+
});
|
|
5816
|
+
// Count total steps for navigation
|
|
5817
|
+
const totalSteps = items ? items.length : React.Children.count(children);
|
|
5818
|
+
// Render items if provided
|
|
5819
|
+
const renderSteps = () => {
|
|
5820
|
+
if (items && items.length > 0) {
|
|
5821
|
+
return items.map((item, index) => (jsx(Step, { isActive: index === value, isCompleted: index < value, label: item.label, icon: item.icon, completedIcon: item.completedIcon, clickable: item.clickable, stepNumber: showStepNumbers ? index + 1 : undefined, onClick: item.clickable && onStepClick ? () => onStepClick(index) : undefined, className: item.className }, index)));
|
|
5822
|
+
}
|
|
5823
|
+
// If children are provided, clone them with active/completed states
|
|
5824
|
+
if (children) {
|
|
5825
|
+
return React.Children.map(children, (child, index) => {
|
|
5826
|
+
if (React.isValidElement(child)) {
|
|
5827
|
+
return React.cloneElement(child, {
|
|
5828
|
+
isActive: index === value,
|
|
5829
|
+
isCompleted: index < value,
|
|
5830
|
+
stepNumber: showStepNumbers ? index + 1 : undefined,
|
|
5831
|
+
onClick: child.props.clickable && onStepClick
|
|
5832
|
+
? () => onStepClick(index)
|
|
5833
|
+
: child.props.onClick,
|
|
5834
|
+
});
|
|
5835
|
+
}
|
|
5836
|
+
return child;
|
|
5837
|
+
});
|
|
5838
|
+
}
|
|
5839
|
+
return null;
|
|
5840
|
+
};
|
|
5841
|
+
return (jsxs("div", { className: combinedClasses, ...rest, children: [jsx("ul", { className: listClasses, children: renderSteps() }), hasNavigation && (jsxs("div", { className: stepsNavigationClass, children: [jsx("button", { type: "button", className: prevButtonClass, disabled: value === 0, onClick: onPrev ?? (() => onStepClick?.(value - 1)), children: prevLabel ?? 'Previous' }), jsx("button", { type: "button", className: nextButtonClass, disabled: value === totalSteps - 1, onClick: onNext ?? (() => onStepClick?.(value + 1)), children: nextLabel ?? 'Next' })] }))] }));
|
|
5842
|
+
};
|
|
5843
|
+
// Attach Step as static property
|
|
5844
|
+
const Steps = withSubComponents(StepsComponent, { Step }, 'Steps');
|
|
5845
|
+
|
|
5846
|
+
/**
|
|
5847
|
+
* The `Sidebar` component provides a slide-out navigation panel that appears from the left or right side of the screen.
|
|
5848
|
+
*
|
|
5849
|
+
* @function
|
|
5850
|
+
* @param {SidebarProps} props - Props for the Sidebar component.
|
|
5851
|
+
* @param {React.Ref<HTMLElement>} ref - Forwarded ref to the sidebar element.
|
|
5852
|
+
* @returns {JSX.Element | null} The rendered sidebar component.
|
|
5853
|
+
*
|
|
5854
|
+
* @example
|
|
5855
|
+
* // Basic sidebar
|
|
5856
|
+
* const [isOpen, setIsOpen] = useState(false);
|
|
5857
|
+
* <Sidebar isOpen={isOpen} onClose={() => setIsOpen(false)}>
|
|
5858
|
+
* <Menu>...</Menu>
|
|
5859
|
+
* </Sidebar>
|
|
5860
|
+
*
|
|
5861
|
+
* @example
|
|
5862
|
+
* // Right-side sidebar with custom width
|
|
5863
|
+
* <Sidebar
|
|
5864
|
+
* isOpen={isOpen}
|
|
5865
|
+
* onClose={() => setIsOpen(false)}
|
|
5866
|
+
* position="right"
|
|
5867
|
+
* width="320px"
|
|
5868
|
+
* >
|
|
5869
|
+
* <div>Panel content</div>
|
|
5870
|
+
* </Sidebar>
|
|
5871
|
+
*/
|
|
5872
|
+
const SidebarComponent = forwardRef(({ isOpen, onClose, position = 'left', width = '260px', isFullwidth, fullWidth, overlay = true, overlayClose = true, escapeClose = true, canCancel = true, children, inline = false, className, style, ...props }, ref) => {
|
|
5873
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
5874
|
+
const sidebarRef = useRef(null);
|
|
5875
|
+
// Cleanup returned by a consumer's callback ref on attach, held until detach.
|
|
5876
|
+
const consumerCleanupRef = useRef(null);
|
|
5877
|
+
const resolvedFullwidth = isFullwidth ?? fullWidth ?? false;
|
|
5878
|
+
// Close handler
|
|
5879
|
+
const handleClose = useCallback(() => {
|
|
5880
|
+
if (canCancel && onClose) {
|
|
5881
|
+
onClose();
|
|
4911
5882
|
}
|
|
4912
5883
|
}, [canCancel, onClose]);
|
|
4913
5884
|
// Handle overlay click
|
|
@@ -5577,198 +6548,69 @@ const notifyDialogListeners = () => {
|
|
|
5577
6548
|
/**
|
|
5578
6549
|
* Programmatic dialog API for showing dialogs from anywhere.
|
|
5579
6550
|
*/
|
|
5580
|
-
const dialog = {
|
|
5581
|
-
/**
|
|
5582
|
-
* Show an alert dialog. Returns a promise that resolves when closed.
|
|
5583
|
-
*/
|
|
5584
|
-
alert: (options) => {
|
|
5585
|
-
return new Promise(resolve => {
|
|
5586
|
-
const opts = typeof options === 'string' ? { message: options } : options;
|
|
5587
|
-
currentDialog = {
|
|
5588
|
-
type: 'alert',
|
|
5589
|
-
options: opts,
|
|
5590
|
-
resolve: () => resolve(),
|
|
5591
|
-
};
|
|
5592
|
-
notifyDialogListeners();
|
|
5593
|
-
});
|
|
5594
|
-
},
|
|
5595
|
-
/**
|
|
5596
|
-
* Show a confirm dialog. Returns a promise that resolves to true/false.
|
|
5597
|
-
*/
|
|
5598
|
-
confirm: (options) => {
|
|
5599
|
-
return new Promise(resolve => {
|
|
5600
|
-
const opts = typeof options === 'string' ? { message: options } : options;
|
|
5601
|
-
currentDialog = {
|
|
5602
|
-
type: 'confirm',
|
|
5603
|
-
options: opts,
|
|
5604
|
-
resolve: value => resolve(Boolean(value)),
|
|
5605
|
-
};
|
|
5606
|
-
notifyDialogListeners();
|
|
5607
|
-
});
|
|
5608
|
-
},
|
|
5609
|
-
/**
|
|
5610
|
-
* Close the current dialog.
|
|
5611
|
-
*/
|
|
5612
|
-
close: (value) => {
|
|
5613
|
-
if (currentDialog) {
|
|
5614
|
-
currentDialog.resolve(value);
|
|
5615
|
-
currentDialog = null;
|
|
5616
|
-
notifyDialogListeners();
|
|
5617
|
-
}
|
|
5618
|
-
},
|
|
5619
|
-
/**
|
|
5620
|
-
* Subscribe to dialog changes.
|
|
5621
|
-
*/
|
|
5622
|
-
subscribe: (listener) => {
|
|
5623
|
-
dialogListeners.add(listener);
|
|
5624
|
-
return () => dialogListeners.delete(listener);
|
|
5625
|
-
},
|
|
5626
|
-
};
|
|
5627
|
-
/**
|
|
5628
|
-
* DialogContainer component to render programmatic dialogs.
|
|
5629
|
-
* Place this once at the root of your app.
|
|
5630
|
-
*
|
|
5631
|
-
* @function
|
|
5632
|
-
* @returns {JSX.Element | null} The rendered dialog, or null if none is active.
|
|
5633
|
-
*/
|
|
5634
|
-
const DialogContainer = () => {
|
|
5635
|
-
// Starts from the dialog already open instead of none, then follows changes.
|
|
5636
|
-
const current = useSyncExternalStore(dialog.subscribe, getCurrentDialog, getServerDialog);
|
|
5637
|
-
if (!current) {
|
|
5638
|
-
return null;
|
|
5639
|
-
}
|
|
5640
|
-
const { type, options } = current;
|
|
5641
|
-
return (jsx(Dialog, { isOpen: true, title: options.title, message: options.message, type: options.type, confirmText: options.confirmText, cancelText: options.cancelText, icon: options.icon, showCancel: type === 'confirm', focusCancel: options.focusCancel, onConfirm: () => dialog.close(type === 'confirm' ? true : undefined), onCancel: () => dialog.close(type === 'confirm' ? false : undefined) }));
|
|
5642
|
-
};
|
|
5643
|
-
|
|
5644
|
-
const validButtonColors = [...validColors, 'text', 'ghost'];
|
|
5645
|
-
/**
|
|
5646
|
-
* The `Button` component provides a flexible and highly customizable button for your Bulma React UI.
|
|
5647
|
-
*
|
|
5648
|
-
* @function
|
|
5649
|
-
* @param {ButtonProps} props - Props for the Button component.
|
|
5650
|
-
* @param {React.Ref} ref - Forwarded ref to the element `as` renders.
|
|
5651
|
-
* @returns {JSX.Element} The rendered button, anchor, or custom element.
|
|
5652
|
-
* @see {@link https://bulma.io/documentation/elements/button/ | Bulma Button documentation}
|
|
5653
|
-
*/
|
|
5654
|
-
const Button = forwardRef(function Button(props, ref) {
|
|
5655
|
-
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;
|
|
5656
|
-
const { bulmaHelperClasses, rest } = useBulmaClasses({
|
|
5657
|
-
color: textColor,
|
|
5658
|
-
backgroundColor: bgColor,
|
|
5659
|
-
...bulmaProps,
|
|
5660
|
-
});
|
|
5661
|
-
// Generate Bulma classes with prefix
|
|
5662
|
-
const bulmaClasses = usePrefixedClassNames('button', {
|
|
5663
|
-
[`is-${color}`]: color &&
|
|
5664
|
-
validButtonColors.includes(color),
|
|
5665
|
-
[`is-${size}`]: size,
|
|
5666
|
-
'is-outlined': isOutlined,
|
|
5667
|
-
'is-light': isLight,
|
|
5668
|
-
'is-loading': isLoading,
|
|
5669
|
-
'is-static': isStatic,
|
|
5670
|
-
'is-disabled': isDisabled,
|
|
5671
|
-
'is-rounded': isRounded,
|
|
5672
|
-
'is-hovered': isHovered,
|
|
5673
|
-
'is-focused': isFocused,
|
|
5674
|
-
'is-active': isActive,
|
|
5675
|
-
'is-inverted': isInverted,
|
|
5676
|
-
'is-fullwidth': isFullwidth ?? isFullWidth,
|
|
5677
|
-
});
|
|
5678
|
-
// Combine prefixed Bulma classes with unprefixed user className and prefixed helper classes
|
|
5679
|
-
const buttonClasses = classNames(bulmaClasses, bulmaHelperClasses, className);
|
|
5680
|
-
if (Component !== 'button') {
|
|
5681
|
-
// Strip the form-control attributes a link-like element cannot carry, so a
|
|
5682
|
-
// JavaScript consumer or a spread object doesn't put them on the DOM. The
|
|
5683
|
-
// types no longer allow them through; this is the runtime backstop.
|
|
5684
|
-
//
|
|
5685
|
-
// Only for INTRINSIC tags. A custom component (a router Link, ...) owns its
|
|
5686
|
-
// prop contract, and `ComponentPropsWithoutRef<T>` promises the caller that
|
|
5687
|
-
// its props reach it. Stripping names by their spelling alone broke that
|
|
5688
|
-
// promise silently: `<Button as={Custom} name="x" />` type-checked while
|
|
5689
|
-
// `Custom` never received the `name` it requires.
|
|
5690
|
-
//
|
|
5691
|
-
// `autoFocus` and `type` are deliberately NOT stripped: both are valid on
|
|
5692
|
-
// targets this list was filtering them from. `autoFocus` is a global
|
|
5693
|
-
// attribute (React decides per element whether to honour it, which is not
|
|
5694
|
-
// ours to pre-empt), and `type` on an anchor is the MIME hint that
|
|
5695
|
-
// `ButtonProps<'a'>` now types it as.
|
|
5696
|
-
//
|
|
5697
|
-
// The types already answer this for a TypeScript caller: `ButtonProps<T>`
|
|
5698
|
-
// derives from `ComponentPropsWithoutRef<T>`, so only what the target
|
|
5699
|
-
// accepts can be passed. What is left is a runtime backstop for JavaScript
|
|
5700
|
-
// consumers and spread objects.
|
|
5701
|
-
//
|
|
5702
|
-
// Keep it to the two cases where a stray attribute does something. The
|
|
5703
|
-
// submit-overrides belong to a submit control and never make sense on the
|
|
5704
|
-
// anchor path. `disabled` is the one that is VISIBLE: Bulma styles
|
|
5705
|
-
// `.button[disabled]` (background, border, shadow, opacity), so letting it
|
|
5706
|
-
// through to a `<span>` greys the element out — main stripped it, and not
|
|
5707
|
-
// stripping it would be a silent visual change on a types-only release.
|
|
5708
|
-
//
|
|
5709
|
-
// Everything else (`name`, `value`, `form`) is inert where it does not
|
|
5710
|
-
// belong, and enumerating which tags own it is what kept going wrong:
|
|
5711
|
-
// first all nine names on every non-button tag (so `<Button as="input"
|
|
5712
|
-
// name="query" value="Search">` submitted nothing), then a form-control
|
|
5713
|
-
// allowlist that still lost `formAction` on an input and `value` on an
|
|
5714
|
-
// option. Those now ride on the types instead.
|
|
5715
|
-
//
|
|
5716
|
-
// A custom component is exempt entirely — it owns its prop contract, and
|
|
5717
|
-
// `ComponentPropsWithoutRef<T>` promises the caller its props arrive.
|
|
5718
|
-
//
|
|
5719
|
-
// The elements that own `disabled`, per the HTML spec. A closed set, unlike
|
|
5720
|
-
// the open-ended question of which tag owns which attribute.
|
|
5721
|
-
const SUBMIT_OVERRIDES = [
|
|
5722
|
-
'formAction',
|
|
5723
|
-
'formEncType',
|
|
5724
|
-
'formMethod',
|
|
5725
|
-
'formNoValidate',
|
|
5726
|
-
'formTarget',
|
|
5727
|
-
];
|
|
5728
|
-
const OWNS_DISABLED = [
|
|
5729
|
-
'button',
|
|
5730
|
-
'fieldset',
|
|
5731
|
-
'input',
|
|
5732
|
-
'optgroup',
|
|
5733
|
-
'option',
|
|
5734
|
-
'select',
|
|
5735
|
-
'textarea',
|
|
5736
|
-
];
|
|
5737
|
-
// Two independent filters. They are not exclusive — an `<a>` owns neither
|
|
5738
|
-
// the submit-overrides nor `disabled`, so it takes both — and deriving them
|
|
5739
|
-
// from one destructure, or from one if/else, is what broke each of them in
|
|
5740
|
-
// turn: first the overrides came off every intrinsic (an `as="input"` lost
|
|
5741
|
-
// `formAction`), then scoping them to the anchor let `disabled` back onto
|
|
5742
|
-
// it.
|
|
5743
|
-
const forwardedRest = { ...rest };
|
|
5744
|
-
if (typeof Component === 'string' && !isCustomElement(Component)) {
|
|
5745
|
-
// Meaningless anywhere but a submit control, and an anchor is not one.
|
|
5746
|
-
if (Component === 'a') {
|
|
5747
|
-
for (const key of SUBMIT_OVERRIDES)
|
|
5748
|
-
delete forwardedRest[key];
|
|
5749
|
-
}
|
|
5750
|
-
// Visible via Bulma's `.button[disabled]`, so withheld from any element
|
|
5751
|
-
// that does not own it.
|
|
5752
|
-
if (!OWNS_DISABLED.includes(Component))
|
|
5753
|
-
delete forwardedRest.disabled;
|
|
6551
|
+
const dialog = {
|
|
6552
|
+
/**
|
|
6553
|
+
* Show an alert dialog. Returns a promise that resolves when closed.
|
|
6554
|
+
*/
|
|
6555
|
+
alert: (options) => {
|
|
6556
|
+
return new Promise(resolve => {
|
|
6557
|
+
const opts = typeof options === 'string' ? { message: options } : options;
|
|
6558
|
+
currentDialog = {
|
|
6559
|
+
type: 'alert',
|
|
6560
|
+
options: opts,
|
|
6561
|
+
resolve: () => resolve(),
|
|
6562
|
+
};
|
|
6563
|
+
notifyDialogListeners();
|
|
6564
|
+
});
|
|
6565
|
+
},
|
|
6566
|
+
/**
|
|
6567
|
+
* Show a confirm dialog. Returns a promise that resolves to true/false.
|
|
6568
|
+
*/
|
|
6569
|
+
confirm: (options) => {
|
|
6570
|
+
return new Promise(resolve => {
|
|
6571
|
+
const opts = typeof options === 'string' ? { message: options } : options;
|
|
6572
|
+
currentDialog = {
|
|
6573
|
+
type: 'confirm',
|
|
6574
|
+
options: opts,
|
|
6575
|
+
resolve: value => resolve(Boolean(value)),
|
|
6576
|
+
};
|
|
6577
|
+
notifyDialogListeners();
|
|
6578
|
+
});
|
|
6579
|
+
},
|
|
6580
|
+
/**
|
|
6581
|
+
* Close the current dialog.
|
|
6582
|
+
*/
|
|
6583
|
+
close: (value) => {
|
|
6584
|
+
if (currentDialog) {
|
|
6585
|
+
currentDialog.resolve(value);
|
|
6586
|
+
currentDialog = null;
|
|
6587
|
+
notifyDialogListeners();
|
|
5754
6588
|
}
|
|
5755
|
-
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
5764
|
-
|
|
5765
|
-
|
|
5766
|
-
|
|
5767
|
-
|
|
6589
|
+
},
|
|
6590
|
+
/**
|
|
6591
|
+
* Subscribe to dialog changes.
|
|
6592
|
+
*/
|
|
6593
|
+
subscribe: (listener) => {
|
|
6594
|
+
dialogListeners.add(listener);
|
|
6595
|
+
return () => dialogListeners.delete(listener);
|
|
6596
|
+
},
|
|
6597
|
+
};
|
|
6598
|
+
/**
|
|
6599
|
+
* DialogContainer component to render programmatic dialogs.
|
|
6600
|
+
* Place this once at the root of your app.
|
|
6601
|
+
*
|
|
6602
|
+
* @function
|
|
6603
|
+
* @returns {JSX.Element | null} The rendered dialog, or null if none is active.
|
|
6604
|
+
*/
|
|
6605
|
+
const DialogContainer = () => {
|
|
6606
|
+
// Starts from the dialog already open instead of none, then follows changes.
|
|
6607
|
+
const current = useSyncExternalStore(dialog.subscribe, getCurrentDialog, getServerDialog);
|
|
6608
|
+
if (!current) {
|
|
6609
|
+
return null;
|
|
5768
6610
|
}
|
|
5769
|
-
|
|
5770
|
-
});
|
|
5771
|
-
|
|
6611
|
+
const { type, options } = current;
|
|
6612
|
+
return (jsx(Dialog, { isOpen: true, title: options.title, message: options.message, type: options.type, confirmText: options.confirmText, cancelText: options.cancelText, icon: options.icon, showCancel: type === 'confirm', focusCancel: options.focusCancel, onConfirm: () => dialog.close(type === 'confirm' ? true : undefined), onCancel: () => dialog.close(type === 'confirm' ? false : undefined) }));
|
|
6613
|
+
};
|
|
5772
6614
|
|
|
5773
6615
|
/**
|
|
5774
6616
|
* Individual carousel item/slide.
|
|
@@ -10298,7 +11140,11 @@ TextArea.displayName = 'TextArea';
|
|
|
10298
11140
|
const DEFAULT_PICKER_LABELS = {
|
|
10299
11141
|
prevMonth: 'Previous month',
|
|
10300
11142
|
nextMonth: 'Next month',
|
|
11143
|
+
prevYear: 'Previous year',
|
|
11144
|
+
nextYear: 'Next year',
|
|
10301
11145
|
chooseDate: 'Choose date',
|
|
11146
|
+
chooseMonth: 'Choose month',
|
|
11147
|
+
chooseYear: 'Choose year',
|
|
10302
11148
|
hours: 'hours',
|
|
10303
11149
|
minutes: 'minutes',
|
|
10304
11150
|
seconds: 'seconds',
|
|
@@ -10328,6 +11174,8 @@ const mergeLabels = (overrides) => ({
|
|
|
10328
11174
|
});
|
|
10329
11175
|
|
|
10330
11176
|
const DEFAULT_DATE_FORMAT = 'YYYY-MM-DD';
|
|
11177
|
+
const DEFAULT_MONTH_FORMAT = 'YYYY-MM';
|
|
11178
|
+
const DEFAULT_YEAR_FORMAT = 'YYYY';
|
|
10331
11179
|
const DEFAULT_TIME_FORMAT_24 = 'HH:mm';
|
|
10332
11180
|
const DEFAULT_TIME_FORMAT_12 = 'hh:mm A';
|
|
10333
11181
|
const DEFAULT_DATETIME_FORMAT = 'YYYY-MM-DD HH:mm';
|
|
@@ -10633,9 +11481,87 @@ function isWithin(d, min, max) {
|
|
|
10633
11481
|
function startOfDay(d) {
|
|
10634
11482
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
|
10635
11483
|
}
|
|
11484
|
+
function endOfDay(d) {
|
|
11485
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
|
|
11486
|
+
}
|
|
10636
11487
|
function startOfMonth(d) {
|
|
10637
11488
|
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
|
|
10638
11489
|
}
|
|
11490
|
+
/**
|
|
11491
|
+
* Local midnight on `year`-`month`-`day` (`month` from 0), like the
|
|
11492
|
+
* `Date(y, m, d)` constructor except that years 0–99 stay as given rather than
|
|
11493
|
+
* turning into 1900–1999.
|
|
11494
|
+
*/
|
|
11495
|
+
function makeDate(year, month = 0, day = 1) {
|
|
11496
|
+
const d = new Date(2000, 0, 1);
|
|
11497
|
+
d.setFullYear(year, month, day);
|
|
11498
|
+
return d;
|
|
11499
|
+
}
|
|
11500
|
+
/**
|
|
11501
|
+
* First instant of the day, month or year containing `d`. Works by setters on
|
|
11502
|
+
* a copy rather than the `Date(y, m, d)` constructor, which reads years 0–99
|
|
11503
|
+
* as 1900–1999: segmented typing passes through such years on the way to a
|
|
11504
|
+
* four-digit one.
|
|
11505
|
+
*/
|
|
11506
|
+
function startOfPeriod(d, granularity) {
|
|
11507
|
+
const r = new Date(d);
|
|
11508
|
+
if (granularity === 'year')
|
|
11509
|
+
r.setMonth(0, 1);
|
|
11510
|
+
else if (granularity === 'month')
|
|
11511
|
+
r.setDate(1);
|
|
11512
|
+
r.setHours(0, 0, 0, 0);
|
|
11513
|
+
return r;
|
|
11514
|
+
}
|
|
11515
|
+
/** Last instant of the day, month or year containing `d`. */
|
|
11516
|
+
function endOfPeriod(d, granularity) {
|
|
11517
|
+
const r = new Date(d);
|
|
11518
|
+
if (granularity === 'year')
|
|
11519
|
+
r.setMonth(11, 31);
|
|
11520
|
+
// Day 0 of the next month is this month's last day.
|
|
11521
|
+
else if (granularity === 'month')
|
|
11522
|
+
r.setMonth(r.getMonth() + 1, 0);
|
|
11523
|
+
r.setHours(23, 59, 59, 999);
|
|
11524
|
+
return r;
|
|
11525
|
+
}
|
|
11526
|
+
/**
|
|
11527
|
+
* Whether the day `d` falls outside `[min, max]` or is blocked by
|
|
11528
|
+
* `shouldDisableDate` / `unselectableDates`. This is the day grid's rule for a
|
|
11529
|
+
* disabled cell.
|
|
11530
|
+
*/
|
|
11531
|
+
function isDayUnselectable(d, c) {
|
|
11532
|
+
if (!isWithin(d, c.min, c.max))
|
|
11533
|
+
return true;
|
|
11534
|
+
if (c.shouldDisableDate?.(d))
|
|
11535
|
+
return true;
|
|
11536
|
+
if (c.unselectableDates?.some(u => isSameDay(u, d)))
|
|
11537
|
+
return true;
|
|
11538
|
+
return false;
|
|
11539
|
+
}
|
|
11540
|
+
/**
|
|
11541
|
+
* Whether every day of the month or year containing `d` is unselectable, so
|
|
11542
|
+
* the period as a whole can't be picked. Walks the period's days and stops at
|
|
11543
|
+
* the first selectable one. `min` and `max` count whole days here, so a `min`
|
|
11544
|
+
* of 3pm on a month's last day leaves that day, and so its month, selectable.
|
|
11545
|
+
*/
|
|
11546
|
+
function isPeriodUnselectable(d, granularity, c) {
|
|
11547
|
+
const days = {
|
|
11548
|
+
...c,
|
|
11549
|
+
min: c.min && startOfDay(c.min),
|
|
11550
|
+
max: c.max && endOfDay(c.max),
|
|
11551
|
+
};
|
|
11552
|
+
const start = startOfPeriod(d, granularity);
|
|
11553
|
+
const end = endOfPeriod(d, granularity).getTime();
|
|
11554
|
+
// A period wholly outside the bounds needs no walk.
|
|
11555
|
+
if (days.min && end < days.min.getTime())
|
|
11556
|
+
return true;
|
|
11557
|
+
if (days.max && start.getTime() > days.max.getTime())
|
|
11558
|
+
return true;
|
|
11559
|
+
for (let day = start; day.getTime() <= end; day = addDays(day, 1)) {
|
|
11560
|
+
if (!isDayUnselectable(day, days))
|
|
11561
|
+
return false;
|
|
11562
|
+
}
|
|
11563
|
+
return true;
|
|
11564
|
+
}
|
|
10639
11565
|
function addDays(d, n) {
|
|
10640
11566
|
const r = new Date(d);
|
|
10641
11567
|
r.setDate(r.getDate() + n);
|
|
@@ -10729,12 +11655,100 @@ function buildMonthGrid(monthAnchor, firstDayOfWeek = 0) {
|
|
|
10729
11655
|
return cells;
|
|
10730
11656
|
}
|
|
10731
11657
|
|
|
10732
|
-
const
|
|
11658
|
+
const BASE_VIEW = {
|
|
11659
|
+
day: 'days',
|
|
11660
|
+
month: 'months',
|
|
11661
|
+
year: 'years',
|
|
11662
|
+
};
|
|
11663
|
+
// Months in rows of three, so the grid is four rows deep.
|
|
11664
|
+
const MONTH_ROWS = [
|
|
11665
|
+
[0, 1, 2],
|
|
11666
|
+
[3, 4, 5],
|
|
11667
|
+
[6, 7, 8],
|
|
11668
|
+
[9, 10, 11],
|
|
11669
|
+
];
|
|
11670
|
+
const MONTHS_PER_ROW = 3;
|
|
11671
|
+
// The year list's CSS lays it out four to a row.
|
|
11672
|
+
const YEARS_PER_ROW = 4;
|
|
11673
|
+
/**
|
|
11674
|
+
* The month grid's keyboard, after the day grid's: arrows move a month or a
|
|
11675
|
+
* row, Home/End go to the row's ends, PageUp/PageDown move a year. Returns
|
|
11676
|
+
* the months to move by and the direction to search past disabled months, or
|
|
11677
|
+
* `null` for a key the grid doesn't handle. `col` is the focused month's
|
|
11678
|
+
* column.
|
|
11679
|
+
*/
|
|
11680
|
+
function monthStep(key, col) {
|
|
11681
|
+
switch (key) {
|
|
11682
|
+
case 'ArrowLeft':
|
|
11683
|
+
return [-1, -1];
|
|
11684
|
+
case 'ArrowRight':
|
|
11685
|
+
return [1, 1];
|
|
11686
|
+
case 'ArrowUp':
|
|
11687
|
+
return [-MONTHS_PER_ROW, -1];
|
|
11688
|
+
case 'ArrowDown':
|
|
11689
|
+
return [MONTHS_PER_ROW, 1];
|
|
11690
|
+
case 'PageUp':
|
|
11691
|
+
return [-12, -1];
|
|
11692
|
+
case 'PageDown':
|
|
11693
|
+
return [12, 1];
|
|
11694
|
+
case 'Home':
|
|
11695
|
+
return [-col, 1];
|
|
11696
|
+
case 'End':
|
|
11697
|
+
return [MONTHS_PER_ROW - 1 - col, -1];
|
|
11698
|
+
default:
|
|
11699
|
+
return null;
|
|
11700
|
+
}
|
|
11701
|
+
}
|
|
11702
|
+
/**
|
|
11703
|
+
* The month nearest `month` that `disabled` leaves enabled, `month` itself
|
|
11704
|
+
* when it is. At equal distance the later month wins. With every month
|
|
11705
|
+
* disabled it stays `month`, as there is nothing to reach.
|
|
11706
|
+
*/
|
|
11707
|
+
function nearestEnabledMonth(month, disabled) {
|
|
11708
|
+
if (!disabled[month])
|
|
11709
|
+
return month;
|
|
11710
|
+
for (let d = 1; d < 12; d++) {
|
|
11711
|
+
if (month + d < 12 && !disabled[month + d])
|
|
11712
|
+
return month + d;
|
|
11713
|
+
if (month - d >= 0 && !disabled[month - d])
|
|
11714
|
+
return month - d;
|
|
11715
|
+
}
|
|
11716
|
+
return month;
|
|
11717
|
+
}
|
|
11718
|
+
/**
|
|
11719
|
+
* The year list's keyboard when it is the selection surface: arrows move a
|
|
11720
|
+
* year or a row, Home/End go to the list's ends. Returns the year to start
|
|
11721
|
+
* from and the direction to search past disabled years, or `null` for a key
|
|
11722
|
+
* the list doesn't handle.
|
|
11723
|
+
*/
|
|
11724
|
+
function yearStep(key, focusedYear, years) {
|
|
11725
|
+
switch (key) {
|
|
11726
|
+
case 'ArrowLeft':
|
|
11727
|
+
return [focusedYear - 1, -1];
|
|
11728
|
+
case 'ArrowRight':
|
|
11729
|
+
return [focusedYear + 1, 1];
|
|
11730
|
+
case 'ArrowUp':
|
|
11731
|
+
return [focusedYear - YEARS_PER_ROW, -1];
|
|
11732
|
+
case 'ArrowDown':
|
|
11733
|
+
return [focusedYear + YEARS_PER_ROW, 1];
|
|
11734
|
+
case 'Home':
|
|
11735
|
+
return [years[0], 1];
|
|
11736
|
+
case 'End':
|
|
11737
|
+
return [years[years.length - 1], -1];
|
|
11738
|
+
default:
|
|
11739
|
+
return null;
|
|
11740
|
+
}
|
|
11741
|
+
}
|
|
11742
|
+
const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max, shouldDisableDate, unselectableDates, firstDayOfWeek = 0, locale, dayNames, monthNames, nearbyMonthDays = true, color, size, className, id, autoFocusCell = false, labels, yearsRange, granularity = 'day', }) => {
|
|
10733
11743
|
const gridRef = useRef(null);
|
|
11744
|
+
const monthGridRef = useRef(null);
|
|
10734
11745
|
const yearGridRef = useRef(null);
|
|
10735
11746
|
const { classPrefix } = useConfig();
|
|
10736
11747
|
const t = mergeLabels(labels);
|
|
10737
|
-
const
|
|
11748
|
+
const baseView = BASE_VIEW[granularity];
|
|
11749
|
+
const isDayGranularity = granularity === 'day';
|
|
11750
|
+
const isYearGranularity = granularity === 'year';
|
|
11751
|
+
const [view, setView] = useState(baseView);
|
|
10738
11752
|
const computedDayNames = useMemo(() => {
|
|
10739
11753
|
if (dayNames && dayNames.length === 7)
|
|
10740
11754
|
return dayNames;
|
|
@@ -10749,16 +11763,28 @@ const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max,
|
|
|
10749
11763
|
return monthNames;
|
|
10750
11764
|
return getMonthNames(locale, 'long');
|
|
10751
11765
|
}, [monthNames, locale]);
|
|
11766
|
+
// The month grid's cells are too narrow for long names, so they show the
|
|
11767
|
+
// short form unless the caller supplied names of their own.
|
|
11768
|
+
const monthCellNames = useMemo(() => {
|
|
11769
|
+
if (monthNames && monthNames.length === 12)
|
|
11770
|
+
return monthNames;
|
|
11771
|
+
return getMonthNames(locale, 'short');
|
|
11772
|
+
}, [monthNames, locale]);
|
|
10752
11773
|
const cells = useMemo(() => buildMonthGrid(focusedDate, firstDayOfWeek), [focusedDate, firstDayOfWeek]);
|
|
10753
|
-
const isDateUnselectable = useCallback((d) => {
|
|
10754
|
-
|
|
10755
|
-
|
|
10756
|
-
|
|
10757
|
-
|
|
10758
|
-
|
|
10759
|
-
|
|
10760
|
-
|
|
10761
|
-
|
|
11774
|
+
const isDateUnselectable = useCallback((d) => isDayUnselectable(d, { min, max, shouldDisableDate, unselectableDates }), [min, max, shouldDisableDate, unselectableDates]);
|
|
11775
|
+
// A month or year is out of reach only when every day in it is.
|
|
11776
|
+
const isMonthUnselectable = useCallback((d) => isPeriodUnselectable(d, 'month', {
|
|
11777
|
+
min,
|
|
11778
|
+
max,
|
|
11779
|
+
shouldDisableDate,
|
|
11780
|
+
unselectableDates,
|
|
11781
|
+
}), [min, max, shouldDisableDate, unselectableDates]);
|
|
11782
|
+
const isYearUnselectable = useCallback((year) => isPeriodUnselectable(makeDate(year), 'year', {
|
|
11783
|
+
min,
|
|
11784
|
+
max,
|
|
11785
|
+
shouldDisableDate,
|
|
11786
|
+
unselectableDates,
|
|
11787
|
+
}), [min, max, shouldDisableDate, unselectableDates]);
|
|
10762
11788
|
// Step from `next` by `direction` days until we hit a selectable date,
|
|
10763
11789
|
// skipping past dates blocked by min/max, shouldDisableDate, or
|
|
10764
11790
|
// unselectableDates. If the whole searched range is disabled, focus stays
|
|
@@ -10825,6 +11851,38 @@ const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max,
|
|
|
10825
11851
|
break;
|
|
10826
11852
|
}
|
|
10827
11853
|
}, [focusedDate, firstDayOfWeek, isDateUnselectable, moveFocus, onSelect]);
|
|
11854
|
+
// The month grid's counterpart to `moveFocus`: step by `direction` months
|
|
11855
|
+
// until a month with a selectable day turns up, stopping at the min/max
|
|
11856
|
+
// months. The cap guards against a range with nothing selectable.
|
|
11857
|
+
const moveMonthFocus = useCallback((next, direction) => {
|
|
11858
|
+
let candidate = next;
|
|
11859
|
+
for (let i = 0; i < 120; i++) {
|
|
11860
|
+
const month = startOfMonth(candidate).getTime();
|
|
11861
|
+
if (min && month < startOfMonth(min).getTime())
|
|
11862
|
+
return;
|
|
11863
|
+
if (max && month > startOfMonth(max).getTime())
|
|
11864
|
+
return;
|
|
11865
|
+
if (!isMonthUnselectable(candidate)) {
|
|
11866
|
+
onFocusedDateChange(candidate);
|
|
11867
|
+
return;
|
|
11868
|
+
}
|
|
11869
|
+
candidate = addMonths(candidate, direction);
|
|
11870
|
+
}
|
|
11871
|
+
}, [min, max, isMonthUnselectable, onFocusedDateChange]);
|
|
11872
|
+
const handleMonthKeyDown = useCallback((e) => {
|
|
11873
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
11874
|
+
e.preventDefault();
|
|
11875
|
+
if (!isMonthUnselectable(focusedDate)) {
|
|
11876
|
+
onSelect(startOfMonth(focusedDate));
|
|
11877
|
+
}
|
|
11878
|
+
return;
|
|
11879
|
+
}
|
|
11880
|
+
const step = monthStep(e.key, focusedDate.getMonth() % MONTHS_PER_ROW);
|
|
11881
|
+
if (!step)
|
|
11882
|
+
return;
|
|
11883
|
+
e.preventDefault();
|
|
11884
|
+
moveMonthFocus(addMonths(focusedDate, step[0]), step[1]);
|
|
11885
|
+
}, [focusedDate, isMonthUnselectable, moveMonthFocus, onSelect]);
|
|
10828
11886
|
const calendarClass = usePrefixedClassNames('dateinput', {
|
|
10829
11887
|
[`is-${color}`]: !!color,
|
|
10830
11888
|
[`is-${size}`]: !!size,
|
|
@@ -10841,21 +11899,44 @@ const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max,
|
|
|
10841
11899
|
const dayNamesRowClass = usePrefixedClassNames('dateinput-day-names');
|
|
10842
11900
|
const dayNameClass = usePrefixedClassNames('dateinput-day-name');
|
|
10843
11901
|
const gridClass = usePrefixedClassNames('dateinput-grid');
|
|
11902
|
+
const monthsGridClass = usePrefixedClassNames('dateinput-months-grid');
|
|
11903
|
+
const monthsRowClass = usePrefixedClassNames('dateinput-months-row');
|
|
10844
11904
|
const yearsGridClass = usePrefixedClassNames('dateinput-years-grid');
|
|
11905
|
+
const focusedYear = focusedDate.getFullYear();
|
|
11906
|
+
const today = new Date();
|
|
11907
|
+
const todayYear = today.getFullYear();
|
|
11908
|
+
// The header steps a month at a time over the day grid and a year at a
|
|
11909
|
+
// time over the month grid, kept inside min/max.
|
|
10845
11910
|
const prevMonthAnchor = addMonths(focusedDate, -1);
|
|
10846
11911
|
const nextMonthAnchor = addMonths(focusedDate, 1);
|
|
10847
|
-
const
|
|
10848
|
-
prevMonthAnchor
|
|
10849
|
-
|
|
10850
|
-
const
|
|
10851
|
-
nextMonthAnchor
|
|
10852
|
-
|
|
11912
|
+
const prevAnchor = isDayGranularity
|
|
11913
|
+
? prevMonthAnchor
|
|
11914
|
+
: clampDate(addYears(focusedDate, -1), min, max);
|
|
11915
|
+
const nextAnchor = isDayGranularity
|
|
11916
|
+
? nextMonthAnchor
|
|
11917
|
+
: clampDate(addYears(focusedDate, 1), min, max);
|
|
11918
|
+
const prevDisabled = isDayGranularity
|
|
11919
|
+
? !!(min &&
|
|
11920
|
+
prevMonthAnchor.getTime() < startOfDay(min).getTime() &&
|
|
11921
|
+
isSameMonth(focusedDate, min))
|
|
11922
|
+
: !!(min && focusedYear <= min.getFullYear());
|
|
11923
|
+
const nextDisabled = isDayGranularity
|
|
11924
|
+
? !!(max &&
|
|
11925
|
+
nextMonthAnchor.getTime() > startOfDay(max).getTime() &&
|
|
11926
|
+
isSameMonth(focusedDate, max))
|
|
11927
|
+
: !!(max && focusedYear >= max.getFullYear());
|
|
10853
11928
|
const labelId = id ? `${id}-label` : undefined;
|
|
10854
|
-
const monthLabel =
|
|
11929
|
+
const monthLabel = isDayGranularity
|
|
11930
|
+
? `${computedMonthNames[focusedDate.getMonth()]} ${focusedYear}`
|
|
11931
|
+
: String(focusedYear);
|
|
11932
|
+
// As the selection surface the year list is where focus moves, so its
|
|
11933
|
+
// window stays centred on the year focused at mount: re-centring on every
|
|
11934
|
+
// move would reflow the list under the keyboard.
|
|
11935
|
+
const [mountYear] = useState(focusedYear);
|
|
11936
|
+
const windowYear = isYearGranularity ? mountYear : focusedYear;
|
|
10855
11937
|
const yearList = useMemo(() => {
|
|
10856
|
-
const
|
|
10857
|
-
const
|
|
10858
|
-
const maxYear = max ? max.getFullYear() : focusedYear + 100;
|
|
11938
|
+
const minYear = min ? min.getFullYear() : windowYear - 100;
|
|
11939
|
+
const maxYear = max ? max.getFullYear() : windowYear + 100;
|
|
10859
11940
|
const [lo, hi] = yearsRange ?? [minYear, maxYear];
|
|
10860
11941
|
const start = Math.max(lo, minYear);
|
|
10861
11942
|
const end = Math.min(hi, maxYear);
|
|
@@ -10863,14 +11944,59 @@ const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max,
|
|
|
10863
11944
|
for (let y = start; y <= end; y++)
|
|
10864
11945
|
out.push(y);
|
|
10865
11946
|
return out;
|
|
10866
|
-
}, [
|
|
11947
|
+
}, [windowYear, min, max, yearsRange]);
|
|
11948
|
+
// Only the selection surface disables years; as navigation every listed
|
|
11949
|
+
// year can be visited.
|
|
11950
|
+
const disabledYears = useMemo(() => new Set(isYearGranularity ? yearList.filter(y => isYearUnselectable(y)) : []), [isYearGranularity, yearList, isYearUnselectable]);
|
|
11951
|
+
// Which of the focused year's months have no selectable day.
|
|
11952
|
+
const disabledMonths = useMemo(() => granularity === 'month'
|
|
11953
|
+
? Array.from({ length: 12 }, (_, m) => isMonthUnselectable(makeDate(focusedYear, m)))
|
|
11954
|
+
: [], [granularity, focusedYear, isMonthUnselectable]);
|
|
11955
|
+
// The month grid's one tab stop. A disabled button can't take focus, so
|
|
11956
|
+
// when the focused month is disabled the stop moves to the nearest enabled
|
|
11957
|
+
// month, and focusing it makes it the focused month.
|
|
11958
|
+
const tabStopMonth = nearestEnabledMonth(focusedDate.getMonth(), disabledMonths);
|
|
10867
11959
|
const handleYearSelect = useCallback((year) => {
|
|
10868
11960
|
const next = new Date(focusedDate);
|
|
10869
11961
|
next.setFullYear(year);
|
|
10870
|
-
// Keep month/day
|
|
10871
|
-
|
|
10872
|
-
|
|
10873
|
-
|
|
11962
|
+
// Keep month/day. The month grid also clamps into [min, max] so its
|
|
11963
|
+
// focused month is never one the bounds rule out.
|
|
11964
|
+
onFocusedDateChange(isDayGranularity ? next : clampDate(next, min, max));
|
|
11965
|
+
setView(baseView);
|
|
11966
|
+
}, [focusedDate, onFocusedDateChange, isDayGranularity, min, max, baseView]);
|
|
11967
|
+
// Picking from the year list as the selection surface: focus follows the
|
|
11968
|
+
// pick and `onSelect` gets the year's first day.
|
|
11969
|
+
const handleYearPick = useCallback((year) => {
|
|
11970
|
+
onFocusedDateChange(clampDate(makeDate(year), min, max));
|
|
11971
|
+
onSelect(makeDate(year));
|
|
11972
|
+
}, [min, max, onFocusedDateChange, onSelect]);
|
|
11973
|
+
// Step through the listed years from `year` by `direction`, skipping
|
|
11974
|
+
// disabled ones, and focus the first selectable year found.
|
|
11975
|
+
const moveYearFocus = useCallback((year, direction) => {
|
|
11976
|
+
const first = yearList[0];
|
|
11977
|
+
const last = yearList[yearList.length - 1];
|
|
11978
|
+
for (let y = year; y >= first && y <= last; y += direction) {
|
|
11979
|
+
if (!disabledYears.has(y)) {
|
|
11980
|
+
onFocusedDateChange(clampDate(makeDate(y), min, max));
|
|
11981
|
+
return;
|
|
11982
|
+
}
|
|
11983
|
+
}
|
|
11984
|
+
}, [yearList, disabledYears, onFocusedDateChange, min, max]);
|
|
11985
|
+
const handleYearListKeyDown = useCallback((e) => {
|
|
11986
|
+
if (!isYearGranularity) {
|
|
11987
|
+
// As navigation, Escape returns to the grid the list was opened from.
|
|
11988
|
+
if (e.key === 'Escape') {
|
|
11989
|
+
e.preventDefault();
|
|
11990
|
+
setView(baseView);
|
|
11991
|
+
}
|
|
11992
|
+
return;
|
|
11993
|
+
}
|
|
11994
|
+
const step = yearStep(e.key, focusedYear, yearList);
|
|
11995
|
+
if (!step)
|
|
11996
|
+
return;
|
|
11997
|
+
e.preventDefault();
|
|
11998
|
+
moveYearFocus(step[0], step[1]);
|
|
11999
|
+
}, [isYearGranularity, baseView, focusedYear, yearList, moveYearFocus]);
|
|
10874
12000
|
useEffect(() => {
|
|
10875
12001
|
if (view !== 'days')
|
|
10876
12002
|
return;
|
|
@@ -10879,17 +12005,55 @@ const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max,
|
|
|
10879
12005
|
const target = gridRef.current.querySelector('[data-focused="true"]');
|
|
10880
12006
|
target?.focus();
|
|
10881
12007
|
}, [autoFocusCell, focusedDate, view]);
|
|
10882
|
-
//
|
|
12008
|
+
// The month grid takes focus when the popover asks for it, and keeps it on
|
|
12009
|
+
// the focused month while the keyboard moves through the grid.
|
|
12010
|
+
useEffect(() => {
|
|
12011
|
+
const grid = monthGridRef.current;
|
|
12012
|
+
if (view !== 'months' || !grid)
|
|
12013
|
+
return;
|
|
12014
|
+
if (!autoFocusCell && !grid.contains(document.activeElement))
|
|
12015
|
+
return;
|
|
12016
|
+
grid.querySelector('[data-focused="true"]')?.focus();
|
|
12017
|
+
}, [autoFocusCell, focusedDate, view]);
|
|
12018
|
+
// When the year view opens as navigation, scroll the focused year into
|
|
12019
|
+
// view.
|
|
10883
12020
|
useEffect(() => {
|
|
10884
|
-
if (view !== 'years' || !yearGridRef.current)
|
|
12021
|
+
if (view !== 'years' || isYearGranularity || !yearGridRef.current)
|
|
10885
12022
|
return;
|
|
10886
12023
|
const sel = yearGridRef.current.querySelector('[data-focused-year="true"]');
|
|
10887
12024
|
sel?.scrollIntoView({ block: 'center' });
|
|
10888
12025
|
sel?.focus();
|
|
10889
|
-
}, [view]);
|
|
10890
|
-
|
|
10891
|
-
|
|
10892
|
-
|
|
12026
|
+
}, [view, isYearGranularity]);
|
|
12027
|
+
// As the selection surface the year list opens with the focused year in
|
|
12028
|
+
// its middle. It scrolls the list itself rather than calling
|
|
12029
|
+
// `scrollIntoView`, which would also scroll the page to an inline calendar
|
|
12030
|
+
// on load, and focuses that year only when the popover asks.
|
|
12031
|
+
useEffect(() => {
|
|
12032
|
+
const list = yearGridRef.current;
|
|
12033
|
+
if (!isYearGranularity || !list)
|
|
12034
|
+
return;
|
|
12035
|
+
const target = list.querySelector('[data-focused-year="true"]');
|
|
12036
|
+
if (!target)
|
|
12037
|
+
return;
|
|
12038
|
+
const rect = target.getBoundingClientRect();
|
|
12039
|
+
list.scrollTop +=
|
|
12040
|
+
rect.top -
|
|
12041
|
+
list.getBoundingClientRect().top -
|
|
12042
|
+
(list.clientHeight - rect.height) / 2;
|
|
12043
|
+
if (autoFocusCell)
|
|
12044
|
+
target.focus({ preventScroll: true });
|
|
12045
|
+
}, [isYearGranularity, autoFocusCell]);
|
|
12046
|
+
// Keyboard moves keep DOM focus on the focused year while the list has it.
|
|
12047
|
+
useEffect(() => {
|
|
12048
|
+
const list = yearGridRef.current;
|
|
12049
|
+
if (!isYearGranularity || !list)
|
|
12050
|
+
return;
|
|
12051
|
+
if (!list.contains(document.activeElement))
|
|
12052
|
+
return;
|
|
12053
|
+
list.querySelector('[data-focused-year="true"]')?.focus();
|
|
12054
|
+
}, [isYearGranularity, focusedYear]);
|
|
12055
|
+
const navArrow = (d) => (jsx("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": "true", children: jsx("path", { d: d, fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) }));
|
|
12056
|
+
return (jsxs("div", { className: classNames(calendarClass, className), id: id, children: [jsxs("div", { className: headerClass, children: [isYearGranularity ? (jsx("span", { className: monthLabelClass, id: labelId, children: monthLabel })) : (jsxs("button", { type: "button", className: monthTriggerClass, "aria-haspopup": "listbox", "aria-expanded": view === 'years', onClick: () => setView(v => (v === 'years' ? baseView : 'years')), children: [jsx("span", { className: monthLabelClass, id: labelId, "aria-live": "polite", children: monthLabel }), jsx("svg", { className: monthCaretClass, viewBox: "0 0 12 12", width: "10", height: "10", "aria-hidden": "true", children: jsx("path", { d: "M2 4l4 4 4-4", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round" }) })] })), !isYearGranularity && (jsxs("div", { className: navGroupClass, children: [jsx("button", { type: "button", className: navPrevClass, "aria-label": isDayGranularity ? t.prevMonth : t.prevYear, disabled: prevDisabled || view !== baseView, onClick: () => onFocusedDateChange(prevAnchor), children: navArrow('M10 3l-5 5 5 5') }), jsx("button", { type: "button", className: navNextClass, "aria-label": isDayGranularity ? t.nextMonth : t.nextYear, disabled: nextDisabled || view !== baseView, onClick: () => onFocusedDateChange(nextAnchor), children: navArrow('M6 3l5 5-5 5') })] }))] }), view === 'days' && (jsxs(Fragment, { children: [jsx("div", { className: dayNamesRowClass, "aria-hidden": "true", children: computedDayNames.map((name, i) => (jsx("div", { className: dayNameClass, children: name }, i))) }), jsx("div", { ref: gridRef, role: "grid", "aria-labelledby": labelId, className: gridClass, onKeyDown: handleKeyDown, children: cells.map(cell => {
|
|
10893
12057
|
const disabled = isDateUnselectable(cell.date);
|
|
10894
12058
|
const isSelected = !!value && isSameDay(value, cell.date);
|
|
10895
12059
|
const isFocused = isSameDay(cell.date, focusedDate);
|
|
@@ -10907,156 +12071,56 @@ const Calendar = ({ value, focusedDate, onSelect, onFocusedDateChange, min, max,
|
|
|
10907
12071
|
onFocusedDateChange(cell.date);
|
|
10908
12072
|
onSelect(cell.date);
|
|
10909
12073
|
}, style: !display ? { visibility: 'hidden' } : undefined, children: cell.date.getDate() }, cell.date.toISOString()));
|
|
10910
|
-
}) })] }))
|
|
10911
|
-
|
|
10912
|
-
|
|
10913
|
-
|
|
10914
|
-
|
|
10915
|
-
|
|
12074
|
+
}) })] })), view === 'months' && (jsx("div", { ref: monthGridRef, role: "grid", "aria-labelledby": labelId, className: monthsGridClass, onKeyDown: handleMonthKeyDown, children: MONTH_ROWS.map(row => (jsx("div", { role: "row", className: monthsRowClass, children: row.map(month => {
|
|
12075
|
+
const date = makeDate(focusedYear, month);
|
|
12076
|
+
const disabled = disabledMonths[month];
|
|
12077
|
+
const isSelected = !!value && isSameMonth(value, date);
|
|
12078
|
+
const isFocused = month === tabStopMonth;
|
|
12079
|
+
const isCurrent = isSameMonth(date, today);
|
|
12080
|
+
const cellClass = prefixedClassNames(classPrefix, 'dateinput-month-cell', {
|
|
12081
|
+
'is-selected': isSelected,
|
|
12082
|
+
'is-today': isCurrent,
|
|
12083
|
+
'is-disabled': disabled,
|
|
12084
|
+
});
|
|
12085
|
+
return (jsx("button", { type: "button", role: "gridcell", tabIndex: isFocused ? 0 : -1, "aria-selected": isSelected, "aria-disabled": disabled, "aria-current": isCurrent ? 'date' : undefined, "aria-label": computedMonthNames[month], "data-focused": isFocused ? 'true' : undefined, disabled: disabled, className: cellClass, onFocus: () => {
|
|
12086
|
+
// Reached by Tab while the focused month is disabled,
|
|
12087
|
+
// or by pointer: keys move on from here.
|
|
12088
|
+
if (month !== focusedDate.getMonth()) {
|
|
12089
|
+
onFocusedDateChange(clampDate(date, min, max));
|
|
12090
|
+
}
|
|
12091
|
+
}, onClick: () => {
|
|
12092
|
+
onFocusedDateChange(clampDate(date, min, max));
|
|
12093
|
+
onSelect(date);
|
|
12094
|
+
}, children: monthCellNames[month] }, month));
|
|
12095
|
+
}) }, row[0]))) })), view === 'years' && (jsx("div", { ref: yearGridRef, className: yearsGridClass, role: "listbox", "aria-label": isYearGranularity ? t.chooseYear : monthLabel, onKeyDown: handleYearListKeyDown, children: yearList.map(year => {
|
|
10916
12096
|
const isFocused = year === focusedYear;
|
|
10917
12097
|
const isToday = year === todayYear;
|
|
12098
|
+
// As navigation the focused year is the current one. As the
|
|
12099
|
+
// selection surface the value's year is selected and focus roams.
|
|
12100
|
+
const isSelected = isYearGranularity
|
|
12101
|
+
? !!value && value.getFullYear() === year
|
|
12102
|
+
: isFocused;
|
|
12103
|
+
const disabled = disabledYears.has(year);
|
|
10918
12104
|
const cellCls = prefixedClassNames(classPrefix, 'dateinput-year-cell', {
|
|
10919
|
-
'is-selected':
|
|
12105
|
+
'is-selected': isSelected,
|
|
10920
12106
|
'is-today': isToday,
|
|
12107
|
+
'is-disabled': disabled,
|
|
10921
12108
|
});
|
|
10922
|
-
return (jsx("button", { type: "button", role: "option", "aria-selected":
|
|
12109
|
+
return (jsx("button", { type: "button", role: "option", "aria-selected": isSelected, "aria-disabled": isYearGranularity ? disabled : undefined, "aria-current": isYearGranularity && isToday ? 'date' : undefined, "data-focused-year": isFocused ? 'true' : undefined, tabIndex: isFocused ? 0 : -1, disabled: disabled, className: cellCls, onClick: () => isYearGranularity
|
|
12110
|
+
? handleYearPick(year)
|
|
12111
|
+
: handleYearSelect(year), children: year }, year));
|
|
10923
12112
|
}) }))] }));
|
|
10924
12113
|
};
|
|
10925
12114
|
Calendar.displayName = 'Calendar';
|
|
10926
12115
|
|
|
10927
|
-
const FOCUSABLE_SELECTOR = [
|
|
10928
|
-
'a[href]',
|
|
10929
|
-
'button:not([disabled])',
|
|
10930
|
-
'input:not([disabled]):not([type="hidden"])',
|
|
10931
|
-
'select:not([disabled])',
|
|
10932
|
-
'textarea:not([disabled])',
|
|
10933
|
-
'[tabindex]:not([tabindex="-1"])',
|
|
10934
|
-
].join(',');
|
|
10935
|
-
function useFocusTrap(containerRef, active, options = {}) {
|
|
10936
|
-
const { initialFocusRef, restoreFocus = true } = options;
|
|
10937
|
-
useEffect(() => {
|
|
10938
|
-
if (!active)
|
|
10939
|
-
return undefined;
|
|
10940
|
-
const container = containerRef.current;
|
|
10941
|
-
if (!container)
|
|
10942
|
-
return undefined;
|
|
10943
|
-
const previouslyFocused = typeof document !== 'undefined'
|
|
10944
|
-
? document.activeElement
|
|
10945
|
-
: null;
|
|
10946
|
-
const focusInitial = () => {
|
|
10947
|
-
if (initialFocusRef?.current) {
|
|
10948
|
-
initialFocusRef.current.focus();
|
|
10949
|
-
return;
|
|
10950
|
-
}
|
|
10951
|
-
const focusables = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => !el.hasAttribute('disabled'));
|
|
10952
|
-
const target = focusables[0] ?? container;
|
|
10953
|
-
target.focus();
|
|
10954
|
-
};
|
|
10955
|
-
focusInitial();
|
|
10956
|
-
const handleKeyDown = (e) => {
|
|
10957
|
-
if (e.key !== 'Tab')
|
|
10958
|
-
return;
|
|
10959
|
-
const focusables = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => !el.hasAttribute('disabled'));
|
|
10960
|
-
if (focusables.length === 0) {
|
|
10961
|
-
e.preventDefault();
|
|
10962
|
-
return;
|
|
10963
|
-
}
|
|
10964
|
-
const first = focusables[0];
|
|
10965
|
-
const last = focusables[focusables.length - 1];
|
|
10966
|
-
const activeEl = document.activeElement;
|
|
10967
|
-
if (e.shiftKey && activeEl === first) {
|
|
10968
|
-
e.preventDefault();
|
|
10969
|
-
last.focus();
|
|
10970
|
-
}
|
|
10971
|
-
else if (!e.shiftKey && activeEl === last) {
|
|
10972
|
-
e.preventDefault();
|
|
10973
|
-
first.focus();
|
|
10974
|
-
}
|
|
10975
|
-
};
|
|
10976
|
-
container.addEventListener('keydown', handleKeyDown);
|
|
10977
|
-
return () => {
|
|
10978
|
-
container.removeEventListener('keydown', handleKeyDown);
|
|
10979
|
-
if (restoreFocus && previouslyFocused?.focus) {
|
|
10980
|
-
previouslyFocused.focus();
|
|
10981
|
-
}
|
|
10982
|
-
};
|
|
10983
|
-
}, [active, containerRef, initialFocusRef, restoreFocus]);
|
|
10984
|
-
}
|
|
10985
|
-
|
|
10986
12116
|
const isBrowser = typeof window !== 'undefined';
|
|
10987
|
-
function resolveAuto(rect, panelWidth, panelHeight) {
|
|
10988
|
-
const vw = window.innerWidth;
|
|
10989
|
-
const vh = window.innerHeight;
|
|
10990
|
-
const fitsBelow = rect.bottom + panelHeight + 4 <= vh;
|
|
10991
|
-
const fitsRight = rect.left + panelWidth <= vw;
|
|
10992
|
-
if (fitsBelow && fitsRight)
|
|
10993
|
-
return 'bottom-left';
|
|
10994
|
-
if (fitsBelow && !fitsRight)
|
|
10995
|
-
return 'bottom-right';
|
|
10996
|
-
if (!fitsBelow && fitsRight)
|
|
10997
|
-
return 'top-left';
|
|
10998
|
-
return 'top-right';
|
|
10999
|
-
}
|
|
11000
12117
|
const PickerPopover = ({ isOpen, onClose, anchorRef, children, position = 'bottom-left', appendToBody = false, className, trapFocus = true, closeOnClickOutside = true, closeOnEscape = true, ariaLabel, ariaLabelledBy, role = 'dialog', id, }) => {
|
|
11001
12118
|
const panelRef = useRef(null);
|
|
11002
|
-
const
|
|
11003
|
-
|
|
12119
|
+
const resolved = useAnchoredPosition(anchorRef, panelRef, {
|
|
12120
|
+
active: isOpen,
|
|
12121
|
+
position,
|
|
12122
|
+
fixed: appendToBody,
|
|
11004
12123
|
});
|
|
11005
|
-
const updatePosition = useCallback(() => {
|
|
11006
|
-
if (!isBrowser || !isOpen)
|
|
11007
|
-
return;
|
|
11008
|
-
const anchor = anchorRef.current;
|
|
11009
|
-
const panel = panelRef.current;
|
|
11010
|
-
if (!anchor || !panel)
|
|
11011
|
-
return;
|
|
11012
|
-
const rect = anchor.getBoundingClientRect();
|
|
11013
|
-
const panelRect = panel.getBoundingClientRect();
|
|
11014
|
-
const w = panelRect.width;
|
|
11015
|
-
const h = panelRect.height;
|
|
11016
|
-
const finalPos = position === 'auto' ? resolveAuto(rect, w, h) : position;
|
|
11017
|
-
if (!appendToBody) {
|
|
11018
|
-
setResolved({ position: finalPos });
|
|
11019
|
-
return;
|
|
11020
|
-
}
|
|
11021
|
-
let top = 0;
|
|
11022
|
-
let left = 0;
|
|
11023
|
-
switch (finalPos) {
|
|
11024
|
-
case 'bottom-left':
|
|
11025
|
-
top = rect.bottom + 4;
|
|
11026
|
-
left = rect.left;
|
|
11027
|
-
break;
|
|
11028
|
-
case 'bottom-right':
|
|
11029
|
-
top = rect.bottom + 4;
|
|
11030
|
-
left = rect.right - w;
|
|
11031
|
-
break;
|
|
11032
|
-
case 'top-left':
|
|
11033
|
-
top = rect.top - h - 4;
|
|
11034
|
-
left = rect.left;
|
|
11035
|
-
break;
|
|
11036
|
-
case 'top-right':
|
|
11037
|
-
top = rect.top - h - 4;
|
|
11038
|
-
left = rect.right - w;
|
|
11039
|
-
break;
|
|
11040
|
-
}
|
|
11041
|
-
setResolved({ position: finalPos, top, left });
|
|
11042
|
-
}, [anchorRef, appendToBody, isOpen, position]);
|
|
11043
|
-
useLayoutEffect(() => {
|
|
11044
|
-
if (!isOpen)
|
|
11045
|
-
return undefined;
|
|
11046
|
-
updatePosition();
|
|
11047
|
-
if (!isBrowser)
|
|
11048
|
-
return undefined;
|
|
11049
|
-
const handler = () => updatePosition();
|
|
11050
|
-
window.addEventListener('resize', handler, { passive: true });
|
|
11051
|
-
window.addEventListener('scroll', handler, {
|
|
11052
|
-
passive: true,
|
|
11053
|
-
capture: true,
|
|
11054
|
-
});
|
|
11055
|
-
return () => {
|
|
11056
|
-
window.removeEventListener('resize', handler);
|
|
11057
|
-
window.removeEventListener('scroll', handler, { capture: true });
|
|
11058
|
-
};
|
|
11059
|
-
}, [isOpen, updatePosition]);
|
|
11060
12124
|
useEffect(() => {
|
|
11061
12125
|
if (!isOpen || !closeOnClickOutside || !isBrowser)
|
|
11062
12126
|
return undefined;
|
|
@@ -11087,7 +12151,7 @@ const PickerPopover = ({ isOpen, onClose, anchorRef, children, position = 'botto
|
|
|
11087
12151
|
document.addEventListener('keydown', handler);
|
|
11088
12152
|
return () => document.removeEventListener('keydown', handler);
|
|
11089
12153
|
}, [isOpen, closeOnEscape, onClose]);
|
|
11090
|
-
useFocusTrap(panelRef, isOpen && trapFocus);
|
|
12154
|
+
useFocusTrap(panelRef, { active: isOpen && trapFocus });
|
|
11091
12155
|
const panelClass = usePrefixedClassNames('picker-popover', {
|
|
11092
12156
|
'is-active': isOpen,
|
|
11093
12157
|
[`is-${resolved.position}`]: true,
|
|
@@ -11479,8 +12543,14 @@ const SEPARATOR_RE = /[/:\-.\s]/;
|
|
|
11479
12543
|
* null and the input transparently falls back to free-form text entry.
|
|
11480
12544
|
*/
|
|
11481
12545
|
function useSegmentedEntry(params) {
|
|
11482
|
-
const { format, value, commitValue, formatFn, tryParse, text, setText, makeBaseDate, locale, min, max, isBlocked, disabled, readOnly, editable = true, popover = true, openOnFocus = true, closeOnSelect = false, isOpen, setOpen, inputRef, containerRef, onFocus, onClick, onKeyDown, onBlur, } = params;
|
|
11483
|
-
const segmentMap = useMemo(() =>
|
|
12546
|
+
const { format, skipKinds, value, commitValue, formatFn, tryParse, text, setText, makeBaseDate, locale, min, max, isBlocked, disabled, readOnly, editable = true, popover = true, openOnFocus = true, closeOnSelect = false, isOpen, setOpen, inputRef, containerRef, onFocus, onClick, onKeyDown, onBlur, } = params;
|
|
12547
|
+
const segmentMap = useMemo(() => {
|
|
12548
|
+
const map = typeof format === 'string' ? buildSegmentMap(format) : null;
|
|
12549
|
+
if (!map || !skipKinds)
|
|
12550
|
+
return map;
|
|
12551
|
+
const editable = map.editable.filter(i => !skipKinds.includes(map.segments[i].kind));
|
|
12552
|
+
return editable.length > 0 ? { ...map, editable } : null;
|
|
12553
|
+
}, [format, skipKinds]);
|
|
11484
12554
|
const [activeSegmentIdx, setActiveSegmentIdx] = useState(null);
|
|
11485
12555
|
// Buffer of digits typed within the current segment, cleared on segment
|
|
11486
12556
|
// change. Kept in a ref because key handlers read+update synchronously
|
|
@@ -11784,13 +12854,64 @@ function useSegmentedEntry(params) {
|
|
|
11784
12854
|
};
|
|
11785
12855
|
}
|
|
11786
12856
|
|
|
11787
|
-
const
|
|
11788
|
-
|
|
12857
|
+
const support = new Map();
|
|
12858
|
+
/**
|
|
12859
|
+
* Whether this browser implements `<input type={type}>`. A browser that
|
|
12860
|
+
* doesn't know a type reads it back as `'text'`, so this sets the type on a
|
|
12861
|
+
* detached input and checks what sticks. The answer can't change within a
|
|
12862
|
+
* page, so it is worked out once per type.
|
|
12863
|
+
*
|
|
12864
|
+
* It needs a DOM, so callers ask only after hydration. It also can't see a
|
|
12865
|
+
* browser that recognises a type without drawing a control for it: desktop
|
|
12866
|
+
* Safari reports `'month'` but shows a plain text box.
|
|
12867
|
+
*/
|
|
12868
|
+
function supportsInputType(type) {
|
|
12869
|
+
let supported = support.get(type);
|
|
12870
|
+
if (supported === undefined) {
|
|
12871
|
+
const input = document.createElement('input');
|
|
12872
|
+
input.setAttribute('type', type);
|
|
12873
|
+
supported = input.type === type;
|
|
12874
|
+
support.set(type, supported);
|
|
12875
|
+
}
|
|
12876
|
+
return supported;
|
|
12877
|
+
}
|
|
12878
|
+
|
|
12879
|
+
const pad2$1 = (n) => String(n).padStart(2, '0');
|
|
12880
|
+
/**
|
|
12881
|
+
* The value as the native input and the hidden form input carry it:
|
|
12882
|
+
* `YYYY-MM-DD`, `YYYY-MM` or `YYYY`, the shapes `<input type="date">` and
|
|
12883
|
+
* `<input type="month">` use. The year is padded to four digits, as those
|
|
12884
|
+
* inputs require and as the `YYYY` token displays it.
|
|
12885
|
+
*/
|
|
12886
|
+
const toIsoValue = (d, granularity) => {
|
|
12887
|
+
const year = String(d.getFullYear()).padStart(4, '0');
|
|
12888
|
+
if (granularity === 'year')
|
|
12889
|
+
return year;
|
|
12890
|
+
const month = `${year}-${pad2$1(d.getMonth() + 1)}`;
|
|
12891
|
+
return granularity === 'month' ? month : `${month}-${pad2$1(d.getDate())}`;
|
|
12892
|
+
};
|
|
12893
|
+
/** Read a native `type="date"` or `type="month"` value back into a Date. */
|
|
12894
|
+
const fromIsoValue = (s, granularity) => {
|
|
12895
|
+
if (granularity === 'month') {
|
|
12896
|
+
const m = /^(\d{4})-(\d{2})$/.exec(s);
|
|
12897
|
+
return m ? makeDate(Number(m[1]), Number(m[2]) - 1) : null;
|
|
12898
|
+
}
|
|
11789
12899
|
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
|
11790
12900
|
if (!m)
|
|
11791
12901
|
return null;
|
|
11792
12902
|
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
11793
12903
|
};
|
|
12904
|
+
const DEFAULT_FORMATS = {
|
|
12905
|
+
day: DEFAULT_DATE_FORMAT,
|
|
12906
|
+
month: DEFAULT_MONTH_FORMAT,
|
|
12907
|
+
year: DEFAULT_YEAR_FORMAT,
|
|
12908
|
+
};
|
|
12909
|
+
// Segments finer than the granularity render but are never edited.
|
|
12910
|
+
const SKIPPED_SEGMENTS = {
|
|
12911
|
+
day: undefined,
|
|
12912
|
+
month: ['day'],
|
|
12913
|
+
year: ['month', 'day'],
|
|
12914
|
+
};
|
|
11794
12915
|
/**
|
|
11795
12916
|
* Raw DateInput — input + popover calendar without Field/Control wrapping.
|
|
11796
12917
|
* Use `DateInput` for the convenience wrapper.
|
|
@@ -11800,8 +12921,10 @@ const fromIsoDate = (s) => {
|
|
|
11800
12921
|
* @returns {JSX.Element}
|
|
11801
12922
|
*/
|
|
11802
12923
|
const DateInputBase = forwardRef((props, ref) => {
|
|
11803
|
-
const { value: controlledValue, defaultValue, onChange, onOpen, onClose, min, max, disabled, readOnly, placeholder, format, parse, locale, inline = false, mobileNative = 'auto', editable = true, popover = true, openOnFocus = true, closeOnSelect = true, position = 'bottom-left', appendToBody = false, color, size, isRounded, shouldDisableDate, unselectableDates, firstDayOfWeek = 0, dayNames, monthNames, nearbyMonthDays = true, className, name, form, required, id, onFocus, onClick, onKeyDown, onBlur, iconLeftName: _iconLeftName, triggerIcon = true, triggerIconName = 'chevron-down', labels, ...rest } = props;
|
|
12924
|
+
const { value: controlledValue, defaultValue, onChange, onOpen, onClose, min, max, disabled, readOnly, placeholder, granularity = 'day', format, parse, locale, inline = false, mobileNative = 'auto', editable = true, popover = true, openOnFocus = true, closeOnSelect = true, position = 'bottom-left', appendToBody = false, color, size, isRounded, shouldDisableDate, unselectableDates, firstDayOfWeek = 0, dayNames, monthNames, nearbyMonthDays = true, className, name, form, required, id, onFocus, onClick, onKeyDown, onBlur, iconLeftName: _iconLeftName, triggerIcon = true, triggerIconName = 'chevron-down', labels, ...rest } = props;
|
|
11804
12925
|
const t = mergeLabels(labels);
|
|
12926
|
+
const isDayGranularity = granularity === 'day';
|
|
12927
|
+
const resolvedFormat = format ?? DEFAULT_FORMATS[granularity];
|
|
11805
12928
|
const isControlled = controlledValue !== undefined;
|
|
11806
12929
|
const [internalValue, setInternalValue] = useState(defaultValue ?? null);
|
|
11807
12930
|
const value = isControlled ? (controlledValue ?? null) : internalValue;
|
|
@@ -11816,7 +12939,7 @@ const DateInputBase = forwardRef((props, ref) => {
|
|
|
11816
12939
|
setFocusedDate(prev => clampDate(prev, min, max));
|
|
11817
12940
|
}, [min, max]);
|
|
11818
12941
|
const [open, setOpenState] = useState(false);
|
|
11819
|
-
const [text, setText] = useState(value ? formatDate(value,
|
|
12942
|
+
const [text, setText] = useState(value ? formatDate(value, resolvedFormat, locale) : '');
|
|
11820
12943
|
const inputRef = useRef(null);
|
|
11821
12944
|
const containerRef = useRef(null);
|
|
11822
12945
|
const reactId = useId();
|
|
@@ -11825,7 +12948,14 @@ const DateInputBase = forwardRef((props, ref) => {
|
|
|
11825
12948
|
const { shouldUseNative } = useNativeMobilePicker({
|
|
11826
12949
|
force: mobileNative === 'auto' ? undefined : mobileNative,
|
|
11827
12950
|
});
|
|
11828
|
-
|
|
12951
|
+
// The day picker goes native as it always has. A month picker does only
|
|
12952
|
+
// where the browser implements the month input, asked after hydration so
|
|
12953
|
+
// the server and the first client render agree. HTML has no year input,
|
|
12954
|
+
// so a year picker always renders the calendar.
|
|
12955
|
+
const hydrated = useIsHydrated();
|
|
12956
|
+
const hasNativeInput = isDayGranularity ||
|
|
12957
|
+
(granularity === 'month' && hydrated && supportsInputType('month'));
|
|
12958
|
+
const useNative = !inline && shouldUseNative && hasNativeInput;
|
|
11829
12959
|
const inputClass = usePrefixedClassNames('input', {
|
|
11830
12960
|
[`is-${color}`]: !!color,
|
|
11831
12961
|
[`is-${size}`]: !!size,
|
|
@@ -11846,24 +12976,31 @@ const DateInputBase = forwardRef((props, ref) => {
|
|
|
11846
12976
|
}, [onOpen, onClose]);
|
|
11847
12977
|
// Sync displayed text with value when value changes externally.
|
|
11848
12978
|
useEffect(() => {
|
|
11849
|
-
setText(value ? formatDate(value,
|
|
11850
|
-
}, [value,
|
|
12979
|
+
setText(value ? formatDate(value, resolvedFormat, locale) : '');
|
|
12980
|
+
}, [value, resolvedFormat, locale]);
|
|
11851
12981
|
const commitValue = useCallback((next) => {
|
|
12982
|
+
// A month or year value is its period's first day, however the
|
|
12983
|
+
// calendar, typing or parsing arrived at it.
|
|
12984
|
+
const committed = next && !isDayGranularity ? startOfPeriod(next, granularity) : next;
|
|
11852
12985
|
if (!isControlled)
|
|
11853
|
-
setInternalValue(
|
|
12986
|
+
setInternalValue(committed);
|
|
11854
12987
|
// Keep the calendar's focused cell tracking typed / parsed values.
|
|
11855
|
-
if (
|
|
11856
|
-
setFocusedDate(
|
|
11857
|
-
onChange?.(
|
|
11858
|
-
}, [isControlled, onChange]);
|
|
12988
|
+
if (committed)
|
|
12989
|
+
setFocusedDate(committed);
|
|
12990
|
+
onChange?.(committed);
|
|
12991
|
+
}, [isControlled, onChange, isDayGranularity, granularity]);
|
|
12992
|
+
// At month or year granularity the bounds widen to the periods holding
|
|
12993
|
+
// them, so the month or year containing `min` stays reachable.
|
|
12994
|
+
const periodMin = useMemo(() => (min && !isDayGranularity ? startOfPeriod(min, granularity) : min), [min, isDayGranularity, granularity]);
|
|
12995
|
+
const periodMax = useMemo(() => (max && !isDayGranularity ? endOfPeriod(max, granularity) : max), [max, isDayGranularity, granularity]);
|
|
11859
12996
|
const handleSelect = useCallback((d) => {
|
|
11860
|
-
if (!isWithin(d,
|
|
12997
|
+
if (!isWithin(d, periodMin, periodMax))
|
|
11861
12998
|
return;
|
|
11862
12999
|
commitValue(d);
|
|
11863
13000
|
setFocusedDate(d);
|
|
11864
13001
|
if (closeOnSelect)
|
|
11865
13002
|
setOpen(false);
|
|
11866
|
-
}, [
|
|
13003
|
+
}, [periodMin, periodMax, commitValue, closeOnSelect, setOpen]);
|
|
11867
13004
|
const tryParse = useCallback((s) => {
|
|
11868
13005
|
const trimmed = s.trim();
|
|
11869
13006
|
if (!trimmed)
|
|
@@ -11871,37 +13008,72 @@ const DateInputBase = forwardRef((props, ref) => {
|
|
|
11871
13008
|
const fmt = typeof format === 'string' ? format : undefined;
|
|
11872
13009
|
return parse
|
|
11873
13010
|
? parse(trimmed)
|
|
11874
|
-
: parseDate(trimmed, fmt ??
|
|
11875
|
-
}, [parse, format, locale]);
|
|
13011
|
+
: parseDate(trimmed, fmt ?? DEFAULT_FORMATS[granularity]);
|
|
13012
|
+
}, [parse, format, locale, granularity]);
|
|
11876
13013
|
// The Date the user edits when starting without a current value (today at
|
|
11877
|
-
// midnight; the hook clamps to
|
|
13014
|
+
// midnight, or the start of this month or year; the hook clamps to
|
|
13015
|
+
// min/max on commit).
|
|
11878
13016
|
const makeBaseDate = useCallback(() => {
|
|
11879
13017
|
const d = new Date();
|
|
11880
13018
|
d.setHours(0, 0, 0, 0);
|
|
11881
|
-
return d;
|
|
11882
|
-
}, []);
|
|
13019
|
+
return isDayGranularity ? d : startOfPeriod(d, granularity);
|
|
13020
|
+
}, [isDayGranularity, granularity]);
|
|
11883
13021
|
const inputReadOnlyAttr = !!readOnly || !editable;
|
|
11884
13022
|
const canOpen = !!popover && !disabled && !readOnly;
|
|
11885
13023
|
// Blocking predicate for manual entry, matching the calendar's
|
|
11886
|
-
// disabled-cell logic (shouldDisableDate + unselectableDates by day
|
|
13024
|
+
// disabled-cell logic (shouldDisableDate + unselectableDates by day, or
|
|
13025
|
+
// every day of the month or year blocked at those granularities).
|
|
11887
13026
|
const isBlocked = useMemo(() => {
|
|
13027
|
+
if (!isDayGranularity) {
|
|
13028
|
+
const constraints = { min, max, shouldDisableDate, unselectableDates };
|
|
13029
|
+
return (d) => isPeriodUnselectable(d, granularity, constraints);
|
|
13030
|
+
}
|
|
11888
13031
|
if (!shouldDisableDate && !unselectableDates?.length)
|
|
11889
13032
|
return undefined;
|
|
11890
13033
|
return (d) => !!shouldDisableDate?.(d) ||
|
|
11891
13034
|
!!unselectableDates?.some(u => isSameDay(u, d));
|
|
11892
|
-
}, [
|
|
11893
|
-
|
|
11894
|
-
|
|
13035
|
+
}, [
|
|
13036
|
+
isDayGranularity,
|
|
13037
|
+
granularity,
|
|
13038
|
+
min,
|
|
13039
|
+
max,
|
|
13040
|
+
shouldDisableDate,
|
|
13041
|
+
unselectableDates,
|
|
13042
|
+
]);
|
|
13043
|
+
// Typing, and the re-parse on blur or Enter, that lands in the period the
|
|
13044
|
+
// value already holds commits nothing, so a `value` elsewhere in its month
|
|
13045
|
+
// or year isn't rewritten by focus passing through the field.
|
|
13046
|
+
const commitTyped = useCallback((next) => {
|
|
13047
|
+
if (next &&
|
|
13048
|
+
value &&
|
|
13049
|
+
!isDayGranularity &&
|
|
13050
|
+
startOfPeriod(next, granularity).getTime() ===
|
|
13051
|
+
startOfPeriod(value, granularity).getTime()) {
|
|
13052
|
+
setText(formatDate(value, resolvedFormat, locale));
|
|
13053
|
+
return;
|
|
13054
|
+
}
|
|
13055
|
+
commitValue(next);
|
|
13056
|
+
}, [
|
|
11895
13057
|
value,
|
|
13058
|
+
isDayGranularity,
|
|
13059
|
+
granularity,
|
|
13060
|
+
resolvedFormat,
|
|
13061
|
+
locale,
|
|
11896
13062
|
commitValue,
|
|
13063
|
+
]);
|
|
13064
|
+
const { inputHandlers } = useSegmentedEntry({
|
|
13065
|
+
format: resolvedFormat,
|
|
13066
|
+
skipKinds: SKIPPED_SEGMENTS[granularity],
|
|
13067
|
+
value,
|
|
13068
|
+
commitValue: commitTyped,
|
|
11897
13069
|
formatFn: formatDate,
|
|
11898
13070
|
tryParse,
|
|
11899
13071
|
text,
|
|
11900
13072
|
setText,
|
|
11901
13073
|
makeBaseDate,
|
|
11902
13074
|
locale,
|
|
11903
|
-
min,
|
|
11904
|
-
max,
|
|
13075
|
+
min: periodMin,
|
|
13076
|
+
max: periodMax,
|
|
11905
13077
|
isBlocked,
|
|
11906
13078
|
disabled,
|
|
11907
13079
|
readOnly,
|
|
@@ -11928,21 +13100,31 @@ const DateInputBase = forwardRef((props, ref) => {
|
|
|
11928
13100
|
ref.current =
|
|
11929
13101
|
node;
|
|
11930
13102
|
}, [ref]);
|
|
11931
|
-
// Native mobile path renders <input type="date"
|
|
13103
|
+
// Native mobile path renders <input type="date"> (type="month" for a
|
|
13104
|
+
// month picker).
|
|
11932
13105
|
if (useNative) {
|
|
11933
|
-
return (jsx("input", { ...cleanRest, ref: combinedRef, type:
|
|
11934
|
-
const parsed = e.target.value
|
|
13106
|
+
return (jsx("input", { ...cleanRest, ref: combinedRef, type: isDayGranularity ? 'date' : 'month', className: classNames(inputClass, bulmaHelperClasses, className), value: value ? toIsoValue(value, granularity) : '', onChange: e => {
|
|
13107
|
+
const parsed = e.target.value
|
|
13108
|
+
? fromIsoValue(e.target.value, granularity)
|
|
13109
|
+
: null;
|
|
11935
13110
|
commitValue(parsed);
|
|
11936
|
-
}, min: min ?
|
|
11937
|
-
}
|
|
11938
|
-
const
|
|
13111
|
+
}, min: min ? toIsoValue(min, granularity) : undefined, max: max ? toIsoValue(max, granularity) : undefined, disabled: disabled, readOnly: readOnly, placeholder: placeholder, name: name, form: form, required: required, id: id }));
|
|
13112
|
+
}
|
|
13113
|
+
const chooseLabel = granularity === 'month'
|
|
13114
|
+
? t.chooseMonth
|
|
13115
|
+
: granularity === 'year'
|
|
13116
|
+
? t.chooseYear
|
|
13117
|
+
: t.chooseDate;
|
|
13118
|
+
const calendar = (jsx(Calendar
|
|
13119
|
+
// A new granularity starts the calendar over on its own view.
|
|
13120
|
+
, { granularity: granularity, value: value, focusedDate: focusedDate, onSelect: handleSelect, onFocusedDateChange: setFocusedDate, min: min, max: max, shouldDisableDate: shouldDisableDate, unselectableDates: unselectableDates, firstDayOfWeek: firstDayOfWeek, locale: locale, dayNames: dayNames, monthNames: monthNames, nearbyMonthDays: nearbyMonthDays, color: color, size: size, id: popoverId, autoFocusCell: open, labels: labels }, granularity));
|
|
11939
13121
|
if (inline) {
|
|
11940
|
-
return (jsxs("div", { ...cleanRest, ref: containerRef, className: classNames(containerClass, bulmaHelperClasses, className), children: [calendar, name && (jsx("input", { type: "hidden", name: name, form: form, value: value ?
|
|
13122
|
+
return (jsxs("div", { ...cleanRest, ref: containerRef, className: classNames(containerClass, bulmaHelperClasses, className), children: [calendar, name && (jsx("input", { type: "hidden", name: name, form: form, value: value ? toIsoValue(value, granularity) : '', required: required }))] }));
|
|
11941
13123
|
}
|
|
11942
13124
|
return (jsxs("div", { ref: containerRef, className: classNames(containerClass, bulmaHelperClasses, className), ...cleanRest, children: [jsx("input", { ref: combinedRef, type: "text", role: "combobox", "aria-haspopup": "dialog", "aria-expanded": open, "aria-controls": popoverId, autoComplete: "off", className: inputClass, value: text, placeholder: placeholder, disabled: disabled, readOnly: inputReadOnlyAttr, name: name, form: form, required: required, id: id, ...inputHandlers }), popover && triggerIcon && (jsx("button", { type: "button", className: triggerClass, onClick: () => {
|
|
11943
13125
|
if (canOpen)
|
|
11944
13126
|
setOpen(!open);
|
|
11945
|
-
}, disabled: !canOpen, "aria-label":
|
|
13127
|
+
}, disabled: !canOpen, "aria-label": chooseLabel, "aria-haspopup": "dialog", "aria-controls": popoverId, "aria-expanded": open, tabIndex: canOpen ? 0 : -1, children: jsx(Icon, { name: triggerIconName, size: size }) })), popover && (jsx(PickerPopover, { isOpen: open, onClose: handlePopoverClose, anchorRef: containerRef, position: position, appendToBody: appendToBody, ariaLabel: chooseLabel, id: popoverId, children: calendar }))] }));
|
|
11946
13128
|
});
|
|
11947
13129
|
DateInputBase.displayName = 'DateInputBase';
|
|
11948
13130
|
|
|
@@ -12915,6 +14097,9 @@ const TimeInputBase = forwardRef((props, ref) => {
|
|
|
12915
14097
|
const mobileFooterClass = usePrefixedClassNames('timeinput-footer-mobile');
|
|
12916
14098
|
const mobileFooterResetClass = usePrefixedClassNames('timeinput-footer-reset');
|
|
12917
14099
|
const mobileFooterDoneClass = usePrefixedClassNames('timeinput-footer-done');
|
|
14100
|
+
// Set from the moment the popover starts to close until the close has
|
|
14101
|
+
// committed. See `setOpenFromInput`.
|
|
14102
|
+
const closingRef = useRef(false);
|
|
12918
14103
|
const setOpen = useCallback((next) => {
|
|
12919
14104
|
setOpenState(prev => {
|
|
12920
14105
|
if (prev === next)
|
|
@@ -12924,11 +14109,26 @@ const TimeInputBase = forwardRef((props, ref) => {
|
|
|
12924
14109
|
onOpen?.();
|
|
12925
14110
|
}
|
|
12926
14111
|
else {
|
|
14112
|
+
closingRef.current = true;
|
|
12927
14113
|
onClose?.();
|
|
12928
14114
|
}
|
|
12929
14115
|
return next;
|
|
12930
14116
|
});
|
|
12931
14117
|
}, [onOpen, onClose, value]);
|
|
14118
|
+
// React runs a commit's effect cleanups before any of its effects, so on a
|
|
14119
|
+
// close this runs after the popover's focus trap has handed focus back.
|
|
14120
|
+
useEffect(() => {
|
|
14121
|
+
closingRef.current = false;
|
|
14122
|
+
}, [open]);
|
|
14123
|
+
// As the popover closes, its focus trap hands focus back to the input.
|
|
14124
|
+
// That focus is not the user arriving, so under `openOnFocus` it must not
|
|
14125
|
+
// open the popover again. The input asks to open only on focus, click or
|
|
14126
|
+
// ArrowDown, so a request while a close commits can only be that one.
|
|
14127
|
+
const setOpenFromInput = useCallback((next) => {
|
|
14128
|
+
if (next && closingRef.current)
|
|
14129
|
+
return;
|
|
14130
|
+
setOpen(next);
|
|
14131
|
+
}, [setOpen]);
|
|
12932
14132
|
useEffect(() => {
|
|
12933
14133
|
setText(value ? formatTime(value, defaultFormat, locale) : '');
|
|
12934
14134
|
}, [value, defaultFormat, locale]);
|
|
@@ -12985,7 +14185,7 @@ const TimeInputBase = forwardRef((props, ref) => {
|
|
|
12985
14185
|
openOnFocus,
|
|
12986
14186
|
closeOnSelect,
|
|
12987
14187
|
isOpen: open,
|
|
12988
|
-
setOpen,
|
|
14188
|
+
setOpen: setOpenFromInput,
|
|
12989
14189
|
inputRef,
|
|
12990
14190
|
containerRef,
|
|
12991
14191
|
onFocus,
|
|
@@ -14663,5 +15863,5 @@ const Section = ({ size, className, children, color, bgColor, textColor, style,
|
|
|
14663
15863
|
return (jsx("section", { className: sectionClasses, style: mergeBulmaStyles(bulmaHelperStyles, style), ...rest, children: children }));
|
|
14664
15864
|
};
|
|
14665
15865
|
|
|
14666
|
-
export { Autocomplete, Avatar, Avatars, Badge, Block, Box, Breadcrumb, Button, Buttons, Card, Carousel, CarouselItem, Cell, Checkbox, Checkboxes, ClientOnly, Code, Collapse, Collapses, Column, Columns, ConfigProvider, Container, Content, Control, DateInput, DateInputBase, DateTimeInput, DateTimeInputBase, Delete, Dialog, DialogContainer, Divider, Dropdown, DropdownDivider, DropdownItem, Emphasis, Field, FieldBody, FieldLabel, Figure, File, Footer, Grid, Hero, HeroBody, HeroFoot, HeroHead, Icon, IconText, Image, Input, InputBase, Level, LevelItem, LevelLeft, LevelRight, Link, LinkButton, ListItem, Loader, Loading, Media, MediaContent, MediaLeft, MediaRight, Menu, MenuItem, MenuLabel, MenuList, Message, Modal, Navbar, NavbarBrand, NavbarBurger, NavbarDivider, NavbarDropdown, NavbarDropdownMenu, NavbarEnd, NavbarItem, NavbarLink, NavbarMenu, NavbarStart, Notification, NotificationContainer, NumberInput, Numberinput, OrderedList, Pagination, PaginationEllipsis, PaginationLink, PaginationList, PaginationNext, PaginationPrevious, Panel, PanelBlock, PanelButtonBlock, PanelCheckboxBlock, PanelHeading, PanelIcon, PanelInputBlock, PanelTabs, Paragraph, Pre, Progress, Radio, Radios, Rate, Reveal, Section, Select, SelectBase, Sidebar, Skeleton, Slider, Span, Step, Steps, Strong, SubTitle, Switch, Tab, TabContentItem, TabItem, TabList, Table, Tabs, TabsContent, Tag, TagInput, Taginput, Tags, Tbody, Td, TextArea, TextAreaBase, Tfoot, Th, Thead, Theme, TimeInput, TimeInputBase, Title, Toast, ToastContainer, Tooltip, Tr, UnorderedList, checkboxColors, checkboxSizes, classNames, createPrefixedClassNames, dialog, isBrowser$1 as isBrowser, mergeBulmaStyles, notification, prefixedClassNames, radioColors, radioSizes, switchColors, switchSizes, toast, useBulmaClasses, useClassPrefix, useColorClasses, useColorStyles, useConfig, useFlexboxClasses, useIconLibrary, useInsideControl, useInsideField, useIsHydrated, useOtherClasses, usePrefixedClass, usePrefixedClassNames, useSpacingClasses, useTypographyClasses, useVisibilityClasses, validAlignContents, validAlignItems, validAlignSelfs, validAlignments$1 as validAlignments, validAspectRatios, validAxisOverflows, validColorShades, validColors, validCursors, validDisplays, validFlexDirections, validFlexGrowShrink, validFlexWraps, validFloats, validFontFamilies, validInteractions, validJustifyContents, validOverflows, validPositions, validRadii, validResponsives, validSchemeColors, validShadows, validSizes$1 as validSizes, validTableColors, validTextSizes, validTextTransforms, validTextWeights, validViewports, validVisibilities };
|
|
15866
|
+
export { Autocomplete, Avatar, Avatars, Badge, Block, Box, Breadcrumb, Button, Buttons, Card, Carousel, CarouselItem, Cell, Checkbox, Checkboxes, ClientOnly, Code, Collapse, Collapses, Column, Columns, ConfigProvider, Container, Content, Control, DateInput, DateInputBase, DateTimeInput, DateTimeInputBase, Delete, Dialog, DialogContainer, Divider, Dropdown, DropdownDivider, DropdownItem, Emphasis, Field, FieldBody, FieldLabel, Figure, File, Footer, Grid, Hero, HeroBody, HeroFoot, HeroHead, Icon, IconText, Image, Input, InputBase, Level, LevelItem, LevelLeft, LevelRight, Link, LinkButton, ListItem, Loader, Loading, Media, MediaContent, MediaLeft, MediaRight, Menu, MenuItem, MenuLabel, MenuList, Message, Modal, Navbar, NavbarBrand, NavbarBurger, NavbarDivider, NavbarDropdown, NavbarDropdownMenu, NavbarEnd, NavbarItem, NavbarLink, NavbarMenu, NavbarStart, Notification, NotificationContainer, NumberInput, Numberinput, OrderedList, Pagination, PaginationEllipsis, PaginationLink, PaginationList, PaginationNext, PaginationPrevious, Panel, PanelBlock, PanelButtonBlock, PanelCheckboxBlock, PanelHeading, PanelIcon, PanelInputBlock, PanelTabs, Paragraph, Popover, PopoverBody, PopoverClose, PopoverFooter, PopoverHeader, Portal, Pre, Progress, Radio, Radios, Rate, Reveal, Section, Select, SelectBase, Sidebar, Skeleton, Slider, Span, Step, Steps, Strong, SubTitle, Switch, Tab, TabContentItem, TabItem, TabList, Table, Tabs, TabsContent, Tag, TagInput, Taginput, Tags, Tbody, Td, TextArea, TextAreaBase, Tfoot, Th, Thead, Theme, TimeInput, TimeInputBase, Title, Toast, ToastContainer, Tooltip, Tr, UnorderedList, checkboxColors, checkboxSizes, classNames, createPrefixedClassNames, dialog, isBrowser$1 as isBrowser, mergeBulmaStyles, notification, prefixedClassNames, radioColors, radioSizes, switchColors, switchSizes, toast, useBulmaClasses, useClassPrefix, useColorClasses, useColorStyles, useConfig, useFlexboxClasses, useFocusTrap, useIconLibrary, useInsideControl, useInsideField, useIsHydrated, useOtherClasses, usePrefixedClass, usePrefixedClassNames, useSpacingClasses, useTypographyClasses, useVisibilityClasses, validAlignContents, validAlignItems, validAlignSelfs, validAlignments$1 as validAlignments, validAspectRatios, validAxisOverflows, validColorShades, validColors, validCursors, validDisplays, validFlexDirections, validFlexGrowShrink, validFlexWraps, validFloats, validFontFamilies, validInteractions, validJustifyContents, validOverflows, validPositions, validRadii, validResponsives, validSchemeColors, validShadows, validSizes$1 as validSizes, validTableColors, validTextSizes, validTextTransforms, validTextWeights, validViewports, validVisibilities };
|
|
14667
15867
|
//# sourceMappingURL=index.esm.js.map
|