@versini/ui-dialog 8.0.6
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/LICENSE +21 -0
- package/README.md +132 -0
- package/dist/index.d.ts +95 -0
- package/dist/index.js +705 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Arno Versini
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# @versini/ui-panel
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@versini/ui-panel)
|
|
4
|
+
>)
|
|
5
|
+
|
|
6
|
+
> An accessible React slide-out panel component built with TypeScript and TailwindCSS.
|
|
7
|
+
|
|
8
|
+
The Panel component provides slide-out panels and drawers with focus management, keyboard navigation, document title management, optional animations, and customizable positioning / sizing.
|
|
9
|
+
|
|
10
|
+
## Table of Contents
|
|
11
|
+
|
|
12
|
+
- [Table of Contents](#table-of-contents)
|
|
13
|
+
- [Features](#features)
|
|
14
|
+
- [Installation](#installation)
|
|
15
|
+
- [Usage](#usage)
|
|
16
|
+
- [Examples](#examples)
|
|
17
|
+
- [Message Box Variant](#message-box-variant)
|
|
18
|
+
- [Animated Panel (Fade)](#animated-panel-fade)
|
|
19
|
+
- [API](#api)
|
|
20
|
+
- [Panel Props](#panel-props)
|
|
21
|
+
|
|
22
|
+
## Features
|
|
23
|
+
|
|
24
|
+
- **🪟 Versatile Layouts**: Standard panel and message box variants (`kind` prop)
|
|
25
|
+
- **🎯 Focus Management**: Uses underlying modal primitives for proper focus trapping & return
|
|
26
|
+
- **♿ Accessible**: ARIA compliant structure with heading, description, close control
|
|
27
|
+
- **🎬 Optional Animations**: Slide or fade entrance animations (`animation` / `animationType`)
|
|
28
|
+
- **📐 Responsive Sizing**: Predefined max widths (`small`, `medium`, `large`) above md breakpoint
|
|
29
|
+
- **🧩 Composable**: Footer slot for actions / extra content
|
|
30
|
+
- **🧪 Type Safe**: Fully typed props with inline documentation
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install @versini/ui-panel
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> **Note**: This component requires TailwindCSS and the `@versini/ui-styles` plugin for proper styling. See the [installation documentation](https://versini-org.github.io/ui-components/?path=/docs/getting-started-installation--docs) for complete setup instructions.
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
import { Panel } from "@versini/ui-panel";
|
|
44
|
+
import { useState } from "react";
|
|
45
|
+
|
|
46
|
+
function App() {
|
|
47
|
+
const [open, setOpen] = useState(false);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<>
|
|
51
|
+
<button onClick={() => setOpen(true)}>Open Panel</button>
|
|
52
|
+
<Panel title="Panel Title" open={open} onOpenChange={setOpen}>
|
|
53
|
+
Panel content goes here.
|
|
54
|
+
</Panel>
|
|
55
|
+
</>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Examples
|
|
61
|
+
|
|
62
|
+
### Message Box Variant
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
import { Panel } from "@versini/ui-panel";
|
|
66
|
+
import { useState } from "react";
|
|
67
|
+
|
|
68
|
+
function MessageBoxExample() {
|
|
69
|
+
const [open, setOpen] = useState(false);
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
<button onClick={() => setOpen(true)}>Show Message</button>
|
|
73
|
+
<Panel
|
|
74
|
+
kind="messagebox"
|
|
75
|
+
title="Session Expired"
|
|
76
|
+
open={open}
|
|
77
|
+
onOpenChange={setOpen}
|
|
78
|
+
footer={
|
|
79
|
+
<div className="flex justify-end gap-2">
|
|
80
|
+
<button
|
|
81
|
+
className="px-3 py-1 rounded bg-surface-lighter"
|
|
82
|
+
onClick={() => setOpen(false)}
|
|
83
|
+
>
|
|
84
|
+
Dismiss
|
|
85
|
+
</button>
|
|
86
|
+
<button className="px-3 py-1 rounded bg-blue-600 text-white">
|
|
87
|
+
Re‑authenticate
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
}
|
|
91
|
+
>
|
|
92
|
+
Your session has expired. Please sign in again to continue.
|
|
93
|
+
</Panel>
|
|
94
|
+
</>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Animated Panel (Fade)
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
<Panel
|
|
103
|
+
title="Animated Panel"
|
|
104
|
+
open={open}
|
|
105
|
+
onOpenChange={setOpen}
|
|
106
|
+
animation
|
|
107
|
+
animationType="fade"
|
|
108
|
+
>
|
|
109
|
+
Content with fade animation.
|
|
110
|
+
</Panel>
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## API
|
|
114
|
+
|
|
115
|
+
### Panel Props
|
|
116
|
+
|
|
117
|
+
| Prop | Type | Default | Description |
|
|
118
|
+
| --------------- | ------------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
119
|
+
| `open` | `boolean` | - | Whether the panel is open. |
|
|
120
|
+
| `onOpenChange` | `(open: boolean) => void` | - | Callback fired when open state changes. |
|
|
121
|
+
| `title` | `string` | - | Title displayed in the header (also used to augment `document.title`). |
|
|
122
|
+
| `children` | `React.ReactNode` | - | Main content of the panel. |
|
|
123
|
+
| `footer` | `React.ReactNode` | - | Optional footer content (actions, etc.). |
|
|
124
|
+
| `className` | `string` | - | Extra classes applied to width wrapper (overrides default width). |
|
|
125
|
+
| `borderMode` | `"dark" \| "light"` | `"light"` | Visual style of border / surface. |
|
|
126
|
+
| `kind` | `"panel" \| "messagebox"` | `"panel"` | Layout variant. |
|
|
127
|
+
| `animation` | `boolean` | `false` | Enable entrance animation. |
|
|
128
|
+
| `animationType` | `"slide" \| "fade"` | `"slide"` | Animation style (only when `animation` is true). |
|
|
129
|
+
| `maxWidth` | `"small" \| "medium" \| "large"` | `"medium"` | Max width applied (≥ md breakpoint) for `kind="panel"`. |
|
|
130
|
+
| `initialFocus` | `number \| React.RefObject<HTMLElement \| null>` | `0` | Which element to initially focus when the Panel opens. Can be a tabbable index (0 = close button), a ref to an element, or -1 to disable. |
|
|
131
|
+
|
|
132
|
+
> Also inherits any valid props for the underlying modal primitives where relevant.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { JSX } from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
export declare const ANIMATION_FADE = "fade";
|
|
4
|
+
|
|
5
|
+
export declare const ANIMATION_SLIDE = "slide";
|
|
6
|
+
|
|
7
|
+
export declare const LARGE = "large";
|
|
8
|
+
|
|
9
|
+
export declare const MEDIUM = "medium";
|
|
10
|
+
|
|
11
|
+
export declare const MESSAGEBOX_CLASSNAME = "av-messagebox";
|
|
12
|
+
|
|
13
|
+
export declare const NONE = "none";
|
|
14
|
+
|
|
15
|
+
export declare const Panel: ({ open, onOpenChange, title, children, footer, borderMode, kind, className, animation, animationType, maxWidth, maxHeight, blurEffect, initialFocus, }: PanelProps) => JSX.Element;
|
|
16
|
+
|
|
17
|
+
export declare const PANEL_CLASSNAME = "av-panel";
|
|
18
|
+
|
|
19
|
+
declare type PanelProps = {
|
|
20
|
+
/**
|
|
21
|
+
* Class name to apply to the Panel - this will ONLY override the default width styles.
|
|
22
|
+
*/
|
|
23
|
+
className?: string;
|
|
24
|
+
/**
|
|
25
|
+
* The children to render.
|
|
26
|
+
*/
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
/**
|
|
29
|
+
* Callback fired when the component is opened or closed.
|
|
30
|
+
* @param open whether or not the menu is open
|
|
31
|
+
*/
|
|
32
|
+
onOpenChange: (open: boolean) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Whether or not to open the component..
|
|
35
|
+
* @default false
|
|
36
|
+
*/
|
|
37
|
+
open: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The title to use for the panel.
|
|
40
|
+
*/
|
|
41
|
+
title: string;
|
|
42
|
+
/**
|
|
43
|
+
* The type of Panel border.
|
|
44
|
+
*/
|
|
45
|
+
borderMode?: "dark" | "light";
|
|
46
|
+
/**
|
|
47
|
+
* The content to render in the footer.
|
|
48
|
+
*/
|
|
49
|
+
footer?: React.ReactNode;
|
|
50
|
+
/**
|
|
51
|
+
* The type of Panel. This will change the layout of the Panel.
|
|
52
|
+
*/
|
|
53
|
+
kind?: "panel" | "messagebox";
|
|
54
|
+
/**
|
|
55
|
+
* Whether or not to animate the opening and closing of the Panel.
|
|
56
|
+
*/
|
|
57
|
+
animation?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* The type of animation to use when opening and closing the Panel.
|
|
60
|
+
* NOTE: This is only used when `animation` is set to `true`.
|
|
61
|
+
* @default "slide"
|
|
62
|
+
*/
|
|
63
|
+
animationType?: "slide" | "fade";
|
|
64
|
+
/**
|
|
65
|
+
* The maximum width of the Panel when kind is "panel".
|
|
66
|
+
* NOTE: This does not affect messageboxes, which have a fixed width.
|
|
67
|
+
* @default "medium"
|
|
68
|
+
*/
|
|
69
|
+
maxWidth?: "small" | "medium" | "large";
|
|
70
|
+
/**
|
|
71
|
+
* The maximum height of the Panel or Messagebox.
|
|
72
|
+
* @default large for Panel, small for Messagebox
|
|
73
|
+
*/
|
|
74
|
+
maxHeight?: "small" | "medium" | "large";
|
|
75
|
+
/**
|
|
76
|
+
* The blur effect to apply to the header and footer backgrounds.
|
|
77
|
+
* @default "none"
|
|
78
|
+
*/
|
|
79
|
+
blurEffect?: "none" | "small" | "medium" | "large";
|
|
80
|
+
/**
|
|
81
|
+
* Which element to initially focus when the Panel opens.
|
|
82
|
+
* Can be a number (tabbable index, 0 = first tabbable element which is
|
|
83
|
+
* the close button), a ref to an element, or -1 to disable initial focus.
|
|
84
|
+
* @default 0
|
|
85
|
+
*/
|
|
86
|
+
initialFocus?: number | React.RefObject<HTMLElement | null>;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export declare const SMALL = "small";
|
|
90
|
+
|
|
91
|
+
export declare const TYPE_MESSAGEBOX = "messagebox";
|
|
92
|
+
|
|
93
|
+
export declare const TYPE_PANEL = "panel";
|
|
94
|
+
|
|
95
|
+
export { }
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
@versini/ui-dialog v8.0.6
|
|
3
|
+
© 2025 gizmette.com
|
|
4
|
+
*/
|
|
5
|
+
try {
|
|
6
|
+
if (!window.__VERSINI_UI_DIALOG__) {
|
|
7
|
+
window.__VERSINI_UI_DIALOG__ = {
|
|
8
|
+
version: "8.0.6",
|
|
9
|
+
buildTime: "12/18/2025 07:24 PM EST",
|
|
10
|
+
homepage: "https://www.npmjs.com/package/@versini/ui-dialog",
|
|
11
|
+
license: "MIT",
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
} catch (error) {
|
|
15
|
+
// nothing to declare officer
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
19
|
+
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
|
20
|
+
import clsx from "clsx";
|
|
21
|
+
import { createPortal } from "react-dom";
|
|
22
|
+
|
|
23
|
+
;// CONCATENATED MODULE: ./src/common/constants.ts
|
|
24
|
+
const MESSAGEBOX_CLASSNAME = "av-messagebox";
|
|
25
|
+
const PANEL_CLASSNAME = "av-panel";
|
|
26
|
+
const TYPE_PANEL = "panel";
|
|
27
|
+
const TYPE_MESSAGEBOX = "messagebox";
|
|
28
|
+
const ANIMATION_SLIDE = "slide";
|
|
29
|
+
const ANIMATION_FADE = "fade";
|
|
30
|
+
const SMALL = "small";
|
|
31
|
+
const MEDIUM = "medium";
|
|
32
|
+
const LARGE = "large";
|
|
33
|
+
const NONE = "none";
|
|
34
|
+
|
|
35
|
+
;// CONCATENATED MODULE: external "react/jsx-runtime"
|
|
36
|
+
|
|
37
|
+
;// CONCATENATED MODULE: external "react"
|
|
38
|
+
|
|
39
|
+
;// CONCATENATED MODULE: external "clsx"
|
|
40
|
+
|
|
41
|
+
;// CONCATENATED MODULE: external "react-dom"
|
|
42
|
+
|
|
43
|
+
;// CONCATENATED MODULE: ./src/components/Panel/dialogStackManager.ts
|
|
44
|
+
/**
|
|
45
|
+
* Dialog Stack Manager
|
|
46
|
+
*
|
|
47
|
+
* Implements the W3C WAI-ARIA APG pattern for managing nested modal dialogs.
|
|
48
|
+
* This module maintains a stack of open dialogs and coordinates focus event
|
|
49
|
+
* listeners between them.
|
|
50
|
+
*
|
|
51
|
+
* Key features:
|
|
52
|
+
* - Only the topmost dialog has active focus listeners
|
|
53
|
+
* - When a nested dialog opens, parent listeners are suspended
|
|
54
|
+
* - When a dialog closes, parent listeners are restored
|
|
55
|
+
* - Programmatic focus changes are flagged to prevent event interference
|
|
56
|
+
*
|
|
57
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/examples/dialog/
|
|
58
|
+
*/ /**
|
|
59
|
+
* Stack of currently open dialogs. The last item is the topmost dialog.
|
|
60
|
+
*/ const openDialogStack = [];
|
|
61
|
+
/**
|
|
62
|
+
* Flag to ignore focus changes during programmatic focus operations.
|
|
63
|
+
* When true, focus event handlers should return early to prevent interference.
|
|
64
|
+
*
|
|
65
|
+
* This is critical for nested dialogs: when a child dialog closes and
|
|
66
|
+
* programmatically returns focus to its trigger element inside the parent,
|
|
67
|
+
* we don't want the parent's focusin handler to interfere.
|
|
68
|
+
*/ let ignoreFocusChanges = false;
|
|
69
|
+
/**
|
|
70
|
+
* Get whether focus changes should be ignored.
|
|
71
|
+
* Focus event handlers should check this and return early if true.
|
|
72
|
+
*/ function shouldIgnoreFocusChanges() {
|
|
73
|
+
return ignoreFocusChanges;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Execute a function while ignoring focus change events.
|
|
77
|
+
* Use this when programmatically moving focus to prevent event handlers
|
|
78
|
+
* from interfering.
|
|
79
|
+
*
|
|
80
|
+
* @param fn - Function to execute (typically contains focus() calls)
|
|
81
|
+
*/ function withIgnoredFocusChanges(fn) {
|
|
82
|
+
ignoreFocusChanges = true;
|
|
83
|
+
try {
|
|
84
|
+
fn();
|
|
85
|
+
} finally{
|
|
86
|
+
// Use setTimeout to ensure the flag stays true through any
|
|
87
|
+
// microtasks that might fire focus events
|
|
88
|
+
setTimeout(()=>{
|
|
89
|
+
ignoreFocusChanges = false;
|
|
90
|
+
}, 0);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Register a dialog in the stack when it opens.
|
|
95
|
+
* If there's a parent dialog, its listeners are suspended.
|
|
96
|
+
*
|
|
97
|
+
* @param entry - The dialog entry to register
|
|
98
|
+
*/ function registerDialog(entry) {
|
|
99
|
+
// If there's already a dialog open, suspend its listeners
|
|
100
|
+
if (openDialogStack.length > 0) {
|
|
101
|
+
const currentTop = openDialogStack[openDialogStack.length - 1];
|
|
102
|
+
currentTop.removeListeners();
|
|
103
|
+
}
|
|
104
|
+
// Add the new dialog to the stack and activate its listeners
|
|
105
|
+
openDialogStack.push(entry);
|
|
106
|
+
entry.addListeners();
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Unregister a dialog from the stack when it closes.
|
|
110
|
+
* If there's a parent dialog, its listeners are restored.
|
|
111
|
+
*
|
|
112
|
+
* @param dialogRef - Reference to the dialog element being closed
|
|
113
|
+
*/ function unregisterDialog(dialogRef) {
|
|
114
|
+
const index = openDialogStack.findIndex((entry)=>entry.dialogRef === dialogRef);
|
|
115
|
+
/* c8 ignore next 3 - defensive check */ if (index === -1) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// Remove listeners from the dialog being closed
|
|
119
|
+
const [removedEntry] = openDialogStack.splice(index, 1);
|
|
120
|
+
removedEntry.removeListeners();
|
|
121
|
+
// If there's a parent dialog, restore its listeners
|
|
122
|
+
if (openDialogStack.length > 0) {
|
|
123
|
+
const newTop = openDialogStack[openDialogStack.length - 1];
|
|
124
|
+
newTop.addListeners();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Get the current number of open dialogs.
|
|
129
|
+
* Useful for debugging and testing.
|
|
130
|
+
*/ function getOpenDialogCount() {
|
|
131
|
+
return openDialogStack.length;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Check if a given dialog is the topmost (current) dialog.
|
|
135
|
+
*
|
|
136
|
+
* @param dialogRef - Reference to the dialog element to check
|
|
137
|
+
*/ function isTopmostDialog(dialogRef) {
|
|
138
|
+
if (openDialogStack.length === 0) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
return openDialogStack[openDialogStack.length - 1].dialogRef === dialogRef;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Reset the stack (for testing purposes only).
|
|
145
|
+
* @internal
|
|
146
|
+
*/ function _resetStackForTesting() {
|
|
147
|
+
while(openDialogStack.length > 0){
|
|
148
|
+
openDialogStack.pop();
|
|
149
|
+
}
|
|
150
|
+
ignoreFocusChanges = false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
;// CONCATENATED MODULE: ./src/components/Panel/PanelPortal.tsx
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Selector for all focusable elements within a container. Based on W3C WAI-ARIA
|
|
162
|
+
* practices for dialog focus management.
|
|
163
|
+
*/ const FOCUSABLE_SELECTOR = [
|
|
164
|
+
'a[href]:not([disabled]):not([tabindex="-1"])',
|
|
165
|
+
'button:not([disabled]):not([tabindex="-1"])',
|
|
166
|
+
'textarea:not([disabled]):not([tabindex="-1"])',
|
|
167
|
+
'input:not([disabled]):not([tabindex="-1"])',
|
|
168
|
+
'select:not([disabled]):not([tabindex="-1"])',
|
|
169
|
+
'[tabindex]:not([tabindex="-1"]):not([disabled])',
|
|
170
|
+
'audio[controls]:not([tabindex="-1"])',
|
|
171
|
+
'video[controls]:not([tabindex="-1"])',
|
|
172
|
+
'details:not([tabindex="-1"])'
|
|
173
|
+
].join(", ");
|
|
174
|
+
/**
|
|
175
|
+
* Portal component for rendering the Panel as a modal dialog using the native
|
|
176
|
+
* HTML <dialog> element with showModal(). This provides:
|
|
177
|
+
* - Native focus trapping (works correctly on iPad with physical keyboard)
|
|
178
|
+
* - Native ESC key handling via cancel event
|
|
179
|
+
* - Native backdrop via ::backdrop pseudo-element
|
|
180
|
+
* - Native inert background (no need for manual scroll lock)
|
|
181
|
+
* - Top layer rendering (no need for createPortal)
|
|
182
|
+
*
|
|
183
|
+
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog
|
|
184
|
+
* @see https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
|
|
185
|
+
*
|
|
186
|
+
*/ function PanelPortal({ open, onClose, children, className, style, title, initialFocus = 0, kind = /* inlined export .TYPE_PANEL */ ("panel") }) {
|
|
187
|
+
const labelId = useId();
|
|
188
|
+
const descriptionId = useId();
|
|
189
|
+
const dialogRef = useRef(null);
|
|
190
|
+
const previouslyFocusedRef = useRef(null);
|
|
191
|
+
/**
|
|
192
|
+
* Get all focusable elements within the dialog. Excludes focus sentinel
|
|
193
|
+
* elements used for circular focus trapping.
|
|
194
|
+
*/ const getFocusableElements = useCallback(()=>{
|
|
195
|
+
/* c8 ignore next 3 - defensive check, dialogRef is always set when open */ if (!dialogRef.current) {
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
const elements = dialogRef.current.querySelectorAll(FOCUSABLE_SELECTOR);
|
|
199
|
+
return Array.from(elements).filter((el)=>el.offsetParent !== null && // Filter out hidden elements
|
|
200
|
+
!el.hasAttribute("data-focus-sentinel"));
|
|
201
|
+
}, []);
|
|
202
|
+
/**
|
|
203
|
+
* Focus a specific element by index, or the element referenced by a ref.
|
|
204
|
+
*/ const focusElement = useCallback((target)=>{
|
|
205
|
+
if (typeof target === "number") {
|
|
206
|
+
if (target === -1) {
|
|
207
|
+
// -1 means don't focus anything.
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const focusableElements = getFocusableElements();
|
|
211
|
+
if (focusableElements.length > 0) {
|
|
212
|
+
const index = Math.min(target, focusableElements.length - 1);
|
|
213
|
+
focusableElements[index]?.focus();
|
|
214
|
+
}
|
|
215
|
+
} else if (target?.current) {
|
|
216
|
+
target.current.focus();
|
|
217
|
+
}
|
|
218
|
+
}, [
|
|
219
|
+
getFocusableElements
|
|
220
|
+
]);
|
|
221
|
+
/**
|
|
222
|
+
* Handle the native cancel event (fired when ESC is pressed). This replaces
|
|
223
|
+
* the custom keydown handler since the native dialog handles ESC
|
|
224
|
+
* automatically.
|
|
225
|
+
*/ const handleCancel = useCallback((event)=>{
|
|
226
|
+
// Prevent the default close behavior so we can control it via onClose.
|
|
227
|
+
event.preventDefault();
|
|
228
|
+
onClose();
|
|
229
|
+
}, [
|
|
230
|
+
onClose
|
|
231
|
+
]);
|
|
232
|
+
/**
|
|
233
|
+
* Handle Tab key to implement circular focus trapping. Native dialog focus
|
|
234
|
+
* trapping may not be circular in all browsers, so we manually wrap focus from
|
|
235
|
+
* last to first element (and vice versa). Uses document-level event listener
|
|
236
|
+
* for better iPad physical keyboard support.
|
|
237
|
+
*
|
|
238
|
+
* IMPORTANT: On iPad Safari with a physical keyboard, the Tab key does not
|
|
239
|
+
* automatically navigate between focusable elements. We must manually handle
|
|
240
|
+
* ALL Tab key presses, not just wrapping cases.
|
|
241
|
+
*
|
|
242
|
+
*/ const handleKeyDown = useCallback((event)=>{
|
|
243
|
+
if (event.key !== "Tab" || !dialogRef.current) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const focusableElements = getFocusableElements();
|
|
247
|
+
/* c8 ignore next 4 - edge case: dialog with no focusable elements */ if (focusableElements.length === 0) {
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const firstElement = focusableElements[0];
|
|
252
|
+
const lastElement = focusableElements[focusableElements.length - 1];
|
|
253
|
+
const activeElement = document.activeElement;
|
|
254
|
+
// Find the current index of the focused element.
|
|
255
|
+
const currentIndex = focusableElements.indexOf(activeElement);
|
|
256
|
+
/**
|
|
257
|
+
* Always prevent default and manually handle focus navigation. This is
|
|
258
|
+
* required for iPad Safari with physical keyboard, where Tab key doesn't
|
|
259
|
+
* automatically navigate between focusable elements.
|
|
260
|
+
*/ event.preventDefault();
|
|
261
|
+
if (event.shiftKey) {
|
|
262
|
+
// Shift+Tab: move to previous element, wrap to last if on first.
|
|
263
|
+
if (activeElement === firstElement || currentIndex <= 0) {
|
|
264
|
+
lastElement?.focus();
|
|
265
|
+
} else {
|
|
266
|
+
focusableElements[currentIndex - 1]?.focus();
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
// Tab: move to next element, wrap to first if on last.
|
|
270
|
+
if (activeElement === lastElement || currentIndex >= focusableElements.length - 1) {
|
|
271
|
+
firstElement?.focus();
|
|
272
|
+
} else {
|
|
273
|
+
focusableElements[currentIndex + 1]?.focus();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}, [
|
|
277
|
+
getFocusableElements
|
|
278
|
+
]);
|
|
279
|
+
/**
|
|
280
|
+
* Handle focus events to ensure focus stays within the dialog. This catches
|
|
281
|
+
* focus that escapes via Tab key on iPad Safari or other means.
|
|
282
|
+
*
|
|
283
|
+
* Uses the dialog stack manager's ignore flag to prevent interference during
|
|
284
|
+
* programmatic focus operations (e.g., when a nested dialog closes and returns
|
|
285
|
+
* focus to its trigger element).
|
|
286
|
+
*
|
|
287
|
+
*/ /* v8 ignore next 20 - focus escape handling for iPad Safari, hard to test in jsdom */ const handleFocusIn = useCallback((event)=>{
|
|
288
|
+
// Ignore focus changes triggered by programmatic focus operations.
|
|
289
|
+
if (shouldIgnoreFocusChanges()) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (!dialogRef.current || dialogRef.current.contains(event.target)) {
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// Focus escaped the dialog, bring it back.
|
|
296
|
+
const focusableElements = getFocusableElements();
|
|
297
|
+
if (focusableElements.length > 0) {
|
|
298
|
+
focusableElements[0]?.focus();
|
|
299
|
+
}
|
|
300
|
+
}, [
|
|
301
|
+
getFocusableElements
|
|
302
|
+
]);
|
|
303
|
+
/**
|
|
304
|
+
* Handle clicks on the backdrop (the area outside the dialog content). Native
|
|
305
|
+
* dialog doesn't provide backdrop click handling, so we use the dialog
|
|
306
|
+
* element's click event and check if the click target is the dialog itself
|
|
307
|
+
* (not a child element).
|
|
308
|
+
*/ /* v8 ignore next 9 - backdrop clicks are disabled by design in current implementation */ const handleDialogClick = useCallback((_event)=>{
|
|
309
|
+
/**
|
|
310
|
+
* If the click is directly on the dialog element (the backdrop area), not on
|
|
311
|
+
* any child element, then close the dialog. Currently disabled -
|
|
312
|
+
* outsidePress is false by design. if (_event.target === dialogRef.current)
|
|
313
|
+
* { onClose(); }
|
|
314
|
+
*/ }, []);
|
|
315
|
+
/**
|
|
316
|
+
* Focus sentinel handler - when a sentinel element receives focus, redirect
|
|
317
|
+
* focus to the appropriate element inside the dialog. This handles iPad
|
|
318
|
+
* Safari's Tab key behavior which can bypass event listeners.
|
|
319
|
+
*/ const handleSentinelFocus = useCallback((position)=>{
|
|
320
|
+
const focusableElements = getFocusableElements();
|
|
321
|
+
/* c8 ignore next 3 - edge case: dialog with no focusable elements */ if (focusableElements.length === 0) {
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (position === "start") {
|
|
325
|
+
// Focus came from the end, wrap to last element.
|
|
326
|
+
focusableElements[focusableElements.length - 1]?.focus();
|
|
327
|
+
} else {
|
|
328
|
+
// Focus came from the start, wrap to first element.
|
|
329
|
+
focusableElements[0]?.focus();
|
|
330
|
+
}
|
|
331
|
+
}, [
|
|
332
|
+
getFocusableElements
|
|
333
|
+
]);
|
|
334
|
+
/**
|
|
335
|
+
* Effect to show/hide the dialog and manage focus. Uses the dialog stack
|
|
336
|
+
* manager to coordinate listeners between nested dialogs.
|
|
337
|
+
*/ useEffect(()=>{
|
|
338
|
+
const dialog = dialogRef.current;
|
|
339
|
+
/* c8 ignore next 3 - defensive check */ if (!dialog) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
// Store the currently focused element to restore later.
|
|
343
|
+
previouslyFocusedRef.current = document.activeElement;
|
|
344
|
+
// Show the dialog as a modal.
|
|
345
|
+
if (!dialog.open) {
|
|
346
|
+
dialog.showModal();
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Add cancel event listener for ESC key (always needed, not managed by
|
|
350
|
+
* stack).
|
|
351
|
+
*/ dialog.addEventListener("cancel", handleCancel);
|
|
352
|
+
/**
|
|
353
|
+
* Define listener management functions for the stack manager. These will be
|
|
354
|
+
* called when this dialog becomes/stops being the topmost dialog.
|
|
355
|
+
*/ const addListeners = ()=>{
|
|
356
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
357
|
+
document.addEventListener("focusin", handleFocusIn);
|
|
358
|
+
};
|
|
359
|
+
const removeListeners = ()=>{
|
|
360
|
+
document.removeEventListener("keydown", handleKeyDown);
|
|
361
|
+
document.removeEventListener("focusin", handleFocusIn);
|
|
362
|
+
};
|
|
363
|
+
/**
|
|
364
|
+
* Register this dialog with the stack manager. This will suspend parent
|
|
365
|
+
* dialog listeners if any exist.
|
|
366
|
+
*/ registerDialog({
|
|
367
|
+
dialogRef: dialog,
|
|
368
|
+
addListeners,
|
|
369
|
+
removeListeners
|
|
370
|
+
});
|
|
371
|
+
/**
|
|
372
|
+
* Set initial focus after a small delay to ensure the DOM is ready. This
|
|
373
|
+
* works around React's autoFocus prop not working with native dialog.
|
|
374
|
+
*/ const focusTimer = setTimeout(()=>{
|
|
375
|
+
focusElement(initialFocus);
|
|
376
|
+
}, 0);
|
|
377
|
+
// Capture the previously focused element for restoration in cleanup.
|
|
378
|
+
const previouslyFocused = previouslyFocusedRef.current;
|
|
379
|
+
return ()=>{
|
|
380
|
+
clearTimeout(focusTimer);
|
|
381
|
+
dialog.removeEventListener("cancel", handleCancel);
|
|
382
|
+
/**
|
|
383
|
+
* Unregister from the stack manager. This will restore parent dialog
|
|
384
|
+
* listeners if any exist.
|
|
385
|
+
*/ unregisterDialog(dialog);
|
|
386
|
+
// Close the dialog if it's still open.
|
|
387
|
+
if (dialog.open) {
|
|
388
|
+
dialog.close();
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Restore focus to the previously focused element if it's still in the DOM.
|
|
392
|
+
* Use withIgnoredFocusChanges to prevent parent dialog's handleFocusIn from
|
|
393
|
+
* interfering with focus restoration.
|
|
394
|
+
*/ if (previouslyFocused?.isConnected) {
|
|
395
|
+
withIgnoredFocusChanges(()=>{
|
|
396
|
+
if (previouslyFocused.isConnected) {
|
|
397
|
+
previouslyFocused.focus();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}, [
|
|
403
|
+
handleCancel,
|
|
404
|
+
handleKeyDown,
|
|
405
|
+
handleFocusIn,
|
|
406
|
+
initialFocus,
|
|
407
|
+
focusElement
|
|
408
|
+
]);
|
|
409
|
+
/* c8 ignore next 3 - early return when panel is closed */ if (!open) {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
const isMessageBox = kind === TYPE_MESSAGEBOX;
|
|
413
|
+
const dialogClass = clsx(/**
|
|
414
|
+
* Center the dialog on screen with fixed positioning. Native dialog uses
|
|
415
|
+
* position: fixed by default.
|
|
416
|
+
* - Panel on mobile: inset-0 + no margin for full screen.
|
|
417
|
+
* - Panel on desktop: inset-x-0 + top/bottom auto for vertical centering.
|
|
418
|
+
* - MessageBox: Always centered (both mobile and desktop) since it doesn't
|
|
419
|
+
* take full screen.
|
|
420
|
+
*/ "fixed max-h-none max-w-none p-0", {
|
|
421
|
+
// Panel: full screen on mobile, centered on desktop
|
|
422
|
+
"inset-0 m-0 sm:inset-auto sm:inset-x-0 sm:top-1/2 sm:-translate-y-1/2 sm:mx-auto": !isMessageBox,
|
|
423
|
+
// MessageBox: always centered at all breakpoints
|
|
424
|
+
"inset-auto inset-x-0 top-1/2 -translate-y-1/2 mx-auto z-100": isMessageBox
|
|
425
|
+
}, /**
|
|
426
|
+
* Backdrop styling via Tailwind's backdrop: variant for ::backdrop
|
|
427
|
+
* pseudo-element. Full black on mobile, 80% opacity on desktop (matches
|
|
428
|
+
* original overlay).
|
|
429
|
+
*/ "backdrop:bg-black sm:backdrop:bg-black/80", className);
|
|
430
|
+
return /*#__PURE__*/ createPortal(/*#__PURE__*/ jsxs("dialog", {
|
|
431
|
+
ref: dialogRef,
|
|
432
|
+
"aria-labelledby": labelId,
|
|
433
|
+
"aria-describedby": descriptionId,
|
|
434
|
+
className: dialogClass,
|
|
435
|
+
style: style,
|
|
436
|
+
onClick: handleDialogClick,
|
|
437
|
+
children: [
|
|
438
|
+
/*#__PURE__*/ jsx("span", {
|
|
439
|
+
tabIndex: 0,
|
|
440
|
+
onFocus: ()=>handleSentinelFocus("start"),
|
|
441
|
+
"data-focus-sentinel": "start",
|
|
442
|
+
style: {
|
|
443
|
+
position: "absolute",
|
|
444
|
+
width: 1,
|
|
445
|
+
height: 1,
|
|
446
|
+
padding: 0,
|
|
447
|
+
margin: -1,
|
|
448
|
+
overflow: "hidden",
|
|
449
|
+
clip: "rect(0, 0, 0, 0)",
|
|
450
|
+
whiteSpace: "nowrap",
|
|
451
|
+
border: 0
|
|
452
|
+
},
|
|
453
|
+
"aria-hidden": "true"
|
|
454
|
+
}),
|
|
455
|
+
/*#__PURE__*/ jsx("span", {
|
|
456
|
+
id: labelId,
|
|
457
|
+
className: "sr-only",
|
|
458
|
+
children: title
|
|
459
|
+
}),
|
|
460
|
+
children,
|
|
461
|
+
/*#__PURE__*/ jsx("span", {
|
|
462
|
+
tabIndex: 0,
|
|
463
|
+
onFocus: ()=>handleSentinelFocus("end"),
|
|
464
|
+
"data-focus-sentinel": "end",
|
|
465
|
+
style: {
|
|
466
|
+
position: "absolute",
|
|
467
|
+
width: 1,
|
|
468
|
+
height: 1,
|
|
469
|
+
padding: 0,
|
|
470
|
+
margin: -1,
|
|
471
|
+
overflow: "hidden",
|
|
472
|
+
clip: "rect(0, 0, 0, 0)",
|
|
473
|
+
whiteSpace: "nowrap",
|
|
474
|
+
border: 0
|
|
475
|
+
},
|
|
476
|
+
"aria-hidden": "true"
|
|
477
|
+
})
|
|
478
|
+
]
|
|
479
|
+
}), document.body);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
;// CONCATENATED MODULE: ./src/components/Panel/utilities.ts
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
const getFooterAndHeaderCommonClasses = ({ blurEffect })=>{
|
|
486
|
+
return clsx("absolute left-0 right-0 z-20 backdrop-brightness-50", {
|
|
487
|
+
"backdrop-blur-sm": blurEffect === /* inlined export .SMALL */ ("small"),
|
|
488
|
+
"backdrop-blur-md": blurEffect === /* inlined export .MEDIUM */ ("medium"),
|
|
489
|
+
"backdrop-blur-lg": blurEffect === /* inlined export .LARGE */ ("large"),
|
|
490
|
+
"bg-surface-darker": blurEffect === /* inlined export .NONE */ ("none")
|
|
491
|
+
});
|
|
492
|
+
};
|
|
493
|
+
const getPanelClassName = ({ className, kind, borderMode, animation, maxWidth = /* inlined export .MEDIUM */ ("medium"), maxHeight, blurEffect = /* inlined export .NONE */ ("none"), hasFooter })=>{
|
|
494
|
+
const effectiveMaxHeight = maxHeight ?? (kind === /* inlined export .TYPE_PANEL */ ("panel") ? /* inlined export .LARGE */ ("large") : /* inlined export .SMALL */ ("small"));
|
|
495
|
+
return {
|
|
496
|
+
outerWrapper: clsx("prose prose-lighter flex flex-col bg-surface-dark overflow-hidden", {
|
|
497
|
+
"duration-200 ease-out": animation,
|
|
498
|
+
/**
|
|
499
|
+
* Panel styles
|
|
500
|
+
*/ [`${PANEL_CLASSNAME} sm:rounded-3xl sm:border`]: kind === /* inlined export .TYPE_PANEL */ ("panel"),
|
|
501
|
+
/**
|
|
502
|
+
* Widths and max widths for Panel when no className is provided
|
|
503
|
+
*/ ["w-full sm:w-[95%] md:max-w-2xl"]: kind === /* inlined export .TYPE_PANEL */ ("panel") && !className && maxWidth === /* inlined export .SMALL */ ("small"),
|
|
504
|
+
["w-full sm:w-[95%] md:max-w-3xl"]: kind === /* inlined export .TYPE_PANEL */ ("panel") && !className && maxWidth === /* inlined export .MEDIUM */ ("medium"),
|
|
505
|
+
["w-full sm:w-[95%] md:max-w-4xl"]: kind === /* inlined export .TYPE_PANEL */ ("panel") && !className && maxWidth === /* inlined export .LARGE */ ("large"),
|
|
506
|
+
/**
|
|
507
|
+
* Heights and max heights for Panel
|
|
508
|
+
* Mobile: full height (h-full works with inset-0), Desktop: shrink-to-fit with max-height constraint
|
|
509
|
+
*/ "h-full sm:h-auto min-h-40 sm:min-h-0 max-h-full sm:max-h-[40vh]": kind === /* inlined export .TYPE_PANEL */ ("panel") && effectiveMaxHeight === /* inlined export .SMALL */ ("small"),
|
|
510
|
+
"h-full sm:h-auto min-h-40 sm:min-h-0 max-h-full sm:max-h-[60vh]": kind === /* inlined export .TYPE_PANEL */ ("panel") && effectiveMaxHeight === /* inlined export .MEDIUM */ ("medium"),
|
|
511
|
+
"h-full sm:h-auto min-h-40 sm:min-h-0 max-h-full sm:max-h-[95vh]": kind === /* inlined export .TYPE_PANEL */ ("panel") && effectiveMaxHeight === /* inlined export .LARGE */ ("large"),
|
|
512
|
+
/**
|
|
513
|
+
* Panel border colors
|
|
514
|
+
*/ "sm:border-border-dark": borderMode === "dark" && kind === /* inlined export .TYPE_PANEL */ ("panel"),
|
|
515
|
+
"sm:border-border-accent": borderMode === "light" && kind === /* inlined export .TYPE_PANEL */ ("panel"),
|
|
516
|
+
/**
|
|
517
|
+
* Messagebox styles
|
|
518
|
+
*/ [`${MESSAGEBOX_CLASSNAME} rounded-3xl border`]: kind === TYPE_MESSAGEBOX,
|
|
519
|
+
/**
|
|
520
|
+
* Widths and max widths for Messagebox when no className is provided
|
|
521
|
+
*/ ["w-[95%] sm:w-[50%] md:max-w-2xl"]: kind === TYPE_MESSAGEBOX && !className,
|
|
522
|
+
/**
|
|
523
|
+
* Heights and max heights for Messagebox
|
|
524
|
+
*/ "h-64": kind === TYPE_MESSAGEBOX && effectiveMaxHeight === /* inlined export .SMALL */ ("small"),
|
|
525
|
+
"h-80": kind === TYPE_MESSAGEBOX && effectiveMaxHeight === /* inlined export .MEDIUM */ ("medium"),
|
|
526
|
+
"h-96": kind === TYPE_MESSAGEBOX && effectiveMaxHeight === /* inlined export .LARGE */ ("large"),
|
|
527
|
+
/**
|
|
528
|
+
* Messagebox border colors
|
|
529
|
+
*/ "border-border-dark": borderMode === "dark" && kind === TYPE_MESSAGEBOX,
|
|
530
|
+
"border-border-accent": borderMode === "light" && kind === TYPE_MESSAGEBOX,
|
|
531
|
+
[`${className}`]: !!className
|
|
532
|
+
}),
|
|
533
|
+
innerWrapper: "content flex flex-col rounded-[inherit] relative min-h-full isolate",
|
|
534
|
+
scrollableContent: clsx("flex-1 overflow-y-auto overflow-x-hidden", "pt-12", {
|
|
535
|
+
"pb-12": hasFooter
|
|
536
|
+
}),
|
|
537
|
+
footer: clsx(getFooterAndHeaderCommonClasses({
|
|
538
|
+
blurEffect
|
|
539
|
+
}), "p-2 bottom-0", "sm:min-h-auto h-12", {
|
|
540
|
+
"min-h-20": hasFooter && kind === /* inlined export .TYPE_PANEL */ ("panel"),
|
|
541
|
+
"sm:rounded-b-3xl": kind === /* inlined export .TYPE_PANEL */ ("panel"),
|
|
542
|
+
"rounded-b-3xl": kind === TYPE_MESSAGEBOX
|
|
543
|
+
}),
|
|
544
|
+
header: clsx("flex flex-row-reverse items-center justify-between h-12", getFooterAndHeaderCommonClasses({
|
|
545
|
+
blurEffect
|
|
546
|
+
}), "top-0", {
|
|
547
|
+
"sm:rounded-t-3xl": kind === /* inlined export .TYPE_PANEL */ ("panel"),
|
|
548
|
+
"rounded-t-3xl": kind === TYPE_MESSAGEBOX
|
|
549
|
+
}),
|
|
550
|
+
title: "mb-0 pt-2 pl-4 pr-2 pb-2",
|
|
551
|
+
closeWrapper: "pr-[18px]",
|
|
552
|
+
closeButton: clsx("flex items-center justify-center", "size-3", "p-1", "rounded-full", "border", "border-transparent", "text-[rgba(255,96,92,1)]", "bg-[rgba(255,96,92,1)]", "shadow-[0_0_0_0.5px_red]", "focus:outline", "focus:outline-2", "focus:outline-offset-2", "focus:outline-focus-light", "hover:text-copy-dark", "focus:text-copy-dark", "active:bg-[#ba504a]", // Extended touch target using pseudo-element
|
|
553
|
+
"relative", "before:content-['']", "before:absolute", "before:-top-4", "before:-right-4", "before:-bottom-4", "before:-left-4"),
|
|
554
|
+
content: "p-4 rounded-3xl"
|
|
555
|
+
};
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
;// CONCATENATED MODULE: ./src/components/Panel/Panel.tsx
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
const Panel = ({ open, onOpenChange, title, children, footer, borderMode = "light", kind = /* inlined export .TYPE_PANEL */ ("panel"), className, animation = false, animationType = /* inlined export .ANIMATION_SLIDE */ ("slide"), maxWidth = /* inlined export .MEDIUM */ ("medium"), maxHeight, blurEffect = /* inlined export .NONE */ ("none"), initialFocus })=>{
|
|
565
|
+
const originalTitleRef = useRef("");
|
|
566
|
+
/* v8 ignore next 9 */ const [animationStyles, setAnimationStyles] = useState(!animation ? {} : animationType === /* inlined export .ANIMATION_FADE */ ("fade") ? {
|
|
567
|
+
opacity: 0
|
|
568
|
+
} : {
|
|
569
|
+
transform: "translateY(-100vh)"
|
|
570
|
+
});
|
|
571
|
+
const panelClassName = getPanelClassName({
|
|
572
|
+
className,
|
|
573
|
+
kind,
|
|
574
|
+
borderMode,
|
|
575
|
+
animation,
|
|
576
|
+
maxWidth,
|
|
577
|
+
maxHeight,
|
|
578
|
+
blurEffect,
|
|
579
|
+
hasFooter: Boolean(footer)
|
|
580
|
+
});
|
|
581
|
+
/**
|
|
582
|
+
* Handle close button click.
|
|
583
|
+
*/ const handleClose = useCallback(()=>{
|
|
584
|
+
onOpenChange(false);
|
|
585
|
+
}, [
|
|
586
|
+
onOpenChange
|
|
587
|
+
]);
|
|
588
|
+
/**
|
|
589
|
+
* If the panel is opened, set the document title to the panel's title. If it's
|
|
590
|
+
* closed, restore the original document.title.
|
|
591
|
+
*/ useEffect(()=>{
|
|
592
|
+
if (open) {
|
|
593
|
+
originalTitleRef.current = document.title;
|
|
594
|
+
document.title = `${title} | ${originalTitleRef.current}`;
|
|
595
|
+
}
|
|
596
|
+
return ()=>{
|
|
597
|
+
if (open) {
|
|
598
|
+
document.title = originalTitleRef.current;
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
}, [
|
|
602
|
+
title,
|
|
603
|
+
open
|
|
604
|
+
]);
|
|
605
|
+
/**
|
|
606
|
+
* Effect to handle the opening and closing animations.
|
|
607
|
+
*/ /* v8 ignore next 31 */ useEffect(()=>{
|
|
608
|
+
if (!animation) {
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
if (open) {
|
|
612
|
+
setAnimationStyles(!animation ? {} : animationType === /* inlined export .ANIMATION_FADE */ ("fade") ? {
|
|
613
|
+
opacity: 0
|
|
614
|
+
} : {
|
|
615
|
+
transform: "translateY(-666vh)"
|
|
616
|
+
});
|
|
617
|
+
/**
|
|
618
|
+
* Small delay to ensure the opening state is applied after the component is
|
|
619
|
+
* rendered.
|
|
620
|
+
*/ const timer = setTimeout(()=>{
|
|
621
|
+
setAnimationStyles(!animation ? {} : animationType === /* inlined export .ANIMATION_FADE */ ("fade") ? {
|
|
622
|
+
opacity: 1
|
|
623
|
+
} : {
|
|
624
|
+
transform: "translateY(0)"
|
|
625
|
+
});
|
|
626
|
+
}, 100);
|
|
627
|
+
return ()=>clearTimeout(timer);
|
|
628
|
+
}
|
|
629
|
+
}, [
|
|
630
|
+
open,
|
|
631
|
+
animation,
|
|
632
|
+
animationType
|
|
633
|
+
]);
|
|
634
|
+
return /*#__PURE__*/ jsx(PanelPortal, {
|
|
635
|
+
open: open,
|
|
636
|
+
onClose: handleClose,
|
|
637
|
+
className: panelClassName.outerWrapper,
|
|
638
|
+
style: animationStyles,
|
|
639
|
+
title: title,
|
|
640
|
+
initialFocus: initialFocus,
|
|
641
|
+
kind: kind,
|
|
642
|
+
children: /*#__PURE__*/ jsxs("div", {
|
|
643
|
+
className: panelClassName.innerWrapper,
|
|
644
|
+
children: [
|
|
645
|
+
/*#__PURE__*/ jsxs("div", {
|
|
646
|
+
className: panelClassName.header,
|
|
647
|
+
children: [
|
|
648
|
+
/*#__PURE__*/ jsx("div", {
|
|
649
|
+
className: panelClassName.closeWrapper,
|
|
650
|
+
children: /*#__PURE__*/ jsx("button", {
|
|
651
|
+
className: panelClassName.closeButton,
|
|
652
|
+
type: "button",
|
|
653
|
+
"aria-label": "Close",
|
|
654
|
+
onClick: handleClose,
|
|
655
|
+
children: /*#__PURE__*/ jsx("span", {
|
|
656
|
+
children: /*#__PURE__*/ jsx("svg", {
|
|
657
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
658
|
+
className: "size-3",
|
|
659
|
+
viewBox: "0 0 384 512",
|
|
660
|
+
fill: "currentColor",
|
|
661
|
+
role: "img",
|
|
662
|
+
"aria-hidden": "true",
|
|
663
|
+
focusable: "false",
|
|
664
|
+
children: /*#__PURE__*/ jsx("path", {
|
|
665
|
+
d: "M297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256l105.3-105.4c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3z",
|
|
666
|
+
opacity: "1"
|
|
667
|
+
})
|
|
668
|
+
})
|
|
669
|
+
})
|
|
670
|
+
})
|
|
671
|
+
}),
|
|
672
|
+
/*#__PURE__*/ jsx("h1", {
|
|
673
|
+
className: panelClassName.title,
|
|
674
|
+
children: title
|
|
675
|
+
})
|
|
676
|
+
]
|
|
677
|
+
}),
|
|
678
|
+
/*#__PURE__*/ jsx("div", {
|
|
679
|
+
className: panelClassName.scrollableContent,
|
|
680
|
+
children: /*#__PURE__*/ jsx("div", {
|
|
681
|
+
className: panelClassName.content,
|
|
682
|
+
children: children
|
|
683
|
+
})
|
|
684
|
+
}),
|
|
685
|
+
footer && /*#__PURE__*/ jsx("div", {
|
|
686
|
+
className: panelClassName.footer,
|
|
687
|
+
children: footer
|
|
688
|
+
})
|
|
689
|
+
]
|
|
690
|
+
})
|
|
691
|
+
});
|
|
692
|
+
};
|
|
693
|
+
|
|
694
|
+
;// CONCATENATED MODULE: ./src/components/index.ts
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
var __webpack_exports__ANIMATION_FADE = /* inlined export .ANIMATION_FADE */ ("fade");
|
|
699
|
+
var __webpack_exports__ANIMATION_SLIDE = /* inlined export .ANIMATION_SLIDE */ ("slide");
|
|
700
|
+
var __webpack_exports__LARGE = /* inlined export .LARGE */ ("large");
|
|
701
|
+
var __webpack_exports__MEDIUM = /* inlined export .MEDIUM */ ("medium");
|
|
702
|
+
var __webpack_exports__NONE = /* inlined export .NONE */ ("none");
|
|
703
|
+
var __webpack_exports__SMALL = /* inlined export .SMALL */ ("small");
|
|
704
|
+
var __webpack_exports__TYPE_PANEL = /* inlined export .TYPE_PANEL */ ("panel");
|
|
705
|
+
export { MESSAGEBOX_CLASSNAME, PANEL_CLASSNAME, Panel, TYPE_MESSAGEBOX, __webpack_exports__ANIMATION_FADE as ANIMATION_FADE, __webpack_exports__ANIMATION_SLIDE as ANIMATION_SLIDE, __webpack_exports__LARGE as LARGE, __webpack_exports__MEDIUM as MEDIUM, __webpack_exports__NONE as NONE, __webpack_exports__SMALL as SMALL, __webpack_exports__TYPE_PANEL as TYPE_PANEL };
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@versini/ui-dialog",
|
|
3
|
+
"version": "8.0.6",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"author": "Arno Versini",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://www.npmjs.com/package/@versini/ui-dialog",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git@github.com:aversini/ui-components.git"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "dist/index.js",
|
|
16
|
+
"types": "dist/index.d.ts",
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build:check": "tsc",
|
|
23
|
+
"build:js": "rslib build",
|
|
24
|
+
"build:types": "echo 'Types now built with rslib'",
|
|
25
|
+
"build": "npm-run-all --serial clean build:check build:js",
|
|
26
|
+
"clean": "rimraf dist tmp",
|
|
27
|
+
"dev:js": "rslib build --watch",
|
|
28
|
+
"dev:types": "echo 'Types now watched with rslib'",
|
|
29
|
+
"dev": "rslib build --watch",
|
|
30
|
+
"lint": "biome lint src",
|
|
31
|
+
"lint:fix": "biome check src --write --no-errors-on-unmatched",
|
|
32
|
+
"prettier": "biome check --write --no-errors-on-unmatched",
|
|
33
|
+
"start": "static-server dist --port 5173",
|
|
34
|
+
"test:coverage:ui": "vitest --coverage --ui",
|
|
35
|
+
"test:coverage": "vitest run --coverage",
|
|
36
|
+
"test:update": "vitest run --update",
|
|
37
|
+
"test:visual": "playwright test -c playwright-ct.config.ts",
|
|
38
|
+
"test:visual:report": "playwright show-report playwright-report",
|
|
39
|
+
"test:visual:update": "playwright test -c playwright-ct.config.ts --update-snapshots",
|
|
40
|
+
"test:visual:ui": "playwright test -c playwright-ct.config.ts --ui",
|
|
41
|
+
"test:watch": "vitest",
|
|
42
|
+
"test": "vitest run"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@testing-library/jest-dom": "6.9.1"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@tailwindcss/typography": "0.5.19",
|
|
49
|
+
"clsx": "2.1.1",
|
|
50
|
+
"tailwindcss": "4.1.18"
|
|
51
|
+
},
|
|
52
|
+
"sideEffects": [
|
|
53
|
+
"**/*.css"
|
|
54
|
+
],
|
|
55
|
+
"gitHead": "3582aaec11fa1d50f3d6ef280d4f06adaf4746e4"
|
|
56
|
+
}
|