forked from Creditra/Creditra-Frontend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathFormMessage.tsx
More file actions
175 lines (158 loc) · 5.52 KB
/
Copy pathFormMessage.tsx
File metadata and controls
175 lines (158 loc) · 5.52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
import { AlertCircle, CheckCircle, Info, AlertTriangle } from "lucide-react";
const DEFAULT_ANNOUNCEMENT_DELAY_MS = 300;
interface FormMessageProps {
/** Optional stable id so callers can wire `aria-describedby`. */
id?: string;
/** Strong leading line; omit for message-only renders. */
title?: ReactNode;
/** Body text or rich node. Optional so `reserveSpace` can render an empty slot. */
message?: ReactNode;
/**
* Visual treatment. `inline` is compact, used directly under inputs;
* `alert` is the bigger boxed style used above modal forms.
*/
tone?: "inline" | "alert";
/**
* Severity. Drives both the icon and the colour band. `error` is kept
* as an alias of `danger` so callers from other parts of the app can
* use the more conventional name.
*/
type?: "success" | "danger" | "warning" | "info" | "error";
/**
* When true, the slot reserves vertical space (`min-height`) even
* while empty. Used by forms that would otherwise jitter as the
* message appears/disappears.
*/
reserveSpace?: boolean;
/** Override the default reserved height (52 px inline, 88 px alert). */
minHeight?: number;
/**
* Time (ms) to wait before announcing the message to screen readers.
* The visual message is rendered immediately; only the `aria-live`
* readback is debounced. Defaults to 300 ms, which is long enough to
* coalesce rapid edits ("10" → "100" → "1000") into a single
* readback but short enough to feel responsive.
*
* Setting to `0` disables the debounce — useful in tests and for
* critical errors that must be read immediately.
*/
announceDelayMs?: number;
/** Pass-through inline style for one-off layout tweaks. */
style?: CSSProperties;
/** Pass-through class name appended to the slot container. */
className?: string;
}
function getPlainText(node: ReactNode): string {
if (node == null || typeof node === "boolean") {
return "";
}
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(getPlainText).filter(Boolean).join(" ");
}
if (typeof node === "object" && "props" in node) {
const props = node.props as { children?: ReactNode };
return getPlainText(props.children);
}
return "";
}
function useDebouncedAnnouncement(text: string, delay: number) {
const [announcedText, setAnnouncedText] = useState("");
useEffect(() => {
// `delay === 0` is a valid signal to skip the debounce entirely
// (used by tests + critical errors). We do not read the live
// region straight off the prop because passing `delay === 0` to
// setTimeout would still queue a microtask-deferred readback.
if (!text) {
setAnnouncedText("");
return;
}
if (delay <= 0) {
setAnnouncedText(text);
return;
}
const timer = window.setTimeout(() => {
setAnnouncedText(text);
}, delay);
return () => {
window.clearTimeout(timer);
};
}, [delay, text]);
return announcedText;
}
/**
* Tone-coded inline message for form fields and form-level alerts.
*
* The visual message is rendered immediately, but the live announcement is
* debounced (default 300 ms) so assistive technology hears the settled
* validation state instead of every intermediate keystroke. Callers can
* override the debounce with `announceDelayMs` — `0` disables it.
*
* Use `reserveSpace` on the canonical version below an input to prevent
* layout shift when a message toggles on or off.
*/
export function FormMessage({
id,
title,
message,
tone = "inline",
type = "danger",
reserveSpace = false,
minHeight,
announceDelayMs = DEFAULT_ANNOUNCEMENT_DELAY_MS,
style,
className = "",
}: FormMessageProps) {
const hasContent = Boolean(title) || Boolean(message);
const announcement = useDebouncedAnnouncement(
[getPlainText(title), getPlainText(message)].filter(Boolean).join(" "),
announceDelayMs,
);
if (!hasContent && !reserveSpace) {
return null;
}
const slotClassName = [
"form-message-slot",
tone === "alert" ? "form-message-slot--alert" : "form-message-slot--inline",
className,
]
.filter(Boolean)
.join(" ");
return (
<div
className={slotClassName}
style={{
minHeight: reserveSpace
? minHeight ?? (tone === "alert" ? 88 : 52)
: undefined,
...style,
}}
>
{hasContent ? (
<>
<div
id={id}
className={`form-message form-message--${type === 'error' ? 'danger' : type} form-message--${tone}`}
>
{type === 'success' && <CheckCircle className="form-message__icon" aria-hidden="true" />}
{(type === 'danger' || type === 'error') && <AlertCircle className="form-message__icon" aria-hidden="true" />}
{type === 'warning' && <AlertTriangle className="form-message__icon" aria-hidden="true" />}
{type === 'info' && <Info className="form-message__icon" aria-hidden="true" />}
<div className="form-message__content">
{title ? <strong className="form-message__title">{title}</strong> : null}
{message ? <p className="form-message__text">{message}</p> : null}
</div>
</div>
{announcement ? (
<div className="sr-only" role="alert" aria-live="assertive" aria-atomic="true">
{announcement}
</div>
) : null}
</>
) : null}
</div>
);
}