@owlmeans/web-panel 0.1.18-rc.17 → 0.1.18-rc.19
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/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/web-panel/SKILL.md +29 -1
- package/build/components/index.d.ts +1 -0
- package/build/components/index.d.ts.map +1 -1
- package/build/components/index.js +1 -0
- package/build/components/index.js.map +1 -1
- package/build/components/toaster.d.ts +16 -0
- package/build/components/toaster.d.ts.map +1 -0
- package/build/components/toaster.js +52 -0
- package/build/components/toaster.js.map +1 -0
- package/package.json +3 -2
- package/src/components/index.ts +1 -0
- package/src/components/toaster.tsx +68 -0
- package/tests/context.ts +4 -0
- package/tests/harness/mount.tsx +31 -18
- package/tests/toaster.spec.ts +59 -0
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/web-panel",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-08-
|
|
4
|
+
"version": "0.1.18-rc.18",
|
|
5
|
+
"generatedAt": "2026-08-30T20:53:30.853Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -8,7 +8,7 @@ user-invocable: false
|
|
|
8
8
|
# @owlmeans/web-panel
|
|
9
9
|
|
|
10
10
|
**Layer:** Web (React)
|
|
11
|
-
**Install:** `"@owlmeans/web-panel": "^0.1.18-rc.
|
|
11
|
+
**Install:** `"@owlmeans/web-panel": "^0.1.18-rc.18"` in `dependencies`
|
|
12
12
|
|
|
13
13
|
## Key Exports
|
|
14
14
|
|
|
@@ -17,6 +17,7 @@ user-invocable: false
|
|
|
17
17
|
| `makeContext<C, T>(cfg)` | Base web context factory (shadcn/Radix + default OwlMeans router) |
|
|
18
18
|
| `NavLayout` | The application shell — header, section menu, screen menu, content, footer |
|
|
19
19
|
| `TopNav` / `SideNav` / `Footer` | The shell's pieces, mountable on their own |
|
|
20
|
+
| `Toaster` | The application's toast surface — mounted once, in the layout |
|
|
20
21
|
| `components` submodule | shadcn/Radix panel/form components |
|
|
21
22
|
| Re-exports from `@owlmeans/client-panel` | Cross-platform panel primitives, incl. `usePanelNav` and the `PanelNav*` types |
|
|
22
23
|
| `main`, `exports`, `context`, `modules`, `types` | Wiring helpers |
|
|
@@ -146,6 +147,33 @@ Rules that make the shell behave:
|
|
|
146
147
|
- **Vendor `navigation-menu`.** `SideNav` builds on the existing `Button`; `TopNav` uses the shadcn
|
|
147
148
|
`navigation-menu` primitive — see the `@` contract below.
|
|
148
149
|
|
|
150
|
+
### Toasts — `Toaster`
|
|
151
|
+
|
|
152
|
+
An action that succeeded or failed says so in a toast. The surface is `Toaster`; the messages are
|
|
153
|
+
`toast.success(...)` / `toast.error(...)` imported from **`sonner`** by whatever raised them.
|
|
154
|
+
|
|
155
|
+
```tsx
|
|
156
|
+
import { NavLayout, Toaster } from '@owlmeans/web-panel'
|
|
157
|
+
|
|
158
|
+
export const MainLayout: FC<PropsWithChildren> = ({ children }) => <>
|
|
159
|
+
<NavLayout nav={navConfig} title="My App">{children}</NavLayout>
|
|
160
|
+
<Toaster />
|
|
161
|
+
</>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
- **Mount it exactly once, in the layout.** `toast()` writes to a module-global store, so two
|
|
165
|
+
mounted `Toaster`s render every message twice and none renders any of them — both failures are
|
|
166
|
+
silent, which is why `tests/toaster.spec.ts` asserts the count rather than the presence.
|
|
167
|
+
- **Colours come from the app's own tokens** — `--popover`, `--popover-foreground`, `--border` —
|
|
168
|
+
so a toast matches every other floating surface. A caller's `style` merges over them.
|
|
169
|
+
- **The theme follows the `.dark` class on the document element**, not a theme provider: the
|
|
170
|
+
package reads the class an app's `next-themes`, the owl theme provider, or a hand-written
|
|
171
|
+
toggle all set, and depends on none of them. Pass `theme` explicitly to override.
|
|
172
|
+
- Defaults are `richColors`, `closeButton`, `duration={5000}`, `position="top-right"`; every one
|
|
173
|
+
of them, and every other `ToasterProps` field, is overridable per app.
|
|
174
|
+
- `sonner` is a dependency of this package, so nothing is required of the consumer — but an app
|
|
175
|
+
raising its own toasts should declare `sonner` too, at a range that resolves to the same copy.
|
|
176
|
+
|
|
149
177
|
## Consumer setup — the `@` contract and Tailwind
|
|
150
178
|
|
|
151
179
|
`web-panel` emits `@/components/ui/*` and `@/lib/utils` verbatim; the app's bundler resolves `@` to
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAE/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,mBAAmB,CAAA;AACjC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AAEjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,qBAAqB,CAAA;AAEnC,cAAc,mBAAmB,CAAA;AAEjC,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAE/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,mBAAmB,CAAA;AACjC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AAEjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,qBAAqB,CAAA;AAEnC,cAAc,mBAAmB,CAAA;AAEjC,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAEA,cAAc,sBAAsB,CAAA;AACpC,cAAc,mBAAmB,CAAA;AACjC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AAEjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,qBAAqB,CAAA;AAEnC,cAAc,mBAAmB,CAAA;AAEjC,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAEA,cAAc,sBAAsB,CAAA;AACpC,cAAc,mBAAmB,CAAA;AACjC,cAAc,gBAAgB,CAAA;AAC9B,cAAc,mBAAmB,CAAA;AAEjC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,sBAAsB,CAAA;AACpC,cAAc,wBAAwB,CAAA;AACtC,cAAc,qBAAqB,CAAA;AAEnC,cAAc,mBAAmB,CAAA;AAEjC,cAAc,YAAY,CAAA;AAC1B,cAAc,WAAW,CAAA;AACzB,cAAc,WAAW,CAAA;AACzB,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { FC } from 'react';
|
|
2
|
+
import type { ToasterProps } from 'sonner';
|
|
3
|
+
/**
|
|
4
|
+
* The application's toast surface — mount it ONCE, in the layout.
|
|
5
|
+
*
|
|
6
|
+
* `toast()` from `sonner` is a module-global store, so a second mounted `Toaster` renders every
|
|
7
|
+
* message twice and no mounted `Toaster` renders none of them at all with nothing reported. The
|
|
8
|
+
* layout is therefore the only place this belongs.
|
|
9
|
+
*
|
|
10
|
+
* Colours come from the app's own theme tokens rather than sonner's palette: the CSS variables
|
|
11
|
+
* below are what its stylesheet paints with, so a toast follows `--popover` the way every other
|
|
12
|
+
* floating surface in the app does. A caller's `style` is merged OVER them, so an app that has to
|
|
13
|
+
* shift or recolour the stack keeps the rest.
|
|
14
|
+
*/
|
|
15
|
+
export declare const Toaster: FC<ToasterProps>;
|
|
16
|
+
//# sourceMappingURL=toaster.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"toaster.d.ts","sourceRoot":"","sources":["../../src/components/toaster.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,EAAE,EAAE,MAAM,OAAO,CAAA;AAG9C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAA;AAkC1C;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,EAAE,EAAE,CAAC,YAAY,CAkBpC,CAAA"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Toaster as Sonner } from 'sonner';
|
|
4
|
+
import { cn } from '@/lib/utils';
|
|
5
|
+
const DARK_CLASS = 'dark';
|
|
6
|
+
/**
|
|
7
|
+
* Follow whatever puts `.dark` on the document element.
|
|
8
|
+
*
|
|
9
|
+
* The panel's Tailwind theme switches on that class (`@custom-variant dark (&:is(.dark *))`),
|
|
10
|
+
* and every OwlMeans app drives it from something different — `next-themes` with
|
|
11
|
+
* `attribute="class"`, the owl theme provider, or nothing at all. Reading the class rather than a
|
|
12
|
+
* provider's hook means this component follows all three and depends on none of them: a package
|
|
13
|
+
* that imported `next-themes` would force it on every consumer of `@owlmeans/web-panel`, and a
|
|
14
|
+
* second copy of a theme provider is its own class of bug.
|
|
15
|
+
*
|
|
16
|
+
* `'system'` is the answer before the first paint and whenever nothing sets the class — sonner
|
|
17
|
+
* resolves it from `prefers-color-scheme`, which is what an app with no toggle means by it.
|
|
18
|
+
*/
|
|
19
|
+
const useDocumentTheme = () => {
|
|
20
|
+
const [theme, setTheme] = useState('system');
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const root = document.documentElement;
|
|
23
|
+
const read = () => setTheme(root.classList.contains(DARK_CLASS) ? 'dark' : 'system');
|
|
24
|
+
read();
|
|
25
|
+
const observer = new MutationObserver(read);
|
|
26
|
+
observer.observe(root, { attributes: true, attributeFilter: ['class'] });
|
|
27
|
+
return () => observer.disconnect();
|
|
28
|
+
}, []);
|
|
29
|
+
return theme;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The application's toast surface — mount it ONCE, in the layout.
|
|
33
|
+
*
|
|
34
|
+
* `toast()` from `sonner` is a module-global store, so a second mounted `Toaster` renders every
|
|
35
|
+
* message twice and no mounted `Toaster` renders none of them at all with nothing reported. The
|
|
36
|
+
* layout is therefore the only place this belongs.
|
|
37
|
+
*
|
|
38
|
+
* Colours come from the app's own theme tokens rather than sonner's palette: the CSS variables
|
|
39
|
+
* below are what its stylesheet paints with, so a toast follows `--popover` the way every other
|
|
40
|
+
* floating surface in the app does. A caller's `style` is merged OVER them, so an app that has to
|
|
41
|
+
* shift or recolour the stack keeps the rest.
|
|
42
|
+
*/
|
|
43
|
+
export const Toaster = ({ style, theme, className, ...props }) => {
|
|
44
|
+
const detected = useDocumentTheme();
|
|
45
|
+
return _jsx(Sonner, { theme: theme ?? detected, className: cn('toaster group', className), richColors: true, closeButton: true, duration: 5000, position: "top-right", style: {
|
|
46
|
+
'--normal-bg': 'var(--popover)',
|
|
47
|
+
'--normal-text': 'var(--popover-foreground)',
|
|
48
|
+
'--normal-border': 'var(--border)',
|
|
49
|
+
...(style ?? {})
|
|
50
|
+
}, ...props });
|
|
51
|
+
};
|
|
52
|
+
//# sourceMappingURL=toaster.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"toaster.js","sourceRoot":"","sources":["../../src/components/toaster.tsx"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AAC3C,OAAO,EAAE,OAAO,IAAI,MAAM,EAAE,MAAM,QAAQ,CAAA;AAE1C,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAA;AAEhC,MAAM,UAAU,GAAG,MAAM,CAAA;AAEzB;;;;;;;;;;;;GAYG;AACH,MAAM,gBAAgB,GAAG,GAA0B,EAAE;IACnD,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAwB,QAAQ,CAAC,CAAA;IAEnE,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,IAAI,GAAG,QAAQ,CAAC,eAAe,CAAA;QACrC,MAAM,IAAI,GAAG,GAAG,EAAE,CAAC,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAA;QACpF,IAAI,EAAE,CAAA;QACN,MAAM,QAAQ,GAAG,IAAI,gBAAgB,CAAC,IAAI,CAAC,CAAA;QAC3C,QAAQ,CAAC,OAAO,CAAC,IAAI,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE,eAAe,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;QAExE,OAAO,GAAG,EAAE,CAAC,QAAQ,CAAC,UAAU,EAAE,CAAA;IACpC,CAAC,EAAE,EAAE,CAAC,CAAA;IAEN,OAAO,KAAK,CAAA;AACd,CAAC,CAAA;AAED;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,OAAO,GAAqB,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,GAAG,KAAK,EAAE,EAAE,EAAE;IACjF,MAAM,QAAQ,GAAG,gBAAgB,EAAE,CAAA;IAEnC,OAAO,KAAC,MAAM,IACZ,KAAK,EAAE,KAAK,IAAI,QAAQ,EACxB,SAAS,EAAE,EAAE,CAAC,eAAe,EAAE,SAAS,CAAC,EACzC,UAAU,QACV,WAAW,QACX,QAAQ,EAAE,IAAI,EACd,QAAQ,EAAC,WAAW,EACpB,KAAK,EAAE;YACL,aAAa,EAAE,gBAAgB;YAC/B,eAAe,EAAE,2BAA2B;YAC5C,iBAAiB,EAAE,eAAe;YAClC,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC;SACA,KACd,KAAK,GACT,CAAA;AACJ,CAAC,CAAA"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/web-panel",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.19",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -63,7 +63,8 @@
|
|
|
63
63
|
"@owlmeans/web-router": "^0.1.18-rc.8",
|
|
64
64
|
"i18next-browser-languagedetector": "^8.0.0",
|
|
65
65
|
"qrcode.react": "^4.2.0",
|
|
66
|
-
"react-google-recaptcha": "^3.1.0"
|
|
66
|
+
"react-google-recaptcha": "^3.1.0",
|
|
67
|
+
"sonner": "^2.0.7"
|
|
67
68
|
},
|
|
68
69
|
"peerDependencies": {
|
|
69
70
|
"@radix-ui/react-label": "*",
|
package/src/components/index.ts
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { CSSProperties, FC } from 'react'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
import { Toaster as Sonner } from 'sonner'
|
|
4
|
+
import type { ToasterProps } from 'sonner'
|
|
5
|
+
import { cn } from '@/lib/utils'
|
|
6
|
+
|
|
7
|
+
const DARK_CLASS = 'dark'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Follow whatever puts `.dark` on the document element.
|
|
11
|
+
*
|
|
12
|
+
* The panel's Tailwind theme switches on that class (`@custom-variant dark (&:is(.dark *))`),
|
|
13
|
+
* and every OwlMeans app drives it from something different — `next-themes` with
|
|
14
|
+
* `attribute="class"`, the owl theme provider, or nothing at all. Reading the class rather than a
|
|
15
|
+
* provider's hook means this component follows all three and depends on none of them: a package
|
|
16
|
+
* that imported `next-themes` would force it on every consumer of `@owlmeans/web-panel`, and a
|
|
17
|
+
* second copy of a theme provider is its own class of bug.
|
|
18
|
+
*
|
|
19
|
+
* `'system'` is the answer before the first paint and whenever nothing sets the class — sonner
|
|
20
|
+
* resolves it from `prefers-color-scheme`, which is what an app with no toggle means by it.
|
|
21
|
+
*/
|
|
22
|
+
const useDocumentTheme = (): ToasterProps['theme'] => {
|
|
23
|
+
const [theme, setTheme] = useState<ToasterProps['theme']>('system')
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const root = document.documentElement
|
|
27
|
+
const read = () => setTheme(root.classList.contains(DARK_CLASS) ? 'dark' : 'system')
|
|
28
|
+
read()
|
|
29
|
+
const observer = new MutationObserver(read)
|
|
30
|
+
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
|
|
31
|
+
|
|
32
|
+
return () => observer.disconnect()
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
return theme
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The application's toast surface — mount it ONCE, in the layout.
|
|
40
|
+
*
|
|
41
|
+
* `toast()` from `sonner` is a module-global store, so a second mounted `Toaster` renders every
|
|
42
|
+
* message twice and no mounted `Toaster` renders none of them at all with nothing reported. The
|
|
43
|
+
* layout is therefore the only place this belongs.
|
|
44
|
+
*
|
|
45
|
+
* Colours come from the app's own theme tokens rather than sonner's palette: the CSS variables
|
|
46
|
+
* below are what its stylesheet paints with, so a toast follows `--popover` the way every other
|
|
47
|
+
* floating surface in the app does. A caller's `style` is merged OVER them, so an app that has to
|
|
48
|
+
* shift or recolour the stack keeps the rest.
|
|
49
|
+
*/
|
|
50
|
+
export const Toaster: FC<ToasterProps> = ({ style, theme, className, ...props }) => {
|
|
51
|
+
const detected = useDocumentTheme()
|
|
52
|
+
|
|
53
|
+
return <Sonner
|
|
54
|
+
theme={theme ?? detected}
|
|
55
|
+
className={cn('toaster group', className)}
|
|
56
|
+
richColors
|
|
57
|
+
closeButton
|
|
58
|
+
duration={5000}
|
|
59
|
+
position="top-right"
|
|
60
|
+
style={{
|
|
61
|
+
'--normal-bg': 'var(--popover)',
|
|
62
|
+
'--normal-text': 'var(--popover-foreground)',
|
|
63
|
+
'--normal-border': 'var(--border)',
|
|
64
|
+
...(style ?? {})
|
|
65
|
+
} as CSSProperties}
|
|
66
|
+
{...props}
|
|
67
|
+
/>
|
|
68
|
+
}
|
package/tests/context.ts
CHANGED
|
@@ -25,6 +25,10 @@ export const getHarnessUrl = async (): Promise<string> => {
|
|
|
25
25
|
alias: { '@': resolve(here, '../src/@') },
|
|
26
26
|
dedupe: ['react', 'react-dom'],
|
|
27
27
|
},
|
|
28
|
+
// Pre-bundled at server start rather than discovered while the first request is being
|
|
29
|
+
// served: a dependency found mid-transform re-optimizes and reloads the page underneath
|
|
30
|
+
// the navigation, which the first `goto` of a cold run pays for as a timeout.
|
|
31
|
+
optimizeDeps: { include: ['sonner'] },
|
|
28
32
|
server: { port: 0 },
|
|
29
33
|
logLevel: 'warn'
|
|
30
34
|
})
|
package/tests/harness/mount.tsx
CHANGED
|
@@ -8,8 +8,9 @@ import { BASE, HOME } from '@owlmeans/context'
|
|
|
8
8
|
import { entrypoint } from '@owlmeans/client-entrypoint'
|
|
9
9
|
import { frontend, route } from '@owlmeans/route'
|
|
10
10
|
import { handler, useNavigate } from '@owlmeans/client'
|
|
11
|
+
import { toast } from 'sonner'
|
|
11
12
|
import type { PanelNavConfig, PanelNavLink } from '../../src/index.js'
|
|
12
|
-
import { makeContext, modules as baseModules, NavLayout, PanelApp } from '../../src/index.js'
|
|
13
|
+
import { makeContext, modules as baseModules, NavLayout, PanelApp, Toaster } from '../../src/index.js'
|
|
13
14
|
|
|
14
15
|
// A real app: a context, a layout entrypoint rendering NavLayout, and screens under it.
|
|
15
16
|
// The nav model resolves the active screen from the router, so nothing here may be faked.
|
|
@@ -48,22 +49,34 @@ const footerLinks: PanelNavLink[] = [
|
|
|
48
49
|
|
|
49
50
|
const screen = (id: string, text: string): FC => () => <div id={id}>{text}</div>
|
|
50
51
|
|
|
51
|
-
const Layout: FC<PropsWithChildren> = ({ children }) =>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
52
|
+
const Layout: FC<PropsWithChildren> = ({ children }) => <>
|
|
53
|
+
<NavLayout
|
|
54
|
+
nav={navConfig}
|
|
55
|
+
title="Harness"
|
|
56
|
+
actions={<button id="action-slot">action</button>}
|
|
57
|
+
footer={footerLinks}
|
|
58
|
+
// A DARK APPLICATION SHELL, which is what a themed app does to the root: a contrasting
|
|
59
|
+
// surface pair, both halves correct. The header paints its own opaque background, so it is
|
|
60
|
+
// a different surface, and everything in it must stay legible against `--background`
|
|
61
|
+
// rather than against this. The harness carries it permanently so every navigation test
|
|
62
|
+
// runs against the hostile case instead of a default-coloured page.
|
|
63
|
+
className="bg-primary text-primary-foreground"
|
|
64
|
+
// A WIDTH-ONLY rhythm override, which is what a design pass writes when it wants a wider
|
|
65
|
+
// page. It names the width and nothing else, so the centring and the side padding must
|
|
66
|
+
// survive it — substituting this for the default is a page running flush to the window edge.
|
|
67
|
+
containerClassName="max-w-[1280px]"
|
|
68
|
+
>{children}</NavLayout>
|
|
69
|
+
{/* Mounted ONCE, in the layout — exactly where an application mounts it. */}
|
|
70
|
+
<Toaster />
|
|
71
|
+
</>
|
|
72
|
+
|
|
73
|
+
/** The toast surface's exercise: a screen action that reports its outcome. */
|
|
74
|
+
const PrefsScreen: FC = () => <div id="prefs">
|
|
75
|
+
prefs-screen
|
|
76
|
+
<button id="fire-toast" onClick={() => toast.success('preferences saved')}>save</button>
|
|
77
|
+
{/* Outlives any assertion — a theme check must not race the 5s default dismissal. */}
|
|
78
|
+
<button id="fire-sticky" onClick={() => toast.error('sticky failure', { duration: 600_000 })}>fail</button>
|
|
79
|
+
</div>
|
|
67
80
|
|
|
68
81
|
/** A grouping screen — it renders whichever child the router matched. */
|
|
69
82
|
const ReportsGroup: FC<PropsWithChildren> = ({ children }) => <div id="reports-group">{children}</div>
|
|
@@ -115,7 +128,7 @@ const modules = [
|
|
|
115
128
|
route(alias.reportDetail, '/detail', frontend({ parent: alias.reports })),
|
|
116
129
|
handler(screen('detail', 'detail-screen'))
|
|
117
130
|
),
|
|
118
|
-
entrypoint(route(alias.prefs, '/prefs', frontend({ parent: BASE })), handler(
|
|
131
|
+
entrypoint(route(alias.prefs, '/prefs', frontend({ parent: BASE })), handler(PrefsScreen)),
|
|
119
132
|
]
|
|
120
133
|
|
|
121
134
|
context.registerEntrypoints(modules)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { closeBrowser, mountComponent } from '@owlmeans/test-ui'
|
|
3
|
+
import { HARNESS_URL } from './context.js'
|
|
4
|
+
|
|
5
|
+
// Browser work does not fit the 5s default: a cold harness compiles the app on first request.
|
|
6
|
+
const TIMEOUT = 30_000
|
|
7
|
+
|
|
8
|
+
afterAll(async () => {
|
|
9
|
+
await closeBrowser()
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
const open = async (path: string) => mountComponent({ url: `${HARNESS_URL.replace(/\/$/, '')}${path}` })
|
|
13
|
+
|
|
14
|
+
describe('@owlmeans/web-panel — the toast surface', () => {
|
|
15
|
+
test('an action reports its outcome, once, through the layout-mounted Toaster', async () => {
|
|
16
|
+
// The whole point of shipping the Toaster from the panel: a screen calls `toast()` from
|
|
17
|
+
// `sonner` and something the LAYOUT mounted renders it. Both failure modes are silent —
|
|
18
|
+
// no mount renders nothing at all, two mounts render every message twice — so the count
|
|
19
|
+
// is asserted rather than the mere presence.
|
|
20
|
+
const { page, close } = await open('/prefs')
|
|
21
|
+
try {
|
|
22
|
+
await page.waitForSelector('#prefs')
|
|
23
|
+
await page.locator('#fire-toast').click()
|
|
24
|
+
|
|
25
|
+
const toast = page.locator('[data-sonner-toast]')
|
|
26
|
+
await toast.first().waitFor({ state: 'visible' })
|
|
27
|
+
expect(await page.locator('[data-sonner-toaster]').count()).toBe(1)
|
|
28
|
+
expect(await toast.count()).toBe(1)
|
|
29
|
+
expect(await toast.first().textContent()).toContain('preferences saved')
|
|
30
|
+
} finally {
|
|
31
|
+
await close()
|
|
32
|
+
}
|
|
33
|
+
}, TIMEOUT)
|
|
34
|
+
|
|
35
|
+
test('the toasts follow the document theme class', async () => {
|
|
36
|
+
// The panel reads `.dark` off the document element rather than a theme provider's hook, so
|
|
37
|
+
// it follows next-themes, the owl theme provider and a hand-set class alike. A toast painted
|
|
38
|
+
// in the wrong theme is the visible half; the invisible half is a package that would have
|
|
39
|
+
// forced `next-themes` on every consumer.
|
|
40
|
+
const { page, close } = await open('/prefs')
|
|
41
|
+
try {
|
|
42
|
+
await page.waitForSelector('#prefs')
|
|
43
|
+
await page.locator('#fire-sticky').click()
|
|
44
|
+
// The toast is what becomes visible; the list around it is a zero-box positioning
|
|
45
|
+
// container, so waiting on the list itself never resolves.
|
|
46
|
+
await page.locator('[data-sonner-toast]').first().waitFor({ state: 'visible' })
|
|
47
|
+
const toaster = page.locator('[data-sonner-toaster]')
|
|
48
|
+
expect(await toaster.getAttribute('data-sonner-theme')).toBe('light')
|
|
49
|
+
|
|
50
|
+
await page.evaluate(() => document.documentElement.classList.add('dark'))
|
|
51
|
+
await page.waitForFunction(
|
|
52
|
+
() => document.querySelector('[data-sonner-toaster]')?.getAttribute('data-sonner-theme') === 'dark'
|
|
53
|
+
)
|
|
54
|
+
expect(await toaster.getAttribute('data-sonner-theme')).toBe('dark')
|
|
55
|
+
} finally {
|
|
56
|
+
await close()
|
|
57
|
+
}
|
|
58
|
+
}, TIMEOUT)
|
|
59
|
+
})
|