@allxsmith/bestax-bulma 5.59.0 → 5.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bestax.css +1 -1
- package/dist/bestax.css.map +1 -1
- package/dist/esm/components/Affix.js +46 -0
- package/dist/esm/components/Affix.js.map +1 -0
- package/dist/esm/components/Carousel.js +3 -43
- package/dist/esm/components/Carousel.js.map +1 -1
- package/dist/esm/components/Command.js +3 -3
- package/dist/esm/components/Dialog.js +2 -6
- package/dist/esm/components/Dialog.js.map +1 -1
- package/dist/esm/components/Modal.js +12 -24
- package/dist/esm/components/Modal.js.map +1 -1
- package/dist/esm/components/Navbar.js +3 -1
- package/dist/esm/components/Navbar.js.map +1 -1
- package/dist/esm/components/Popover.js +7 -27
- package/dist/esm/components/Popover.js.map +1 -1
- package/dist/esm/components/Sidebar.js +17 -58
- package/dist/esm/components/Sidebar.js.map +1 -1
- package/dist/esm/form/ColorPicker.js +4 -4
- package/dist/esm/form/ColorPicker.js.map +1 -1
- package/dist/esm/form/Combobox.js +3 -3
- package/dist/esm/helpers/overlayStack.js +33 -1
- package/dist/esm/helpers/overlayStack.js.map +1 -1
- package/dist/esm/helpers/portal.js +28 -1
- package/dist/esm/helpers/portal.js.map +1 -1
- package/dist/esm/helpers/useBulmaViewport.js +82 -0
- package/dist/esm/helpers/useBulmaViewport.js.map +1 -0
- package/dist/esm/helpers/useInertSiblings.js +7 -3
- package/dist/esm/helpers/useInertSiblings.js.map +1 -1
- package/dist/esm/helpers/useOutsidePress.js +53 -0
- package/dist/esm/helpers/useOutsidePress.js.map +1 -0
- package/dist/esm/helpers/useOverlayFocus.js +74 -0
- package/dist/esm/helpers/useOverlayFocus.js.map +1 -0
- package/dist/esm/layout/AppFrame.js +438 -0
- package/dist/esm/layout/AppFrame.js.map +1 -0
- package/dist/extras.css +1 -1
- package/dist/extras.css.map +1 -1
- package/dist/index.cjs +754 -160
- package/dist/index.cjs.map +1 -1
- package/dist/index.esm.js +2 -0
- package/dist/index.esm.js.map +1 -1
- package/dist/types/components/Affix.d.ts +67 -0
- package/dist/types/components/Navbar.d.ts +4 -3
- package/dist/types/helpers/overlayStack.d.ts +17 -0
- package/dist/types/helpers/portal.d.ts +42 -0
- package/dist/types/helpers/useBulmaViewport.d.ts +34 -0
- package/dist/types/helpers/useInertSiblings.d.ts +4 -1
- package/dist/types/helpers/useOutsidePress.d.ts +24 -0
- package/dist/types/helpers/useOverlayFocus.d.ts +44 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/layout/AppFrame.d.ts +306 -0
- package/dist/types-cjs/components/Affix.d.ts +67 -0
- package/dist/types-cjs/components/Navbar.d.ts +4 -3
- package/dist/types-cjs/helpers/overlayStack.d.ts +17 -0
- package/dist/types-cjs/helpers/portal.d.ts +42 -0
- package/dist/types-cjs/helpers/useBulmaViewport.d.ts +34 -0
- package/dist/types-cjs/helpers/useInertSiblings.d.ts +4 -1
- package/dist/types-cjs/helpers/useOutsidePress.d.ts +24 -0
- package/dist/types-cjs/helpers/useOverlayFocus.d.ts +44 -0
- package/dist/types-cjs/index.d.ts +2 -0
- package/dist/types-cjs/layout/AppFrame.d.ts +306 -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/_affix.scss +74 -0
- package/src/scss/components/_index.scss +1 -0
- package/src/scss/layout/_appframe.scss +306 -0
- package/src/scss/layout/_index.scss +1 -0
|
@@ -76,16 +76,20 @@ const above = (el) => el.parentElement ?? el.getRootNode().host;
|
|
|
76
76
|
* @param active - Whether what's beside it is inert.
|
|
77
77
|
* @param wholePage - Whether that goes for the whole page rather than only
|
|
78
78
|
* the element's siblings.
|
|
79
|
+
* @param except - Ref to an element beside it to leave reachable too, such
|
|
80
|
+
* as the overlay's own backdrop, which has to take the presses that close
|
|
81
|
+
* it rather than let them through to what's under it.
|
|
79
82
|
*/
|
|
80
|
-
function useInertSiblings(ref, active, wholePage) {
|
|
83
|
+
function useInertSiblings(ref, active, wholePage, except) {
|
|
81
84
|
useEffect(() => {
|
|
82
85
|
const el = ref.current;
|
|
83
86
|
if (!active || !el)
|
|
84
87
|
return undefined;
|
|
85
88
|
const doc = el.ownerDocument;
|
|
89
|
+
const keep = except?.current;
|
|
86
90
|
const held = [];
|
|
87
91
|
for (let node = el; node && node !== doc.body; node = wholePage ? above(node) : undefined) {
|
|
88
|
-
held.push(...siblingsOf(node).filter(hold));
|
|
92
|
+
held.push(...siblingsOf(node).filter(sibling => sibling !== keep && hold(sibling)));
|
|
89
93
|
}
|
|
90
94
|
// Focus this takes away, to hand back later. It's looked up as `el`'s
|
|
91
95
|
// tree sees it, where focus inside a nested shadow root reads as its
|
|
@@ -100,7 +104,7 @@ function useInertSiblings(ref, active, wholePage) {
|
|
|
100
104
|
if (doc.activeElement === doc.body)
|
|
101
105
|
taken?.focus();
|
|
102
106
|
};
|
|
103
|
-
}, [ref, active, wholePage]);
|
|
107
|
+
}, [ref, active, wholePage, except]);
|
|
104
108
|
}
|
|
105
109
|
|
|
106
110
|
export { useInertSiblings };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useInertSiblings.js","sources":["../../../src/helpers/useInertSiblings.ts"],"sourcesContent":["// INTERNAL: deliberately not exported from src/index.ts.\nimport { useEffect } from 'react';\nimport type { RefObject } from 'react';\nimport { getActiveElementInTree, getDeepestActiveElement } from './shadowDom';\n\n/**\n * How many callers hold each element inert. Shared across callers, the way\n * the scroll lock's count is, so two that cover the same element don't take\n * `inert` off it while the other still needs it. An element that was inert\n * before any caller came isn't counted, so it's left as it was.\n */\nconst holds = new Map<Element, number>();\n\n/**\n * Makes `el` inert for one more caller. Returns `false`, touching nothing,\n * when it was inert already and no caller made it so.\n */\nfunction hold(el: Element): boolean {\n const count = holds.get(el) ?? 0;\n if (count === 0 && el.hasAttribute('inert')) return false;\n holds.set(el, count + 1);\n el.setAttribute('inert', '');\n return true;\n}\n\n/** Lets go of one caller's hold, and takes `inert` off with the last one. */\nfunction release(el: Element): void {\n const count = (holds.get(el) as number) - 1;\n if (count > 0) {\n holds.set(el, count);\n return;\n }\n holds.delete(el);\n el.removeAttribute('inert');\n}\n\n/** The element's siblings, in a shadow root as anywhere else. */\nfunction siblingsOf(el: Element): Element[] {\n const siblings: Element[] = [];\n for (let s = el.previousElementSibling; s; s = s.previousElementSibling) {\n siblings.push(s);\n }\n for (let s = el.nextElementSibling; s; s = s.nextElementSibling) {\n siblings.push(s);\n }\n return siblings;\n}\n\n/**\n * The element above `el` in the page: its parent, or the host of the shadow\n * root it sits at the top of. Nothing above the top of a detached tree.\n */\nconst above = (el: Element): Element | undefined =>\n el.parentElement ?? (el.getRootNode() as Partial<ShadowRoot>).host;\n\n/**\n * Makes the elements beside `ref`'s element inert while `active` is true,\n * and takes it off again when `active` turns false or the caller unmounts.\n * With `wholePage`, it does the same beside each element above it up to\n * `<body>`, so everything on the page but `ref`'s element is inert, the way a\n * modal layer leaves it. The element itself and its own content are never\n * touched.\n *\n * It sets the `inert` attribute through the DOM in an effect, so it works the\n * same on React 18 and 19 and a server render has nothing to show for it. An\n * element that was inert already is left alone, and keeps its `inert` once\n * this lets go. It makes inert what's there when it turns on: an element that\n * mounts beside it afterwards isn't. It follows the DOM, so content a web\n * component renders around a slot this sits in, and text with no element of\n * its own, stays as it is.\n *\n * Focus on an element that turns inert is lost: the browser moves it to\n * `<body>`. So when focus was inside what this made inert, and is still on\n * `<body>` when this lets go, it goes back to the element that had it. Focus\n * this never took isn't handed anywhere, and focus the user or the app has\n * put somewhere else meanwhile stays there.\n *\n * @function useInertSiblings\n * @param ref - Ref to the element that stays reachable.\n * @param active - Whether what's beside it is inert.\n * @param wholePage - Whether that goes for the whole page rather than only\n * the element's siblings.\n */\nexport function useInertSiblings(\n ref: RefObject<Element | null>,\n active: boolean,\n wholePage: boolean
|
|
1
|
+
{"version":3,"file":"useInertSiblings.js","sources":["../../../src/helpers/useInertSiblings.ts"],"sourcesContent":["// INTERNAL: deliberately not exported from src/index.ts.\nimport { useEffect } from 'react';\nimport type { RefObject } from 'react';\nimport { getActiveElementInTree, getDeepestActiveElement } from './shadowDom';\n\n/**\n * How many callers hold each element inert. Shared across callers, the way\n * the scroll lock's count is, so two that cover the same element don't take\n * `inert` off it while the other still needs it. An element that was inert\n * before any caller came isn't counted, so it's left as it was.\n */\nconst holds = new Map<Element, number>();\n\n/**\n * Makes `el` inert for one more caller. Returns `false`, touching nothing,\n * when it was inert already and no caller made it so.\n */\nfunction hold(el: Element): boolean {\n const count = holds.get(el) ?? 0;\n if (count === 0 && el.hasAttribute('inert')) return false;\n holds.set(el, count + 1);\n el.setAttribute('inert', '');\n return true;\n}\n\n/** Lets go of one caller's hold, and takes `inert` off with the last one. */\nfunction release(el: Element): void {\n const count = (holds.get(el) as number) - 1;\n if (count > 0) {\n holds.set(el, count);\n return;\n }\n holds.delete(el);\n el.removeAttribute('inert');\n}\n\n/** The element's siblings, in a shadow root as anywhere else. */\nfunction siblingsOf(el: Element): Element[] {\n const siblings: Element[] = [];\n for (let s = el.previousElementSibling; s; s = s.previousElementSibling) {\n siblings.push(s);\n }\n for (let s = el.nextElementSibling; s; s = s.nextElementSibling) {\n siblings.push(s);\n }\n return siblings;\n}\n\n/**\n * The element above `el` in the page: its parent, or the host of the shadow\n * root it sits at the top of. Nothing above the top of a detached tree.\n */\nconst above = (el: Element): Element | undefined =>\n el.parentElement ?? (el.getRootNode() as Partial<ShadowRoot>).host;\n\n/**\n * Makes the elements beside `ref`'s element inert while `active` is true,\n * and takes it off again when `active` turns false or the caller unmounts.\n * With `wholePage`, it does the same beside each element above it up to\n * `<body>`, so everything on the page but `ref`'s element is inert, the way a\n * modal layer leaves it. The element itself and its own content are never\n * touched.\n *\n * It sets the `inert` attribute through the DOM in an effect, so it works the\n * same on React 18 and 19 and a server render has nothing to show for it. An\n * element that was inert already is left alone, and keeps its `inert` once\n * this lets go. It makes inert what's there when it turns on: an element that\n * mounts beside it afterwards isn't. It follows the DOM, so content a web\n * component renders around a slot this sits in, and text with no element of\n * its own, stays as it is.\n *\n * Focus on an element that turns inert is lost: the browser moves it to\n * `<body>`. So when focus was inside what this made inert, and is still on\n * `<body>` when this lets go, it goes back to the element that had it. Focus\n * this never took isn't handed anywhere, and focus the user or the app has\n * put somewhere else meanwhile stays there.\n *\n * @function useInertSiblings\n * @param ref - Ref to the element that stays reachable.\n * @param active - Whether what's beside it is inert.\n * @param wholePage - Whether that goes for the whole page rather than only\n * the element's siblings.\n * @param except - Ref to an element beside it to leave reachable too, such\n * as the overlay's own backdrop, which has to take the presses that close\n * it rather than let them through to what's under it.\n */\nexport function useInertSiblings(\n ref: RefObject<Element | null>,\n active: boolean,\n wholePage: boolean,\n except?: RefObject<Element | null>\n): void {\n useEffect(() => {\n const el = ref.current;\n if (!active || !el) return undefined;\n const doc = el.ownerDocument;\n const keep = except?.current;\n\n const held: Element[] = [];\n for (\n let node: Element | undefined = el;\n node && node !== doc.body;\n node = wholePage ? above(node) : undefined\n ) {\n held.push(\n ...siblingsOf(node).filter(sibling => sibling !== keep && hold(sibling))\n );\n }\n\n // Focus this takes away, to hand back later. It's looked up as `el`'s\n // tree sees it, where focus inside a nested shadow root reads as its\n // host, so it compares with what's held. What gets focused again is the\n // element that really had focus, inside that shadow root.\n const focused = getActiveElementInTree(el);\n const taken = held.some(other => other.contains(focused))\n ? (getDeepestActiveElement(doc) as HTMLElement | null)\n : null;\n\n return () => {\n held.forEach(release);\n if (doc.activeElement === doc.body) taken?.focus();\n };\n }, [ref, active, wholePage, except]);\n}\n"],"names":[],"mappings":";;;;;AAAA;AAKA;;;;;AAKG;AACH,MAAM,KAAK,GAAG,IAAI,GAAG,EAAmB;AAExC;;;AAGG;AACH,SAAS,IAAI,CAAC,EAAW,EAAA;IACvB,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC;IAChC,IAAI,KAAK,KAAK,CAAC,IAAI,EAAE,CAAC,YAAY,CAAC,OAAO,CAAC;AAAE,QAAA,OAAO,KAAK;IACzD,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,KAAK,GAAG,CAAC,CAAC;AACxB,IAAA,EAAE,CAAC,YAAY,CAAC,OAAO,EAAE,EAAE,CAAC;AAC5B,IAAA,OAAO,IAAI;AACb;AAEA;AACA,SAAS,OAAO,CAAC,EAAW,EAAA;IAC1B,MAAM,KAAK,GAAI,KAAK,CAAC,GAAG,CAAC,EAAE,CAAY,GAAG,CAAC;AAC3C,IAAA,IAAI,KAAK,GAAG,CAAC,EAAE;AACb,QAAA,KAAK,CAAC,GAAG,CAAC,EAAE,EAAE,KAAK,CAAC;QACpB;IACF;AACA,IAAA,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC;AAChB,IAAA,EAAE,CAAC,eAAe,CAAC,OAAO,CAAC;AAC7B;AAEA;AACA,SAAS,UAAU,CAAC,EAAW,EAAA;IAC7B,MAAM,QAAQ,GAAc,EAAE;AAC9B,IAAA,KAAK,IAAI,CAAC,GAAG,EAAE,CAAC,sBAAsB,EAAE,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,sBAAsB,EAAE;AACvE,QAAA,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;IAClB;AACA,IAAA,KAAK,IAAI,CAAC,GAAG,EAAE,CAAC,kBAAkB,EAAE,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,kBAAkB,EAAE;AAC/D,QAAA,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC;IAClB;AACA,IAAA,OAAO,QAAQ;AACjB;AAEA;;;AAGG;AACH,MAAM,KAAK,GAAG,CAAC,EAAW,KACxB,EAAE,CAAC,aAAa,IAAK,EAAE,CAAC,WAAW,EAA0B,CAAC,IAAI;AAEpE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BG;AACG,SAAU,gBAAgB,CAC9B,GAA8B,EAC9B,MAAe,EACf,SAAkB,EAClB,MAAkC,EAAA;IAElC,SAAS,CAAC,MAAK;AACb,QAAA,MAAM,EAAE,GAAG,GAAG,CAAC,OAAO;AACtB,QAAA,IAAI,CAAC,MAAM,IAAI,CAAC,EAAE;AAAE,YAAA,OAAO,SAAS;AACpC,QAAA,MAAM,GAAG,GAAG,EAAE,CAAC,aAAa;AAC5B,QAAA,MAAM,IAAI,GAAG,MAAM,EAAE,OAAO;QAE5B,MAAM,IAAI,GAAc,EAAE;AAC1B,QAAA,KACE,IAAI,IAAI,GAAwB,EAAE,EAClC,IAAI,IAAI,IAAI,KAAK,GAAG,CAAC,IAAI,EACzB,IAAI,GAAG,SAAS,GAAG,KAAK,CAAC,IAAI,CAAC,GAAG,SAAS,EAC1C;YACA,IAAI,CAAC,IAAI,CACP,GAAG,UAAU,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,OAAO,IAAI,OAAO,KAAK,IAAI,IAAI,IAAI,CAAC,OAAO,CAAC,CAAC,CACzE;QACH;;;;;AAMA,QAAA,MAAM,OAAO,GAAG,sBAAsB,CAAC,EAAE,CAAC;AAC1C,QAAA,MAAM,KAAK,GAAG,IAAI,CAAC,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,QAAQ,CAAC,OAAO,CAAC;AACtD,cAAG,uBAAuB,CAAC,GAAG;cAC5B,IAAI;AAER,QAAA,OAAO,MAAK;AACV,YAAA,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC;AACrB,YAAA,IAAI,GAAG,CAAC,aAAa,KAAK,GAAG,CAAC,IAAI;gBAAE,KAAK,EAAE,KAAK,EAAE;AACpD,QAAA,CAAC;IACH,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,CAAC,CAAC;AACtC;;"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/* @allxsmith/bestax-bulma — AI agents: see AGENTS.md in the package root, or https://bestax.io/llms.txt */
|
|
2
|
+
'use client';
|
|
3
|
+
import { useRef, useEffect, useCallback } from 'react';
|
|
4
|
+
import { useLatestRef } from './useLatestRef.js';
|
|
5
|
+
import { isEventInside } from './shadowDom.js';
|
|
6
|
+
|
|
7
|
+
// INTERNAL: deliberately not exported from src/index.ts.
|
|
8
|
+
/**
|
|
9
|
+
* Calls `onOutsidePress` for a `pointerdown` outside `ref`'s element while
|
|
10
|
+
* `active`, as a Popover closes on one.
|
|
11
|
+
*
|
|
12
|
+
* A press counts as inside in two ways: on the element's own path in the DOM,
|
|
13
|
+
* and anywhere in a React tree the returned handler is put on, as
|
|
14
|
+
* `onPointerDownCapture`. React's tree includes content portaled out of it,
|
|
15
|
+
* which the DOM path doesn't, and React has seen a press there by the time
|
|
16
|
+
* the document hears it.
|
|
17
|
+
*
|
|
18
|
+
* The listener is on the element's own document, an iframe's when the tree
|
|
19
|
+
* renders into one, and reads the latest `onOutsidePress` as a press
|
|
20
|
+
* arrives, so it stays put while the callback changes identity.
|
|
21
|
+
*
|
|
22
|
+
* @function useOutsidePress
|
|
23
|
+
* @param ref - Ref to the element a press inside of doesn't count.
|
|
24
|
+
* @param active - Whether to listen.
|
|
25
|
+
* @param onOutsidePress - Called with each press outside.
|
|
26
|
+
* @returns The `onPointerDownCapture` handler that marks a press in its tree
|
|
27
|
+
* as inside.
|
|
28
|
+
*/
|
|
29
|
+
function useOutsidePress(ref, active, onOutsidePress) {
|
|
30
|
+
// The last pointerdown React saw inside.
|
|
31
|
+
const insidePressRef = useRef(null);
|
|
32
|
+
const onOutsidePressRef = useLatestRef(onOutsidePress);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const element = ref.current;
|
|
35
|
+
if (!active || !element)
|
|
36
|
+
return undefined;
|
|
37
|
+
const handlePointerDown = (e) => {
|
|
38
|
+
if (e === insidePressRef.current || isEventInside(e, ref.current)) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
onOutsidePressRef.current(e);
|
|
42
|
+
};
|
|
43
|
+
const doc = element.ownerDocument;
|
|
44
|
+
doc.addEventListener('pointerdown', handlePointerDown);
|
|
45
|
+
return () => doc.removeEventListener('pointerdown', handlePointerDown);
|
|
46
|
+
}, [ref, active, onOutsidePressRef]);
|
|
47
|
+
return useCallback((event) => {
|
|
48
|
+
insidePressRef.current = event.nativeEvent;
|
|
49
|
+
}, []);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export { useOutsidePress };
|
|
53
|
+
//# sourceMappingURL=useOutsidePress.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useOutsidePress.js","sources":["../../../src/helpers/useOutsidePress.ts"],"sourcesContent":["// INTERNAL: deliberately not exported from src/index.ts.\nimport { useCallback, useEffect, useRef } from 'react';\nimport type React from 'react';\nimport type { RefObject } from 'react';\nimport { useLatestRef } from './useLatestRef';\nimport { isEventInside } from './shadowDom';\n\n/**\n * Calls `onOutsidePress` for a `pointerdown` outside `ref`'s element while\n * `active`, as a Popover closes on one.\n *\n * A press counts as inside in two ways: on the element's own path in the DOM,\n * and anywhere in a React tree the returned handler is put on, as\n * `onPointerDownCapture`. React's tree includes content portaled out of it,\n * which the DOM path doesn't, and React has seen a press there by the time\n * the document hears it.\n *\n * The listener is on the element's own document, an iframe's when the tree\n * renders into one, and reads the latest `onOutsidePress` as a press\n * arrives, so it stays put while the callback changes identity.\n *\n * @function useOutsidePress\n * @param ref - Ref to the element a press inside of doesn't count.\n * @param active - Whether to listen.\n * @param onOutsidePress - Called with each press outside.\n * @returns The `onPointerDownCapture` handler that marks a press in its tree\n * as inside.\n */\nexport function useOutsidePress(\n ref: RefObject<Element | null>,\n active: boolean,\n onOutsidePress: (event: PointerEvent) => void\n): (event: React.PointerEvent) => void {\n // The last pointerdown React saw inside.\n const insidePressRef = useRef<Event | null>(null);\n const onOutsidePressRef = useLatestRef(onOutsidePress);\n\n useEffect(() => {\n const element = ref.current;\n if (!active || !element) return undefined;\n const handlePointerDown = (e: PointerEvent) => {\n if (e === insidePressRef.current || isEventInside(e, ref.current)) {\n return;\n }\n onOutsidePressRef.current(e);\n };\n const doc = element.ownerDocument;\n doc.addEventListener('pointerdown', handlePointerDown);\n return () => doc.removeEventListener('pointerdown', handlePointerDown);\n }, [ref, active, onOutsidePressRef]);\n\n return useCallback((event: React.PointerEvent) => {\n insidePressRef.current = event.nativeEvent;\n }, []);\n}\n"],"names":[],"mappings":";;;;;;AAAA;AAOA;;;;;;;;;;;;;;;;;;;;AAoBG;SACa,eAAe,CAC7B,GAA8B,EAC9B,MAAe,EACf,cAA6C,EAAA;;AAG7C,IAAA,MAAM,cAAc,GAAG,MAAM,CAAe,IAAI,CAAC;AACjD,IAAA,MAAM,iBAAiB,GAAG,YAAY,CAAC,cAAc,CAAC;IAEtD,SAAS,CAAC,MAAK;AACb,QAAA,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO;AAC3B,QAAA,IAAI,CAAC,MAAM,IAAI,CAAC,OAAO;AAAE,YAAA,OAAO,SAAS;AACzC,QAAA,MAAM,iBAAiB,GAAG,CAAC,CAAe,KAAI;AAC5C,YAAA,IAAI,CAAC,KAAK,cAAc,CAAC,OAAO,IAAI,aAAa,CAAC,CAAC,EAAE,GAAG,CAAC,OAAO,CAAC,EAAE;gBACjE;YACF;AACA,YAAA,iBAAiB,CAAC,OAAO,CAAC,CAAC,CAAC;AAC9B,QAAA,CAAC;AACD,QAAA,MAAM,GAAG,GAAG,OAAO,CAAC,aAAa;AACjC,QAAA,GAAG,CAAC,gBAAgB,CAAC,aAAa,EAAE,iBAAiB,CAAC;QACtD,OAAO,MAAM,GAAG,CAAC,mBAAmB,CAAC,aAAa,EAAE,iBAAiB,CAAC;IACxE,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,iBAAiB,CAAC,CAAC;AAEpC,IAAA,OAAO,WAAW,CAAC,CAAC,KAAyB,KAAI;AAC/C,QAAA,cAAc,CAAC,OAAO,GAAG,KAAK,CAAC,WAAW;IAC5C,CAAC,EAAE,EAAE,CAAC;AACR;;"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/* @allxsmith/bestax-bulma — AI agents: see AGENTS.md in the package root, or https://bestax.io/llms.txt */
|
|
2
|
+
'use client';
|
|
3
|
+
import { useRef, useEffect } from 'react';
|
|
4
|
+
import { useClientLayoutEffect } from './useClientLayoutEffect.js';
|
|
5
|
+
import { useLatestRef } from './useLatestRef.js';
|
|
6
|
+
import { isModalOverlayOpen } from './overlayStack.js';
|
|
7
|
+
import { focusTabStop } from './useFocusTrap.js';
|
|
8
|
+
import { getActiveElementInTree, getDeepestActiveElement } from './shadowDom.js';
|
|
9
|
+
|
|
10
|
+
// INTERNAL: deliberately not exported from src/index.ts.
|
|
11
|
+
/**
|
|
12
|
+
* Moves focus into an overlay as it opens, to the first stop Tab wraps to
|
|
13
|
+
* inside `ref`'s element or the element itself, and hands it back to
|
|
14
|
+
* whatever had it as it closes, as `Modal` does. Focus goes back only while
|
|
15
|
+
* the overlay still holds it, or it's been dropped to `<body>`: closing one
|
|
16
|
+
* under another overlay mustn't pull focus out of the one on top, and focus
|
|
17
|
+
* moved out to the page stays there. The opener is found wherever it sits,
|
|
18
|
+
* inside a shadow root too, in the overlay's own document, an iframe's when
|
|
19
|
+
* it renders into one. It's read in a layout effect as the overlay opens, so
|
|
20
|
+
* before the commit's passive effects, such as one that makes the page
|
|
21
|
+
* inert. Content inside the overlay with a layout effect of its own, or an
|
|
22
|
+
* `autoFocus`, can still move focus first, and then there's no opener to go
|
|
23
|
+
* back to: focus is never handed back into the overlay that's closing.
|
|
24
|
+
*
|
|
25
|
+
* @function useOverlayFocus
|
|
26
|
+
* @param ref - Ref to the overlay's element.
|
|
27
|
+
* @param open - Whether the overlay is open.
|
|
28
|
+
* @param options - Whether it's modal, what remounts it, and whether a
|
|
29
|
+
* close hands focus back.
|
|
30
|
+
*/
|
|
31
|
+
function useOverlayFocus(ref, open, { modal, remountKey, restore }) {
|
|
32
|
+
const openerRef = useRef(null);
|
|
33
|
+
const restoreRef = useLatestRef(restore);
|
|
34
|
+
// The element that had focus, wherever it sits: a portaled overlay renders
|
|
35
|
+
// under `document.body` while its opener can be inside a shadow root.
|
|
36
|
+
useClientLayoutEffect(() => {
|
|
37
|
+
if (!open)
|
|
38
|
+
return;
|
|
39
|
+
const element = ref.current;
|
|
40
|
+
/* istanbul ignore if: as below, the ref is set while the overlay is open */
|
|
41
|
+
if (!element)
|
|
42
|
+
return;
|
|
43
|
+
// Focus already inside, where an `autoFocus` in the overlay puts it, has
|
|
44
|
+
// no opener to go back to.
|
|
45
|
+
openerRef.current = element.contains(getActiveElementInTree(element))
|
|
46
|
+
? null
|
|
47
|
+
: getDeepestActiveElement(element.ownerDocument);
|
|
48
|
+
}, [open, ref]);
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!open)
|
|
51
|
+
return undefined;
|
|
52
|
+
const element = ref.current;
|
|
53
|
+
/* istanbul ignore if: the overlay renders whenever it's open, so its ref is set before this runs */
|
|
54
|
+
if (!element)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (modal || !isModalOverlayOpen()) {
|
|
57
|
+
focusTabStop(element);
|
|
58
|
+
}
|
|
59
|
+
return () => {
|
|
60
|
+
// A removed subtree leaves focus on <body>, which still counts as held.
|
|
61
|
+
const active = getActiveElementInTree(element);
|
|
62
|
+
const held = active === element.ownerDocument.body || element.contains(active);
|
|
63
|
+
// Asked now rather than as it opened: it's about this close.
|
|
64
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
+
if (held && (restoreRef.current?.() ?? true)) {
|
|
66
|
+
openerRef.current?.focus();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- `modal` is read as the overlay opens, on purpose
|
|
70
|
+
}, [open, remountKey, ref, restoreRef]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { useOverlayFocus };
|
|
74
|
+
//# sourceMappingURL=useOverlayFocus.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useOverlayFocus.js","sources":["../../../src/helpers/useOverlayFocus.ts"],"sourcesContent":["// INTERNAL: deliberately not exported from src/index.ts.\nimport { useEffect, useRef } from 'react';\nimport type { RefObject } from 'react';\nimport { useClientLayoutEffect } from './useClientLayoutEffect';\nimport { useLatestRef } from './useLatestRef';\nimport { isModalOverlayOpen } from './overlayStack';\nimport { focusTabStop } from './useFocusTrap';\nimport { getActiveElementInTree, getDeepestActiveElement } from './shadowDom';\n\n/** Options for `useOverlayFocus`. */\nexport interface OverlayFocusOptions {\n /**\n * Whether the overlay is modal. One that isn't, opened while a modal\n * overlay is open under it, leaves focus where it is: that overlay keeps\n * Tab, so a Tab from this one's controls would only go back to it. It's\n * read as the overlay opens, so a change while it's open moves nothing.\n */\n modal: boolean;\n /**\n * A value that remounts the overlay's nodes while it's open when it\n * changes, such as a new portal target. Focus is handed back from the old\n * nodes and moved into the new ones.\n */\n remountKey?: unknown;\n /**\n * Asked as the overlay closes, while it still holds focus: `false` leaves\n * focus where it is, for a close that shouldn't send it back, such as one\n * whose overlay stays on the page as something else.\n */\n restore?: () => boolean;\n}\n\n/**\n * Moves focus into an overlay as it opens, to the first stop Tab wraps to\n * inside `ref`'s element or the element itself, and hands it back to\n * whatever had it as it closes, as `Modal` does. Focus goes back only while\n * the overlay still holds it, or it's been dropped to `<body>`: closing one\n * under another overlay mustn't pull focus out of the one on top, and focus\n * moved out to the page stays there. The opener is found wherever it sits,\n * inside a shadow root too, in the overlay's own document, an iframe's when\n * it renders into one. It's read in a layout effect as the overlay opens, so\n * before the commit's passive effects, such as one that makes the page\n * inert. Content inside the overlay with a layout effect of its own, or an\n * `autoFocus`, can still move focus first, and then there's no opener to go\n * back to: focus is never handed back into the overlay that's closing.\n *\n * @function useOverlayFocus\n * @param ref - Ref to the overlay's element.\n * @param open - Whether the overlay is open.\n * @param options - Whether it's modal, what remounts it, and whether a\n * close hands focus back.\n */\nexport function useOverlayFocus(\n ref: RefObject<HTMLElement | null>,\n open: boolean,\n { modal, remountKey, restore }: OverlayFocusOptions\n): void {\n const openerRef = useRef<HTMLElement | null>(null);\n const restoreRef = useLatestRef(restore);\n\n // The element that had focus, wherever it sits: a portaled overlay renders\n // under `document.body` while its opener can be inside a shadow root.\n useClientLayoutEffect(() => {\n if (!open) return;\n const element = ref.current;\n /* istanbul ignore if: as below, the ref is set while the overlay is open */\n if (!element) return;\n // Focus already inside, where an `autoFocus` in the overlay puts it, has\n // no opener to go back to.\n openerRef.current = element.contains(getActiveElementInTree(element))\n ? null\n : (getDeepestActiveElement(element.ownerDocument) as HTMLElement | null);\n }, [open, ref]);\n\n useEffect(() => {\n if (!open) return undefined;\n const element = ref.current;\n /* istanbul ignore if: the overlay renders whenever it's open, so its ref is set before this runs */\n if (!element) return undefined;\n\n if (modal || !isModalOverlayOpen()) {\n focusTabStop(element);\n }\n\n return () => {\n // A removed subtree leaves focus on <body>, which still counts as held.\n const active = getActiveElementInTree(element);\n const held =\n active === element.ownerDocument.body || element.contains(active);\n // Asked now rather than as it opened: it's about this close.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n if (held && (restoreRef.current?.() ?? true)) {\n openerRef.current?.focus();\n }\n };\n // eslint-disable-next-line react-hooks/exhaustive-deps -- `modal` is read as the overlay opens, on purpose\n }, [open, remountKey, ref, restoreRef]);\n}\n"],"names":[],"mappings":";;;;;;;;;AAAA;AAgCA;;;;;;;;;;;;;;;;;;;AAmBG;AACG,SAAU,eAAe,CAC7B,GAAkC,EAClC,IAAa,EACb,EAAE,KAAK,EAAE,UAAU,EAAE,OAAO,EAAuB,EAAA;AAEnD,IAAA,MAAM,SAAS,GAAG,MAAM,CAAqB,IAAI,CAAC;AAClD,IAAA,MAAM,UAAU,GAAG,YAAY,CAAC,OAAO,CAAC;;;IAIxC,qBAAqB,CAAC,MAAK;AACzB,QAAA,IAAI,CAAC,IAAI;YAAE;AACX,QAAA,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO;;AAE3B,QAAA,IAAI,CAAC,OAAO;YAAE;;;QAGd,SAAS,CAAC,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAAC,OAAO,CAAC;AAClE,cAAE;AACF,cAAG,uBAAuB,CAAC,OAAO,CAAC,aAAa,CAAwB;AAC5E,IAAA,CAAC,EAAE,CAAC,IAAI,EAAE,GAAG,CAAC,CAAC;IAEf,SAAS,CAAC,MAAK;AACb,QAAA,IAAI,CAAC,IAAI;AAAE,YAAA,OAAO,SAAS;AAC3B,QAAA,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO;;AAE3B,QAAA,IAAI,CAAC,OAAO;AAAE,YAAA,OAAO,SAAS;AAE9B,QAAA,IAAI,KAAK,IAAI,CAAC,kBAAkB,EAAE,EAAE;YAClC,YAAY,CAAC,OAAO,CAAC;QACvB;AAEA,QAAA,OAAO,MAAK;;AAEV,YAAA,MAAM,MAAM,GAAG,sBAAsB,CAAC,OAAO,CAAC;AAC9C,YAAA,MAAM,IAAI,GACR,MAAM,KAAK,OAAO,CAAC,aAAa,CAAC,IAAI,IAAI,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;;;AAGnE,YAAA,IAAI,IAAI,KAAK,UAAU,CAAC,OAAO,IAAI,IAAI,IAAI,CAAC,EAAE;AAC5C,gBAAA,SAAS,CAAC,OAAO,EAAE,KAAK,EAAE;YAC5B;AACF,QAAA,CAAC;;IAEH,CAAC,EAAE,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,EAAE,UAAU,CAAC,CAAC;AACzC;;"}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/* @allxsmith/bestax-bulma — AI agents: see AGENTS.md in the package root, or https://bestax.io/llms.txt */
|
|
2
|
+
'use client';
|
|
3
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
4
|
+
import React, { createContext, forwardRef, useContext, useRef, useCallback, useEffect, useState, useId, useMemo } from 'react';
|
|
5
|
+
import { usePrefixedClassNames, classNames } from '../helpers/classNames.js';
|
|
6
|
+
import { useBulmaClasses } from '../helpers/useBulmaClasses.js';
|
|
7
|
+
import { withSubComponents } from '../helpers/withSubComponents.js';
|
|
8
|
+
import { flattenChildren } from '../helpers/flattenChildren.js';
|
|
9
|
+
import { useLatestRef } from '../helpers/useLatestRef.js';
|
|
10
|
+
import { useOverlayStack, useOverlayEscape } from '../helpers/overlayStack.js';
|
|
11
|
+
import { useScrollLock } from '../helpers/scrollLock.js';
|
|
12
|
+
import { useInertSiblings } from '../helpers/useInertSiblings.js';
|
|
13
|
+
import { trapTab } from '../helpers/useFocusTrap.js';
|
|
14
|
+
import { useOverlayFocus } from '../helpers/useOverlayFocus.js';
|
|
15
|
+
import { useOutsidePress } from '../helpers/useOutsidePress.js';
|
|
16
|
+
import { getActiveElementInTree } from '../helpers/shadowDom.js';
|
|
17
|
+
import { useBulmaBreakpoint } from '../helpers/useBulmaViewport.js';
|
|
18
|
+
import { warnOnce } from '../helpers/devWarnings.js';
|
|
19
|
+
import { NavbarBurger } from '../components/Navbar.js';
|
|
20
|
+
import { Delete } from '../elements/Delete.js';
|
|
21
|
+
|
|
22
|
+
const FrameContext = createContext(null);
|
|
23
|
+
/** A width prop as CSS: a number is pixels. */
|
|
24
|
+
const cssLength = (value) => typeof value === 'number' ? `${value}px` : value;
|
|
25
|
+
/**
|
|
26
|
+
* Hands `node` to a part's own ref and to the one forwarded to it. A
|
|
27
|
+
* forwarded callback that returns a cleanup, as React 19 allows, has it held
|
|
28
|
+
* in `cleanup` and run on detach in place of a call with `null`, on React 18
|
|
29
|
+
* as well.
|
|
30
|
+
*/
|
|
31
|
+
function setRefs(node, own, forwarded, cleanup) {
|
|
32
|
+
own.current = node;
|
|
33
|
+
if (typeof forwarded === 'function') {
|
|
34
|
+
if (node === null) {
|
|
35
|
+
const detach = cleanup.current;
|
|
36
|
+
cleanup.current = null;
|
|
37
|
+
if (detach)
|
|
38
|
+
detach();
|
|
39
|
+
else
|
|
40
|
+
forwarded(null);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const returned = forwarded(node);
|
|
44
|
+
cleanup.current =
|
|
45
|
+
typeof returned === 'function' ? returned : null;
|
|
46
|
+
}
|
|
47
|
+
else if (forwarded) {
|
|
48
|
+
forwarded.current = node;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Warns when a part has an `id` the frame couldn't read, because it isn't a
|
|
53
|
+
* direct child, so the skip link or the burger points at the wrong one.
|
|
54
|
+
*/
|
|
55
|
+
function warnOnUnreadId(part, id, expected) {
|
|
56
|
+
if (id && id !== expected) {
|
|
57
|
+
warnOnce(`AppFrame:${part}-id`, `[bestax-bulma] <AppFrame.${part}> has an id <AppFrame> can't read, ` +
|
|
58
|
+
`so ${part === 'Main' ? 'the skip link' : "the burger's aria-controls"} ` +
|
|
59
|
+
`misses it. Put AppFrame.${part} directly inside AppFrame.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The top bar of an AppFrame, which sticks while the page scrolls.
|
|
64
|
+
*
|
|
65
|
+
* @function
|
|
66
|
+
* @param {AppFrameHeaderProps} props - Props for AppFrame.Header.
|
|
67
|
+
* @param {React.Ref<HTMLElement>} ref - Forwarded ref to the `<header>`.
|
|
68
|
+
* @returns {JSX.Element} The rendered header.
|
|
69
|
+
*/
|
|
70
|
+
const AppFrameHeader = forwardRef(({ className, children, ...props }, ref) => {
|
|
71
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
72
|
+
const headerClass = usePrefixedClassNames('app-frame-header');
|
|
73
|
+
return (jsx("header", { ref: ref, className: classNames(headerClass, bulmaHelperClasses, className), ...rest, children: children }));
|
|
74
|
+
});
|
|
75
|
+
AppFrameHeader.displayName = 'AppFrame.Header';
|
|
76
|
+
/**
|
|
77
|
+
* The navigation of an AppFrame: a column beside the page from the breakpoint up, and a drawer below it.
|
|
78
|
+
*
|
|
79
|
+
* @function
|
|
80
|
+
* @param {AppFrameNavProps} props - Props for AppFrame.Nav.
|
|
81
|
+
* @param {React.Ref<HTMLElement>} ref - Forwarded ref to the `<nav>`.
|
|
82
|
+
* @returns {JSX.Element} The rendered nav, and the drawer's backdrop.
|
|
83
|
+
*/
|
|
84
|
+
const AppFrameNav = forwardRef(({ className, style, width, overlay = true, closeOnEscape = true, closeOnClickOutside = true, closeOnNavigate = true, onClick, onPointerDownCapture, children, ...props }, ref) => {
|
|
85
|
+
const frame = useContext(FrameContext);
|
|
86
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
87
|
+
const navClass = usePrefixedClassNames('app-frame-nav');
|
|
88
|
+
const closeClass = usePrefixedClassNames('app-frame-close');
|
|
89
|
+
const backdropClass = usePrefixedClassNames('app-frame-backdrop');
|
|
90
|
+
const activeClass = usePrefixedClassNames('is-active');
|
|
91
|
+
const navRef = useRef(null);
|
|
92
|
+
const backdropRef = useRef(null);
|
|
93
|
+
const navCleanupRef = useRef(null);
|
|
94
|
+
const navRefs = useCallback((node) => setRefs(node, navRef, ref, navCleanupRef), [ref]);
|
|
95
|
+
const navId = rest.id || frame?.state.navId;
|
|
96
|
+
if (frame)
|
|
97
|
+
warnOnUnreadId('Nav', rest.id, frame.state.navId);
|
|
98
|
+
// The drawer is open only below the breakpoint. Above it the nav is a
|
|
99
|
+
// column, and none of what follows applies.
|
|
100
|
+
const isDrawer = !!frame?.state.isDrawer;
|
|
101
|
+
const open = isDrawer && !!frame?.state.navOpen;
|
|
102
|
+
// The open drawer is Bulma's active state, on the nav as on a navbar's
|
|
103
|
+
// menu, and on its backdrop.
|
|
104
|
+
const shown = { [activeClass]: !!frame?.state.navOpen };
|
|
105
|
+
const modal = open && overlay;
|
|
106
|
+
const close = () => frame?.state.setNavOpen(false);
|
|
107
|
+
// The rest of the frame goes inert: the header, the page, the aside and
|
|
108
|
+
// the footer, but not the backdrop, which takes the presses outside the
|
|
109
|
+
// drawer rather than let them through to what's under it. Not the whole
|
|
110
|
+
// page, since a Modal or a toast renders beside the frame, often into an
|
|
111
|
+
// element that's there before the drawer opens, and a Modal opened from
|
|
112
|
+
// the drawer has to stay usable. First, so that as the drawer closes it
|
|
113
|
+
// lets go before focus goes back to the burger: an inert element can't
|
|
114
|
+
// take focus.
|
|
115
|
+
useInertSiblings(navRef, modal, false, backdropRef);
|
|
116
|
+
const isTopmost = useOverlayStack(open, overlay);
|
|
117
|
+
useScrollLock(modal);
|
|
118
|
+
// Tab stays inside while the drawer is the topmost modal overlay, as in
|
|
119
|
+
// Sidebar, so a Modal opened from the drawer holds Tab until it closes.
|
|
120
|
+
useEffect(() => {
|
|
121
|
+
if (!modal)
|
|
122
|
+
return undefined;
|
|
123
|
+
return trapTab(() => navRef.current, window, () => isTopmost('modal'));
|
|
124
|
+
}, [modal, isTopmost]);
|
|
125
|
+
useOverlayEscape(open && closeOnEscape, isTopmost, close);
|
|
126
|
+
// Without an overlay there's no backdrop: a press outside the drawer
|
|
127
|
+
// closes it, as Popover's does, while it's the topmost overlay, so a
|
|
128
|
+
// press in a Modal opened over it is the Modal's. A press on a control
|
|
129
|
+
// that names the nav in its `aria-controls`, such as the burger, is left
|
|
130
|
+
// to that control, which toggles it.
|
|
131
|
+
const markInsidePress = useOutsidePress(navRef, open && !overlay && closeOnClickOutside, e => {
|
|
132
|
+
const id = navRef.current?.id;
|
|
133
|
+
const controlsNav = (node) => !!node
|
|
134
|
+
.getAttribute?.('aria-controls')
|
|
135
|
+
?.split(/\s+/)
|
|
136
|
+
.includes(id);
|
|
137
|
+
if (!isTopmost() || e.composedPath().some(controlsNav))
|
|
138
|
+
return;
|
|
139
|
+
close();
|
|
140
|
+
});
|
|
141
|
+
// Focus moves to the drawer's first tab stop, the close button, as it
|
|
142
|
+
// opens, and back to whatever had it as it closes, unless it has left
|
|
143
|
+
// the drawer. After a press outside, the press's own mousedown then takes
|
|
144
|
+
// it wherever the press goes. When the window widened, the drawer is the
|
|
145
|
+
// column again, and focus on its links stays; the close button hides
|
|
146
|
+
// there, so focus on it, or dropped from it to <body>, goes back too.
|
|
147
|
+
const isDrawerRef = useLatestRef(isDrawer);
|
|
148
|
+
useOverlayFocus(navRef, open, {
|
|
149
|
+
modal: overlay,
|
|
150
|
+
restore: () => {
|
|
151
|
+
if (isDrawerRef.current)
|
|
152
|
+
return true;
|
|
153
|
+
const nav = navRef.current;
|
|
154
|
+
const active = getActiveElementInTree(nav);
|
|
155
|
+
return (!nav.contains(active) || !!active?.classList.contains(closeClass));
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const handleClick = (e) => {
|
|
159
|
+
onClick?.(e);
|
|
160
|
+
if (!open ||
|
|
161
|
+
!closeOnNavigate ||
|
|
162
|
+
e.button !== 0 ||
|
|
163
|
+
e.metaKey ||
|
|
164
|
+
e.ctrlKey ||
|
|
165
|
+
e.shiftKey ||
|
|
166
|
+
e.altKey) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Not skipped for a click whose default was prevented: a router's
|
|
170
|
+
// link prevents it to navigate without a page load. Only a link the nav
|
|
171
|
+
// holds: one in a Modal portaled from it is the Modal's.
|
|
172
|
+
const link = e.target.closest('a[href]');
|
|
173
|
+
if (!link || !navRef.current?.contains(link))
|
|
174
|
+
return;
|
|
175
|
+
if (link.hasAttribute('download'))
|
|
176
|
+
return;
|
|
177
|
+
const target = link.getAttribute('target');
|
|
178
|
+
if (target && target !== '_self')
|
|
179
|
+
return;
|
|
180
|
+
close();
|
|
181
|
+
};
|
|
182
|
+
const navStyle = width === undefined
|
|
183
|
+
? style
|
|
184
|
+
: {
|
|
185
|
+
...style,
|
|
186
|
+
'--bulma-app-frame-nav-width': cssLength(width),
|
|
187
|
+
};
|
|
188
|
+
return (jsxs(Fragment, { children: [jsxs("nav", { ref: navRefs, className: classNames(navClass, shown, bulmaHelperClasses, className), style: navStyle, onClick: handleClick, onPointerDownCapture: e => {
|
|
189
|
+
onPointerDownCapture?.(e);
|
|
190
|
+
markInsidePress(e);
|
|
191
|
+
}, ...rest, id: navId, "aria-label": rest['aria-label'] ||
|
|
192
|
+
(rest['aria-labelledby'] ? undefined : frame?.labels.nav), children: [frame && (jsx(Delete, { className: closeClass, size: "medium", ariaLabel: frame.labels.closeNav, onClick: close })), children] }), frame && overlay && (jsx("div", { ref: backdropRef, className: classNames(backdropClass, shown), "aria-hidden": "true", onClick: closeOnClickOutside
|
|
193
|
+
? () => {
|
|
194
|
+
// Not under an overlay opened over the drawer.
|
|
195
|
+
if (isTopmost())
|
|
196
|
+
close();
|
|
197
|
+
}
|
|
198
|
+
: undefined }))] }));
|
|
199
|
+
});
|
|
200
|
+
AppFrameNav.displayName = 'AppFrame.Nav';
|
|
201
|
+
/**
|
|
202
|
+
* The page's main content in an AppFrame, which the skip link moves focus to.
|
|
203
|
+
*
|
|
204
|
+
* @function
|
|
205
|
+
* @param {AppFrameMainProps} props - Props for AppFrame.Main.
|
|
206
|
+
* @param {React.Ref<HTMLElement>} ref - Forwarded ref to the `<main>`.
|
|
207
|
+
* @returns {JSX.Element} The rendered main.
|
|
208
|
+
*/
|
|
209
|
+
const AppFrameMain = forwardRef(({ className, children, ...props }, ref) => {
|
|
210
|
+
const frame = useContext(FrameContext);
|
|
211
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
212
|
+
const mainClass = usePrefixedClassNames('app-frame-main');
|
|
213
|
+
if (frame)
|
|
214
|
+
warnOnUnreadId('Main', rest.id, frame.mainId);
|
|
215
|
+
return (jsx("main", { ref: ref, tabIndex: -1, className: classNames(mainClass, bulmaHelperClasses, className), ...rest, id: rest.id || frame?.mainId, children: children }));
|
|
216
|
+
});
|
|
217
|
+
AppFrameMain.displayName = 'AppFrame.Main';
|
|
218
|
+
/**
|
|
219
|
+
* Content beside the page in an AppFrame, which moves under it below the breakpoint.
|
|
220
|
+
*
|
|
221
|
+
* @function
|
|
222
|
+
* @param {AppFrameAsideProps} props - Props for AppFrame.Aside.
|
|
223
|
+
* @param {React.Ref<HTMLElement>} ref - Forwarded ref to the `<aside>`.
|
|
224
|
+
* @returns {JSX.Element} The rendered aside.
|
|
225
|
+
*/
|
|
226
|
+
const AppFrameAside = forwardRef(({ className, style, width, children, ...props }, ref) => {
|
|
227
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
228
|
+
const asideClass = usePrefixedClassNames('app-frame-aside');
|
|
229
|
+
const asideStyle = width === undefined
|
|
230
|
+
? style
|
|
231
|
+
: {
|
|
232
|
+
...style,
|
|
233
|
+
'--bulma-app-frame-aside-width': cssLength(width),
|
|
234
|
+
};
|
|
235
|
+
return (jsx("aside", { ref: ref, className: classNames(asideClass, bulmaHelperClasses, className), style: asideStyle, ...rest, children: children }));
|
|
236
|
+
});
|
|
237
|
+
AppFrameAside.displayName = 'AppFrame.Aside';
|
|
238
|
+
/**
|
|
239
|
+
* The bottom of an AppFrame, under the page.
|
|
240
|
+
*
|
|
241
|
+
* @function
|
|
242
|
+
* @param {AppFrameFooterProps} props - Props for AppFrame.Footer.
|
|
243
|
+
* @param {React.Ref<HTMLElement>} ref - Forwarded ref to the `<footer>`.
|
|
244
|
+
* @returns {JSX.Element} The rendered footer.
|
|
245
|
+
*/
|
|
246
|
+
const AppFrameFooter = forwardRef(({ className, children, ...props }, ref) => {
|
|
247
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses(props);
|
|
248
|
+
const footerClass = usePrefixedClassNames('app-frame-footer');
|
|
249
|
+
return (jsx("footer", { ref: ref, className: classNames(footerClass, bulmaHelperClasses, className), ...rest, children: children }));
|
|
250
|
+
});
|
|
251
|
+
AppFrameFooter.displayName = 'AppFrame.Footer';
|
|
252
|
+
/**
|
|
253
|
+
* The button that opens the drawer, or collapses the column from the breakpoint up: Navbar.Burger, wired to the frame.
|
|
254
|
+
*
|
|
255
|
+
* @function
|
|
256
|
+
* @param {AppFrameBurgerProps} props - Props for AppFrame.Burger.
|
|
257
|
+
* @param {React.Ref<HTMLButtonElement>} ref - Forwarded ref to the `<button>`.
|
|
258
|
+
* @returns {JSX.Element} The rendered burger.
|
|
259
|
+
*/
|
|
260
|
+
const AppFrameBurger = forwardRef(({ className, onClick, ...props }, ref) => {
|
|
261
|
+
// Outside a frame it's a Navbar.Burger that controls nothing.
|
|
262
|
+
const frame = useContext(FrameContext);
|
|
263
|
+
const burgerClass = usePrefixedClassNames('app-frame-burger');
|
|
264
|
+
const state = frame?.state;
|
|
265
|
+
// Until the frame knows whether the nav is a drawer, the burger says
|
|
266
|
+
// nothing it can't stand behind: no `aria-expanded`, and its bars. It can't
|
|
267
|
+
// act before then anyway, since that's before hydration. Outside a frame
|
|
268
|
+
// it's a plain Navbar.Burger.
|
|
269
|
+
const known = !!frame?.modeKnown;
|
|
270
|
+
const expanded = known && !!state && (state.isDrawer ? state.navOpen : !state.navCollapsed);
|
|
271
|
+
return (jsx(NavbarBurger, { ref: ref, ...props, className: classNames(burgerClass, className), "aria-label": props['aria-label'] || frame?.labels.burger, active: expanded, "aria-expanded": frame && !known ? null : expanded, "aria-controls": state?.navId, onClick: e => {
|
|
272
|
+
onClick?.(e);
|
|
273
|
+
if (e.defaultPrevented || !frame)
|
|
274
|
+
return;
|
|
275
|
+
// Safari, and Firefox on a Mac, don't focus a button that's pressed.
|
|
276
|
+
// Focused, it's the element the drawer hands focus back to.
|
|
277
|
+
e.currentTarget.focus();
|
|
278
|
+
frame.state.toggleNav();
|
|
279
|
+
} }));
|
|
280
|
+
});
|
|
281
|
+
AppFrameBurger.displayName = 'AppFrame.Burger';
|
|
282
|
+
const isMain = (child) => React.isValidElement(child) && child.type === AppFrameMain;
|
|
283
|
+
const isNav = (child) => React.isValidElement(child) && child.type === AppFrameNav;
|
|
284
|
+
/**
|
|
285
|
+
* The `AppFrame` component lays out an app's pages: a header across the top, a navigation column down the side, the page, and an aside and a footer, with the nav a drawer behind a burger on narrow screens.
|
|
286
|
+
*
|
|
287
|
+
* The header and the column stick while the document scrolls, and a skip
|
|
288
|
+
* link comes first. The layout is CSS, so the server renders it as the
|
|
289
|
+
* browser does. It holds two states, each yours to control: whether the
|
|
290
|
+
* drawer is open, and whether the column is collapsed. `AppFrame.Burger`
|
|
291
|
+
* and `useAppFrame()` toggle whichever applies at the current width.
|
|
292
|
+
*
|
|
293
|
+
* @function
|
|
294
|
+
* @param {AppFrameProps} props - Props for the AppFrame component.
|
|
295
|
+
* @param {React.Ref<HTMLDivElement>} ref - Forwarded ref to the root `<div>`.
|
|
296
|
+
* @returns {JSX.Element} The rendered frame.
|
|
297
|
+
*
|
|
298
|
+
* @example
|
|
299
|
+
* <AppFrame>
|
|
300
|
+
* <AppFrame.Header>
|
|
301
|
+
* <Navbar aria-label="Site">
|
|
302
|
+
* <Navbar.Brand>
|
|
303
|
+
* <Navbar.Item href="/">Acme</Navbar.Item>
|
|
304
|
+
* <AppFrame.Burger />
|
|
305
|
+
* </Navbar.Brand>
|
|
306
|
+
* </Navbar>
|
|
307
|
+
* </AppFrame.Header>
|
|
308
|
+
* <AppFrame.Nav>
|
|
309
|
+
* <Menu>…</Menu>
|
|
310
|
+
* </AppFrame.Nav>
|
|
311
|
+
* <AppFrame.Main>…</AppFrame.Main>
|
|
312
|
+
* </AppFrame>
|
|
313
|
+
*/
|
|
314
|
+
const AppFrameRoot = forwardRef(({ className, textColor, bgColor, breakpoint = 'desktop', navOpen: navOpenProp, defaultNavOpen = false, onNavOpenChange, navCollapsed: navCollapsedProp, defaultNavCollapsed = false, onNavCollapsedChange, skipLink = true, labels, children, ...props }, ref) => {
|
|
315
|
+
const { bulmaHelperClasses, rest } = useBulmaClasses({
|
|
316
|
+
color: textColor,
|
|
317
|
+
backgroundColor: bgColor,
|
|
318
|
+
...props,
|
|
319
|
+
});
|
|
320
|
+
const rootRef = useRef(null);
|
|
321
|
+
const rootCleanupRef = useRef(null);
|
|
322
|
+
const rootRefs = useCallback((node) => setRefs(node, rootRef, ref, rootCleanupRef), [ref]);
|
|
323
|
+
const [openState, setOpenState] = useState(defaultNavOpen);
|
|
324
|
+
const [collapsedState, setCollapsedState] = useState(defaultNavCollapsed);
|
|
325
|
+
const navOpen = navOpenProp ?? openState;
|
|
326
|
+
const navCollapsed = navCollapsedProp ?? collapsedState;
|
|
327
|
+
const openControlled = navOpenProp !== undefined;
|
|
328
|
+
const collapsedControlled = navCollapsedProp !== undefined;
|
|
329
|
+
const onNavOpenChangeRef = useLatestRef(onNavOpenChange);
|
|
330
|
+
const onNavCollapsedChangeRef = useLatestRef(onNavCollapsedChange);
|
|
331
|
+
const setNavOpen = useCallback((open) => {
|
|
332
|
+
onNavOpenChangeRef.current?.(open);
|
|
333
|
+
if (!openControlled)
|
|
334
|
+
setOpenState(open);
|
|
335
|
+
}, [openControlled, onNavOpenChangeRef]);
|
|
336
|
+
const setNavCollapsed = useCallback((collapsed) => {
|
|
337
|
+
onNavCollapsedChangeRef.current?.(collapsed);
|
|
338
|
+
if (!collapsedControlled)
|
|
339
|
+
setCollapsedState(collapsed);
|
|
340
|
+
}, [collapsedControlled, onNavCollapsedChangeRef]);
|
|
341
|
+
// The layout switches in the stylesheet. The script needs the width
|
|
342
|
+
// only to know which state the burger toggles, and to close the drawer
|
|
343
|
+
// once the window is at or past the breakpoint.
|
|
344
|
+
const wide = useBulmaBreakpoint(breakpoint);
|
|
345
|
+
const isDrawer = wide === false;
|
|
346
|
+
const isWide = wide === true;
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- the width is known only in the browser, and the drawer closes in answer to it
|
|
349
|
+
if (isWide && navOpen)
|
|
350
|
+
setNavOpen(false);
|
|
351
|
+
}, [isWide, navOpen, setNavOpen]);
|
|
352
|
+
const toggleNav = useCallback(() => {
|
|
353
|
+
if (isDrawer)
|
|
354
|
+
setNavOpen(!navOpen);
|
|
355
|
+
else
|
|
356
|
+
setNavCollapsed(!navCollapsed);
|
|
357
|
+
}, [isDrawer, navOpen, navCollapsed, setNavOpen, setNavCollapsed]);
|
|
358
|
+
// The ids of the parts the skip link and the burger point at, read off
|
|
359
|
+
// the children so the server renders them too.
|
|
360
|
+
const baseId = useId();
|
|
361
|
+
const parts = flattenChildren(children);
|
|
362
|
+
const mainId = parts.find(isMain)?.props.id || `${baseId}-main`;
|
|
363
|
+
const navId = parts.find(isNav)?.props.id || `${baseId}-nav`;
|
|
364
|
+
const { skipLink: skipText, nav, closeNav, burger } = labels ?? {};
|
|
365
|
+
const value = useMemo(() => ({
|
|
366
|
+
state: {
|
|
367
|
+
navOpen,
|
|
368
|
+
setNavOpen,
|
|
369
|
+
navCollapsed,
|
|
370
|
+
setNavCollapsed,
|
|
371
|
+
isDrawer,
|
|
372
|
+
toggleNav,
|
|
373
|
+
navId,
|
|
374
|
+
},
|
|
375
|
+
labels: {
|
|
376
|
+
skipLink: skipText || 'Skip to main content',
|
|
377
|
+
nav: nav || 'Sections',
|
|
378
|
+
closeNav: closeNav || 'Close navigation',
|
|
379
|
+
burger: burger || undefined,
|
|
380
|
+
},
|
|
381
|
+
mainId,
|
|
382
|
+
modeKnown: wide !== null,
|
|
383
|
+
}), [
|
|
384
|
+
navOpen,
|
|
385
|
+
setNavOpen,
|
|
386
|
+
navCollapsed,
|
|
387
|
+
setNavCollapsed,
|
|
388
|
+
isDrawer,
|
|
389
|
+
toggleNav,
|
|
390
|
+
navId,
|
|
391
|
+
skipText,
|
|
392
|
+
nav,
|
|
393
|
+
closeNav,
|
|
394
|
+
burger,
|
|
395
|
+
mainId,
|
|
396
|
+
wide,
|
|
397
|
+
]);
|
|
398
|
+
// Its header and footer are the page's banner and contentinfo only
|
|
399
|
+
// outside `main` and sectioning content, as HTML-AAM maps them.
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
const host = rootRef.current?.parentElement?.closest('main, article, aside, nav, section');
|
|
402
|
+
if (host) {
|
|
403
|
+
warnOnce('AppFrame:nested', `[bestax-bulma] <AppFrame> sits inside a <${host.localName}>, where ` +
|
|
404
|
+
"its header and footer are no longer the page's banner and " +
|
|
405
|
+
'contentinfo landmarks. Put AppFrame at the top of the page.');
|
|
406
|
+
}
|
|
407
|
+
}, []);
|
|
408
|
+
const rootClass = usePrefixedClassNames('app-frame', {
|
|
409
|
+
[`is-${breakpoint}`]: true,
|
|
410
|
+
'is-nav-collapsed': navCollapsed,
|
|
411
|
+
});
|
|
412
|
+
const skipClass = usePrefixedClassNames('app-frame-skip-link');
|
|
413
|
+
return (jsxs("div", { ref: rootRefs, className: classNames(rootClass, bulmaHelperClasses, className), ...rest, children: [skipLink && (jsx("a", { className: skipClass, href: `#${mainId}`, children: value.labels.skipLink })), jsx(FrameContext.Provider, { value: value, children: children })] }));
|
|
414
|
+
});
|
|
415
|
+
/**
|
|
416
|
+
* The frame's state, for a control of your own inside it: whether the
|
|
417
|
+
* drawer is open and the column collapsed, setters that go through the
|
|
418
|
+
* frame's callbacks, and `toggleNav()`, which does what the burger does.
|
|
419
|
+
* Outside an AppFrame it's `null`, as the library's other context hooks
|
|
420
|
+
* are, so a control that can sit outside one renders a fallback or nothing.
|
|
421
|
+
*
|
|
422
|
+
* @function useAppFrame
|
|
423
|
+
* @returns The frame's state, or `null` outside an AppFrame.
|
|
424
|
+
*/
|
|
425
|
+
function useAppFrame() {
|
|
426
|
+
return useContext(FrameContext)?.state ?? null;
|
|
427
|
+
}
|
|
428
|
+
const AppFrame = withSubComponents(AppFrameRoot, {
|
|
429
|
+
Header: AppFrameHeader,
|
|
430
|
+
Nav: AppFrameNav,
|
|
431
|
+
Main: AppFrameMain,
|
|
432
|
+
Aside: AppFrameAside,
|
|
433
|
+
Footer: AppFrameFooter,
|
|
434
|
+
Burger: AppFrameBurger,
|
|
435
|
+
}, 'AppFrame');
|
|
436
|
+
|
|
437
|
+
export { AppFrame, AppFrameAside, AppFrameBurger, AppFrameFooter, AppFrameHeader, AppFrameMain, AppFrameNav, useAppFrame };
|
|
438
|
+
//# sourceMappingURL=AppFrame.js.map
|