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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oihana-next-ui",
3
- "version": "0.1.47",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "Oihana Next.js UI component library — reusable components, hooks and utilities built with React 19, Next.js, Tailwind CSS and DaisyUI",
6
6
  "author": {
@@ -1,9 +1,10 @@
1
1
  'use client' ;
2
2
 
3
- import FlagMenuDemo from '@/demo/menus/FlagMenuDemo';
4
- import MenuNavigationDemo from '@/demo/menus/MenuNavigationDemo';
5
- import Container from '@/display/Container' ;
6
- import Page from '@/display/Page' ;
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 = 'secondary' ,
42
- icon = MdRefresh ,
43
- motion = Jump ,
41
+ color = 'secondary' ,
42
+ icon = MdRefresh ,
43
+ motion = Jump ,
44
44
  motionProps = { delay: 0.3 } ,
45
- path = 'components.buttons.refresh' ,
46
- shape = 'circle' ,
47
- size = 'md' ,
45
+ path = 'components.buttons.refresh' ,
46
+ shape = 'circle' ,
47
+ size = 'md' ,
48
48
  ...rest
49
49
  }) =>
50
50
  (
@@ -232,7 +232,7 @@ const Modal =
232
232
  </div>
233
233
  )}
234
234
 
235
- <div className={`overflow-y-auto h-full p-2 py-4 ${ contentClassName || '' }`}>
235
+ <div className={ cn( 'overflow-y-auto h-full p-2 py-4' , contentClassName ) }>
236
236
  { children }
237
237
  </div>
238
238
 
@@ -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 : mappedBadge ,
68
- className ,
69
- Icon ,
70
- id ,
71
- items : mappedItems ,
72
- label : mappedLabel ,
73
- path ,
74
- type ,
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
- * const navigation =
24
- * [
25
- * { id: 'home' , path: '/' , Icon: HomeIcon } ,
26
- * { id: 'about' , path: '/about' , Icon: InfoIcon } ,
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 locale = useI18n( i18nPath ) ;
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={ { navigation , setNavigation } }>
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 &amp; 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 defaultNavigation={ navigation } >
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
- className ,
25
- Icon ,
26
- iconSize = 20 ,
27
- items ,
28
- label ,
29
- onAction ,
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 open>
34
- <summary>
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
- { active } ,
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 = { classNames }
129
- href = { path }
130
- onClick = { handleClick }
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 = { classNames }
138
- onClick = { handleClick }
150
+ className = { classNames }
151
+ onClick = { handleClick }
152
+ data-active = { active || undefined }
139
153
  >
140
154
  { content }
141
155
  </a>
package/src/version.js CHANGED
@@ -1,3 +1,3 @@
1
- const version = "0.1.47" ;
1
+ const version = "0.2.0" ;
2
2
 
3
3
  export default version ;