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