oihana-next-ui 0.1.47 → 0.2.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/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 +1 -1
- 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/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/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
|
(
|
|
@@ -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 ;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the ids of every collapse whose subtree contains the current
|
|
3
|
+
* pathname.
|
|
4
|
+
*
|
|
5
|
+
* @module contexts/navigation/helpers/findActiveAncestorIds
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import notEmpty from 'vegas-js-core/src/strings/notEmpty' ;
|
|
9
|
+
|
|
10
|
+
import { COLLAPSE } from '../types' ;
|
|
11
|
+
import containsActivePath from './containsActivePath' ;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Walks the navigation tree and returns the id of every `COLLAPSE` node
|
|
15
|
+
* whose subtree contains a `LINK` matching `pathname`. Order is
|
|
16
|
+
* outer-first (root collapses come before their nested ones), which is
|
|
17
|
+
* convenient for callers that want to open ancestors top-down.
|
|
18
|
+
*
|
|
19
|
+
* Pure, no React, no side effects. Safe on `null`/`undefined`.
|
|
20
|
+
*
|
|
21
|
+
* @param {Object[]} [items] - Navigation tree (typically the provider's
|
|
22
|
+
* internal `navigation` array).
|
|
23
|
+
* @param {string} [pathname] - Current pathname.
|
|
24
|
+
* @returns {string[]} Array of collapse ids; empty when nothing matches.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```js
|
|
28
|
+
* findActiveAncestorIds(
|
|
29
|
+
* [{ id: 'lab', type: 'collapse', items: [
|
|
30
|
+
* { id: 'navigation', type: 'collapse', items: [
|
|
31
|
+
* { type: 'link', path: '/lab/menus' }
|
|
32
|
+
* ] }
|
|
33
|
+
* ] }],
|
|
34
|
+
* '/lab/menus'
|
|
35
|
+
* ) ; // → [ 'lab' , 'navigation' ]
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
const findActiveAncestorIds = ( items , pathname ) =>
|
|
39
|
+
{
|
|
40
|
+
const ids = [] ;
|
|
41
|
+
|
|
42
|
+
if ( !Array.isArray( items ) || items.length === 0 || !notEmpty( pathname ) )
|
|
43
|
+
{
|
|
44
|
+
return ids ;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const walk = ( list ) =>
|
|
48
|
+
{
|
|
49
|
+
for ( const item of list )
|
|
50
|
+
{
|
|
51
|
+
if ( !item || item.type !== COLLAPSE )
|
|
52
|
+
{
|
|
53
|
+
continue ;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if ( !containsActivePath( item , pathname ) )
|
|
57
|
+
{
|
|
58
|
+
continue ;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if ( notEmpty( item.id ) )
|
|
62
|
+
{
|
|
63
|
+
ids.push( item.id ) ;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if ( Array.isArray( item.items ) && item.items.length > 0 )
|
|
67
|
+
{
|
|
68
|
+
walk( item.items ) ;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} ;
|
|
72
|
+
|
|
73
|
+
walk( items ) ;
|
|
74
|
+
|
|
75
|
+
return ids ;
|
|
76
|
+
} ;
|
|
77
|
+
|
|
78
|
+
export default findActiveAncestorIds ;
|
|
@@ -15,6 +15,8 @@ import mapI18nBadge from './mapI18nBadge'
|
|
|
15
15
|
* @param {React.ComponentType} [item.Icon] - Icon component.
|
|
16
16
|
* @param {Object} [item.badge] - Badge configuration.
|
|
17
17
|
* @param {Object[]} [item.items] - Child items (for collapse type).
|
|
18
|
+
* @param {boolean} [item.defaultOpen] - Per-item override for the
|
|
19
|
+
* collapse open/closed default. Only meaningful when `type === 'collapse'`.
|
|
18
20
|
* @param {Object} locale - Locale data.
|
|
19
21
|
*
|
|
20
22
|
* @returns {Object} Mapped navigation item with localized label.
|
|
@@ -51,7 +53,7 @@ import mapI18nBadge from './mapI18nBadge'
|
|
|
51
53
|
*/
|
|
52
54
|
const mapI18nItem = ( item , locale ) =>
|
|
53
55
|
{
|
|
54
|
-
const { badge , className , Icon , id , items , label , path , type } = item ;
|
|
56
|
+
const { badge , className , defaultOpen , Icon , id , items , label , path , type } = item ;
|
|
55
57
|
|
|
56
58
|
const mappedItems = type === COLLAPSE && items?.length > 0
|
|
57
59
|
? items.map( ( child ) => mapI18nItem( child , locale ) )
|
|
@@ -64,14 +66,15 @@ const mapI18nItem = ( item , locale ) =>
|
|
|
64
66
|
const mappedLabel = locale?.[ id ] ?? label ?? '' ;
|
|
65
67
|
|
|
66
68
|
return {
|
|
67
|
-
badge
|
|
68
|
-
className
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
69
|
+
badge : mappedBadge ,
|
|
70
|
+
className ,
|
|
71
|
+
defaultOpen ,
|
|
72
|
+
Icon ,
|
|
73
|
+
id ,
|
|
74
|
+
items : mappedItems ,
|
|
75
|
+
label : mappedLabel ,
|
|
76
|
+
path ,
|
|
77
|
+
type ,
|
|
75
78
|
} ;
|
|
76
79
|
} ;
|
|
77
80
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure resolver for the open/closed state of a single collapse node.
|
|
3
|
+
*
|
|
4
|
+
* @module contexts/navigation/helpers/resolveCollapseOpen
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import notEmpty from 'vegas-js-core/src/strings/notEmpty' ;
|
|
8
|
+
|
|
9
|
+
import { COLLAPSE_MODES , DEFAULT_COLLAPSE_MODE } from './constants' ;
|
|
10
|
+
import containsActivePath from './containsActivePath' ;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolves the effective open state for a collapse, in this priority
|
|
14
|
+
* order:
|
|
15
|
+
*
|
|
16
|
+
* 1. Persisted user choice (`persisted[id]`) — explicit win.
|
|
17
|
+
* 2. Auto-mode pathname match — only when `defaultMode === 'auto'`.
|
|
18
|
+
* 3. Per-item `defaultOpen` — author override on the item.
|
|
19
|
+
* 4. Global `defaultMode` — `'open'` truthy, anything else falsy.
|
|
20
|
+
*
|
|
21
|
+
* Pure: no React, no DOM, no storage access. Safe to call during render.
|
|
22
|
+
*
|
|
23
|
+
* @param {Object} params
|
|
24
|
+
* @param {string} [params.id] - Item id (used to look up `persisted`).
|
|
25
|
+
* @param {Object} [params.item] - The collapse item itself (read for
|
|
26
|
+
* `defaultOpen` and walked for the auto-mode pathname match).
|
|
27
|
+
* @param {Record<string, boolean>} [params.persisted] - Map loaded from
|
|
28
|
+
* storage; missing keys mean "no user choice yet".
|
|
29
|
+
* @param {string} [params.pathname] - Current pathname. Only consulted
|
|
30
|
+
* in `auto` mode.
|
|
31
|
+
* @param {'open' | 'closed' | 'auto'} [params.defaultMode] - Global
|
|
32
|
+
* provider mode. Defaults to `'open'`.
|
|
33
|
+
* @returns {boolean} Effective open state.
|
|
34
|
+
*/
|
|
35
|
+
const resolveCollapseOpen = (
|
|
36
|
+
{
|
|
37
|
+
id ,
|
|
38
|
+
item ,
|
|
39
|
+
persisted ,
|
|
40
|
+
pathname ,
|
|
41
|
+
defaultMode = DEFAULT_COLLAPSE_MODE ,
|
|
42
|
+
} = {} ) =>
|
|
43
|
+
{
|
|
44
|
+
if ( notEmpty( id ) && persisted && Object.hasOwn( persisted , id ) )
|
|
45
|
+
{
|
|
46
|
+
return Boolean( persisted[ id ] ) ;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if ( defaultMode === COLLAPSE_MODES.AUTO && containsActivePath( item , pathname ) )
|
|
50
|
+
{
|
|
51
|
+
return true ;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if ( item && typeof item.defaultOpen === 'boolean' )
|
|
55
|
+
{
|
|
56
|
+
return item.defaultOpen ;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return defaultMode === COLLAPSE_MODES.OPEN ;
|
|
60
|
+
} ;
|
|
61
|
+
|
|
62
|
+
export default resolveCollapseOpen ;
|
|
@@ -1,32 +1,51 @@
|
|
|
1
1
|
'use client' ;
|
|
2
2
|
|
|
3
|
-
import { useState } from 'react' ;
|
|
3
|
+
import { useCallback , useEffect , useMemo , useRef , useState } from 'react' ;
|
|
4
|
+
|
|
5
|
+
import { usePathname } from 'next/navigation' ;
|
|
4
6
|
|
|
5
7
|
import useI18n from '../locale/useI18n' ;
|
|
6
8
|
|
|
9
|
+
import {
|
|
10
|
+
loadCollapseState ,
|
|
11
|
+
persistCollapseState ,
|
|
12
|
+
} from './helpers/collapseStorage' ;
|
|
13
|
+
import {
|
|
14
|
+
COLLAPSE_MODES ,
|
|
15
|
+
COLLAPSE_MODE_VALUES ,
|
|
16
|
+
DEFAULT_COLLAPSE_MODE ,
|
|
17
|
+
} from './helpers/constants' ;
|
|
18
|
+
import findActiveAncestorIds from './helpers/findActiveAncestorIds' ;
|
|
7
19
|
import mapI18nItem from './helpers/mapI18nItem' ;
|
|
20
|
+
import resolveCollapseOpen from './helpers/resolveCollapseOpen' ;
|
|
8
21
|
|
|
9
22
|
import NavigationContext from './context' ;
|
|
10
23
|
|
|
11
24
|
/**
|
|
12
|
-
* Provides navigation context with i18n support
|
|
25
|
+
* Provides navigation context with i18n support and optional collapse
|
|
26
|
+
* state persistence.
|
|
13
27
|
*
|
|
14
28
|
* @param {Object} props
|
|
15
29
|
* @param {React.ReactNode} props.children - Child components.
|
|
16
30
|
* @param {Object[]} props.defaultNavigation - Default navigation items.
|
|
17
31
|
* @param {string} [props.i18nPath='navigation'] - Locale path for navigation labels.
|
|
32
|
+
* @param {'open' | 'closed' | 'auto'} [props.defaultMode='open'] - Open/closed
|
|
33
|
+
* default applied to collapse items. `'auto'` opens collapses whose
|
|
34
|
+
* subtree contains the current pathname.
|
|
35
|
+
* @param {string} [props.storageKey] - Opt-in localStorage key. When
|
|
36
|
+
* omitted, collapse state lives only in memory and resets on reload.
|
|
37
|
+
* Choose a namespaced, versioned key in your app (e.g. `'my-app:nav:v1'`)
|
|
38
|
+
* to avoid cross-app collisions and to allow future schema bumps.
|
|
18
39
|
*
|
|
19
40
|
* @returns {React.ReactElement} The provider component.
|
|
20
41
|
*
|
|
21
42
|
* @example
|
|
22
43
|
* ```jsx
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* <NavigationProvider defaultNavigation={ navigation }>
|
|
44
|
+
* <NavigationProvider
|
|
45
|
+
* defaultNavigation = { navigation }
|
|
46
|
+
* defaultMode = "auto"
|
|
47
|
+
* storageKey = "my-app:nav:v1"
|
|
48
|
+
* >
|
|
30
49
|
* { children }
|
|
31
50
|
* </NavigationProvider>
|
|
32
51
|
* ```
|
|
@@ -36,9 +55,16 @@ const NavigationProvider =
|
|
|
36
55
|
children ,
|
|
37
56
|
defaultNavigation ,
|
|
38
57
|
i18nPath = 'navigation' ,
|
|
58
|
+
defaultMode : defaultModeProp = DEFAULT_COLLAPSE_MODE ,
|
|
59
|
+
storageKey ,
|
|
39
60
|
} ) =>
|
|
40
61
|
{
|
|
41
|
-
const
|
|
62
|
+
const defaultMode = COLLAPSE_MODE_VALUES.includes( defaultModeProp )
|
|
63
|
+
? defaultModeProp
|
|
64
|
+
: DEFAULT_COLLAPSE_MODE ;
|
|
65
|
+
|
|
66
|
+
const locale = useI18n( i18nPath ) ;
|
|
67
|
+
const pathname = usePathname() ;
|
|
42
68
|
|
|
43
69
|
const [ _navigation , setNavigation ] = useState( defaultNavigation ) ;
|
|
44
70
|
|
|
@@ -46,11 +72,137 @@ const NavigationProvider =
|
|
|
46
72
|
? _navigation.map( ( item ) => mapI18nItem( item , locale ) )
|
|
47
73
|
: null ;
|
|
48
74
|
|
|
75
|
+
// Collapse state is initialised empty so the first server render is
|
|
76
|
+
// deterministic (no localStorage on the server). Hydration happens in
|
|
77
|
+
// the effect below, after mount, which never causes a hydration
|
|
78
|
+
// mismatch because `<details>` is forgiving of an `open` flip.
|
|
79
|
+
const [ collapses , setCollapses ] = useState( {} ) ;
|
|
80
|
+
|
|
81
|
+
const hydratedRef = useRef( false ) ;
|
|
82
|
+
|
|
83
|
+
useEffect( () =>
|
|
84
|
+
{
|
|
85
|
+
if ( hydratedRef.current )
|
|
86
|
+
{
|
|
87
|
+
return ;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
hydratedRef.current = true ;
|
|
91
|
+
|
|
92
|
+
const loaded = loadCollapseState( storageKey ) ;
|
|
93
|
+
|
|
94
|
+
if ( loaded && Object.keys( loaded ).length > 0 )
|
|
95
|
+
{
|
|
96
|
+
setCollapses( loaded ) ;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
, [ storageKey ] ) ;
|
|
100
|
+
|
|
101
|
+
// Auto-mode "follow the route" behaviour: on every real pathname
|
|
102
|
+
// transition (not on the initial mount, not on reload), force-open
|
|
103
|
+
// every collapse whose subtree contains the new pathname and
|
|
104
|
+
// persist the change. This mirrors what VS Code / GitHub sidebars
|
|
105
|
+
// do — navigating into a nested page reveals the hosting branch
|
|
106
|
+
// even if the user had collapsed it earlier. The user can still
|
|
107
|
+
// collapse it back; the override only kicks in on the next real
|
|
108
|
+
// pathname change. The initial mount is skipped on purpose so that
|
|
109
|
+
// a refresh on the current page respects the persisted choice.
|
|
110
|
+
const previousPathnameRef = useRef( null ) ;
|
|
111
|
+
|
|
112
|
+
useEffect( () =>
|
|
113
|
+
{
|
|
114
|
+
const previous = previousPathnameRef.current ;
|
|
115
|
+
previousPathnameRef.current = pathname ;
|
|
116
|
+
|
|
117
|
+
if ( defaultMode !== COLLAPSE_MODES.AUTO || !pathname || previous === null || previous === pathname )
|
|
118
|
+
{
|
|
119
|
+
return ;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const ancestorIds = findActiveAncestorIds( _navigation , pathname ) ;
|
|
123
|
+
|
|
124
|
+
if ( ancestorIds.length === 0 )
|
|
125
|
+
{
|
|
126
|
+
return ;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
setCollapses( ( previous ) =>
|
|
130
|
+
{
|
|
131
|
+
let changed = false ;
|
|
132
|
+
const next = { ...previous } ;
|
|
133
|
+
|
|
134
|
+
for ( const id of ancestorIds )
|
|
135
|
+
{
|
|
136
|
+
if ( next[ id ] !== true )
|
|
137
|
+
{
|
|
138
|
+
next[ id ] = true ;
|
|
139
|
+
changed = true ;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if ( !changed )
|
|
144
|
+
{
|
|
145
|
+
return previous ;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
persistCollapseState( storageKey , next ) ;
|
|
149
|
+
|
|
150
|
+
return next ;
|
|
151
|
+
} ) ;
|
|
152
|
+
}
|
|
153
|
+
, [ pathname , defaultMode , storageKey , _navigation ] ) ;
|
|
154
|
+
|
|
155
|
+
const setCollapse = useCallback( ( id , open ) =>
|
|
156
|
+
{
|
|
157
|
+
if ( !id )
|
|
158
|
+
{
|
|
159
|
+
return ;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
setCollapses( ( previous ) =>
|
|
163
|
+
{
|
|
164
|
+
if ( previous[ id ] === open )
|
|
165
|
+
{
|
|
166
|
+
return previous ;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const next = { ...previous , [ id ] : open } ;
|
|
170
|
+
|
|
171
|
+
persistCollapseState( storageKey , next ) ;
|
|
172
|
+
|
|
173
|
+
return next ;
|
|
174
|
+
} ) ;
|
|
175
|
+
}
|
|
176
|
+
, [ storageKey ] ) ;
|
|
177
|
+
|
|
178
|
+
const getCollapseOpen = useCallback( ( id , item ) =>
|
|
179
|
+
resolveCollapseOpen
|
|
180
|
+
({
|
|
181
|
+
id ,
|
|
182
|
+
item ,
|
|
183
|
+
persisted : collapses ,
|
|
184
|
+
pathname ,
|
|
185
|
+
defaultMode ,
|
|
186
|
+
})
|
|
187
|
+
, [ collapses , pathname , defaultMode ] ) ;
|
|
188
|
+
|
|
189
|
+
const value = useMemo( () =>
|
|
190
|
+
({
|
|
191
|
+
navigation ,
|
|
192
|
+
setNavigation ,
|
|
193
|
+
defaultMode ,
|
|
194
|
+
collapses ,
|
|
195
|
+
setCollapse ,
|
|
196
|
+
getCollapseOpen ,
|
|
197
|
+
pathname ,
|
|
198
|
+
})
|
|
199
|
+
, [ navigation , defaultMode , collapses , setCollapse , getCollapseOpen , pathname ] ) ;
|
|
200
|
+
|
|
49
201
|
return (
|
|
50
|
-
<NavigationContext value={
|
|
202
|
+
<NavigationContext value={ value }>
|
|
51
203
|
{ children }
|
|
52
204
|
</NavigationContext>
|
|
53
205
|
) ;
|
|
54
206
|
} ;
|
|
55
207
|
|
|
56
|
-
export default NavigationProvider ;
|
|
208
|
+
export default NavigationProvider ;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client' ;
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react' ;
|
|
4
|
+
|
|
5
|
+
import useNavigation from './useNavigation' ;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* React hook that exposes the open/closed state and toggle helpers for a
|
|
9
|
+
* single collapse, identified by `id`.
|
|
10
|
+
*
|
|
11
|
+
* Useful for building custom UIs on top of the navigation tree (for
|
|
12
|
+
* instance an "expand all / collapse all" control, or a custom collapse
|
|
13
|
+
* skin that does not reuse `<Collapse>`). Inside the standard
|
|
14
|
+
* `<Collapse>` component the same wiring happens internally — you only
|
|
15
|
+
* need this hook for bespoke views.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} id - Stable item id matching the navigation config.
|
|
18
|
+
* @param {Object} [item] - Optional item reference. When provided, the
|
|
19
|
+
* `auto` mode and the per-item `defaultOpen` override are honoured;
|
|
20
|
+
* when omitted, only persisted state and the global `defaultMode` are
|
|
21
|
+
* considered.
|
|
22
|
+
*
|
|
23
|
+
* @returns {{
|
|
24
|
+
* open: boolean,
|
|
25
|
+
* toggle: () => void,
|
|
26
|
+
* set: (open: boolean) => void
|
|
27
|
+
* }}
|
|
28
|
+
*
|
|
29
|
+
* @throws {Error} If used outside `NavigationProvider`.
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```jsx
|
|
33
|
+
* const { open , toggle } = useNavigationCollapse( 'lab' ) ;
|
|
34
|
+
*
|
|
35
|
+
* return (
|
|
36
|
+
* <button type="button" onClick={ toggle }>
|
|
37
|
+
* { open ? 'Hide lab' : 'Show lab' }
|
|
38
|
+
* </button>
|
|
39
|
+
* ) ;
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
const useNavigationCollapse = ( id , item ) =>
|
|
43
|
+
{
|
|
44
|
+
const { getCollapseOpen , setCollapse } = useNavigation() ;
|
|
45
|
+
|
|
46
|
+
const open = getCollapseOpen( id , item ) ;
|
|
47
|
+
|
|
48
|
+
const set = useCallback( ( next ) =>
|
|
49
|
+
{
|
|
50
|
+
setCollapse( id , Boolean( next ) ) ;
|
|
51
|
+
}
|
|
52
|
+
, [ id , setCollapse ] ) ;
|
|
53
|
+
|
|
54
|
+
const toggle = useCallback( () =>
|
|
55
|
+
{
|
|
56
|
+
setCollapse( id , !open ) ;
|
|
57
|
+
}
|
|
58
|
+
, [ id , open , setCollapse ] ) ;
|
|
59
|
+
|
|
60
|
+
return { open , toggle , set } ;
|
|
61
|
+
} ;
|
|
62
|
+
|
|
63
|
+
export default useNavigationCollapse ;
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
'use client' ;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CollapsePersistenceDemo — three side-by-side `NavigationProvider`
|
|
5
|
+
* instances showcasing the `defaultMode` and `storageKey` props.
|
|
6
|
+
*
|
|
7
|
+
* Each card mounts its own provider with a tiny three-item tree and
|
|
8
|
+
* mirrors its `localStorage` payload, so the persistence and
|
|
9
|
+
* follow-the-route behaviours can be exercised without leaving the
|
|
10
|
+
* page. The cards intentionally use distinct storage keys to avoid
|
|
11
|
+
* cross-talk.
|
|
12
|
+
*
|
|
13
|
+
* @module demo/menus/CollapsePersistenceDemo
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { useEffect , useState } from 'react' ;
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
MdFolder as GroupIcon ,
|
|
20
|
+
MdInsertLink as LinkIcon ,
|
|
21
|
+
MdSubdirectoryArrowRight as ChildIcon ,
|
|
22
|
+
} from 'react-icons/md' ;
|
|
23
|
+
|
|
24
|
+
import NavigationProvider from '@/contexts/navigation/provider' ;
|
|
25
|
+
import useNavigation from '@/contexts/navigation/useNavigation' ;
|
|
26
|
+
import { COLLAPSE , LINK } from '@/contexts/navigation/types' ;
|
|
27
|
+
|
|
28
|
+
import Menu from '@/display/ui/navigation/Menu' ;
|
|
29
|
+
|
|
30
|
+
const STORAGE_KEY_CLOSED = 'oihana-next-ui:demo:collapses:closed' ;
|
|
31
|
+
const STORAGE_KEY_AUTO = 'oihana-next-ui:demo:collapses:auto' ;
|
|
32
|
+
|
|
33
|
+
const demoItems =
|
|
34
|
+
[
|
|
35
|
+
{
|
|
36
|
+
id : 'group-a' ,
|
|
37
|
+
type : COLLAPSE ,
|
|
38
|
+
label : 'Group A' ,
|
|
39
|
+
Icon : GroupIcon ,
|
|
40
|
+
items :
|
|
41
|
+
[
|
|
42
|
+
{ id : 'a-menus' , type : LINK , label : 'Menus page' , path : '/lab/menus' , Icon : LinkIcon } ,
|
|
43
|
+
{ id : 'a-other' , type : LINK , label : 'Fictive page' , path : '/demo/a/other' , Icon : LinkIcon } ,
|
|
44
|
+
] ,
|
|
45
|
+
} ,
|
|
46
|
+
{
|
|
47
|
+
id : 'group-b' ,
|
|
48
|
+
type : COLLAPSE ,
|
|
49
|
+
label : 'Group B (defaultOpen=false)' ,
|
|
50
|
+
Icon : GroupIcon ,
|
|
51
|
+
defaultOpen : false ,
|
|
52
|
+
items :
|
|
53
|
+
[
|
|
54
|
+
{ id : 'b-one' , type : LINK , label : 'B1' , path : '/demo/b/one' , Icon : ChildIcon } ,
|
|
55
|
+
{ id : 'b-two' , type : LINK , label : 'B2' , path : '/demo/b/two' , Icon : ChildIcon } ,
|
|
56
|
+
] ,
|
|
57
|
+
} ,
|
|
58
|
+
] ;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Reads `useNavigation()` and forwards its tree to the standard `Menu`,
|
|
62
|
+
* so each card renders a proper sidebar-style menu without depending
|
|
63
|
+
* on the global `<Sidebar>` chrome.
|
|
64
|
+
*/
|
|
65
|
+
const DemoMenu = () =>
|
|
66
|
+
{
|
|
67
|
+
const { navigation } = useNavigation() ;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Menu
|
|
71
|
+
className = "menu w-full bg-base-100 rounded-box gap-2 p-2 border border-base-300"
|
|
72
|
+
items = { navigation }
|
|
73
|
+
/>
|
|
74
|
+
) ;
|
|
75
|
+
} ;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Live inspector that mirrors the JSON payload at the given storage key.
|
|
79
|
+
* Refreshes on a low-frequency interval — accurate enough for a demo,
|
|
80
|
+
* cheap enough to ignore.
|
|
81
|
+
*/
|
|
82
|
+
const StorageInspector = ( { storageKey } ) =>
|
|
83
|
+
{
|
|
84
|
+
const [ value , setValue ] = useState( null ) ;
|
|
85
|
+
|
|
86
|
+
useEffect( () =>
|
|
87
|
+
{
|
|
88
|
+
if ( !storageKey )
|
|
89
|
+
{
|
|
90
|
+
return undefined ;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const refresh = () =>
|
|
94
|
+
{
|
|
95
|
+
try
|
|
96
|
+
{
|
|
97
|
+
setValue( window.localStorage.getItem( storageKey ) ) ;
|
|
98
|
+
}
|
|
99
|
+
catch
|
|
100
|
+
{
|
|
101
|
+
setValue( null ) ;
|
|
102
|
+
}
|
|
103
|
+
} ;
|
|
104
|
+
|
|
105
|
+
refresh() ;
|
|
106
|
+
|
|
107
|
+
const id = window.setInterval( refresh , 500 ) ;
|
|
108
|
+
|
|
109
|
+
return () => window.clearInterval( id ) ;
|
|
110
|
+
}
|
|
111
|
+
, [ storageKey ] ) ;
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<pre className="text-xs font-mono whitespace-pre-wrap break-all bg-base-200 rounded p-2 min-h-12">
|
|
115
|
+
{ value || <span className="opacity-60">(empty)</span> }
|
|
116
|
+
</pre>
|
|
117
|
+
) ;
|
|
118
|
+
} ;
|
|
119
|
+
|
|
120
|
+
const DemoCard = ( { title , description , defaultMode , storageKey } ) =>
|
|
121
|
+
{
|
|
122
|
+
const handleClear = () =>
|
|
123
|
+
{
|
|
124
|
+
if ( storageKey )
|
|
125
|
+
{
|
|
126
|
+
try
|
|
127
|
+
{
|
|
128
|
+
window.localStorage.removeItem( storageKey ) ;
|
|
129
|
+
}
|
|
130
|
+
catch
|
|
131
|
+
{
|
|
132
|
+
/* ignore */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
window.location.reload() ;
|
|
137
|
+
} ;
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<div className="card bg-base-200 shadow-xl">
|
|
141
|
+
<div className="card-body gap-3">
|
|
142
|
+
<div>
|
|
143
|
+
<h3 className="card-title text-sm">{ title }</h3>
|
|
144
|
+
<p className="text-xs opacity-70">{ description }</p>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<NavigationProvider
|
|
148
|
+
defaultNavigation = { demoItems }
|
|
149
|
+
defaultMode = { defaultMode }
|
|
150
|
+
storageKey = { storageKey }
|
|
151
|
+
i18nPath = "demo.collapsePersistence"
|
|
152
|
+
>
|
|
153
|
+
<DemoMenu />
|
|
154
|
+
</NavigationProvider>
|
|
155
|
+
|
|
156
|
+
{ storageKey
|
|
157
|
+
? (
|
|
158
|
+
<>
|
|
159
|
+
<div className="text-xs opacity-70">
|
|
160
|
+
<code className="font-mono">{ storageKey }</code>
|
|
161
|
+
</div>
|
|
162
|
+
<StorageInspector storageKey={ storageKey } />
|
|
163
|
+
<button
|
|
164
|
+
type = "button"
|
|
165
|
+
className = "btn btn-xs btn-outline self-start"
|
|
166
|
+
onClick = { handleClear }
|
|
167
|
+
>
|
|
168
|
+
Clear & reload
|
|
169
|
+
</button>
|
|
170
|
+
</>
|
|
171
|
+
)
|
|
172
|
+
: (
|
|
173
|
+
<div className="text-xs italic opacity-60">
|
|
174
|
+
No storageKey — collapse state lives only in memory.
|
|
175
|
+
</div>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
) ;
|
|
181
|
+
} ;
|
|
182
|
+
|
|
183
|
+
const CollapsePersistenceDemo = () =>
|
|
184
|
+
(
|
|
185
|
+
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
186
|
+
|
|
187
|
+
<DemoCard
|
|
188
|
+
title = "defaultMode = 'open'"
|
|
189
|
+
description = "Legacy behaviour — every collapse starts open. No storageKey, no persistence."
|
|
190
|
+
defaultMode = "open"
|
|
191
|
+
/>
|
|
192
|
+
|
|
193
|
+
<DemoCard
|
|
194
|
+
title = "defaultMode = 'closed' + storageKey"
|
|
195
|
+
description = "All collapses start closed. Toggles persist across reloads. Group B's defaultOpen=false has no visible effect (already closed)."
|
|
196
|
+
defaultMode = "closed"
|
|
197
|
+
storageKey = { STORAGE_KEY_CLOSED }
|
|
198
|
+
/>
|
|
199
|
+
|
|
200
|
+
<DemoCard
|
|
201
|
+
title = "defaultMode = 'auto' + storageKey"
|
|
202
|
+
description = "Group A opens automatically on /lab/menus (its 'Menus page' link matches the current pathname). Group B starts closed via per-item defaultOpen=false override."
|
|
203
|
+
defaultMode = "auto"
|
|
204
|
+
storageKey = { STORAGE_KEY_AUTO }
|
|
205
|
+
/>
|
|
206
|
+
|
|
207
|
+
</div>
|
|
208
|
+
) ;
|
|
209
|
+
|
|
210
|
+
export default CollapsePersistenceDemo ;
|
|
@@ -64,7 +64,11 @@ const Application = ( { children , initialLang } ) =>
|
|
|
64
64
|
<ThemeProvider>
|
|
65
65
|
<ApplicationProvider>
|
|
66
66
|
<FullScreenProvider>
|
|
67
|
-
<NavigationProvider
|
|
67
|
+
<NavigationProvider
|
|
68
|
+
defaultNavigation = { navigation }
|
|
69
|
+
defaultMode = "auto"
|
|
70
|
+
storageKey = "oihana-next-ui:lab:nav"
|
|
71
|
+
>
|
|
68
72
|
<LoadingProvider>
|
|
69
73
|
<ToastProvider>
|
|
70
74
|
<SelectProvider>
|
|
@@ -1,16 +1,55 @@
|
|
|
1
|
+
'use client' ;
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Collapse component for nested navigation menus.
|
|
3
5
|
*
|
|
4
6
|
* @module display/ui/navigation/Collapse
|
|
5
7
|
*/
|
|
6
8
|
|
|
9
|
+
import { use , useCallback , useMemo } from 'react' ;
|
|
10
|
+
|
|
11
|
+
import cn from '../../../themes/helpers/cn' ;
|
|
12
|
+
|
|
13
|
+
import NavigationContext from '../../../contexts/navigation/context' ;
|
|
14
|
+
import containsActivePath from '../../../contexts/navigation/helpers/containsActivePath' ;
|
|
15
|
+
|
|
7
16
|
import Menu from './Menu' ;
|
|
8
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Default style applied to the `<summary>` of a collapse whose subtree
|
|
20
|
+
* contains the current pathname (active-ancestor marker, option A —
|
|
21
|
+
* minimal). Consumers can override via `activeAncestorClassName`.
|
|
22
|
+
*/
|
|
23
|
+
const DEFAULT_ACTIVE_ANCESTOR_CLASSNAME = 'text-base-content font-semibold' ;
|
|
24
|
+
|
|
9
25
|
/**
|
|
10
26
|
* Renders a collapsible navigation group with nested items.
|
|
11
27
|
*
|
|
28
|
+
* The component supports three usage modes:
|
|
29
|
+
*
|
|
30
|
+
* 1. **Legacy / uncontrolled** — when no `id`, `open`, `defaultOpen` or
|
|
31
|
+
* `onToggle` is provided, renders `<details open>` exactly as before.
|
|
32
|
+
* 2. **Context-driven** — when `id` is provided and the component sits
|
|
33
|
+
* under a `NavigationProvider`, the open state is read from the
|
|
34
|
+
* provider (persisted choice → auto(pathname) → `defaultOpen` → mode)
|
|
35
|
+
* and toggle events are written back, persisting to `localStorage`
|
|
36
|
+
* when the provider received a `storageKey`.
|
|
37
|
+
* 3. **Controlled** — when `open` is provided, the component is fully
|
|
38
|
+
* controlled by the parent and ignores the provider for its own
|
|
39
|
+
* open/closed state. `onToggle` is fired with the next boolean.
|
|
40
|
+
*
|
|
12
41
|
* @param {Object} props
|
|
42
|
+
* @param {string} [props.id] - Stable item id used by the provider for
|
|
43
|
+
* persistence and for the active-ancestor calculation.
|
|
44
|
+
* @param {boolean} [props.open] - Controlled open state. When provided,
|
|
45
|
+
* the component ignores the provider for its own state.
|
|
46
|
+
* @param {boolean} [props.defaultOpen] - Per-item override of the
|
|
47
|
+
* provider's `defaultMode`, used when no persisted choice exists.
|
|
48
|
+
* @param {(open: boolean) => void} [props.onToggle] - Called with the
|
|
49
|
+
* next open value whenever the native `<details>` toggles.
|
|
13
50
|
* @param {string} [props.className] - Additional class names for the inner menu.
|
|
51
|
+
* @param {string} [props.activeAncestorClassName] - Override for the
|
|
52
|
+
* default active-ancestor style applied to the `<summary>`.
|
|
14
53
|
* @param {React.ComponentType<{ size?: number }>} [props.Icon] - Icon component.
|
|
15
54
|
* @param {number} [props.iconSize=20] - Icon size in pixels.
|
|
16
55
|
* @param {Object[]} [props.items] - Nested navigation items.
|
|
@@ -21,17 +60,82 @@ import Menu from './Menu' ;
|
|
|
21
60
|
*/
|
|
22
61
|
const Collapse =
|
|
23
62
|
({
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
63
|
+
id ,
|
|
64
|
+
open : openProp ,
|
|
65
|
+
defaultOpen ,
|
|
66
|
+
onToggle ,
|
|
67
|
+
activeAncestorClassName ,
|
|
68
|
+
className ,
|
|
69
|
+
Icon ,
|
|
70
|
+
iconSize = 20 ,
|
|
71
|
+
items ,
|
|
72
|
+
label ,
|
|
73
|
+
onAction ,
|
|
74
|
+
}) =>
|
|
75
|
+
{
|
|
76
|
+
// Defensive read: a consumer may render <Collapse> without a
|
|
77
|
+
// NavigationProvider (legacy / standalone usage). In that case the
|
|
78
|
+
// context is null and we silently fall back to the legacy behaviour.
|
|
79
|
+
const navigation = use( NavigationContext ) ;
|
|
80
|
+
|
|
81
|
+
const isControlled = typeof openProp === 'boolean' ;
|
|
82
|
+
const isProviderManaged = !isControlled && navigation !== null && typeof id === 'string' && id.length > 0 ;
|
|
83
|
+
|
|
84
|
+
const item = useMemo( () => ({ id , defaultOpen , items , path : undefined })
|
|
85
|
+
, [ id , defaultOpen , items ] ) ;
|
|
86
|
+
|
|
87
|
+
let open ;
|
|
88
|
+
if ( isControlled )
|
|
89
|
+
{
|
|
90
|
+
open = openProp ;
|
|
91
|
+
}
|
|
92
|
+
else if ( isProviderManaged )
|
|
93
|
+
{
|
|
94
|
+
open = navigation.getCollapseOpen( id , item ) ;
|
|
95
|
+
}
|
|
96
|
+
else
|
|
97
|
+
{
|
|
98
|
+
// Legacy fallback: behave like <details open> when nothing is
|
|
99
|
+
// wired up, preserving 100% backward compatibility.
|
|
100
|
+
open = true ;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const pathname = navigation?.pathname ?? null ;
|
|
104
|
+
|
|
105
|
+
const isActiveAncestor = useMemo( () =>
|
|
106
|
+
containsActivePath( { items } , pathname )
|
|
107
|
+
, [ items , pathname ] ) ;
|
|
108
|
+
|
|
109
|
+
const handleToggle = useCallback( ( event ) =>
|
|
110
|
+
{
|
|
111
|
+
const next = Boolean( event.currentTarget.open ) ;
|
|
112
|
+
|
|
113
|
+
if ( onToggle )
|
|
114
|
+
{
|
|
115
|
+
onToggle( next ) ;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if ( !isControlled && isProviderManaged )
|
|
119
|
+
{
|
|
120
|
+
navigation.setCollapse( id , next ) ;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
, [ id , isControlled , isProviderManaged , navigation , onToggle ] ) ;
|
|
124
|
+
|
|
125
|
+
const summaryClassName = cn
|
|
31
126
|
(
|
|
127
|
+
isActiveAncestor && ( activeAncestorClassName ?? DEFAULT_ACTIVE_ANCESTOR_CLASSNAME ) ,
|
|
128
|
+
) ;
|
|
129
|
+
|
|
130
|
+
return (
|
|
32
131
|
<li>
|
|
33
|
-
<details
|
|
34
|
-
|
|
132
|
+
<details
|
|
133
|
+
open = { open }
|
|
134
|
+
onToggle = { handleToggle }
|
|
135
|
+
data-nav-id = { id }
|
|
136
|
+
data-active-ancestor = { isActiveAncestor || undefined }
|
|
137
|
+
>
|
|
138
|
+
<summary className={ summaryClassName }>
|
|
35
139
|
{ Icon && <Icon size={ iconSize } /> }
|
|
36
140
|
{ label }
|
|
37
141
|
</summary>
|
|
@@ -43,5 +147,6 @@ const Collapse =
|
|
|
43
147
|
</details>
|
|
44
148
|
</li>
|
|
45
149
|
) ;
|
|
150
|
+
} ;
|
|
46
151
|
|
|
47
|
-
export default Collapse ;
|
|
152
|
+
export default Collapse ;
|
|
@@ -47,10 +47,21 @@ const getBadge = ( badge ) =>
|
|
|
47
47
|
* @property {boolean} [outline] - Outline style.
|
|
48
48
|
*/
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Default active-link style — neutral, transparent darkening that works
|
|
52
|
+
* in both light and dark themes through DaisyUI semantic tokens. Subtle
|
|
53
|
+
* by design: relies on the `font-medium` weight bump as a secondary cue
|
|
54
|
+
* so the active state stays readable without competing visually with
|
|
55
|
+
* the page content.
|
|
56
|
+
*/
|
|
57
|
+
const DEFAULT_ACTIVE_CLASSNAME = 'bg-base-content/10 font-medium' ;
|
|
58
|
+
|
|
50
59
|
/**
|
|
51
60
|
* @typedef {Object} LinkProps
|
|
52
61
|
* @property {string|BadgeProps} [badge] - Badge label or configuration object.
|
|
53
62
|
* @property {string} [className] - Additional class names.
|
|
63
|
+
* @property {string} [activeClassName] - Override for the default
|
|
64
|
+
* active-link style applied when the current pathname matches `path`.
|
|
54
65
|
* @property {React.ComponentType<{ size?: number }>} [Icon] - Icon component.
|
|
55
66
|
* @property {number} [iconSize=20] - Icon size in pixels.
|
|
56
67
|
* @property {string} [label] - Link text content.
|
|
@@ -85,6 +96,7 @@ const Link =
|
|
|
85
96
|
({
|
|
86
97
|
badge ,
|
|
87
98
|
className ,
|
|
99
|
+
activeClassName ,
|
|
88
100
|
Icon ,
|
|
89
101
|
iconSize = 20 ,
|
|
90
102
|
label ,
|
|
@@ -99,7 +111,7 @@ const Link =
|
|
|
99
111
|
const classNames = cn
|
|
100
112
|
(
|
|
101
113
|
'space-x-4' ,
|
|
102
|
-
|
|
114
|
+
active && ( activeClassName ?? DEFAULT_ACTIVE_CLASSNAME ) ,
|
|
103
115
|
className ,
|
|
104
116
|
) ;
|
|
105
117
|
|
|
@@ -125,17 +137,19 @@ const Link =
|
|
|
125
137
|
{ path
|
|
126
138
|
? (
|
|
127
139
|
<NextLink
|
|
128
|
-
className
|
|
129
|
-
href
|
|
130
|
-
onClick
|
|
140
|
+
className = { classNames }
|
|
141
|
+
href = { path }
|
|
142
|
+
onClick = { handleClick }
|
|
143
|
+
data-active = { active || undefined }
|
|
131
144
|
>
|
|
132
145
|
{ content }
|
|
133
146
|
</NextLink>
|
|
134
147
|
)
|
|
135
148
|
: (
|
|
136
149
|
<a
|
|
137
|
-
className
|
|
138
|
-
onClick
|
|
150
|
+
className = { classNames }
|
|
151
|
+
onClick = { handleClick }
|
|
152
|
+
data-active = { active || undefined }
|
|
139
153
|
>
|
|
140
154
|
{ content }
|
|
141
155
|
</a>
|
package/src/version.js
CHANGED