@attrus-ds/core 0.2.0 → 0.3.1
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 +27 -27
- package/dist/assets/flags/Brazil-rounded.svg +15 -15
- package/dist/assets/flags/Brazil-square.svg +16 -16
- package/dist/assets/flags/Chile-rounded.svg +10 -10
- package/dist/assets/flags/Chile-square.svg +10 -10
- package/dist/assets/flags/Colombia-rounded.svg +9 -9
- package/dist/assets/flags/Colombia-square.svg +9 -9
- package/dist/assets/flags/EUA-rounded.svg +10 -10
- package/dist/assets/flags/EUA-square.svg +10 -10
- package/dist/assets/flags/EUR-rounded.svg +8 -8
- package/dist/assets/flags/EUR-square.svg +8 -8
- package/dist/assets/flags/Mexico-rounded.svg +18 -18
- package/dist/assets/flags/Mexico-square.svg +18 -18
- package/dist/assets/icon-attrus-dark.svg +2 -2
- package/dist/assets/icon-attrus-light.svg +2 -2
- package/dist/assets/logo-attrus-dark.svg +7 -7
- package/dist/assets/logo-attrus-light.svg +7 -7
- package/dist/css/components/buttons.css +301 -301
- package/dist/css/components/callout.css +195 -195
- package/dist/css/components/cards.css +272 -272
- package/dist/css/components/combobox.css +319 -319
- package/dist/css/components/empty-states.css +61 -61
- package/dist/css/components/inputs.css +975 -975
- package/dist/css/components/number-input.css +138 -138
- package/dist/css/components/overlays.css +374 -374
- package/dist/css/components/primitives.css +314 -314
- package/dist/css/components/sidebar.css +179 -179
- package/dist/css/components/smart-search.css +91 -91
- package/dist/css/components/status-pills.css +207 -207
- package/dist/css/components/stepper.css +309 -309
- package/dist/css/components/table.css +602 -602
- package/dist/css/components/tabs.css +319 -319
- package/dist/css/components/toast.css +243 -243
- package/dist/css/components/tooltip.css +231 -231
- package/dist/css/components/topbar.css +148 -148
- package/dist/css/tokens.css +666 -666
- package/dist/react/AppSidebar/AppSidebar.d.ts +50 -50
- package/dist/react/AppSidebar/AppSidebar.tsx +197 -197
- package/dist/react/AppTopbar/AppTopbar.d.ts +58 -58
- package/dist/react/AppTopbar/AppTopbar.tsx +203 -203
- package/dist/react/Atoms/Atoms.d.ts +149 -149
- package/dist/react/Atoms/Atoms.tsx +407 -407
- package/dist/react/Button/Button.d.ts +25 -25
- package/dist/react/Button/Button.tsx +83 -83
- package/dist/react/Callout/Callout.d.ts +29 -29
- package/dist/react/Callout/Callout.tsx +98 -98
- package/dist/react/Card/Card.d.ts +38 -38
- package/dist/react/Card/Card.tsx +93 -93
- package/dist/react/Combobox/Combobox.d.ts +52 -52
- package/dist/react/Combobox/Combobox.tsx +254 -254
- package/dist/react/DataTable/DataTable.d.ts +81 -81
- package/dist/react/DataTable/DataTable.tsx +308 -308
- package/dist/react/Drawer/Drawer.d.ts +35 -35
- package/dist/react/Drawer/Drawer.tsx +226 -226
- package/dist/react/Input/Input.d.ts +155 -155
- package/dist/react/Input/Input.tsx +478 -478
- package/dist/react/Modal/Modal.d.ts +27 -27
- package/dist/react/Modal/Modal.tsx +173 -173
- package/dist/react/NumberInput/NumberInput.d.ts +25 -25
- package/dist/react/NumberInput/NumberInput.tsx +117 -117
- package/dist/react/Pill/Pill.d.ts +31 -31
- package/dist/react/Pill/Pill.tsx +104 -104
- package/dist/react/SearchPill/SearchPill.d.ts +14 -14
- package/dist/react/SearchPill/SearchPill.tsx +40 -40
- package/dist/react/SmartSearch/SmartSearch.d.ts +65 -65
- package/dist/react/SmartSearch/SmartSearch.tsx +271 -271
- package/dist/react/Stepper/Stepper.d.ts +25 -25
- package/dist/react/Stepper/Stepper.tsx +94 -94
- package/dist/react/Tabs/Tabs.d.ts +32 -32
- package/dist/react/Tabs/Tabs.tsx +147 -147
- package/dist/react/Toast/Toast.d.ts +30 -30
- package/dist/react/Toast/Toast.tsx +94 -94
- package/dist/react/Tooltip/Tooltip.d.ts +23 -23
- package/dist/react/Tooltip/Tooltip.tsx +80 -80
- package/dist/react/ZeroState/ZeroState.d.ts +17 -17
- package/dist/react/ZeroState/ZeroState.tsx +45 -45
- package/dist/react/index.ts +35 -35
- package/dist/tokens/attrus-tokens.flat.json +1234 -1234
- package/dist/tokens/attrus-tokens.json +1089 -1089
- package/package.json +64 -64
|
@@ -1,35 +1,35 @@
|
|
|
1
|
-
import * as React from 'react';
|
|
2
|
-
|
|
3
|
-
export type DrawerSide = 'right' | 'left' | 'bottom';
|
|
4
|
-
export type DrawerSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
5
|
-
|
|
6
|
-
export interface DrawerProps {
|
|
7
|
-
open: boolean;
|
|
8
|
-
onClose?: () => void;
|
|
9
|
-
side?: DrawerSide;
|
|
10
|
-
/** Width preset for right/left drawers. */
|
|
11
|
-
size?: DrawerSize;
|
|
12
|
-
title?: React.ReactNode;
|
|
13
|
-
subtitle?: React.ReactNode;
|
|
14
|
-
/** 40×40 header lead tile — caller sets bg/color via leadStyle. */
|
|
15
|
-
lead?: React.ReactNode;
|
|
16
|
-
leadStyle?: React.CSSProperties;
|
|
17
|
-
/** Sticky footer row — canonical Buttons. */
|
|
18
|
-
footer?: React.ReactNode;
|
|
19
|
-
/** Left-side footer aux (total, agreement note) — switches the footer to space-between. */
|
|
20
|
-
footerAux?: React.ReactNode;
|
|
21
|
-
/** Second header row (search pill, tabs) — `.drawer-header-extra`. */
|
|
22
|
-
headerExtra?: React.ReactNode;
|
|
23
|
-
/** Tight header chrome for utility drawers (`.drawer-header.is-dense`). */
|
|
24
|
-
denseHeader?: boolean;
|
|
25
|
-
/** Drag handle bar (bottom drawers) — swipe-down past ~80px dismisses. */
|
|
26
|
-
handle?: boolean;
|
|
27
|
-
/** Gallery/doc rendering: panel only (no scrim), overlay behaviours off
|
|
28
|
-
(focus trap, scroll-lock, Esc). Position it inside a relative frame. */
|
|
29
|
-
static?: boolean;
|
|
30
|
-
children?: React.ReactNode;
|
|
31
|
-
className?: string;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export declare const Drawer: React.FC<DrawerProps>;
|
|
35
|
-
export default Drawer;
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
export type DrawerSide = 'right' | 'left' | 'bottom';
|
|
4
|
+
export type DrawerSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
5
|
+
|
|
6
|
+
export interface DrawerProps {
|
|
7
|
+
open: boolean;
|
|
8
|
+
onClose?: () => void;
|
|
9
|
+
side?: DrawerSide;
|
|
10
|
+
/** Width preset for right/left drawers. */
|
|
11
|
+
size?: DrawerSize;
|
|
12
|
+
title?: React.ReactNode;
|
|
13
|
+
subtitle?: React.ReactNode;
|
|
14
|
+
/** 40×40 header lead tile — caller sets bg/color via leadStyle. */
|
|
15
|
+
lead?: React.ReactNode;
|
|
16
|
+
leadStyle?: React.CSSProperties;
|
|
17
|
+
/** Sticky footer row — canonical Buttons. */
|
|
18
|
+
footer?: React.ReactNode;
|
|
19
|
+
/** Left-side footer aux (total, agreement note) — switches the footer to space-between. */
|
|
20
|
+
footerAux?: React.ReactNode;
|
|
21
|
+
/** Second header row (search pill, tabs) — `.drawer-header-extra`. */
|
|
22
|
+
headerExtra?: React.ReactNode;
|
|
23
|
+
/** Tight header chrome for utility drawers (`.drawer-header.is-dense`). */
|
|
24
|
+
denseHeader?: boolean;
|
|
25
|
+
/** Drag handle bar (bottom drawers) — swipe-down past ~80px dismisses. */
|
|
26
|
+
handle?: boolean;
|
|
27
|
+
/** Gallery/doc rendering: panel only (no scrim), overlay behaviours off
|
|
28
|
+
(focus trap, scroll-lock, Esc). Position it inside a relative frame. */
|
|
29
|
+
static?: boolean;
|
|
30
|
+
children?: React.ReactNode;
|
|
31
|
+
className?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export declare const Drawer: React.FC<DrawerProps>;
|
|
35
|
+
export default Drawer;
|
|
@@ -1,226 +1,226 @@
|
|
|
1
|
-
import * as React from 'react';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* ATTRUS Drawer — typed wrapper over the canonical `.drawer` + `.scrim`
|
|
5
|
-
* (preview/components/overlays.css). Slide-in panel from right/left/
|
|
6
|
-
* bottom with sticky glassy header/footer, lead tile and sizes sm→xl.
|
|
7
|
-
*
|
|
8
|
-
* Open/close: same proven pattern as Modal — internal flag flips
|
|
9
|
-
* `.is-open` via setTimeout (not rAF). Esc and scrim-click call onClose.
|
|
10
|
-
* Exit: when `open` flips false the panel stays MOUNTED for the exit
|
|
11
|
-
* transition (slide back + fade, --duration-slow = 320ms) and only then
|
|
12
|
-
* unmounts — so closing plays the exact reverse of opening.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export type DrawerSide = 'right' | 'left' | 'bottom';
|
|
16
|
-
export type DrawerSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
17
|
-
|
|
18
|
-
export interface DrawerProps {
|
|
19
|
-
open: boolean;
|
|
20
|
-
onClose?: () => void;
|
|
21
|
-
side?: DrawerSide;
|
|
22
|
-
/** Width preset for right/left drawers. */
|
|
23
|
-
size?: DrawerSize;
|
|
24
|
-
title?: React.ReactNode;
|
|
25
|
-
subtitle?: React.ReactNode;
|
|
26
|
-
/** 40×40 header lead tile — caller sets bg/color via leadStyle. */
|
|
27
|
-
lead?: React.ReactNode;
|
|
28
|
-
leadStyle?: React.CSSProperties;
|
|
29
|
-
/** Sticky footer row — canonical Buttons. */
|
|
30
|
-
footer?: React.ReactNode;
|
|
31
|
-
/** Left-side footer aux (total, agreement note) — switches the footer to space-between. */
|
|
32
|
-
footerAux?: React.ReactNode;
|
|
33
|
-
/** Second header row (search pill, tabs) — `.drawer-header-extra`. */
|
|
34
|
-
headerExtra?: React.ReactNode;
|
|
35
|
-
/** Tight header chrome for utility drawers (`.drawer-header.is-dense`). */
|
|
36
|
-
denseHeader?: boolean;
|
|
37
|
-
/** Drag handle bar (bottom drawers). */
|
|
38
|
-
handle?: boolean;
|
|
39
|
-
/** Gallery/doc rendering: panel only (no scrim), overlay behaviours off
|
|
40
|
-
(focus trap, scroll-lock, Esc). Position it inside a relative frame. */
|
|
41
|
-
static?: boolean;
|
|
42
|
-
children?: React.ReactNode;
|
|
43
|
-
className?: string;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const XGlyph: React.FC = () => (
|
|
47
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true">
|
|
48
|
-
<line x1="18" y1="6" x2="6" y2="18" />
|
|
49
|
-
<line x1="6" y1="6" x2="18" y2="18" />
|
|
50
|
-
</svg>
|
|
51
|
-
);
|
|
52
|
-
|
|
53
|
-
export const Drawer: React.FC<DrawerProps> = ({
|
|
54
|
-
open,
|
|
55
|
-
onClose,
|
|
56
|
-
side = 'right',
|
|
57
|
-
size,
|
|
58
|
-
title,
|
|
59
|
-
subtitle,
|
|
60
|
-
lead,
|
|
61
|
-
leadStyle,
|
|
62
|
-
footer,
|
|
63
|
-
footerAux,
|
|
64
|
-
headerExtra,
|
|
65
|
-
denseHeader = false,
|
|
66
|
-
handle = false,
|
|
67
|
-
static: isStatic = false,
|
|
68
|
-
children,
|
|
69
|
-
className,
|
|
70
|
-
}) => {
|
|
71
|
-
const [shown, setShown] = React.useState(false);
|
|
72
|
-
const [mounted, setMounted] = React.useState(open);
|
|
73
|
-
const panelRef = React.useRef<HTMLDivElement>(null);
|
|
74
|
-
|
|
75
|
-
React.useEffect(() => {
|
|
76
|
-
if (open) {
|
|
77
|
-
setMounted(true);
|
|
78
|
-
return undefined;
|
|
79
|
-
}
|
|
80
|
-
// Closing: drop .is-open first so the CSS exit transition (reverse
|
|
81
|
-
// slide + fade) plays, THEN unmount after it finishes (320ms exit).
|
|
82
|
-
setShown(false);
|
|
83
|
-
const t = setTimeout(() => setMounted(false), 320);
|
|
84
|
-
return () => clearTimeout(t);
|
|
85
|
-
}, [open]);
|
|
86
|
-
|
|
87
|
-
// Entry: after the CLOSED state is committed to the DOM, force a layout
|
|
88
|
-
// flush (offsetHeight) so the browser computes the start position, then
|
|
89
|
-
// flip .is-open — guarantees the slide-in transition plays every time.
|
|
90
|
-
// (A bare 10ms timer was flaky: when it fired before first style calc,
|
|
91
|
-
// the panel appeared already open — no animation.)
|
|
92
|
-
React.useEffect(() => {
|
|
93
|
-
if (!open || !mounted || shown) return;
|
|
94
|
-
void panelRef.current?.offsetHeight;
|
|
95
|
-
setShown(true);
|
|
96
|
-
}, [open, mounted, shown]);
|
|
97
|
-
|
|
98
|
-
React.useEffect(() => {
|
|
99
|
-
if (!open || isStatic) return undefined;
|
|
100
|
-
const onKey = (e: KeyboardEvent) => {
|
|
101
|
-
if (e.key === 'Escape' && onClose) onClose();
|
|
102
|
-
};
|
|
103
|
-
document.addEventListener('keydown', onKey);
|
|
104
|
-
return () => document.removeEventListener('keydown', onKey);
|
|
105
|
-
}, [open, onClose, isStatic]);
|
|
106
|
-
|
|
107
|
-
// Body scroll-lock while open (doc a11y contract).
|
|
108
|
-
React.useEffect(() => {
|
|
109
|
-
if (!open || !mounted || isStatic) return undefined;
|
|
110
|
-
const prev = document.body.style.overflow;
|
|
111
|
-
document.body.style.overflow = 'hidden';
|
|
112
|
-
return () => { document.body.style.overflow = prev; };
|
|
113
|
-
}, [open, mounted]);
|
|
114
|
-
|
|
115
|
-
// Focus management: remember the opener, move focus into the panel on
|
|
116
|
-
// open, restore it after close. Tab cycles inside (light focus trap).
|
|
117
|
-
const prevFocus = React.useRef<HTMLElement | null>(null);
|
|
118
|
-
React.useEffect(() => {
|
|
119
|
-
if (isStatic) return;
|
|
120
|
-
if (shown) {
|
|
121
|
-
prevFocus.current = document.activeElement as HTMLElement | null;
|
|
122
|
-
const first = panelRef.current?.querySelector<HTMLElement>(
|
|
123
|
-
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
124
|
-
);
|
|
125
|
-
(first || panelRef.current)?.focus?.();
|
|
126
|
-
} else if (prevFocus.current) {
|
|
127
|
-
prevFocus.current.focus?.();
|
|
128
|
-
prevFocus.current = null;
|
|
129
|
-
}
|
|
130
|
-
}, [shown]);
|
|
131
|
-
|
|
132
|
-
const onTrapKeyDown = (e: React.KeyboardEvent) => {
|
|
133
|
-
if (e.key !== 'Tab' || !panelRef.current) return;
|
|
134
|
-
const focusables = Array.from(
|
|
135
|
-
panelRef.current.querySelectorAll<HTMLElement>(
|
|
136
|
-
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
137
|
-
)
|
|
138
|
-
).filter((el) => !el.hasAttribute('disabled'));
|
|
139
|
-
if (!focusables.length) return;
|
|
140
|
-
const first = focusables[0];
|
|
141
|
-
const last = focusables[focusables.length - 1];
|
|
142
|
-
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
143
|
-
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
// Bottom drawer: swipe-down on the handle dismisses past a threshold.
|
|
147
|
-
const touchY = React.useRef<number | null>(null);
|
|
148
|
-
const onHandleTouchStart = (e: React.TouchEvent) => { touchY.current = e.touches[0].clientY; };
|
|
149
|
-
const onHandleTouchMove = (e: React.TouchEvent) => {
|
|
150
|
-
if (touchY.current == null || !panelRef.current) return;
|
|
151
|
-
const dy = Math.max(0, e.touches[0].clientY - touchY.current);
|
|
152
|
-
panelRef.current.style.transform = `translateY(${dy}px)`;
|
|
153
|
-
panelRef.current.style.transition = 'none';
|
|
154
|
-
};
|
|
155
|
-
const onHandleTouchEnd = (e: React.TouchEvent) => {
|
|
156
|
-
if (touchY.current == null || !panelRef.current) return;
|
|
157
|
-
const dy = e.changedTouches[0].clientY - touchY.current;
|
|
158
|
-
panelRef.current.style.transform = '';
|
|
159
|
-
panelRef.current.style.transition = '';
|
|
160
|
-
touchY.current = null;
|
|
161
|
-
if (dy > 80 && onClose) onClose();
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
if (!mounted) return null;
|
|
165
|
-
|
|
166
|
-
const cls = [
|
|
167
|
-
'drawer',
|
|
168
|
-
`drawer-${side}`,
|
|
169
|
-
size && side !== 'bottom' ? `drawer-${size}` : '',
|
|
170
|
-
shown ? 'is-open' : '',
|
|
171
|
-
className || '',
|
|
172
|
-
]
|
|
173
|
-
.filter(Boolean)
|
|
174
|
-
.join(' ');
|
|
175
|
-
|
|
176
|
-
return (
|
|
177
|
-
<React.Fragment>
|
|
178
|
-
{isStatic ? null : <div className={['scrim', shown ? 'is-open' : ''].filter(Boolean).join(' ')} onClick={onClose} />}
|
|
179
|
-
<div
|
|
180
|
-
ref={panelRef}
|
|
181
|
-
className={cls}
|
|
182
|
-
role="dialog"
|
|
183
|
-
aria-modal="true"
|
|
184
|
-
tabIndex={-1}
|
|
185
|
-
onKeyDown={onTrapKeyDown}
|
|
186
|
-
>
|
|
187
|
-
{handle ? (
|
|
188
|
-
<div
|
|
189
|
-
className="drawer-handle"
|
|
190
|
-
onTouchStart={onHandleTouchStart}
|
|
191
|
-
onTouchMove={onHandleTouchMove}
|
|
192
|
-
onTouchEnd={onHandleTouchEnd}
|
|
193
|
-
/>
|
|
194
|
-
) : null}
|
|
195
|
-
{title != null || subtitle != null || lead != null || onClose ? (
|
|
196
|
-
<div className={['drawer-header', denseHeader ? 'is-dense' : '', headerExtra != null ? 'has-extra' : ''].filter(Boolean).join(' ')}>
|
|
197
|
-
{lead != null ? (
|
|
198
|
-
<span className="drawer-lead" style={leadStyle}>
|
|
199
|
-
{lead}
|
|
200
|
-
</span>
|
|
201
|
-
) : null}
|
|
202
|
-
<div className="drawer-header-text">
|
|
203
|
-
{title != null ? <div className="drawer-title">{title}</div> : null}
|
|
204
|
-
{subtitle != null ? <div className="drawer-subtitle">{subtitle}</div> : null}
|
|
205
|
-
</div>
|
|
206
|
-
{onClose ? (
|
|
207
|
-
<button type="button" className="drawer-close" aria-label="Close" onClick={onClose}>
|
|
208
|
-
<XGlyph />
|
|
209
|
-
</button>
|
|
210
|
-
) : null}
|
|
211
|
-
{headerExtra != null ? <div className="drawer-header-extra">{headerExtra}</div> : null}
|
|
212
|
-
</div>
|
|
213
|
-
) : null}
|
|
214
|
-
{children != null ? <div className="drawer-body">{children}</div> : null}
|
|
215
|
-
{footer != null || footerAux != null ? (
|
|
216
|
-
<div className="drawer-footer" style={footerAux != null ? { justifyContent: 'space-between' } : undefined}>
|
|
217
|
-
{footerAux != null ? <span className="drawer-footer-aux">{footerAux}</span> : null}
|
|
218
|
-
{footer != null ? (footerAux != null ? <span style={{ display: 'inline-flex', gap: 'var(--space-3)' }}>{footer}</span> : footer) : null}
|
|
219
|
-
</div>
|
|
220
|
-
) : null}
|
|
221
|
-
</div>
|
|
222
|
-
</React.Fragment>
|
|
223
|
-
);
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
export default Drawer;
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ATTRUS Drawer — typed wrapper over the canonical `.drawer` + `.scrim`
|
|
5
|
+
* (preview/components/overlays.css). Slide-in panel from right/left/
|
|
6
|
+
* bottom with sticky glassy header/footer, lead tile and sizes sm→xl.
|
|
7
|
+
*
|
|
8
|
+
* Open/close: same proven pattern as Modal — internal flag flips
|
|
9
|
+
* `.is-open` via setTimeout (not rAF). Esc and scrim-click call onClose.
|
|
10
|
+
* Exit: when `open` flips false the panel stays MOUNTED for the exit
|
|
11
|
+
* transition (slide back + fade, --duration-slow = 320ms) and only then
|
|
12
|
+
* unmounts — so closing plays the exact reverse of opening.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type DrawerSide = 'right' | 'left' | 'bottom';
|
|
16
|
+
export type DrawerSize = 'sm' | 'md' | 'lg' | 'xl';
|
|
17
|
+
|
|
18
|
+
export interface DrawerProps {
|
|
19
|
+
open: boolean;
|
|
20
|
+
onClose?: () => void;
|
|
21
|
+
side?: DrawerSide;
|
|
22
|
+
/** Width preset for right/left drawers. */
|
|
23
|
+
size?: DrawerSize;
|
|
24
|
+
title?: React.ReactNode;
|
|
25
|
+
subtitle?: React.ReactNode;
|
|
26
|
+
/** 40×40 header lead tile — caller sets bg/color via leadStyle. */
|
|
27
|
+
lead?: React.ReactNode;
|
|
28
|
+
leadStyle?: React.CSSProperties;
|
|
29
|
+
/** Sticky footer row — canonical Buttons. */
|
|
30
|
+
footer?: React.ReactNode;
|
|
31
|
+
/** Left-side footer aux (total, agreement note) — switches the footer to space-between. */
|
|
32
|
+
footerAux?: React.ReactNode;
|
|
33
|
+
/** Second header row (search pill, tabs) — `.drawer-header-extra`. */
|
|
34
|
+
headerExtra?: React.ReactNode;
|
|
35
|
+
/** Tight header chrome for utility drawers (`.drawer-header.is-dense`). */
|
|
36
|
+
denseHeader?: boolean;
|
|
37
|
+
/** Drag handle bar (bottom drawers). */
|
|
38
|
+
handle?: boolean;
|
|
39
|
+
/** Gallery/doc rendering: panel only (no scrim), overlay behaviours off
|
|
40
|
+
(focus trap, scroll-lock, Esc). Position it inside a relative frame. */
|
|
41
|
+
static?: boolean;
|
|
42
|
+
children?: React.ReactNode;
|
|
43
|
+
className?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const XGlyph: React.FC = () => (
|
|
47
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" aria-hidden="true">
|
|
48
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
49
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
50
|
+
</svg>
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
export const Drawer: React.FC<DrawerProps> = ({
|
|
54
|
+
open,
|
|
55
|
+
onClose,
|
|
56
|
+
side = 'right',
|
|
57
|
+
size,
|
|
58
|
+
title,
|
|
59
|
+
subtitle,
|
|
60
|
+
lead,
|
|
61
|
+
leadStyle,
|
|
62
|
+
footer,
|
|
63
|
+
footerAux,
|
|
64
|
+
headerExtra,
|
|
65
|
+
denseHeader = false,
|
|
66
|
+
handle = false,
|
|
67
|
+
static: isStatic = false,
|
|
68
|
+
children,
|
|
69
|
+
className,
|
|
70
|
+
}) => {
|
|
71
|
+
const [shown, setShown] = React.useState(false);
|
|
72
|
+
const [mounted, setMounted] = React.useState(open);
|
|
73
|
+
const panelRef = React.useRef<HTMLDivElement>(null);
|
|
74
|
+
|
|
75
|
+
React.useEffect(() => {
|
|
76
|
+
if (open) {
|
|
77
|
+
setMounted(true);
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
// Closing: drop .is-open first so the CSS exit transition (reverse
|
|
81
|
+
// slide + fade) plays, THEN unmount after it finishes (320ms exit).
|
|
82
|
+
setShown(false);
|
|
83
|
+
const t = setTimeout(() => setMounted(false), 320);
|
|
84
|
+
return () => clearTimeout(t);
|
|
85
|
+
}, [open]);
|
|
86
|
+
|
|
87
|
+
// Entry: after the CLOSED state is committed to the DOM, force a layout
|
|
88
|
+
// flush (offsetHeight) so the browser computes the start position, then
|
|
89
|
+
// flip .is-open — guarantees the slide-in transition plays every time.
|
|
90
|
+
// (A bare 10ms timer was flaky: when it fired before first style calc,
|
|
91
|
+
// the panel appeared already open — no animation.)
|
|
92
|
+
React.useEffect(() => {
|
|
93
|
+
if (!open || !mounted || shown) return;
|
|
94
|
+
void panelRef.current?.offsetHeight;
|
|
95
|
+
setShown(true);
|
|
96
|
+
}, [open, mounted, shown]);
|
|
97
|
+
|
|
98
|
+
React.useEffect(() => {
|
|
99
|
+
if (!open || isStatic) return undefined;
|
|
100
|
+
const onKey = (e: KeyboardEvent) => {
|
|
101
|
+
if (e.key === 'Escape' && onClose) onClose();
|
|
102
|
+
};
|
|
103
|
+
document.addEventListener('keydown', onKey);
|
|
104
|
+
return () => document.removeEventListener('keydown', onKey);
|
|
105
|
+
}, [open, onClose, isStatic]);
|
|
106
|
+
|
|
107
|
+
// Body scroll-lock while open (doc a11y contract).
|
|
108
|
+
React.useEffect(() => {
|
|
109
|
+
if (!open || !mounted || isStatic) return undefined;
|
|
110
|
+
const prev = document.body.style.overflow;
|
|
111
|
+
document.body.style.overflow = 'hidden';
|
|
112
|
+
return () => { document.body.style.overflow = prev; };
|
|
113
|
+
}, [open, mounted]);
|
|
114
|
+
|
|
115
|
+
// Focus management: remember the opener, move focus into the panel on
|
|
116
|
+
// open, restore it after close. Tab cycles inside (light focus trap).
|
|
117
|
+
const prevFocus = React.useRef<HTMLElement | null>(null);
|
|
118
|
+
React.useEffect(() => {
|
|
119
|
+
if (isStatic) return;
|
|
120
|
+
if (shown) {
|
|
121
|
+
prevFocus.current = document.activeElement as HTMLElement | null;
|
|
122
|
+
const first = panelRef.current?.querySelector<HTMLElement>(
|
|
123
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
124
|
+
);
|
|
125
|
+
(first || panelRef.current)?.focus?.();
|
|
126
|
+
} else if (prevFocus.current) {
|
|
127
|
+
prevFocus.current.focus?.();
|
|
128
|
+
prevFocus.current = null;
|
|
129
|
+
}
|
|
130
|
+
}, [shown]);
|
|
131
|
+
|
|
132
|
+
const onTrapKeyDown = (e: React.KeyboardEvent) => {
|
|
133
|
+
if (e.key !== 'Tab' || !panelRef.current) return;
|
|
134
|
+
const focusables = Array.from(
|
|
135
|
+
panelRef.current.querySelectorAll<HTMLElement>(
|
|
136
|
+
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
137
|
+
)
|
|
138
|
+
).filter((el) => !el.hasAttribute('disabled'));
|
|
139
|
+
if (!focusables.length) return;
|
|
140
|
+
const first = focusables[0];
|
|
141
|
+
const last = focusables[focusables.length - 1];
|
|
142
|
+
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
|
143
|
+
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Bottom drawer: swipe-down on the handle dismisses past a threshold.
|
|
147
|
+
const touchY = React.useRef<number | null>(null);
|
|
148
|
+
const onHandleTouchStart = (e: React.TouchEvent) => { touchY.current = e.touches[0].clientY; };
|
|
149
|
+
const onHandleTouchMove = (e: React.TouchEvent) => {
|
|
150
|
+
if (touchY.current == null || !panelRef.current) return;
|
|
151
|
+
const dy = Math.max(0, e.touches[0].clientY - touchY.current);
|
|
152
|
+
panelRef.current.style.transform = `translateY(${dy}px)`;
|
|
153
|
+
panelRef.current.style.transition = 'none';
|
|
154
|
+
};
|
|
155
|
+
const onHandleTouchEnd = (e: React.TouchEvent) => {
|
|
156
|
+
if (touchY.current == null || !panelRef.current) return;
|
|
157
|
+
const dy = e.changedTouches[0].clientY - touchY.current;
|
|
158
|
+
panelRef.current.style.transform = '';
|
|
159
|
+
panelRef.current.style.transition = '';
|
|
160
|
+
touchY.current = null;
|
|
161
|
+
if (dy > 80 && onClose) onClose();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
if (!mounted) return null;
|
|
165
|
+
|
|
166
|
+
const cls = [
|
|
167
|
+
'drawer',
|
|
168
|
+
`drawer-${side}`,
|
|
169
|
+
size && side !== 'bottom' ? `drawer-${size}` : '',
|
|
170
|
+
shown ? 'is-open' : '',
|
|
171
|
+
className || '',
|
|
172
|
+
]
|
|
173
|
+
.filter(Boolean)
|
|
174
|
+
.join(' ');
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<React.Fragment>
|
|
178
|
+
{isStatic ? null : <div className={['scrim', shown ? 'is-open' : ''].filter(Boolean).join(' ')} onClick={onClose} />}
|
|
179
|
+
<div
|
|
180
|
+
ref={panelRef}
|
|
181
|
+
className={cls}
|
|
182
|
+
role="dialog"
|
|
183
|
+
aria-modal="true"
|
|
184
|
+
tabIndex={-1}
|
|
185
|
+
onKeyDown={onTrapKeyDown}
|
|
186
|
+
>
|
|
187
|
+
{handle ? (
|
|
188
|
+
<div
|
|
189
|
+
className="drawer-handle"
|
|
190
|
+
onTouchStart={onHandleTouchStart}
|
|
191
|
+
onTouchMove={onHandleTouchMove}
|
|
192
|
+
onTouchEnd={onHandleTouchEnd}
|
|
193
|
+
/>
|
|
194
|
+
) : null}
|
|
195
|
+
{title != null || subtitle != null || lead != null || onClose ? (
|
|
196
|
+
<div className={['drawer-header', denseHeader ? 'is-dense' : '', headerExtra != null ? 'has-extra' : ''].filter(Boolean).join(' ')}>
|
|
197
|
+
{lead != null ? (
|
|
198
|
+
<span className="drawer-lead" style={leadStyle}>
|
|
199
|
+
{lead}
|
|
200
|
+
</span>
|
|
201
|
+
) : null}
|
|
202
|
+
<div className="drawer-header-text">
|
|
203
|
+
{title != null ? <div className="drawer-title">{title}</div> : null}
|
|
204
|
+
{subtitle != null ? <div className="drawer-subtitle">{subtitle}</div> : null}
|
|
205
|
+
</div>
|
|
206
|
+
{onClose ? (
|
|
207
|
+
<button type="button" className="drawer-close" aria-label="Close" onClick={onClose}>
|
|
208
|
+
<XGlyph />
|
|
209
|
+
</button>
|
|
210
|
+
) : null}
|
|
211
|
+
{headerExtra != null ? <div className="drawer-header-extra">{headerExtra}</div> : null}
|
|
212
|
+
</div>
|
|
213
|
+
) : null}
|
|
214
|
+
{children != null ? <div className="drawer-body">{children}</div> : null}
|
|
215
|
+
{footer != null || footerAux != null ? (
|
|
216
|
+
<div className="drawer-footer" style={footerAux != null ? { justifyContent: 'space-between' } : undefined}>
|
|
217
|
+
{footerAux != null ? <span className="drawer-footer-aux">{footerAux}</span> : null}
|
|
218
|
+
{footer != null ? (footerAux != null ? <span style={{ display: 'inline-flex', gap: 'var(--space-3)' }}>{footer}</span> : footer) : null}
|
|
219
|
+
</div>
|
|
220
|
+
) : null}
|
|
221
|
+
</div>
|
|
222
|
+
</React.Fragment>
|
|
223
|
+
);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
export default Drawer;
|