oihana-next-ui 0.1.47 → 0.2.1
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/package.json +1 -1
- package/src/app/lab/@tabs/menus/page.js +14 -4
- package/src/components/buttons/RefreshButton.jsx +6 -6
- package/src/components/modals/Modal.jsx +192 -67
- package/src/contexts/navigation/context.js +14 -1
- package/src/contexts/navigation/helpers/collapseStorage.js +129 -0
- package/src/contexts/navigation/helpers/constants.js +59 -0
- package/src/contexts/navigation/helpers/containsActivePath.js +54 -0
- package/src/contexts/navigation/helpers/findActiveAncestorIds.js +78 -0
- package/src/contexts/navigation/helpers/mapI18nItem.js +12 -9
- package/src/contexts/navigation/helpers/resolveCollapseOpen.js +62 -0
- package/src/contexts/navigation/provider.js +164 -12
- package/src/contexts/navigation/useNavigationCollapse.js +63 -0
- package/src/demo/menus/CollapsePersistenceDemo.jsx +210 -0
- package/src/demo/modals/ModalDemo.jsx +161 -1
- package/src/display/Application.jsx +5 -1
- package/src/display/ui/navigation/Collapse.jsx +115 -10
- package/src/display/ui/navigation/Link.jsx +20 -6
- package/src/themes/components/modal.js +4 -0
- package/src/version.js +1 -1
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
'use client' ;
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
3
|
+
import CollapsePersistenceDemo from '@/demo/menus/CollapsePersistenceDemo';
|
|
4
|
+
import FlagMenuDemo from '@/demo/menus/FlagMenuDemo';
|
|
5
|
+
import MenuNavigationDemo from '@/demo/menus/MenuNavigationDemo';
|
|
6
|
+
import Container from '@/display/Container' ;
|
|
7
|
+
import Page from '@/display/Page' ;
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Toasts showcase page.
|
|
@@ -39,6 +40,15 @@ const ToastsShowcase = ({ path = 'app.test' }) =>
|
|
|
39
40
|
</div>
|
|
40
41
|
</Container>
|
|
41
42
|
|
|
43
|
+
{/* Collapse persistence Demo */}
|
|
44
|
+
<Container className="flex flex-col gap-4">
|
|
45
|
+
<h2 className="text-2xl font-bold">Collapse persistence</h2>
|
|
46
|
+
<p className="opacity-70">
|
|
47
|
+
NavigationProvider — defaultMode and storageKey props.
|
|
48
|
+
</p>
|
|
49
|
+
<CollapsePersistenceDemo />
|
|
50
|
+
</Container>
|
|
51
|
+
|
|
42
52
|
</Page>
|
|
43
53
|
) ;
|
|
44
54
|
} ;
|
|
@@ -38,13 +38,13 @@ import MotionButton from './MotionButton' ;
|
|
|
38
38
|
*/
|
|
39
39
|
const RefreshButton =
|
|
40
40
|
({
|
|
41
|
-
color
|
|
42
|
-
icon
|
|
43
|
-
motion
|
|
41
|
+
color = 'secondary' ,
|
|
42
|
+
icon = MdRefresh ,
|
|
43
|
+
motion = Jump ,
|
|
44
44
|
motionProps = { delay: 0.3 } ,
|
|
45
|
-
path
|
|
46
|
-
shape
|
|
47
|
-
size
|
|
45
|
+
path = 'components.buttons.refresh' ,
|
|
46
|
+
shape = 'circle' ,
|
|
47
|
+
size = 'md' ,
|
|
48
48
|
...rest
|
|
49
49
|
}) =>
|
|
50
50
|
(
|
|
@@ -19,73 +19,185 @@ import
|
|
|
19
19
|
}
|
|
20
20
|
from '../../themes/components/modal' ;
|
|
21
21
|
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
22
|
+
const FOOTER_NODE_OVERRIDE_PROPS =
|
|
23
|
+
[
|
|
24
|
+
'agree',
|
|
25
|
+
'disagree',
|
|
26
|
+
'agreeColor',
|
|
27
|
+
'disagreeColor',
|
|
28
|
+
'agreeIcon',
|
|
29
|
+
'disagreeIcon',
|
|
30
|
+
'agreeDisabled',
|
|
31
|
+
'disagreeDisabled',
|
|
32
|
+
'showAgree',
|
|
33
|
+
'showDisagree',
|
|
34
|
+
'showFooter',
|
|
35
|
+
'footerReverse',
|
|
36
|
+
'footerClassName',
|
|
37
|
+
'footerOptions',
|
|
38
|
+
'onAgree',
|
|
39
|
+
'onCancel',
|
|
40
|
+
] ;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Accessible dialog primitive built on top of the native `<dialog>` element and DaisyUI's `modal` styles.
|
|
44
|
+
*
|
|
45
|
+
* Two layout modes:
|
|
46
|
+
*
|
|
47
|
+
* 1. **Standard mode** (default) — header (sticky top), free-flow content area, optional footer
|
|
48
|
+
* rendered as a `modal-action` row with `agree` / `disagree` buttons (sticky bottom). The whole
|
|
49
|
+
* modal-box is the scroll container, header and footer stick to its edges as the user scrolls.
|
|
50
|
+
*
|
|
51
|
+
* 2. **Custom-footer mode** — activated by passing a `footerNode`. The modal-box becomes a vertical
|
|
52
|
+
* flex column: the content area grows and scrolls internally, while `footerNode` is rendered
|
|
53
|
+
* in a dedicated, non-scrollable, border-topped slot at the bottom. Use this for forms with
|
|
54
|
+
* a status text + custom action buttons, or any case where the standard footer is too rigid.
|
|
55
|
+
*
|
|
56
|
+
* When `footerNode` is provided, the standard footer (`agree`, `disagree`, `footerOptions`,
|
|
57
|
+
* `showFooter`, `footerReverse`, `footerClassName`, `onAgree`, `onCancel`, etc.) is **fully
|
|
58
|
+
* ignored** — `footerNode` wins. A `console.warn` is emitted in development if overlapping
|
|
59
|
+
* props are passed alongside.
|
|
60
|
+
*
|
|
61
|
+
* @example Standard usage with agree / disagree
|
|
62
|
+
* ```jsx
|
|
63
|
+
* <Modal
|
|
64
|
+
* ref = { modalRef }
|
|
65
|
+
* title = "Save changes?"
|
|
66
|
+
* agree = "Save"
|
|
67
|
+
* disagree = "Cancel"
|
|
68
|
+
* onAgree = { handleSave }
|
|
69
|
+
* >
|
|
70
|
+
* <p>Your unsaved changes will be lost.</p>
|
|
71
|
+
* </Modal>
|
|
72
|
+
* ```
|
|
73
|
+
*
|
|
74
|
+
* @example Custom footer with scrollable form (no `!important` overrides)
|
|
75
|
+
* ```jsx
|
|
76
|
+
* <Modal
|
|
77
|
+
* ref = { modalRef }
|
|
78
|
+
* title = "Edit profile"
|
|
79
|
+
* footerNode = {
|
|
80
|
+
* <div className="flex items-center gap-3 px-4 py-3">
|
|
81
|
+
* <span className="text-sm text-base-content/60">Saved 2s ago</span>
|
|
82
|
+
* <div className="ml-auto flex gap-2">
|
|
83
|
+
* <Button color="neutral" onClick={ handleCancel }>Cancel</Button>
|
|
84
|
+
* <Button color="primary" onClick={ handleSave }>Save</Button>
|
|
85
|
+
* </div>
|
|
86
|
+
* </div>
|
|
87
|
+
* }
|
|
88
|
+
* >
|
|
89
|
+
* <form className="flex flex-col gap-4">
|
|
90
|
+
* { /* many fields, the form scrolls — footer stays visible *\/ }
|
|
91
|
+
* </form>
|
|
92
|
+
* </Modal>
|
|
93
|
+
* ```
|
|
94
|
+
*
|
|
95
|
+
* @param {Object} props
|
|
96
|
+
* @param {React.ReactNode} [props.title] - Modal title (rendered in the header).
|
|
97
|
+
* @param {React.ReactNode} [props.icon] - Icon shown left of the title.
|
|
98
|
+
* @param {React.ReactNode} [props.headerOptions] - Extra nodes injected in the header row.
|
|
99
|
+
* @param {React.ReactNode} [props.footerOptions] - Extra nodes rendered alongside agree/disagree (standard mode only).
|
|
100
|
+
* @param {React.ReactNode} [props.footerNode] - **Custom footer** that fully replaces the standard footer. Activates the flex-column layout (sticky footer + internal content scroll). When set, all `agree*`/`disagree*`/`footer*`/`onAgree`/`onCancel`/`showFooter` props are ignored.
|
|
101
|
+
* @param {React.ReactNode} [props.children] - Modal body content.
|
|
102
|
+
* @param {string} [props.maxWidth='max-w-2xl'] - Tailwind max-width class for the modal-box.
|
|
103
|
+
* @param {boolean} [props.fullScreen] - Force full-screen modal.
|
|
104
|
+
* @param {string} [props.fullScreenBreakpoint] - Tailwind breakpoint below which the modal becomes full-screen (e.g. `'md'`).
|
|
105
|
+
* @param {string} [props.placement='middle'] - `'top'` | `'middle'` | `'bottom'` | `'start'` | `'end'`.
|
|
106
|
+
* @param {string} [props.responsivePlacement] - Responsive placement (e.g. `'sm:modal-middle'`).
|
|
107
|
+
* @param {boolean} [props.disableBackdropClick=false] - Prevent close on backdrop click.
|
|
108
|
+
* @param {boolean} [props.disableEscapeKeyDown=false] - Prevent close on `Escape`.
|
|
109
|
+
* @param {string} [props.contentClassName] - Extra classes on the content wrapper. In custom-footer mode, the default is `flex-1 min-h-0 overflow-y-auto p-2 py-4`; in standard mode, `overflow-y-auto h-full p-2 py-4`.
|
|
110
|
+
* @param {string} [props.modalBoxClassName] - Extra classes on the modal-box.
|
|
111
|
+
*
|
|
112
|
+
* @see https://daisyui.com/components/modal
|
|
113
|
+
*/
|
|
114
|
+
const Modal = ( props ) =>
|
|
85
115
|
{
|
|
116
|
+
const
|
|
117
|
+
{
|
|
118
|
+
// Header
|
|
119
|
+
closeClassName ,
|
|
120
|
+
closeIcon= <CloseIcon size={20}/>,
|
|
121
|
+
closeTitle = 'Close' ,
|
|
122
|
+
title,
|
|
123
|
+
icon,
|
|
124
|
+
showHeader = true,
|
|
125
|
+
showTitle = true,
|
|
126
|
+
showIcon = true,
|
|
127
|
+
showCloseButton = true,
|
|
128
|
+
headerClassName,
|
|
129
|
+
headerOptions,
|
|
130
|
+
|
|
131
|
+
// Footer (standard mode)
|
|
132
|
+
agree = 'OK',
|
|
133
|
+
disagree = 'Cancel',
|
|
134
|
+
agreeColor = 'primary',
|
|
135
|
+
disagreeColor = 'neutral',
|
|
136
|
+
agreeIcon,
|
|
137
|
+
disagreeIcon,
|
|
138
|
+
agreeDisabled,
|
|
139
|
+
disagreeDisabled,
|
|
140
|
+
showFooter = true,
|
|
141
|
+
showAgree = true,
|
|
142
|
+
showDisagree = true,
|
|
143
|
+
footerReverse = false,
|
|
144
|
+
footerClassName,
|
|
145
|
+
footerOptions,
|
|
146
|
+
onAgree,
|
|
147
|
+
onCancel,
|
|
148
|
+
|
|
149
|
+
// Footer (custom mode)
|
|
150
|
+
footerNode,
|
|
151
|
+
|
|
152
|
+
// Layout
|
|
153
|
+
placement = 'middle',
|
|
154
|
+
responsivePlacement,
|
|
155
|
+
maxWidth = 'max-w-2xl',
|
|
156
|
+
fullScreen,
|
|
157
|
+
fullScreenBreakpoint,
|
|
158
|
+
fullWidth,
|
|
159
|
+
|
|
160
|
+
// Backdrop
|
|
161
|
+
showBackdrop = true,
|
|
162
|
+
disableBackdropClick = false,
|
|
163
|
+
backdropClassName,
|
|
164
|
+
|
|
165
|
+
// Behavior
|
|
166
|
+
disableEscapeKeyDown = false,
|
|
167
|
+
disabled,
|
|
168
|
+
onClose,
|
|
169
|
+
|
|
170
|
+
// Content
|
|
171
|
+
children,
|
|
172
|
+
|
|
173
|
+
// Styling
|
|
174
|
+
className,
|
|
175
|
+
modalBoxClassName,
|
|
176
|
+
contentClassName,
|
|
177
|
+
titleClassName,
|
|
178
|
+
|
|
179
|
+
// Ref
|
|
180
|
+
ref,
|
|
181
|
+
}
|
|
182
|
+
= props ;
|
|
183
|
+
|
|
86
184
|
const dialogRef = useRef( null ) ;
|
|
87
185
|
const titleId = useId() ;
|
|
88
186
|
|
|
187
|
+
const hasCustomFooter = footerNode !== undefined && footerNode !== null ;
|
|
188
|
+
|
|
189
|
+
if ( process.env.NODE_ENV !== 'production' && hasCustomFooter )
|
|
190
|
+
{
|
|
191
|
+
const overlap = FOOTER_NODE_OVERRIDE_PROPS.filter( key => key in props && props[ key ] !== undefined ) ;
|
|
192
|
+
if ( overlap.length > 0 )
|
|
193
|
+
{
|
|
194
|
+
console.warn(
|
|
195
|
+
`[Modal] \`footerNode\` is set, the following overlapping prop(s) are ignored: ${ overlap.join( ', ' ) }. ` +
|
|
196
|
+
`Move this content into \`footerNode\` itself.`
|
|
197
|
+
) ;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
89
201
|
const isAboveBreakpoint = fullScreenBreakpoint
|
|
90
202
|
? useBreakpoint( fullScreenBreakpoint )
|
|
91
203
|
: true ;
|
|
@@ -165,6 +277,7 @@ const Modal =
|
|
|
165
277
|
fullScreen : isFullScreen,
|
|
166
278
|
fullWidth,
|
|
167
279
|
placement : responsivePlacement || placement,
|
|
280
|
+
flexLayout : hasCustomFooter,
|
|
168
281
|
className : modalBoxClassName,
|
|
169
282
|
}) ;
|
|
170
283
|
|
|
@@ -177,6 +290,14 @@ const Modal =
|
|
|
177
290
|
className : backdropClassName,
|
|
178
291
|
}) ;
|
|
179
292
|
|
|
293
|
+
const contentClasses = hasCustomFooter
|
|
294
|
+
? cn( 'flex-1 min-h-0 overflow-y-auto p-2 py-4' , contentClassName )
|
|
295
|
+
: cn( 'overflow-y-auto h-full p-2 py-4' , contentClassName ) ;
|
|
296
|
+
|
|
297
|
+
const headerClasses = hasCustomFooter
|
|
298
|
+
? cn( 'shrink-0 bg-base-100 border-b border-base-300/60 z-10 p-2 pb-3' , headerClassName )
|
|
299
|
+
: cn( 'sticky top-0 bg-base-100 border-b border-base-300/60 z-10 p-2 pb-3' , headerClassName ) ;
|
|
300
|
+
|
|
180
301
|
return (
|
|
181
302
|
<dialog
|
|
182
303
|
aria-labelledby = { showTitle && title ? titleId : undefined }
|
|
@@ -197,7 +318,7 @@ const Modal =
|
|
|
197
318
|
<div className={ modalBoxClasses }>
|
|
198
319
|
|
|
199
320
|
{ showHeader && (
|
|
200
|
-
<div className={
|
|
321
|
+
<div className={ headerClasses }>
|
|
201
322
|
<div className="flex items-center gap-3">
|
|
202
323
|
|
|
203
324
|
{ showIcon && icon && (
|
|
@@ -232,11 +353,15 @@ const Modal =
|
|
|
232
353
|
</div>
|
|
233
354
|
)}
|
|
234
355
|
|
|
235
|
-
<div className={
|
|
356
|
+
<div className={ contentClasses }>
|
|
236
357
|
{ children }
|
|
237
358
|
</div>
|
|
238
359
|
|
|
239
|
-
{
|
|
360
|
+
{ hasCustomFooter ? (
|
|
361
|
+
<div className="shrink-0 bg-base-100 border-t border-base-300/60">
|
|
362
|
+
{ footerNode }
|
|
363
|
+
</div>
|
|
364
|
+
) : showFooter && (
|
|
240
365
|
<div className={`sticky bottom-0 bg-base-100 border-t border-base-300/60 p-0 ${ footerClassName || '' }`}>
|
|
241
366
|
|
|
242
367
|
<div className={ modalActionClasses }>
|
|
@@ -274,4 +399,4 @@ const Modal =
|
|
|
274
399
|
|
|
275
400
|
Modal.displayName = 'Modal' ;
|
|
276
401
|
|
|
277
|
-
export default Modal ;
|
|
402
|
+
export default Modal ;
|
|
@@ -4,6 +4,19 @@ import { createContext } from 'react' ;
|
|
|
4
4
|
* @typedef {Object} NavigationContextValue
|
|
5
5
|
* @property {Object[] | null} navigation - Current navigation items.
|
|
6
6
|
* @property {(value: Object[]) => void} setNavigation - Function to update navigation.
|
|
7
|
+
* @property {'open' | 'closed' | 'auto'} defaultMode - Default open/closed
|
|
8
|
+
* mode applied to collapse items that have no persisted state and no
|
|
9
|
+
* per-item `defaultOpen` override.
|
|
10
|
+
* @property {Record<string, boolean>} collapses - Per-id open/closed map
|
|
11
|
+
* reflecting explicit user choices (post-hydration).
|
|
12
|
+
* @property {(id: string, open: boolean) => void} setCollapse - Records an
|
|
13
|
+
* explicit user choice for a single collapse and persists it when a
|
|
14
|
+
* `storageKey` was supplied to the provider.
|
|
15
|
+
* @property {(id: string, item?: Object) => boolean} getCollapseOpen -
|
|
16
|
+
* Returns the effective open state for a collapse, applying the
|
|
17
|
+
* priority chain: persisted → auto(pathname) → item.defaultOpen → defaultMode.
|
|
18
|
+
* @property {string | null} pathname - Current pathname, captured by the
|
|
19
|
+
* provider so consumers (e.g. `Collapse`) don't have to read it again.
|
|
7
20
|
*/
|
|
8
21
|
|
|
9
22
|
/**
|
|
@@ -15,4 +28,4 @@ const NavigationContext = createContext(
|
|
|
15
28
|
|
|
16
29
|
NavigationContext.displayName = 'NavigationContext' ;
|
|
17
30
|
|
|
18
|
-
export default NavigationContext ;
|
|
31
|
+
export default NavigationContext ;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* localStorage helpers for the navigation collapse state.
|
|
3
|
+
*
|
|
4
|
+
* @module contexts/navigation/helpers/collapseStorage
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import notEmpty from 'vegas-js-core/src/strings/notEmpty' ;
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
COLLAPSE_STATE_ITEMS_KEY ,
|
|
11
|
+
COLLAPSE_STATE_VERSION ,
|
|
12
|
+
COLLAPSE_STATE_VERSION_KEY ,
|
|
13
|
+
} from './constants' ;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Returns true when `window.localStorage` can be read or written.
|
|
17
|
+
* Catches SSR (no `window`), Safari private mode, disabled storage,
|
|
18
|
+
* and any other host-thrown error.
|
|
19
|
+
*
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
const isStorageAvailable = () =>
|
|
23
|
+
{
|
|
24
|
+
try
|
|
25
|
+
{
|
|
26
|
+
return typeof window !== 'undefined'
|
|
27
|
+
&& typeof window.localStorage !== 'undefined' ;
|
|
28
|
+
}
|
|
29
|
+
catch
|
|
30
|
+
{
|
|
31
|
+
return false ;
|
|
32
|
+
}
|
|
33
|
+
} ;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reads the persisted collapse state for a given storage key.
|
|
37
|
+
*
|
|
38
|
+
* Returns an empty object when the key is missing/empty, when storage is
|
|
39
|
+
* unavailable (SSR, private mode, quota errors), when the payload is not
|
|
40
|
+
* JSON, or when the schema version does not match the current one.
|
|
41
|
+
*
|
|
42
|
+
* Never throws.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} [key] - Opaque storage key chosen by the consumer.
|
|
45
|
+
* @returns {Record<string, boolean>} Per-id open/closed map (may be empty).
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```js
|
|
49
|
+
* const state = loadCollapseState( 'my-app:nav:v1' ) ;
|
|
50
|
+
* // → { lab: true, layouts: false }
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export const loadCollapseState = ( key ) =>
|
|
54
|
+
{
|
|
55
|
+
if ( !notEmpty( key ) || !isStorageAvailable() )
|
|
56
|
+
{
|
|
57
|
+
return {} ;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try
|
|
61
|
+
{
|
|
62
|
+
const raw = window.localStorage.getItem( key ) ;
|
|
63
|
+
|
|
64
|
+
if ( !raw )
|
|
65
|
+
{
|
|
66
|
+
return {} ;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const parsed = JSON.parse( raw ) ;
|
|
70
|
+
|
|
71
|
+
if ( !parsed || typeof parsed !== 'object' )
|
|
72
|
+
{
|
|
73
|
+
return {} ;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if ( parsed[ COLLAPSE_STATE_VERSION_KEY ] !== COLLAPSE_STATE_VERSION )
|
|
77
|
+
{
|
|
78
|
+
return {} ;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const items = parsed[ COLLAPSE_STATE_ITEMS_KEY ] ;
|
|
82
|
+
|
|
83
|
+
return ( items && typeof items === 'object' ) ? items : {} ;
|
|
84
|
+
}
|
|
85
|
+
catch
|
|
86
|
+
{
|
|
87
|
+
return {} ;
|
|
88
|
+
}
|
|
89
|
+
} ;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Persists the per-id open/closed map under the given storage key.
|
|
93
|
+
*
|
|
94
|
+
* Silently no-ops when the key is empty, when storage is unavailable, or
|
|
95
|
+
* when the write fails (quota, locked storage, etc.). Never throws.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} [key] - Opaque storage key chosen by the consumer.
|
|
98
|
+
* @param {Record<string, boolean>} [state] - Per-id open/closed map.
|
|
99
|
+
* @returns {boolean} `true` when the write succeeded, `false` otherwise.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```js
|
|
103
|
+
* persistCollapseState( 'my-app:nav:v1' , { lab: true } ) ;
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export const persistCollapseState = ( key , state ) =>
|
|
107
|
+
{
|
|
108
|
+
if ( !notEmpty( key ) || !isStorageAvailable() )
|
|
109
|
+
{
|
|
110
|
+
return false ;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try
|
|
114
|
+
{
|
|
115
|
+
const payload =
|
|
116
|
+
{
|
|
117
|
+
[ COLLAPSE_STATE_VERSION_KEY ] : COLLAPSE_STATE_VERSION ,
|
|
118
|
+
[ COLLAPSE_STATE_ITEMS_KEY ] : state ?? {} ,
|
|
119
|
+
} ;
|
|
120
|
+
|
|
121
|
+
window.localStorage.setItem( key , JSON.stringify( payload ) ) ;
|
|
122
|
+
|
|
123
|
+
return true ;
|
|
124
|
+
}
|
|
125
|
+
catch
|
|
126
|
+
{
|
|
127
|
+
return false ;
|
|
128
|
+
}
|
|
129
|
+
} ;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation collapse persistence constants.
|
|
3
|
+
*
|
|
4
|
+
* @module contexts/navigation/helpers/constants
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Default-mode identifiers for collapse open/closed state.
|
|
9
|
+
*
|
|
10
|
+
* - `OPEN` — every collapse starts open (current legacy behaviour).
|
|
11
|
+
* - `CLOSED` — every collapse starts closed.
|
|
12
|
+
* - `AUTO` — open when a descendant matches the current pathname,
|
|
13
|
+
* closed otherwise.
|
|
14
|
+
*
|
|
15
|
+
* @type {Readonly<{ OPEN: 'open', CLOSED: 'closed', AUTO: 'auto' }>}
|
|
16
|
+
*/
|
|
17
|
+
export const COLLAPSE_MODES = Object.freeze
|
|
18
|
+
({
|
|
19
|
+
OPEN : 'open' ,
|
|
20
|
+
CLOSED : 'closed' ,
|
|
21
|
+
AUTO : 'auto' ,
|
|
22
|
+
}) ;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Allowed values for the `defaultMode` prop of `NavigationProvider`.
|
|
26
|
+
*
|
|
27
|
+
* @type {ReadonlyArray<'open' | 'closed' | 'auto'>}
|
|
28
|
+
*/
|
|
29
|
+
export const COLLAPSE_MODE_VALUES = Object.freeze
|
|
30
|
+
([
|
|
31
|
+
COLLAPSE_MODES.OPEN ,
|
|
32
|
+
COLLAPSE_MODES.CLOSED ,
|
|
33
|
+
COLLAPSE_MODES.AUTO ,
|
|
34
|
+
]) ;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Default mode used when `NavigationProvider` does not receive an explicit
|
|
38
|
+
* `defaultMode`. Matches the legacy behaviour of `<details open>`.
|
|
39
|
+
*/
|
|
40
|
+
export const DEFAULT_COLLAPSE_MODE = COLLAPSE_MODES.OPEN ;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Schema version of the payload written to `localStorage`.
|
|
44
|
+
* Bump when the on-disk shape changes — older payloads are then ignored
|
|
45
|
+
* silently rather than throwing or surfacing stale state.
|
|
46
|
+
*/
|
|
47
|
+
export const COLLAPSE_STATE_VERSION = 1 ;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Key inside the persisted JSON payload that holds the per-id boolean map.
|
|
51
|
+
* Centralised here so the storage helpers and any future migration code
|
|
52
|
+
* agree on the same string.
|
|
53
|
+
*/
|
|
54
|
+
export const COLLAPSE_STATE_ITEMS_KEY = 'items' ;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Key inside the persisted JSON payload that holds the schema version.
|
|
58
|
+
*/
|
|
59
|
+
export const COLLAPSE_STATE_VERSION_KEY = 'v' ;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursive matcher: tells whether a navigation item (typically a
|
|
3
|
+
* `COLLAPSE`) contains a descendant `LINK` whose `path` matches the
|
|
4
|
+
* current pathname.
|
|
5
|
+
*
|
|
6
|
+
* @module contexts/navigation/helpers/containsActivePath
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import notEmpty from 'vegas-js-core/src/strings/notEmpty' ;
|
|
10
|
+
import startsWith from 'vegas-js-core/src/strings/startsWith' ;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Walks the `items` tree under the given navigation node and returns
|
|
14
|
+
* `true` as soon as one descendant has a `path` that matches `pathname`
|
|
15
|
+
* with `startsWith`. The match semantics are intentionally identical to
|
|
16
|
+
* the active-link rule used in `Link.jsx`.
|
|
17
|
+
*
|
|
18
|
+
* Pure, no React, no side effects. Safe on `null`/`undefined`.
|
|
19
|
+
*
|
|
20
|
+
* @param {Object} [item] - Navigation node (collapse or link).
|
|
21
|
+
* @param {Object[]} [item.items] - Children of a collapse node.
|
|
22
|
+
* @param {string} [item.path] - Path of a link node.
|
|
23
|
+
* @param {string} [pathname] - Current pathname (e.g. from `usePathname`).
|
|
24
|
+
* @returns {boolean}
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```js
|
|
28
|
+
* containsActivePath(
|
|
29
|
+
* { items: [ { path: '/lab/grid' } ] } ,
|
|
30
|
+
* '/lab/grid'
|
|
31
|
+
* ) ; // → true
|
|
32
|
+
* ```
|
|
33
|
+
*/
|
|
34
|
+
const containsActivePath = ( item , pathname ) =>
|
|
35
|
+
{
|
|
36
|
+
if ( !item || !notEmpty( pathname ) )
|
|
37
|
+
{
|
|
38
|
+
return false ;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if ( notEmpty( item.path ) && startsWith( pathname , item.path ) )
|
|
42
|
+
{
|
|
43
|
+
return true ;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if ( !Array.isArray( item.items ) || item.items.length === 0 )
|
|
47
|
+
{
|
|
48
|
+
return false ;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return item.items.some( ( child ) => containsActivePath( child , pathname ) ) ;
|
|
52
|
+
} ;
|
|
53
|
+
|
|
54
|
+
export default containsActivePath ;
|