oihana-next-ui 0.1.45 → 0.1.47

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/README.md CHANGED
@@ -100,50 +100,72 @@ Open [http://localhost:3666](http://localhost:3666) to browse the component demo
100
100
  - [Day.js](https://day.js.org/) — Lightweight date library
101
101
  - [Chroma.js](https://www.vis4.net/chromajs/) — Color manipulation library
102
102
 
103
- ### Build the library
103
+ ## Release
104
104
 
105
- ```bash
106
- bun run build:lib
107
- ```
105
+ The package publishes the raw `src/` tree (no build step) — see the `files` and `exports` fields in [`package.json`](./package.json).
108
106
 
109
- ### Watch mode
107
+ ### Versioning
110
108
 
111
- ```bash
112
- bun run build:lib:watch
113
- ```
109
+ This project follows [Semantic Versioning](https://semver.org/) — `MAJOR.MINOR.PATCH` (e.g. `1.2.3`).
114
110
 
115
- ## Release
111
+ | Type | Command | Example | When to use |
112
+ |-------|-------------------------|---------------------|-----------------------------------------------|
113
+ | Patch | `bun run release:patch` | `0.1.46` → `0.1.47` | Bug fix, small tweak |
114
+ | Minor | `bun run release:minor` | `0.1.46` → `0.2.0` | New component or feature, backward compatible |
115
+ | Major | `bun run release:major` | `0.1.46` → `1.0.0` | Breaking change |
116
116
 
117
- ### Versioning
117
+ ### Prerequisites
118
118
 
119
- This project follows [Semantic Versioning](https://semver.org/) — `MAJOR.MINOR.PATCH` (e.g. `1.2.3`).
119
+ - Logged in to npm — `npm whoami` should print your username (otherwise `npm login`).
120
+ - A git remote named `origin-ssh` configured (the `release` script pushes there with `--follow-tags`).
121
+ - A clean working tree, ideally — `release:*` will otherwise commit any pending change as `chore: prepare release` before bumping the version.
122
+
123
+ ### Patch release walkthrough — e.g. `0.1.46` → `0.1.47`
124
+
125
+ 1. **Update [`CHANGELOG.md`](./CHANGELOG.md)** — add a new section under `[Unreleased]` with the new version and date :
126
+
127
+ ~~~markdown
128
+ ## [0.1.47] — 2026-04-27
129
+
130
+ **Components**
131
+ - `XYZ` — what changed and why.
132
+ ~~~
120
133
 
121
- | Type | Command | Example | When to use |
122
- |------|---------|---------|-------------|
123
- | Patch | `bun run release:patch` | `0.1.0` → `0.1.1` | Bug fix, minor tweak |
124
- | Minor | `bun run release:minor` | `0.1.0` → `0.2.0` | New component or feature, backward compatible |
125
- | Major | `bun run release:major` | `0.1.0` → `1.0.0` | Breaking change |
134
+ 2. **Run the release script** :
126
135
 
127
- Each script automatically bumps the version, builds the library, publishes to npm, and pushes the commit and tag to GitHub.
136
+ ```bash
137
+ bun run release:patch
138
+ ```
128
139
 
129
- You can also set a specific version manually :
140
+ What happens, in order all of this is automatic :
141
+
142
+ 1. `stage` — commits any pending change as `chore: prepare release` (skipped if the working tree is clean).
143
+ 2. `npm version patch` — bumps `0.1.46` → `0.1.47` in `package.json`.
144
+ 3. `version` script (auto-run by `npm version`) :
145
+ - `inject-version` writes the new version into `src/version.js` and `public/sw.js`,
146
+ - `generate-exports` refreshes the `exports` field in `package.json`,
147
+ - then stages `src/version.js`, `public/sw.js` and `package.json` for the version commit.
148
+ 4. `npm version` creates the release commit (`0.1.47`) and the matching git tag.
149
+ 5. `postversion` script (auto-run by `npm version`) → `release` :
150
+ - `npm publish --access public` publishes to npm,
151
+ - `git push origin-ssh --follow-tags` pushes the commit and the tag to GitHub.
152
+
153
+ ### Manual / pre-release version
154
+
155
+ Set a specific version manually — `version` + `postversion` still run as above :
130
156
 
131
157
  ```bash
132
158
  npm version 1.0.0
133
- bun run release
134
159
  ```
135
160
 
136
- Or a pre-release version :
161
+ Pre-release versions :
137
162
 
138
163
  ```bash
139
- npm version prerelease --preid=alpha # 0.1.0 → 0.1.1-alpha.0
140
- npm version prerelease --preid=beta # 0.1.0 → 0.1.1-beta.0
141
- npm version prerelease --preid=rc # 0.1.0 → 0.1.1-rc.0
142
- bun run release
164
+ npm version prerelease --preid=alpha # 0.1.46 → 0.1.47-alpha.0
165
+ npm version prerelease --preid=beta # 0.1.46 → 0.1.47-beta.0
166
+ npm version prerelease --preid=rc # 0.1.46 → 0.1.47-rc.0
143
167
  ```
144
168
 
145
- `npm version` automatically updates `package.json`, creates a Git commit and a Git tag.
146
-
147
169
  ## License
148
170
 
149
171
  [Mozilla Public License 2.0](./LICENSE) — © Marc Alcaraz
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oihana-next-ui",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
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": {
@@ -50,29 +50,29 @@
50
50
  "./themes/*": "./src/themes/*"
51
51
  },
52
52
  "peerDependencies": {
53
- "next": "^16.0.0",
53
+ "next": "16.2.3",
54
54
  "react": "^19.0.0",
55
55
  "react-dom": "^19.0.0",
56
56
  "tailwindcss": "^4.0.0",
57
57
  "daisyui": "^5.0.0"
58
58
  },
59
59
  "dependencies": {
60
- "@maskito/core": "^5.1.1",
61
- "@maskito/kit": "^5.1.1",
62
- "@maskito/react": "^5.1.1",
60
+ "@maskito/core": "^5.2.2",
61
+ "@maskito/kit": "^5.2.2",
62
+ "@maskito/react": "^5.2.2",
63
63
  "@use-gesture/react": "^10.3.1",
64
64
  "chroma-js": "^3.2.0",
65
65
  "clsx": "^2.1.1",
66
- "dayjs": "^1.11.19",
66
+ "dayjs": "^1.11.20",
67
67
  "flag-icons": "^7.5.0",
68
68
  "html-react-parser": "^5.2.17",
69
69
  "ky": "^1.14.3",
70
- "motion": "^12.35.0",
71
- "next": "16.1.6",
70
+ "motion": "^12.38.0",
71
+ "next": "16.2.3",
72
72
  "react": "19.2.3",
73
73
  "react-dom": "19.2.3",
74
74
  "react-icons": "^5.6.0",
75
- "react-is": "^19.2.4",
75
+ "react-is": "^19.2.5",
76
76
  "react-markdown": "^10.1.0",
77
77
  "react-syntax-highlighter": "^16.1.1",
78
78
  "react-use": "^17.6.0",
@@ -80,24 +80,24 @@
80
80
  "rehype-raw": "^7.0.0",
81
81
  "remark-breaks": "^4.0.0",
82
82
  "remark-gfm": "^4.0.1",
83
- "sanitize-html": "^2.17.1",
83
+ "sanitize-html": "^2.17.3",
84
84
  "tailwind-merge": "^3.5.0",
85
- "validator": "^13.15.26",
85
+ "validator": "^13.15.35",
86
86
  "vegas-js-core": "^1.0.47"
87
87
  },
88
88
  "devDependencies": {
89
89
  "@biomejs/biome": "2.2.0",
90
- "@tailwindcss/postcss": "^4.2.1",
90
+ "@tailwindcss/postcss": "^4.2.4",
91
91
  "@tailwindcss/typography": "^0.5.19",
92
- "@types/node": "^25.3.3",
92
+ "@types/node": "^25.6.0",
93
93
  "@types/react-syntax-highlighter": "^15.5.13",
94
- "@types/sanitize-html": "^2.16.0",
94
+ "@types/sanitize-html": "^2.16.1",
95
95
  "babel-plugin-react-compiler": "1.0.0",
96
96
  "daisyui": "^5.5.19",
97
97
  "raw-loader": "^4.0.2",
98
98
  "sharp": "^0.34.5",
99
99
  "tailwind-scrollbar": "^4.0.2",
100
- "tailwindcss": "^4.2.1",
100
+ "tailwindcss": "^4.2.4",
101
101
  "tailwindcss-animated": "^2.0.0",
102
102
  "tailwindcss-opentype": "^1.2.0"
103
103
  },
@@ -1,10 +1,11 @@
1
1
  'use client' ;
2
2
 
3
- import CRUDDemo from '@/demo/modals/CRUDModalDemo';
4
- import InputModalDemo from '@/demo/modals/InputModalDemo';
5
- import ModalDemo from '@/demo/modals/ModalDemo';
6
- import Container from '@/display/Container';
7
- import Page from '@/display/Page' ;
3
+ import CRUDDemo from '@/demo/modals/CRUDModalDemo';
4
+ import InputModalDemo from '@/demo/modals/InputModalDemo';
5
+ import ModalDemo from '@/demo/modals/ModalDemo';
6
+ import ToastOverModalDemo from '@/demo/modals/ToastOverModalDemo';
7
+ import Container from '@/display/Container';
8
+ import Page from '@/display/Page' ;
8
9
 
9
10
  /**
10
11
  * Modal showcase page.
@@ -26,6 +27,8 @@ const ModalShowcase = ({ path = 'app.test' }) =>
26
27
 
27
28
  <ModalDemo />
28
29
 
30
+ <ToastOverModalDemo />
31
+
29
32
  <CRUDDemo />
30
33
 
31
34
  <InputModalDemo />
@@ -17,6 +17,7 @@ import { MdColorLens as ColorIcon } from 'react-icons/md' ;
17
17
  *
18
18
  * @param {Object} props
19
19
  * @param {boolean} [props.alpha=false] - Allow alpha channel (8 chars: #RRGGBBAA)
20
+ * @param {number} [props.length] - Explicit hex length (3, 4, 6 or 8). Overrides the default derived from `alpha`. When set, validation requires exactly this length.
20
21
  * @param {boolean} [props.prefixed=true] - Display "#" prefix
21
22
  * @param {boolean} [props.showColorPreview=true] - Show color preview in icon background
22
23
  * @param {boolean} [props.showValidationError=false] - Show error message for invalid colors
@@ -71,6 +72,7 @@ const InputHexColor =
71
72
  error: externalError,
72
73
  icon,
73
74
  iconClassName = 'aspect-square',
75
+ length,
74
76
  onChange: onChangeFromProps,
75
77
  prefixed = true,
76
78
  showColorPreview = true,
@@ -85,6 +87,10 @@ const InputHexColor =
85
87
  const [ currentColor , setCurrentColor ] = useState( externalValue || defaultValue || '' ) ;
86
88
  const [ internalError, setInternalError ] = useState( '' ) ;
87
89
 
90
+ // --------- Resolve max length (explicit length wins over alpha default)
91
+
92
+ const maxLength = length ?? ( alpha ? 8 : 6 ) ;
93
+
88
94
  // --------- Transformations
89
95
 
90
96
  const transform = value =>
@@ -94,8 +100,7 @@ const InputHexColor =
94
100
  return '' ;
95
101
  }
96
102
 
97
- const cleaned = value.replace( /([^0-9A-F]+)/gi, '' ) ;
98
- const maxLength = alpha ? 8 : 6 ;
103
+ const cleaned = value.replace( /([^0-9A-F]+)/gi, '' ) ;
99
104
 
100
105
  return cleaned.substring( 0, maxLength ).toUpperCase() ;
101
106
  } ;
@@ -108,19 +113,21 @@ const InputHexColor =
108
113
  return true ;
109
114
  }
110
115
 
111
- const isValid = validateHexColor( value , alpha ) ;
116
+ // When length is explicit, require exact length; otherwise accept any valid hex form.
117
+ const isValid = length !== undefined
118
+ ? ( value.length === length && validateHexColor( value, alpha ) )
119
+ : validateHexColor( value, alpha ) ;
112
120
 
113
121
  if ( showValidationError )
114
122
  {
115
123
  if ( !isValid )
116
124
  {
117
- const expectedLength = alpha ? 8 : 6 ;
118
125
  const defaultPattern = 'Invalid hex color (expected {0} characters: 0-9, A-F)' ;
119
126
 
120
127
  setInternalError( fastFormat
121
128
  (
122
129
  validationError ?? defaultPattern,
123
- String( expectedLength )
130
+ String( maxLength )
124
131
  ) ) ;
125
132
  }
126
133
  else
@@ -134,12 +141,14 @@ const InputHexColor =
134
141
 
135
142
  const format = prefixed ? value =>
136
143
  {
137
- return value ? `#${value}` : '' ;
144
+ if ( !value ) return '' ;
145
+ return value.startsWith( '#' ) ? value : `#${value}` ;
138
146
  } : undefined ;
139
147
 
140
148
  const process = ( value ) =>
141
149
  {
142
- return value ? `#${value}` : '' ;
150
+ if ( !value ) return '' ;
151
+ return value.startsWith( '#' ) ? value : `#${value}` ;
143
152
  } ;
144
153
 
145
154
  // --------- Intercept onChange to update currentColor
@@ -6,7 +6,9 @@
6
6
  * @module contexts/toasts/ToastProvider
7
7
  */
8
8
 
9
- import { useState } from 'react' ;
9
+ import { useEffect , useRef , useState } from 'react' ;
10
+
11
+ import { createPortal } from 'react-dom' ;
10
12
 
11
13
  import { AnimatePresence , motion } from 'motion/react' ;
12
14
 
@@ -19,6 +21,11 @@ import ToastContext from './context' ;
19
21
  /**
20
22
  * Provides toast notification management.
21
23
  *
24
+ * Toasts are rendered through a React portal whose target dynamically
25
+ * follows the topmost open `<dialog>` (if any), so they paint above modal
26
+ * backdrops via the dialog's own top-layer entry. When no dialog is open,
27
+ * the portal target falls back to `document.body`.
28
+ *
22
29
  * @param {Object} props
23
30
  * @param {React.ReactNode} props.children - Child components.
24
31
  * @param {string} [props.hAlign='end'] - Horizontal alignment.
@@ -58,6 +65,133 @@ const ToastProvider =
58
65
  setToasts( [] ) ;
59
66
  } ;
60
67
 
68
+ // We own a single, stable <div> in the DOM that hosts the toast UI via
69
+ // a React portal. React only ever sees this div as the portal target —
70
+ // it never moves from React's perspective, so the toast subtree is never
71
+ // unmounted and framer-motion animations don't restart.
72
+ //
73
+ // We physically *move* this div between document.body and the topmost
74
+ // open <dialog> via DOM appendChild. When the div is a child of a
75
+ // modal <dialog>, it inherits the dialog's top-layer entry and paints
76
+ // above the dialog's backdrop / contents — no popover trickery needed,
77
+ // and the toast stays interactive (children of a modal aren't inert).
78
+ const [ container , setContainer ] = useState( null ) ;
79
+
80
+ // Tracks dialogs in promotion order (topmost = last). We can't query
81
+ // the browser's top-layer order from JS, so we maintain it ourselves
82
+ // by listening for `open`-attribute mutations on every <dialog>.
83
+ const dialogStackRef = useRef( [] ) ;
84
+
85
+ useEffect( () =>
86
+ {
87
+ if ( typeof document === 'undefined' ) { return ; }
88
+
89
+ const div = document.createElement( 'div' ) ;
90
+ div.setAttribute( 'data-toast-portal' , '' ) ;
91
+ // Take the container out of normal flow so it doesn't become a grid
92
+ // item when living inside a <dialog> with `display: grid` (DaisyUI's
93
+ // .modal layout) — that would push the modal-box off-center. We
94
+ // give it no size and no `inset`, so it's effectively a zero-area
95
+ // anchor; the inner `.toast` div is itself `position: fixed` and
96
+ // positions on the viewport regardless of where this anchor sits.
97
+ div.style.position = 'fixed' ;
98
+ div.style.top = '0' ;
99
+ div.style.left = '0' ;
100
+ div.style.width = '0' ;
101
+ div.style.height = '0' ;
102
+ div.style.margin = '0' ;
103
+ div.style.padding = '0' ;
104
+ if ( zIndex > 0 ) { div.style.zIndex = String( zIndex ) ; }
105
+ document.body.appendChild( div ) ;
106
+ setContainer( div ) ;
107
+
108
+ const moveContainer = () =>
109
+ {
110
+ const stack = dialogStackRef.current ;
111
+ const topmost = stack[ stack.length - 1 ] ;
112
+ const target = topmost && topmost.isConnected && topmost.hasAttribute( 'open' )
113
+ ? topmost
114
+ : document.body ;
115
+
116
+ if ( div.parentNode !== target )
117
+ {
118
+ target.appendChild( div ) ;
119
+ }
120
+ } ;
121
+
122
+ // Initial sync (any dialog already open at mount time?).
123
+ const initialOpen = document.body.querySelectorAll( 'dialog[open]' ) ;
124
+ for ( const d of initialOpen ) { dialogStackRef.current.push( d ) ; }
125
+ moveContainer() ;
126
+
127
+ const handleOpened = dialog =>
128
+ {
129
+ const stack = dialogStackRef.current ;
130
+ const idx = stack.indexOf( dialog ) ;
131
+ if ( idx >= 0 ) { stack.splice( idx , 1 ) ; }
132
+ stack.push( dialog ) ;
133
+ } ;
134
+
135
+ const handleClosed = dialog =>
136
+ {
137
+ const stack = dialogStackRef.current ;
138
+ const idx = stack.indexOf( dialog ) ;
139
+ if ( idx >= 0 ) { stack.splice( idx , 1 ) ; }
140
+ } ;
141
+
142
+ const observer = new MutationObserver( mutations =>
143
+ {
144
+ let changed = false ;
145
+
146
+ for ( const m of mutations )
147
+ {
148
+ if ( m.type === 'attributes' && m.attributeName === 'open' && m.target?.tagName === 'DIALOG' )
149
+ {
150
+ if ( m.target.hasAttribute( 'open' ) ) { handleOpened( m.target ) ; }
151
+ else { handleClosed( m.target ) ; }
152
+ changed = true ;
153
+ }
154
+
155
+ if ( m.type === 'childList' )
156
+ {
157
+ for ( const n of m.removedNodes )
158
+ {
159
+ if ( n?.nodeType === 1 && n.tagName === 'DIALOG' )
160
+ {
161
+ handleClosed( n ) ;
162
+ changed = true ;
163
+ }
164
+ else if ( n?.nodeType === 1 && n.querySelectorAll )
165
+ {
166
+ for ( const d of n.querySelectorAll( 'dialog' ) )
167
+ {
168
+ handleClosed( d ) ;
169
+ changed = true ;
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ if ( changed ) { moveContainer() ; }
177
+ }) ;
178
+
179
+ observer.observe( document.body ,
180
+ {
181
+ childList : true ,
182
+ subtree : true ,
183
+ attributes : true ,
184
+ attributeFilter : [ 'open' ] ,
185
+ }) ;
186
+
187
+ return () =>
188
+ {
189
+ observer.disconnect() ;
190
+ div.remove() ;
191
+ dialogStackRef.current = [] ;
192
+ } ;
193
+ } , [ zIndex ]) ;
194
+
61
195
  const toastClasses = getToastClasses(
62
196
  {
63
197
  beforeClassName : 'pointer-events-none' ,
@@ -67,47 +201,45 @@ const ToastProvider =
67
201
 
68
202
  const contextValue = { toasts , clearAllToasts , closeToast , openToast } ;
69
203
 
204
+ const toastNode = (
205
+ <div className={ toastClasses }>
206
+ <AnimatePresence mode="popLayout">
207
+ {
208
+ toasts.map( ({ id , message , level , options }) =>
209
+ (
210
+ <motion.div
211
+ key = { id }
212
+ className = "w-80 min-w-80 pointer-events-auto"
213
+ initial = {{ opacity: 0 , scale: 0.8 , y: -20 }}
214
+ animate = {{ opacity: 1 , scale: 1 , y: 0 }}
215
+ exit = {{ opacity: 0 , scale: 0.8 , x: 100 }}
216
+ transition = {{ duration: 0.2 , ease: 'easeOut' }}
217
+ layout
218
+ >
219
+ <Alert
220
+ level = { level }
221
+ onClose = { closeToast( id ) }
222
+ showIcon
223
+ showCloseButton
224
+ { ...options }
225
+ >
226
+ { message }
227
+ </Alert>
228
+ </motion.div>
229
+ ) )
230
+ }
231
+ </AnimatePresence>
232
+ </div>
233
+ ) ;
234
+
70
235
  return (
71
236
  <ToastContext value={ contextValue }>
72
-
73
237
  { children }
74
-
75
- <div
76
- className = { toastClasses }
77
- style = { zIndex > 0 ? { zIndex } : undefined }
78
- >
79
- <AnimatePresence mode="popLayout">
80
- {
81
- toasts.map( ({ id , message , level , options }) =>
82
- (
83
- <motion.div
84
- key = { id }
85
- className = "w-80 min-w-80 pointer-events-auto"
86
- initial = {{ opacity: 0 , scale: 0.8 , y: -20 }}
87
- animate = {{ opacity: 1 , scale: 1 , y: 0 }}
88
- exit = {{ opacity: 0 , scale: 0.8 , x: 100 }}
89
- transition = {{ duration: 0.2 , ease: 'easeOut' }}
90
- layout
91
- >
92
- <Alert
93
- level = { level }
94
- onClose = { closeToast( id ) }
95
- showIcon
96
- showCloseButton
97
- { ...options }
98
- >
99
- { message }
100
- </Alert>
101
- </motion.div>
102
- ) )
103
- }
104
- </AnimatePresence>
105
- </div>
106
-
238
+ { container ? createPortal( toastNode , container ) : null }
107
239
  </ToastContext>
108
240
  ) ;
109
241
  } ;
110
242
 
111
243
  ToastProvider.displayName = 'ToastProvider' ;
112
244
 
113
- export default ToastProvider ;
245
+ export default ToastProvider ;
@@ -60,11 +60,12 @@ const InputHexColorDemo = () =>
60
60
 
61
61
  {/* Short format (3 chars) */}
62
62
  <InputHexColor
63
- label = "Short Format (3 chars)"
63
+ length = { 3 }
64
+ label = "Short Format (3 chars)"
64
65
  defaultValue = "F53"
65
- icon = { <ColorIcon /> }
66
- placeholder = "FFF"
67
- helper = "Short format: #rgb"
66
+ icon = { <ColorIcon /> }
67
+ placeholder = "FFF"
68
+ helper = "Short format: #rgb"
68
69
  />
69
70
 
70
71
  {/* With validation error */}
@@ -0,0 +1,260 @@
1
+ 'use client' ;
2
+
3
+ import { useState } from 'react' ;
4
+
5
+ import Badge from '@/components/Badge' ;
6
+ import Button from '@/components/Button' ;
7
+ import Modal from '@/components/modals/Modal' ;
8
+ import useModal from '@/components/modals/hooks/useModal' ;
9
+ import Container from '@/display/Container' ;
10
+ import ToastProvider from '@/contexts/toasts/provider' ;
11
+ import useToast , { ERROR , SUCCESS , WARNING } from '@/contexts/toasts/useToast' ;
12
+
13
+ import
14
+ {
15
+ BOTTOM ,
16
+ END ,
17
+ horizontalAlignments ,
18
+ verticalAlignments ,
19
+ }
20
+ from '@/themes/components/toast' ;
21
+
22
+ /**
23
+ * Inner content of the demo, sitting under a local ToastProvider so that
24
+ * `useToast()` resolves to the demo provider (with its switchable alignment).
25
+ */
26
+ const ToastOverModalInner = () =>
27
+ {
28
+ const { modalRef , open } = useModal() ;
29
+ const { toast } = useToast() ;
30
+
31
+ return (
32
+ <>
33
+ <Button onClick={ open }>
34
+ Open modal with toast trigger
35
+ </Button>
36
+
37
+ <Modal
38
+ ref = { modalRef }
39
+ title = "Toast over Modal"
40
+ agree = "Close"
41
+ showDisagree = { false }
42
+ >
43
+ <div className="flex flex-col gap-4 py-4">
44
+ <p>
45
+ Click any of the buttons below. Each toast must appear <strong>above</strong> the modal backdrop, fully clickable (the close ✕ should work).
46
+ </p>
47
+
48
+ <div className="flex flex-wrap gap-2">
49
+ <Button color="success" onClick={ () => toast( 'Saved successfully!' , SUCCESS ) }>
50
+ Trigger success toast
51
+ </Button>
52
+
53
+ <Button color="warning" onClick={ () => toast( 'Heads up — please review your input.' , WARNING ) }>
54
+ Trigger warning toast
55
+ </Button>
56
+
57
+ <Button color="error" onClick={ () => toast( 'Something went wrong.' , ERROR ) }>
58
+ Trigger error toast
59
+ </Button>
60
+ </div>
61
+ </div>
62
+ </Modal>
63
+ </>
64
+ ) ;
65
+ } ;
66
+
67
+ ToastOverModalInner.displayName = 'ToastOverModalInner' ;
68
+
69
+ /**
70
+ * Stress-test inner: validates the MutationObserver re-promotion when a
71
+ * <dialog> is opened *after* a toast is already visible (the case the
72
+ * basic re-promote-on-new-toast effect cannot cover).
73
+ */
74
+ const ToastStressInner = () =>
75
+ {
76
+ const { modalRef: l1Ref , open: openL1 } = useModal() ;
77
+ const { modalRef: l2Ref , open: openL2 } = useModal() ;
78
+ const { modalRef: l3Ref , open: openL3 } = useModal() ;
79
+
80
+ const { toast } = useToast({ delay: 8000 }) ;
81
+
82
+ const fireToastThenOpen = ( fn , delay = 250 ) => () =>
83
+ {
84
+ toast( 'Toast fired before opening modal — should stay on top!' , SUCCESS ) ;
85
+ setTimeout( fn , delay ) ;
86
+ } ;
87
+
88
+ const fireToastThenStack = () =>
89
+ {
90
+ toast( 'Watch me survive 3 stacked modals.' , WARNING ) ;
91
+ setTimeout( openL1 , 200 ) ;
92
+ setTimeout( openL2 , 500 ) ;
93
+ setTimeout( openL3 , 800 ) ;
94
+ } ;
95
+
96
+ return (
97
+ <div className="flex flex-col gap-3">
98
+
99
+ <div className="flex flex-wrap gap-2">
100
+ <Button color="primary" onClick={ fireToastThenOpen( openL1 ) }>
101
+ Toast → then open Level 1
102
+ </Button>
103
+
104
+ <Button color="secondary" onClick={ fireToastThenStack }>
105
+ Toast → then auto-stack 3 modals
106
+ </Button>
107
+ </div>
108
+
109
+ <Modal
110
+ ref = { l1Ref }
111
+ title = "Level 1 (opened after toast)"
112
+ maxWidth = "max-w-2xl"
113
+ agree = "Close"
114
+ showDisagree = { false }
115
+ >
116
+ <div className="flex flex-col gap-3 py-4">
117
+ <p>
118
+ This modal was opened <strong>after</strong> the toast was already visible.
119
+ The <code className="badge badge-sm">MutationObserver</code> in the provider should have
120
+ re-promoted the toast popover to the top of the top-layer stack.
121
+ </p>
122
+ <Button color="primary" onClick={ openL2 }>
123
+ Open Level 2
124
+ </Button>
125
+ <Button color="error" onClick={ () => toast( 'Toast fired from Level 1!' , ERROR ) }>
126
+ Trigger error toast from here
127
+ </Button>
128
+ </div>
129
+ </Modal>
130
+
131
+ <Modal
132
+ ref = { l2Ref }
133
+ title = "Level 2"
134
+ maxWidth = "max-w-xl"
135
+ agree = "Close"
136
+ showDisagree = { false }
137
+ >
138
+ <div className="flex flex-col gap-3 py-4">
139
+ <p>Level 2 is open. The toast should still be visible above the backdrop.</p>
140
+ <Button color="primary" onClick={ openL3 }>
141
+ Open Level 3
142
+ </Button>
143
+ </div>
144
+ </Modal>
145
+
146
+ <Modal
147
+ ref = { l3Ref }
148
+ title = "Level 3"
149
+ maxWidth = "max-w-md"
150
+ agree = "Close"
151
+ showDisagree = { false }
152
+ >
153
+ <div className="py-4">
154
+ <p>Level 3 — deepest stack. Toast should still be on top.</p>
155
+ </div>
156
+ </Modal>
157
+
158
+ </div>
159
+ ) ;
160
+ } ;
161
+
162
+ ToastStressInner.displayName = 'ToastStressInner' ;
163
+
164
+ /**
165
+ * Demo: prove that toasts can stack above a native &lt;dialog&gt; modal,
166
+ * and let the user switch the toast alignment to verify positioning.
167
+ *
168
+ * @returns {React.JSX.Element}
169
+ */
170
+ const ToastOverModalDemo = () =>
171
+ {
172
+ const [ hAlign , setHAlign ] = useState( END ) ;
173
+ const [ vAlign , setVAlign ] = useState( BOTTOM ) ;
174
+
175
+ return (
176
+ <Container className="flex flex-col gap-6 bg-base-200/60 p-8 rounded-box" maxWidth="max-w-7xl">
177
+
178
+ <h2 className="text-3xl font-bold">Toast over Modal</h2>
179
+
180
+ <p className="text-sm text-base-content/70">
181
+ Click to verify that toasts appear above the modal backdrop. Test case:
182
+ native <code className="badge badge-sm">&lt;dialog&gt;</code> top layer vs <code className="badge badge-sm">ToastProvider</code> popover.
183
+ </p>
184
+
185
+ <div className="flex flex-col gap-3 p-4 rounded-box bg-base-100">
186
+
187
+ <div className="flex flex-wrap items-center gap-2">
188
+ <span className="text-sm font-semibold w-24">Vertical</span>
189
+ { verticalAlignments.map( v =>
190
+ (
191
+ <Button
192
+ key = { v }
193
+ size = "sm"
194
+ color = { vAlign === v ? 'primary' : 'neutral' }
195
+ style = { vAlign === v ? undefined : 'outline' }
196
+ onClick = { () => setVAlign( v ) }
197
+ >
198
+ { v }
199
+ </Button>
200
+ ) ) }
201
+ </div>
202
+
203
+ <div className="flex flex-wrap items-center gap-2">
204
+ <span className="text-sm font-semibold w-24">Horizontal</span>
205
+ { horizontalAlignments.map( h =>
206
+ (
207
+ <Button
208
+ key = { h }
209
+ size = "sm"
210
+ color = { hAlign === h ? 'primary' : 'neutral' }
211
+ style = { hAlign === h ? undefined : 'outline' }
212
+ onClick = { () => setHAlign( h ) }
213
+ >
214
+ { h }
215
+ </Button>
216
+ ) ) }
217
+ </div>
218
+
219
+ <div className="flex flex-wrap items-center gap-2">
220
+ <span className="text-sm font-semibold w-24">Current</span>
221
+ <Badge color="primary">vAlign = { vAlign }</Badge>
222
+ <Badge color="secondary">hAlign = { hAlign }</Badge>
223
+ </div>
224
+
225
+ </div>
226
+
227
+ <ToastProvider hAlign={ hAlign } vAlign={ vAlign }>
228
+
229
+ <ToastOverModalInner />
230
+
231
+ <div className="divider mt-4">Stress test : toast under stacked modals</div>
232
+
233
+ <div className="flex flex-col gap-3">
234
+ <p className="text-sm text-base-content/70">
235
+ These buttons fire a toast <strong>first</strong>, then open a modal a few hundred
236
+ milliseconds later. Without the <code className="badge badge-sm">MutationObserver</code> in
237
+ the provider, the modal would steal the top of the top-layer stack and hide the toast.
238
+ With it, the toast popover is re-promoted as soon as the new <code className="badge badge-sm">&lt;dialog&gt;</code> opens.
239
+ </p>
240
+
241
+ <ToastStressInner />
242
+ </div>
243
+
244
+ </ToastProvider>
245
+
246
+ <div className="text-xs text-base-content/60 leading-relaxed">
247
+ <p>
248
+ Try every combination ({ verticalAlignments.length } × { horizontalAlignments.length } = 9 positions):
249
+ top / middle / bottom × start / center / end. Each toast should anchor at the chosen
250
+ corner, edge or center — independently of where the modal sits.
251
+ </p>
252
+ </div>
253
+
254
+ </Container>
255
+ ) ;
256
+ } ;
257
+
258
+ ToastOverModalDemo.displayName = 'ToastOverModalDemo' ;
259
+
260
+ export default ToastOverModalDemo ;
@@ -47,7 +47,7 @@ const Application = ( { children , initialLang } ) =>
47
47
  { !ready && (
48
48
  <motion.div
49
49
  key = "splash"
50
- className = "fixed inset-0 z-50 bg-base-100"
50
+ className = "fixed inset-0 z-50 bg-base-100 pointer-events-none"
51
51
  initial = { { opacity : 0 } }
52
52
  animate = { { opacity : 1 } }
53
53
  exit = { { opacity : 0 } }
package/src/version.js CHANGED
@@ -1,3 +1,3 @@
1
- const version = "0.1.45" ;
1
+ const version = "0.1.47" ;
2
2
 
3
3
  export default version ;