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 +48 -26
- package/package.json +15 -15
- package/src/app/lab/@tabs/modals/page.js +8 -5
- package/src/components/inputs/InputHexColor.jsx +16 -7
- package/src/contexts/toasts/provider.js +168 -36
- package/src/demo/inputs/InputHexColorDemo.jsx +5 -4
- package/src/demo/modals/ToastOverModalDemo.jsx +260 -0
- package/src/display/Application.jsx +1 -1
- package/src/version.js +1 -1
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
|
-
|
|
103
|
+
## Release
|
|
104
104
|
|
|
105
|
-
|
|
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
|
-
###
|
|
107
|
+
### Versioning
|
|
110
108
|
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
117
|
+
### Prerequisites
|
|
118
118
|
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
+
```bash
|
|
137
|
+
bun run release:patch
|
|
138
|
+
```
|
|
128
139
|
|
|
129
|
-
|
|
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
|
-
|
|
161
|
+
Pre-release versions :
|
|
137
162
|
|
|
138
163
|
```bash
|
|
139
|
-
npm version prerelease --preid=alpha # 0.1.
|
|
140
|
-
npm version prerelease --preid=beta # 0.1.
|
|
141
|
-
npm version prerelease --preid=rc # 0.1.
|
|
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.
|
|
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": "
|
|
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.
|
|
61
|
-
"@maskito/kit": "^5.
|
|
62
|
-
"@maskito/react": "^5.
|
|
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.
|
|
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.
|
|
71
|
-
"next": "16.
|
|
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.
|
|
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.
|
|
83
|
+
"sanitize-html": "^2.17.3",
|
|
84
84
|
"tailwind-merge": "^3.5.0",
|
|
85
|
-
"validator": "^13.15.
|
|
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.
|
|
90
|
+
"@tailwindcss/postcss": "^4.2.4",
|
|
91
91
|
"@tailwindcss/typography": "^0.5.19",
|
|
92
|
-
"@types/node": "^25.
|
|
92
|
+
"@types/node": "^25.6.0",
|
|
93
93
|
"@types/react-syntax-highlighter": "^15.5.13",
|
|
94
|
-
"@types/sanitize-html": "^2.16.
|
|
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.
|
|
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
|
|
4
|
-
import InputModalDemo
|
|
5
|
-
import ModalDemo
|
|
6
|
-
import
|
|
7
|
-
import
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
144
|
+
if ( !value ) return '' ;
|
|
145
|
+
return value.startsWith( '#' ) ? value : `#${value}` ;
|
|
138
146
|
} : undefined ;
|
|
139
147
|
|
|
140
148
|
const process = ( value ) =>
|
|
141
149
|
{
|
|
142
|
-
|
|
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
|
-
|
|
63
|
+
length = { 3 }
|
|
64
|
+
label = "Short Format (3 chars)"
|
|
64
65
|
defaultValue = "F53"
|
|
65
|
-
icon
|
|
66
|
-
placeholder
|
|
67
|
-
helper
|
|
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 <dialog> 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"><dialog></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"><dialog></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