wmx-modal 0.2.5

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.
@@ -0,0 +1,142 @@
1
+ .wmx-drawer-overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ background-color: rgba(15, 23, 42, 0.55);
5
+ backdrop-filter: blur(2px);
6
+ z-index: 1000;
7
+ animation: wmx-drawer-fade 0.15s ease;
8
+ }
9
+
10
+ .wmx-drawer {
11
+ position: fixed;
12
+ background-color: #ffffff;
13
+ box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
14
+ display: flex;
15
+ flex-direction: column;
16
+ outline: none;
17
+ }
18
+
19
+ .wmx-drawer--left,
20
+ .wmx-drawer--right {
21
+ top: 0;
22
+ bottom: 0;
23
+ width: min(380px, 90vw);
24
+ animation-duration: 0.22s;
25
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
26
+ }
27
+ .wmx-drawer--left {
28
+ left: 0;
29
+ animation-name: wmx-drawer-in-left;
30
+ }
31
+ .wmx-drawer--right {
32
+ right: 0;
33
+ animation-name: wmx-drawer-in-right;
34
+ }
35
+
36
+ .wmx-drawer--top,
37
+ .wmx-drawer--bottom {
38
+ left: 0;
39
+ right: 0;
40
+ height: min(380px, 90vh);
41
+ animation-duration: 0.22s;
42
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
43
+ }
44
+ .wmx-drawer--top {
45
+ top: 0;
46
+ animation-name: wmx-drawer-in-top;
47
+ }
48
+ .wmx-drawer--bottom {
49
+ bottom: 0;
50
+ animation-name: wmx-drawer-in-bottom;
51
+ }
52
+
53
+ .wmx-drawer__header {
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: space-between;
57
+ gap: 16px;
58
+ padding: 16px 20px;
59
+ border-bottom: 1px solid #e5e7eb;
60
+ flex-shrink: 0;
61
+ }
62
+
63
+ .wmx-drawer__title {
64
+ font-weight: 600;
65
+ font-size: 16px;
66
+ }
67
+
68
+ .wmx-drawer__close {
69
+ display: inline-flex;
70
+ align-items: center;
71
+ justify-content: center;
72
+ width: 28px;
73
+ height: 28px;
74
+ border: none;
75
+ border-radius: 6px;
76
+ background: transparent;
77
+ font-size: 20px;
78
+ line-height: 1;
79
+ cursor: pointer;
80
+ color: #6b7280;
81
+ padding: 0;
82
+ transition: background-color 0.15s ease, color 0.15s ease;
83
+ }
84
+
85
+ .wmx-drawer__close:hover {
86
+ background-color: #f3f4f6;
87
+ color: #111827;
88
+ }
89
+
90
+ .wmx-drawer__body {
91
+ padding: 20px;
92
+ overflow: auto;
93
+ flex: 1;
94
+ }
95
+
96
+ @keyframes wmx-drawer-fade {
97
+ from {
98
+ opacity: 0;
99
+ }
100
+ to {
101
+ opacity: 1;
102
+ }
103
+ }
104
+ @keyframes wmx-drawer-in-left {
105
+ from {
106
+ transform: translateX(-100%);
107
+ }
108
+ to {
109
+ transform: translateX(0);
110
+ }
111
+ }
112
+ @keyframes wmx-drawer-in-right {
113
+ from {
114
+ transform: translateX(100%);
115
+ }
116
+ to {
117
+ transform: translateX(0);
118
+ }
119
+ }
120
+ @keyframes wmx-drawer-in-top {
121
+ from {
122
+ transform: translateY(-100%);
123
+ }
124
+ to {
125
+ transform: translateY(0);
126
+ }
127
+ }
128
+ @keyframes wmx-drawer-in-bottom {
129
+ from {
130
+ transform: translateY(100%);
131
+ }
132
+ to {
133
+ transform: translateY(0);
134
+ }
135
+ }
136
+
137
+ @media (prefers-reduced-motion: reduce) {
138
+ .wmx-drawer-overlay,
139
+ .wmx-drawer {
140
+ animation: none;
141
+ }
142
+ }
@@ -0,0 +1,13 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+ import "./Drawer.css";
3
+ export type DrawerPlacement = "left" | "right" | "top" | "bottom";
4
+ export interface DrawerProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
5
+ isOpen: boolean;
6
+ onClose: () => void;
7
+ title?: ReactNode;
8
+ placement?: DrawerPlacement;
9
+ closeOnOverlayClick?: boolean;
10
+ closeOnEsc?: boolean;
11
+ children: ReactNode;
12
+ }
13
+ export declare function Drawer({ isOpen, onClose, title, placement, closeOnOverlayClick, closeOnEsc, className, children, ...rest }: DrawerProps): import("react").ReactPortal | null;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { useModalBehavior } from "../useModalBehavior.js";
5
+ import { useFocusTrap } from "../useFocusTrap.js";
6
+ import "./Drawer.css";
7
+ export function Drawer({ isOpen, onClose, title, placement = "right", closeOnOverlayClick = true, closeOnEsc = true, className, children, ...rest }) {
8
+ const containerRef = useRef(null);
9
+ useModalBehavior({ isOpen, onClose, closeOnEsc });
10
+ useFocusTrap(containerRef, isOpen);
11
+ if (!isOpen)
12
+ return null;
13
+ const classes = ["wmx-drawer", `wmx-drawer--${placement}`, className].filter(Boolean).join(" ");
14
+ return createPortal(_jsx("div", { className: "wmx-drawer-overlay", onClick: closeOnOverlayClick ? onClose : undefined, children: _jsxs("div", { ref: containerRef, className: classes, role: "dialog", "aria-modal": "true", tabIndex: -1, onClick: (e) => e.stopPropagation(), ...rest, children: [_jsxs("div", { className: "wmx-drawer__header", children: [title && _jsx("span", { className: "wmx-drawer__title", children: title }), _jsx("button", { type: "button", className: "wmx-drawer__close", "aria-label": "Close", onClick: onClose, children: "\u00D7" })] }), _jsx("div", { className: "wmx-drawer__body", children: children })] }) }), document.body);
15
+ }
@@ -0,0 +1,117 @@
1
+ .wmx-dialog-overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ background-color: rgba(15, 23, 42, 0.55);
5
+ backdrop-filter: blur(2px);
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ z-index: 1000;
10
+ padding: 24px;
11
+ animation: wmx-dialog-fade 0.15s ease;
12
+ }
13
+
14
+ .wmx-dialog {
15
+ background-color: #ffffff;
16
+ border-radius: 14px;
17
+ width: 100%;
18
+ max-height: 90vh;
19
+ overflow: auto;
20
+ box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
21
+ animation: wmx-dialog-scale 0.18s cubic-bezier(0.16, 1, 0.3, 1);
22
+ outline: none;
23
+ }
24
+
25
+ .wmx-dialog--sm {
26
+ max-width: 360px;
27
+ }
28
+
29
+ .wmx-dialog--md {
30
+ max-width: 480px;
31
+ }
32
+
33
+ .wmx-dialog--lg {
34
+ max-width: 640px;
35
+ }
36
+
37
+ .wmx-dialog--xl {
38
+ max-width: 880px;
39
+ }
40
+
41
+ .wmx-dialog--full {
42
+ max-width: none;
43
+ width: calc(100vw - 48px);
44
+ height: calc(100vh - 48px);
45
+ }
46
+
47
+ .wmx-dialog__header {
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: space-between;
51
+ gap: 16px;
52
+ padding: 16px 20px;
53
+ border-bottom: 1px solid #e5e7eb;
54
+ }
55
+
56
+ .wmx-dialog__title {
57
+ font-weight: 600;
58
+ font-size: 16px;
59
+ }
60
+
61
+ .wmx-dialog__close {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ justify-content: center;
65
+ width: 28px;
66
+ height: 28px;
67
+ border: none;
68
+ border-radius: 6px;
69
+ background: transparent;
70
+ font-size: 20px;
71
+ line-height: 1;
72
+ cursor: pointer;
73
+ color: #6b7280;
74
+ padding: 0;
75
+ transition: background-color 0.15s ease, color 0.15s ease;
76
+ }
77
+
78
+ .wmx-dialog__close:hover {
79
+ background-color: #f3f4f6;
80
+ color: #111827;
81
+ }
82
+
83
+ .wmx-dialog__close:focus-visible {
84
+ outline: 2px solid #2563eb;
85
+ outline-offset: 2px;
86
+ }
87
+
88
+ .wmx-dialog__body {
89
+ padding: 20px;
90
+ }
91
+
92
+ @keyframes wmx-dialog-fade {
93
+ from {
94
+ opacity: 0;
95
+ }
96
+ to {
97
+ opacity: 1;
98
+ }
99
+ }
100
+
101
+ @keyframes wmx-dialog-scale {
102
+ from {
103
+ opacity: 0;
104
+ transform: scale(0.96) translateY(4px);
105
+ }
106
+ to {
107
+ opacity: 1;
108
+ transform: scale(1) translateY(0);
109
+ }
110
+ }
111
+
112
+ @media (prefers-reduced-motion: reduce) {
113
+ .wmx-dialog-overlay,
114
+ .wmx-dialog {
115
+ animation: none;
116
+ }
117
+ }
@@ -0,0 +1,13 @@
1
+ import type { HTMLAttributes, ReactNode } from "react";
2
+ import "./Modal.css";
3
+ export type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
4
+ export interface ModalProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
5
+ isOpen: boolean;
6
+ onClose: () => void;
7
+ title?: ReactNode;
8
+ size?: ModalSize;
9
+ closeOnOverlayClick?: boolean;
10
+ closeOnEsc?: boolean;
11
+ children: ReactNode;
12
+ }
13
+ export declare function Modal({ isOpen, onClose, title, size, closeOnOverlayClick, closeOnEsc, className, children, ...rest }: ModalProps): import("react").ReactPortal | null;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef } from "react";
3
+ import { createPortal } from "react-dom";
4
+ import { useModalBehavior } from "../useModalBehavior.js";
5
+ import { useFocusTrap } from "../useFocusTrap.js";
6
+ import "./Modal.css";
7
+ export function Modal({ isOpen, onClose, title, size = "md", closeOnOverlayClick = true, closeOnEsc = true, className, children, ...rest }) {
8
+ const containerRef = useRef(null);
9
+ useModalBehavior({ isOpen, onClose, closeOnEsc });
10
+ useFocusTrap(containerRef, isOpen);
11
+ if (!isOpen)
12
+ return null;
13
+ const classes = ["wmx-dialog", `wmx-dialog--${size}`, className].filter(Boolean).join(" ");
14
+ return createPortal(_jsx("div", { className: "wmx-dialog-overlay", onClick: closeOnOverlayClick ? onClose : undefined, children: _jsxs("div", { ref: containerRef, className: classes, role: "dialog", "aria-modal": "true", tabIndex: -1, onClick: (e) => e.stopPropagation(), ...rest, children: [_jsxs("div", { className: "wmx-dialog__header", children: [title && _jsx("span", { className: "wmx-dialog__title", children: title }), _jsx("button", { type: "button", className: "wmx-dialog__close", "aria-label": "Close", onClick: onClose, children: "\u00D7" })] }), _jsx("div", { className: "wmx-dialog__body", children: children })] }) }), document.body);
15
+ }
@@ -0,0 +1,104 @@
1
+ .wmx-confirm-overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ background-color: rgba(15, 23, 42, 0.55);
5
+ backdrop-filter: blur(2px);
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ z-index: 1100;
10
+ padding: 24px;
11
+ animation: wmx-confirm-fade 0.15s ease;
12
+ }
13
+
14
+ .wmx-confirm {
15
+ background-color: #ffffff;
16
+ border-radius: 14px;
17
+ width: 100%;
18
+ max-width: 400px;
19
+ padding: 22px;
20
+ box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
21
+ animation: wmx-confirm-scale 0.18s cubic-bezier(0.16, 1, 0.3, 1);
22
+ }
23
+
24
+ .wmx-confirm__title {
25
+ font-weight: 600;
26
+ font-size: 16px;
27
+ color: #111827;
28
+ margin-bottom: 8px;
29
+ }
30
+
31
+ .wmx-confirm__description {
32
+ font-size: 14px;
33
+ color: #4b5563;
34
+ line-height: 1.6;
35
+ margin-bottom: 20px;
36
+ }
37
+
38
+ .wmx-confirm__actions {
39
+ display: flex;
40
+ justify-content: flex-end;
41
+ gap: 10px;
42
+ }
43
+
44
+ .wmx-confirm__btn {
45
+ padding: 8px 18px;
46
+ border-radius: 8px;
47
+ font-size: 14px;
48
+ font-weight: 600;
49
+ font-family: inherit;
50
+ cursor: pointer;
51
+ border: 1px solid transparent;
52
+ transition: background-color 0.15s ease, box-shadow 0.15s ease;
53
+ }
54
+
55
+ .wmx-confirm__btn--cancel {
56
+ background-color: #f3f4f6;
57
+ color: #1f2937;
58
+ border-color: #e5e7eb;
59
+ }
60
+ .wmx-confirm__btn--cancel:hover {
61
+ background-color: #e5e7eb;
62
+ }
63
+
64
+ .wmx-confirm__btn--confirm {
65
+ background-color: #2563eb;
66
+ color: #ffffff;
67
+ }
68
+ .wmx-confirm__btn--confirm:hover {
69
+ background-color: #1d4ed8;
70
+ }
71
+
72
+ .wmx-confirm__btn--danger {
73
+ background-color: #dc2626;
74
+ color: #ffffff;
75
+ }
76
+ .wmx-confirm__btn--danger:hover {
77
+ background-color: #b91c1c;
78
+ }
79
+
80
+ @keyframes wmx-confirm-fade {
81
+ from {
82
+ opacity: 0;
83
+ }
84
+ to {
85
+ opacity: 1;
86
+ }
87
+ }
88
+ @keyframes wmx-confirm-scale {
89
+ from {
90
+ opacity: 0;
91
+ transform: scale(0.96) translateY(4px);
92
+ }
93
+ to {
94
+ opacity: 1;
95
+ transform: scale(1) translateY(0);
96
+ }
97
+ }
98
+
99
+ @media (prefers-reduced-motion: reduce) {
100
+ .wmx-confirm-overlay,
101
+ .wmx-confirm {
102
+ animation: none;
103
+ }
104
+ }
@@ -0,0 +1,9 @@
1
+ import "./Confirm.css";
2
+ export interface ConfirmOptions {
3
+ title?: string;
4
+ description?: string;
5
+ confirmText?: string;
6
+ cancelText?: string;
7
+ variant?: "default" | "danger";
8
+ }
9
+ export declare function confirm(options?: ConfirmOptions): Promise<boolean>;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createPortal } from "react-dom";
3
+ import { createRoot } from "react-dom/client";
4
+ import "./Confirm.css";
5
+ function ConfirmDialog({ title = "Are you sure?", description, confirmText = "Confirm", cancelText = "Cancel", variant = "default", onResolve, }) {
6
+ return createPortal(_jsx("div", { className: "wmx-confirm-overlay", onClick: () => onResolve(false), children: _jsxs("div", { className: "wmx-confirm", role: "alertdialog", "aria-modal": "true", onClick: (e) => e.stopPropagation(), children: [_jsx("div", { className: "wmx-confirm__title", children: title }), description && _jsx("div", { className: "wmx-confirm__description", children: description }), _jsxs("div", { className: "wmx-confirm__actions", children: [_jsx("button", { type: "button", className: "wmx-confirm__btn wmx-confirm__btn--cancel", onClick: () => onResolve(false), children: cancelText }), _jsx("button", { type: "button", className: `wmx-confirm__btn wmx-confirm__btn--${variant === "danger" ? "danger" : "confirm"}`, onClick: () => onResolve(true), children: confirmText })] })] }) }), document.body);
7
+ }
8
+ export function confirm(options = {}) {
9
+ return new Promise((resolve) => {
10
+ const container = document.createElement("div");
11
+ document.body.appendChild(container);
12
+ const root = createRoot(container);
13
+ const handleResolve = (result) => {
14
+ root.unmount();
15
+ container.remove();
16
+ resolve(result);
17
+ };
18
+ root.render(_jsx(ConfirmDialog, { ...options, onResolve: handleResolve }));
19
+ });
20
+ }
@@ -0,0 +1,9 @@
1
+ export { Modal } from "./Modal/Modal.js";
2
+ export type { ModalProps, ModalSize } from "./Modal/Modal.js";
3
+ export { Drawer } from "./Drawer/Drawer.js";
4
+ export type { DrawerProps, DrawerPlacement } from "./Drawer/Drawer.js";
5
+ export { confirm } from "./confirm/confirm.js";
6
+ export type { ConfirmOptions } from "./confirm/confirm.js";
7
+ export { useFocusTrap } from "./useFocusTrap.js";
8
+ export { useModalBehavior } from "./useModalBehavior.js";
9
+ export type { UseModalBehaviorOptions } from "./useModalBehavior.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { Modal } from "./Modal/Modal.js";
2
+ export { Drawer } from "./Drawer/Drawer.js";
3
+ export { confirm } from "./confirm/confirm.js";
4
+ export { useFocusTrap } from "./useFocusTrap.js";
5
+ export { useModalBehavior } from "./useModalBehavior.js";
@@ -0,0 +1,2 @@
1
+ import type { RefObject } from "react";
2
+ export declare function useFocusTrap(containerRef: RefObject<HTMLElement>, active: boolean): void;
@@ -0,0 +1,35 @@
1
+ import { useEffect, useRef } from "react";
2
+ const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
3
+ export function useFocusTrap(containerRef, active) {
4
+ const previouslyFocused = useRef(null);
5
+ useEffect(() => {
6
+ if (!active)
7
+ return;
8
+ previouslyFocused.current = document.activeElement;
9
+ const container = containerRef.current;
10
+ const firstFocusable = container?.querySelector(FOCUSABLE_SELECTOR);
11
+ (firstFocusable ?? container)?.focus();
12
+ const handleKeyDown = (e) => {
13
+ if (e.key !== "Tab" || !container)
14
+ return;
15
+ const nodes = Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter((el) => el.offsetParent !== null);
16
+ if (nodes.length === 0)
17
+ return;
18
+ const first = nodes[0];
19
+ const last = nodes[nodes.length - 1];
20
+ if (e.shiftKey && document.activeElement === first) {
21
+ e.preventDefault();
22
+ last.focus();
23
+ }
24
+ else if (!e.shiftKey && document.activeElement === last) {
25
+ e.preventDefault();
26
+ first.focus();
27
+ }
28
+ };
29
+ document.addEventListener("keydown", handleKeyDown);
30
+ return () => {
31
+ document.removeEventListener("keydown", handleKeyDown);
32
+ previouslyFocused.current?.focus();
33
+ };
34
+ }, [active, containerRef]);
35
+ }
@@ -0,0 +1,7 @@
1
+ export interface UseModalBehaviorOptions {
2
+ isOpen: boolean;
3
+ onClose: () => void;
4
+ closeOnEsc?: boolean;
5
+ lockScroll?: boolean;
6
+ }
7
+ export declare function useModalBehavior({ isOpen, onClose, closeOnEsc, lockScroll, }: UseModalBehaviorOptions): void;
@@ -0,0 +1,20 @@
1
+ import { useEffect } from "react";
2
+ export function useModalBehavior({ isOpen, onClose, closeOnEsc = true, lockScroll = true, }) {
3
+ useEffect(() => {
4
+ if (!isOpen)
5
+ return;
6
+ const originalOverflow = document.body.style.overflow;
7
+ if (lockScroll)
8
+ document.body.style.overflow = "hidden";
9
+ const handleKeyDown = (e) => {
10
+ if (closeOnEsc && e.key === "Escape")
11
+ onClose();
12
+ };
13
+ document.addEventListener("keydown", handleKeyDown);
14
+ return () => {
15
+ document.removeEventListener("keydown", handleKeyDown);
16
+ if (lockScroll)
17
+ document.body.style.overflow = originalOverflow;
18
+ };
19
+ }, [isOpen, onClose, closeOnEsc, lockScroll]);
20
+ }
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "wmx-modal",
3
+ "version": "0.2.5",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "scripts": {
8
+ "build": "tsc --project tsconfig.json && node scripts/copy-css.mjs",
9
+ "dev": "tsc --project tsconfig.json --watch"
10
+ },
11
+ "peerDependencies": {
12
+ "react": "^18.2.0",
13
+ "react-dom": "^18.2.0"
14
+ },
15
+ "devDependencies": {
16
+ "@types/react": "^18.2.0",
17
+ "@types/react-dom": "^18.2.0",
18
+ "typescript": "^5.4.0"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src",
23
+ "package.json"
24
+ ]
25
+ }
@@ -0,0 +1,142 @@
1
+ .wmx-drawer-overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ background-color: rgba(15, 23, 42, 0.55);
5
+ backdrop-filter: blur(2px);
6
+ z-index: 1000;
7
+ animation: wmx-drawer-fade 0.15s ease;
8
+ }
9
+
10
+ .wmx-drawer {
11
+ position: fixed;
12
+ background-color: #ffffff;
13
+ box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
14
+ display: flex;
15
+ flex-direction: column;
16
+ outline: none;
17
+ }
18
+
19
+ .wmx-drawer--left,
20
+ .wmx-drawer--right {
21
+ top: 0;
22
+ bottom: 0;
23
+ width: min(380px, 90vw);
24
+ animation-duration: 0.22s;
25
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
26
+ }
27
+ .wmx-drawer--left {
28
+ left: 0;
29
+ animation-name: wmx-drawer-in-left;
30
+ }
31
+ .wmx-drawer--right {
32
+ right: 0;
33
+ animation-name: wmx-drawer-in-right;
34
+ }
35
+
36
+ .wmx-drawer--top,
37
+ .wmx-drawer--bottom {
38
+ left: 0;
39
+ right: 0;
40
+ height: min(380px, 90vh);
41
+ animation-duration: 0.22s;
42
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
43
+ }
44
+ .wmx-drawer--top {
45
+ top: 0;
46
+ animation-name: wmx-drawer-in-top;
47
+ }
48
+ .wmx-drawer--bottom {
49
+ bottom: 0;
50
+ animation-name: wmx-drawer-in-bottom;
51
+ }
52
+
53
+ .wmx-drawer__header {
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: space-between;
57
+ gap: 16px;
58
+ padding: 16px 20px;
59
+ border-bottom: 1px solid #e5e7eb;
60
+ flex-shrink: 0;
61
+ }
62
+
63
+ .wmx-drawer__title {
64
+ font-weight: 600;
65
+ font-size: 16px;
66
+ }
67
+
68
+ .wmx-drawer__close {
69
+ display: inline-flex;
70
+ align-items: center;
71
+ justify-content: center;
72
+ width: 28px;
73
+ height: 28px;
74
+ border: none;
75
+ border-radius: 6px;
76
+ background: transparent;
77
+ font-size: 20px;
78
+ line-height: 1;
79
+ cursor: pointer;
80
+ color: #6b7280;
81
+ padding: 0;
82
+ transition: background-color 0.15s ease, color 0.15s ease;
83
+ }
84
+
85
+ .wmx-drawer__close:hover {
86
+ background-color: #f3f4f6;
87
+ color: #111827;
88
+ }
89
+
90
+ .wmx-drawer__body {
91
+ padding: 20px;
92
+ overflow: auto;
93
+ flex: 1;
94
+ }
95
+
96
+ @keyframes wmx-drawer-fade {
97
+ from {
98
+ opacity: 0;
99
+ }
100
+ to {
101
+ opacity: 1;
102
+ }
103
+ }
104
+ @keyframes wmx-drawer-in-left {
105
+ from {
106
+ transform: translateX(-100%);
107
+ }
108
+ to {
109
+ transform: translateX(0);
110
+ }
111
+ }
112
+ @keyframes wmx-drawer-in-right {
113
+ from {
114
+ transform: translateX(100%);
115
+ }
116
+ to {
117
+ transform: translateX(0);
118
+ }
119
+ }
120
+ @keyframes wmx-drawer-in-top {
121
+ from {
122
+ transform: translateY(-100%);
123
+ }
124
+ to {
125
+ transform: translateY(0);
126
+ }
127
+ }
128
+ @keyframes wmx-drawer-in-bottom {
129
+ from {
130
+ transform: translateY(100%);
131
+ }
132
+ to {
133
+ transform: translateY(0);
134
+ }
135
+ }
136
+
137
+ @media (prefers-reduced-motion: reduce) {
138
+ .wmx-drawer-overlay,
139
+ .wmx-drawer {
140
+ animation: none;
141
+ }
142
+ }
@@ -0,0 +1,62 @@
1
+ import { useRef } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import type { HTMLAttributes, ReactNode } from "react";
4
+ import { useModalBehavior } from "../useModalBehavior.js";
5
+ import { useFocusTrap } from "../useFocusTrap.js";
6
+ import "./Drawer.css";
7
+
8
+ export type DrawerPlacement = "left" | "right" | "top" | "bottom";
9
+
10
+ export interface DrawerProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
11
+ isOpen: boolean;
12
+ onClose: () => void;
13
+ title?: ReactNode;
14
+ placement?: DrawerPlacement;
15
+ closeOnOverlayClick?: boolean;
16
+ closeOnEsc?: boolean;
17
+ children: ReactNode;
18
+ }
19
+
20
+ export function Drawer({
21
+ isOpen,
22
+ onClose,
23
+ title,
24
+ placement = "right",
25
+ closeOnOverlayClick = true,
26
+ closeOnEsc = true,
27
+ className,
28
+ children,
29
+ ...rest
30
+ }: DrawerProps) {
31
+ const containerRef = useRef<HTMLDivElement>(null);
32
+
33
+ useModalBehavior({ isOpen, onClose, closeOnEsc });
34
+ useFocusTrap(containerRef, isOpen);
35
+
36
+ if (!isOpen) return null;
37
+
38
+ const classes = ["wmx-drawer", `wmx-drawer--${placement}`, className].filter(Boolean).join(" ");
39
+
40
+ return createPortal(
41
+ <div className="wmx-drawer-overlay" onClick={closeOnOverlayClick ? onClose : undefined}>
42
+ <div
43
+ ref={containerRef}
44
+ className={classes}
45
+ role="dialog"
46
+ aria-modal="true"
47
+ tabIndex={-1}
48
+ onClick={(e) => e.stopPropagation()}
49
+ {...rest}
50
+ >
51
+ <div className="wmx-drawer__header">
52
+ {title && <span className="wmx-drawer__title">{title}</span>}
53
+ <button type="button" className="wmx-drawer__close" aria-label="Close" onClick={onClose}>
54
+ &times;
55
+ </button>
56
+ </div>
57
+ <div className="wmx-drawer__body">{children}</div>
58
+ </div>
59
+ </div>,
60
+ document.body
61
+ );
62
+ }
@@ -0,0 +1,117 @@
1
+ .wmx-dialog-overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ background-color: rgba(15, 23, 42, 0.55);
5
+ backdrop-filter: blur(2px);
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ z-index: 1000;
10
+ padding: 24px;
11
+ animation: wmx-dialog-fade 0.15s ease;
12
+ }
13
+
14
+ .wmx-dialog {
15
+ background-color: #ffffff;
16
+ border-radius: 14px;
17
+ width: 100%;
18
+ max-height: 90vh;
19
+ overflow: auto;
20
+ box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
21
+ animation: wmx-dialog-scale 0.18s cubic-bezier(0.16, 1, 0.3, 1);
22
+ outline: none;
23
+ }
24
+
25
+ .wmx-dialog--sm {
26
+ max-width: 360px;
27
+ }
28
+
29
+ .wmx-dialog--md {
30
+ max-width: 480px;
31
+ }
32
+
33
+ .wmx-dialog--lg {
34
+ max-width: 640px;
35
+ }
36
+
37
+ .wmx-dialog--xl {
38
+ max-width: 880px;
39
+ }
40
+
41
+ .wmx-dialog--full {
42
+ max-width: none;
43
+ width: calc(100vw - 48px);
44
+ height: calc(100vh - 48px);
45
+ }
46
+
47
+ .wmx-dialog__header {
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: space-between;
51
+ gap: 16px;
52
+ padding: 16px 20px;
53
+ border-bottom: 1px solid #e5e7eb;
54
+ }
55
+
56
+ .wmx-dialog__title {
57
+ font-weight: 600;
58
+ font-size: 16px;
59
+ }
60
+
61
+ .wmx-dialog__close {
62
+ display: inline-flex;
63
+ align-items: center;
64
+ justify-content: center;
65
+ width: 28px;
66
+ height: 28px;
67
+ border: none;
68
+ border-radius: 6px;
69
+ background: transparent;
70
+ font-size: 20px;
71
+ line-height: 1;
72
+ cursor: pointer;
73
+ color: #6b7280;
74
+ padding: 0;
75
+ transition: background-color 0.15s ease, color 0.15s ease;
76
+ }
77
+
78
+ .wmx-dialog__close:hover {
79
+ background-color: #f3f4f6;
80
+ color: #111827;
81
+ }
82
+
83
+ .wmx-dialog__close:focus-visible {
84
+ outline: 2px solid #2563eb;
85
+ outline-offset: 2px;
86
+ }
87
+
88
+ .wmx-dialog__body {
89
+ padding: 20px;
90
+ }
91
+
92
+ @keyframes wmx-dialog-fade {
93
+ from {
94
+ opacity: 0;
95
+ }
96
+ to {
97
+ opacity: 1;
98
+ }
99
+ }
100
+
101
+ @keyframes wmx-dialog-scale {
102
+ from {
103
+ opacity: 0;
104
+ transform: scale(0.96) translateY(4px);
105
+ }
106
+ to {
107
+ opacity: 1;
108
+ transform: scale(1) translateY(0);
109
+ }
110
+ }
111
+
112
+ @media (prefers-reduced-motion: reduce) {
113
+ .wmx-dialog-overlay,
114
+ .wmx-dialog {
115
+ animation: none;
116
+ }
117
+ }
@@ -0,0 +1,62 @@
1
+ import { useRef } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import type { HTMLAttributes, ReactNode } from "react";
4
+ import { useModalBehavior } from "../useModalBehavior.js";
5
+ import { useFocusTrap } from "../useFocusTrap.js";
6
+ import "./Modal.css";
7
+
8
+ export type ModalSize = "sm" | "md" | "lg" | "xl" | "full";
9
+
10
+ export interface ModalProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
11
+ isOpen: boolean;
12
+ onClose: () => void;
13
+ title?: ReactNode;
14
+ size?: ModalSize;
15
+ closeOnOverlayClick?: boolean;
16
+ closeOnEsc?: boolean;
17
+ children: ReactNode;
18
+ }
19
+
20
+ export function Modal({
21
+ isOpen,
22
+ onClose,
23
+ title,
24
+ size = "md",
25
+ closeOnOverlayClick = true,
26
+ closeOnEsc = true,
27
+ className,
28
+ children,
29
+ ...rest
30
+ }: ModalProps) {
31
+ const containerRef = useRef<HTMLDivElement>(null);
32
+
33
+ useModalBehavior({ isOpen, onClose, closeOnEsc });
34
+ useFocusTrap(containerRef, isOpen);
35
+
36
+ if (!isOpen) return null;
37
+
38
+ const classes = ["wmx-dialog", `wmx-dialog--${size}`, className].filter(Boolean).join(" ");
39
+
40
+ return createPortal(
41
+ <div className="wmx-dialog-overlay" onClick={closeOnOverlayClick ? onClose : undefined}>
42
+ <div
43
+ ref={containerRef}
44
+ className={classes}
45
+ role="dialog"
46
+ aria-modal="true"
47
+ tabIndex={-1}
48
+ onClick={(e) => e.stopPropagation()}
49
+ {...rest}
50
+ >
51
+ <div className="wmx-dialog__header">
52
+ {title && <span className="wmx-dialog__title">{title}</span>}
53
+ <button type="button" className="wmx-dialog__close" aria-label="Close" onClick={onClose}>
54
+ &times;
55
+ </button>
56
+ </div>
57
+ <div className="wmx-dialog__body">{children}</div>
58
+ </div>
59
+ </div>,
60
+ document.body
61
+ );
62
+ }
@@ -0,0 +1,104 @@
1
+ .wmx-confirm-overlay {
2
+ position: fixed;
3
+ inset: 0;
4
+ background-color: rgba(15, 23, 42, 0.55);
5
+ backdrop-filter: blur(2px);
6
+ display: flex;
7
+ align-items: center;
8
+ justify-content: center;
9
+ z-index: 1100;
10
+ padding: 24px;
11
+ animation: wmx-confirm-fade 0.15s ease;
12
+ }
13
+
14
+ .wmx-confirm {
15
+ background-color: #ffffff;
16
+ border-radius: 14px;
17
+ width: 100%;
18
+ max-width: 400px;
19
+ padding: 22px;
20
+ box-shadow: 0 20px 60px rgba(15, 23, 42, 0.25);
21
+ animation: wmx-confirm-scale 0.18s cubic-bezier(0.16, 1, 0.3, 1);
22
+ }
23
+
24
+ .wmx-confirm__title {
25
+ font-weight: 600;
26
+ font-size: 16px;
27
+ color: #111827;
28
+ margin-bottom: 8px;
29
+ }
30
+
31
+ .wmx-confirm__description {
32
+ font-size: 14px;
33
+ color: #4b5563;
34
+ line-height: 1.6;
35
+ margin-bottom: 20px;
36
+ }
37
+
38
+ .wmx-confirm__actions {
39
+ display: flex;
40
+ justify-content: flex-end;
41
+ gap: 10px;
42
+ }
43
+
44
+ .wmx-confirm__btn {
45
+ padding: 8px 18px;
46
+ border-radius: 8px;
47
+ font-size: 14px;
48
+ font-weight: 600;
49
+ font-family: inherit;
50
+ cursor: pointer;
51
+ border: 1px solid transparent;
52
+ transition: background-color 0.15s ease, box-shadow 0.15s ease;
53
+ }
54
+
55
+ .wmx-confirm__btn--cancel {
56
+ background-color: #f3f4f6;
57
+ color: #1f2937;
58
+ border-color: #e5e7eb;
59
+ }
60
+ .wmx-confirm__btn--cancel:hover {
61
+ background-color: #e5e7eb;
62
+ }
63
+
64
+ .wmx-confirm__btn--confirm {
65
+ background-color: #2563eb;
66
+ color: #ffffff;
67
+ }
68
+ .wmx-confirm__btn--confirm:hover {
69
+ background-color: #1d4ed8;
70
+ }
71
+
72
+ .wmx-confirm__btn--danger {
73
+ background-color: #dc2626;
74
+ color: #ffffff;
75
+ }
76
+ .wmx-confirm__btn--danger:hover {
77
+ background-color: #b91c1c;
78
+ }
79
+
80
+ @keyframes wmx-confirm-fade {
81
+ from {
82
+ opacity: 0;
83
+ }
84
+ to {
85
+ opacity: 1;
86
+ }
87
+ }
88
+ @keyframes wmx-confirm-scale {
89
+ from {
90
+ opacity: 0;
91
+ transform: scale(0.96) translateY(4px);
92
+ }
93
+ to {
94
+ opacity: 1;
95
+ transform: scale(1) translateY(0);
96
+ }
97
+ }
98
+
99
+ @media (prefers-reduced-motion: reduce) {
100
+ .wmx-confirm-overlay,
101
+ .wmx-confirm {
102
+ animation: none;
103
+ }
104
+ }
@@ -0,0 +1,71 @@
1
+ import { createPortal } from "react-dom";
2
+ import { createRoot } from "react-dom/client";
3
+ import "./Confirm.css";
4
+
5
+ export interface ConfirmOptions {
6
+ title?: string;
7
+ description?: string;
8
+ confirmText?: string;
9
+ cancelText?: string;
10
+ variant?: "default" | "danger";
11
+ }
12
+
13
+ interface ConfirmDialogProps extends ConfirmOptions {
14
+ onResolve: (result: boolean) => void;
15
+ }
16
+
17
+ function ConfirmDialog({
18
+ title = "Are you sure?",
19
+ description,
20
+ confirmText = "Confirm",
21
+ cancelText = "Cancel",
22
+ variant = "default",
23
+ onResolve,
24
+ }: ConfirmDialogProps) {
25
+ return createPortal(
26
+ <div className="wmx-confirm-overlay" onClick={() => onResolve(false)}>
27
+ <div
28
+ className="wmx-confirm"
29
+ role="alertdialog"
30
+ aria-modal="true"
31
+ onClick={(e) => e.stopPropagation()}
32
+ >
33
+ <div className="wmx-confirm__title">{title}</div>
34
+ {description && <div className="wmx-confirm__description">{description}</div>}
35
+ <div className="wmx-confirm__actions">
36
+ <button
37
+ type="button"
38
+ className="wmx-confirm__btn wmx-confirm__btn--cancel"
39
+ onClick={() => onResolve(false)}
40
+ >
41
+ {cancelText}
42
+ </button>
43
+ <button
44
+ type="button"
45
+ className={`wmx-confirm__btn wmx-confirm__btn--${variant === "danger" ? "danger" : "confirm"}`}
46
+ onClick={() => onResolve(true)}
47
+ >
48
+ {confirmText}
49
+ </button>
50
+ </div>
51
+ </div>
52
+ </div>,
53
+ document.body
54
+ );
55
+ }
56
+
57
+ export function confirm(options: ConfirmOptions = {}): Promise<boolean> {
58
+ return new Promise((resolve) => {
59
+ const container = document.createElement("div");
60
+ document.body.appendChild(container);
61
+ const root = createRoot(container);
62
+
63
+ const handleResolve = (result: boolean) => {
64
+ root.unmount();
65
+ container.remove();
66
+ resolve(result);
67
+ };
68
+
69
+ root.render(<ConfirmDialog {...options} onResolve={handleResolve} />);
70
+ });
71
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export { Modal } from "./Modal/Modal.js";
2
+ export type { ModalProps, ModalSize } from "./Modal/Modal.js";
3
+
4
+ export { Drawer } from "./Drawer/Drawer.js";
5
+ export type { DrawerProps, DrawerPlacement } from "./Drawer/Drawer.js";
6
+
7
+ export { confirm } from "./confirm/confirm.js";
8
+ export type { ConfirmOptions } from "./confirm/confirm.js";
9
+
10
+ export { useFocusTrap } from "./useFocusTrap.js";
11
+ export { useModalBehavior } from "./useModalBehavior.js";
12
+ export type { UseModalBehaviorOptions } from "./useModalBehavior.js";
@@ -0,0 +1,44 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { RefObject } from "react";
3
+
4
+ const FOCUSABLE_SELECTOR =
5
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
6
+
7
+ export function useFocusTrap(containerRef: RefObject<HTMLElement>, active: boolean) {
8
+ const previouslyFocused = useRef<HTMLElement | null>(null);
9
+
10
+ useEffect(() => {
11
+ if (!active) return;
12
+
13
+ previouslyFocused.current = document.activeElement as HTMLElement | null;
14
+ const container = containerRef.current;
15
+ const firstFocusable = container?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
16
+ (firstFocusable ?? container)?.focus();
17
+
18
+ const handleKeyDown = (e: KeyboardEvent) => {
19
+ if (e.key !== "Tab" || !container) return;
20
+
21
+ const nodes = Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter(
22
+ (el) => el.offsetParent !== null
23
+ );
24
+ if (nodes.length === 0) return;
25
+
26
+ const first = nodes[0];
27
+ const last = nodes[nodes.length - 1];
28
+
29
+ if (e.shiftKey && document.activeElement === first) {
30
+ e.preventDefault();
31
+ last.focus();
32
+ } else if (!e.shiftKey && document.activeElement === last) {
33
+ e.preventDefault();
34
+ first.focus();
35
+ }
36
+ };
37
+
38
+ document.addEventListener("keydown", handleKeyDown);
39
+ return () => {
40
+ document.removeEventListener("keydown", handleKeyDown);
41
+ previouslyFocused.current?.focus();
42
+ };
43
+ }, [active, containerRef]);
44
+ }
@@ -0,0 +1,32 @@
1
+ import { useEffect } from "react";
2
+
3
+ export interface UseModalBehaviorOptions {
4
+ isOpen: boolean;
5
+ onClose: () => void;
6
+ closeOnEsc?: boolean;
7
+ lockScroll?: boolean;
8
+ }
9
+
10
+ export function useModalBehavior({
11
+ isOpen,
12
+ onClose,
13
+ closeOnEsc = true,
14
+ lockScroll = true,
15
+ }: UseModalBehaviorOptions) {
16
+ useEffect(() => {
17
+ if (!isOpen) return;
18
+
19
+ const originalOverflow = document.body.style.overflow;
20
+ if (lockScroll) document.body.style.overflow = "hidden";
21
+
22
+ const handleKeyDown = (e: KeyboardEvent) => {
23
+ if (closeOnEsc && e.key === "Escape") onClose();
24
+ };
25
+ document.addEventListener("keydown", handleKeyDown);
26
+
27
+ return () => {
28
+ document.removeEventListener("keydown", handleKeyDown);
29
+ if (lockScroll) document.body.style.overflow = originalOverflow;
30
+ };
31
+ }, [isOpen, onClose, closeOnEsc, lockScroll]);
32
+ }