@enderfall/ui 0.1.0 → 0.1.4

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.
Files changed (45) hide show
  1. package/assets/brand/enderfall-lockup.png +0 -0
  2. package/assets/brand/enderfall-lockup.svg +8 -0
  3. package/assets/brand/enderfall-mark.png +0 -0
  4. package/assets/brand/enderfall-mark.svg +8 -0
  5. package/dist/components/Button.d.ts +2 -1
  6. package/dist/components/Button.d.ts.map +1 -1
  7. package/dist/components/Button.js +8 -1
  8. package/dist/components/Dropdown.d.ts.map +1 -1
  9. package/dist/components/Dropdown.js +2 -2
  10. package/package.json +5 -2
  11. package/src/base.css +160 -0
  12. package/src/components/AccessGate.css +24 -0
  13. package/src/components/AccessGate.tsx +61 -0
  14. package/src/components/BookmarkDropdown.css +220 -0
  15. package/src/components/Button.css +183 -0
  16. package/src/components/Button.tsx +20 -0
  17. package/src/components/Dropdown.tsx +570 -0
  18. package/src/components/FloatingFooter.css +49 -0
  19. package/src/components/FloatingFooter.tsx +27 -0
  20. package/src/components/FormField.tsx +29 -0
  21. package/src/components/HeaderMenu.css +280 -0
  22. package/src/components/Input.css +68 -0
  23. package/src/components/Input.tsx +23 -0
  24. package/src/components/MainHeader.css +167 -0
  25. package/src/components/MainHeader.tsx +51 -0
  26. package/src/components/Modal.css +282 -0
  27. package/src/components/Modal.tsx +142 -0
  28. package/src/components/Panel.css +71 -0
  29. package/src/components/Panel.tsx +31 -0
  30. package/src/components/PreferencesModal.tsx +67 -0
  31. package/src/components/SideMenu.tsx +239 -0
  32. package/src/components/Slider.css +114 -0
  33. package/src/components/Slider.tsx +33 -0
  34. package/src/components/StackedCard.css +180 -0
  35. package/src/components/StackedCard.tsx +125 -0
  36. package/src/components/StatDots.css +122 -0
  37. package/src/components/StatDots.tsx +53 -0
  38. package/src/components/Tabs.css +108 -0
  39. package/src/components/Tabs.tsx +68 -0
  40. package/src/components/Toggle.css +161 -0
  41. package/src/components/Toggle.tsx +38 -0
  42. package/src/components/UserMenu.css +273 -0
  43. package/src/index.ts +45 -0
  44. package/src/theme.css +353 -0
  45. package/styles.css +1 -0
@@ -0,0 +1,570 @@
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { Button } from "./Button";
3
+
4
+ export type HeaderMenuItem = {
5
+ id: string;
6
+ label: string;
7
+ content: ReactNode;
8
+ };
9
+
10
+ export type DropdownUserItem = {
11
+ id?: string;
12
+ label: string;
13
+ onClick: () => void;
14
+ disabled?: boolean;
15
+ className?: string;
16
+ title?: string;
17
+ variant?: "default" | "theme-preview";
18
+ };
19
+
20
+ export type DropdownUserListItem = {
21
+ id?: string;
22
+ label: string;
23
+ onClick: () => void;
24
+ onEdit?: () => void;
25
+ onDelete?: () => void;
26
+ disabled?: boolean;
27
+ className?: string;
28
+ title?: string;
29
+ avatarUrl?: string | null;
30
+ avatarFallback?: string;
31
+ subtitle?: string;
32
+ };
33
+
34
+ export type DropdownBookmarkOption = {
35
+ value: string;
36
+ label: string;
37
+ meta?: unknown;
38
+ className?: string;
39
+ };
40
+
41
+ export type DropdownBookmarkSection = {
42
+ label?: string;
43
+ options: DropdownBookmarkOption[];
44
+ };
45
+
46
+ type HeaderVariantProps = {
47
+ variant: "header";
48
+ menus: HeaderMenuItem[];
49
+ menuOpen: string | null;
50
+ onOpenMenu: (id: string) => void;
51
+ onCloseMenu: () => void;
52
+ };
53
+
54
+ type UserVariantProps = {
55
+ variant: "user";
56
+ name: string;
57
+ avatarUrl?: string | null;
58
+ avatarUrlFallback?: string | null;
59
+ avatarAlt?: string;
60
+ avatarFallback?: string;
61
+ items: DropdownUserItem[];
62
+ open?: boolean;
63
+ onOpenChange?: (open: boolean) => void;
64
+ };
65
+
66
+ type UserListVariantProps = {
67
+ variant: "user-list";
68
+ name: string;
69
+ avatarUrl?: string | null;
70
+ avatarUrlFallback?: string | null;
71
+ avatarAlt?: string;
72
+ avatarFallback?: string;
73
+ items: DropdownUserListItem[];
74
+ open?: boolean;
75
+ onOpenChange?: (open: boolean) => void;
76
+ emptyLabel?: string;
77
+ emptyClassName?: string;
78
+ };
79
+
80
+ type BookmarkVariantProps = {
81
+ variant: "bookmark";
82
+ label?: string;
83
+ layout?: "row" | "field";
84
+ value: string;
85
+ triggerLabel?: string;
86
+ placeholder?: string;
87
+ sections: DropdownBookmarkSection[];
88
+ onChange: (value: string, option?: DropdownBookmarkOption) => void;
89
+ renderTriggerIcon?: ReactNode;
90
+ renderItemIcon?: (option: DropdownBookmarkOption) => ReactNode;
91
+ caret?: ReactNode;
92
+ emptyLabel?: string;
93
+ emptyClassName?: string;
94
+ };
95
+
96
+ type DropdownProps =
97
+ | HeaderVariantProps
98
+ | UserVariantProps
99
+ | UserListVariantProps
100
+ | BookmarkVariantProps;
101
+
102
+ const DefaultChevron = ({ open }: { open: boolean }) => (
103
+ <span className={`chevron ${open ? "open" : ""}`} aria-hidden="true">
104
+ <svg viewBox="0 0 24 24">
105
+ <path
106
+ d="M6 9l6 6 6-6"
107
+ fill="none"
108
+ stroke="currentColor"
109
+ strokeWidth="1.6"
110
+ strokeLinecap="round"
111
+ strokeLinejoin="round"
112
+ />
113
+ </svg>
114
+ </span>
115
+ );
116
+
117
+ const DefaultCaret = () => (
118
+ <svg viewBox="0 0 24 24" aria-hidden="true">
119
+ <path
120
+ d="M6 9l6 6 6-6"
121
+ fill="none"
122
+ stroke="currentColor"
123
+ strokeWidth="1.6"
124
+ strokeLinecap="round"
125
+ strokeLinejoin="round"
126
+ />
127
+ </svg>
128
+ );
129
+
130
+ const IconEdit = () => (
131
+ <svg viewBox="0 0 24 24" aria-hidden="true">
132
+ <path
133
+ d="M4 20l4.5-1 9.4-9.4a1.8 1.8 0 0 0 0-2.6l-1-1a1.8 1.8 0 0 0-2.6 0L4.9 15.4 4 20z"
134
+ fill="none"
135
+ stroke="currentColor"
136
+ strokeWidth="1.6"
137
+ strokeLinecap="round"
138
+ strokeLinejoin="round"
139
+ />
140
+ <path
141
+ d="M13.5 6.5l4 4"
142
+ fill="none"
143
+ stroke="currentColor"
144
+ strokeWidth="1.6"
145
+ strokeLinecap="round"
146
+ strokeLinejoin="round"
147
+ />
148
+ </svg>
149
+ );
150
+
151
+ const IconTrash = () => (
152
+ <svg viewBox="0 0 24 24" aria-hidden="true">
153
+ <path
154
+ d="M4 7h16"
155
+ fill="none"
156
+ stroke="currentColor"
157
+ strokeWidth="1.6"
158
+ strokeLinecap="round"
159
+ strokeLinejoin="round"
160
+ />
161
+ <path
162
+ d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"
163
+ fill="none"
164
+ stroke="currentColor"
165
+ strokeWidth="1.6"
166
+ strokeLinecap="round"
167
+ strokeLinejoin="round"
168
+ />
169
+ <path
170
+ d="M7 7l1 12a1 1 0 0 0 1 .9h6a1 1 0 0 0 1-.9l1-12"
171
+ fill="none"
172
+ stroke="currentColor"
173
+ strokeWidth="1.6"
174
+ strokeLinecap="round"
175
+ strokeLinejoin="round"
176
+ />
177
+ </svg>
178
+ );
179
+
180
+ export const Dropdown = (props: DropdownProps) => {
181
+ const closeTimerRef = useRef<number | null>(null);
182
+ const closeDelayMs = 160;
183
+
184
+ useEffect(() => {
185
+ return () => {
186
+ if (closeTimerRef.current !== null) {
187
+ window.clearTimeout(closeTimerRef.current);
188
+ }
189
+ };
190
+ }, []);
191
+
192
+ const cancelScheduledClose = () => {
193
+ if (closeTimerRef.current !== null) {
194
+ window.clearTimeout(closeTimerRef.current);
195
+ closeTimerRef.current = null;
196
+ }
197
+ };
198
+
199
+ if (props.variant === "header") {
200
+ const scheduleClose = () => {
201
+ cancelScheduledClose();
202
+ closeTimerRef.current = window.setTimeout(() => {
203
+ props.onCloseMenu();
204
+ }, closeDelayMs);
205
+ };
206
+
207
+ return (
208
+ <div className="ef-menu-bar">
209
+ {props.menus.map((menu) => (
210
+ <div
211
+ key={menu.id}
212
+ className="ef-menu-group"
213
+ data-open={props.menuOpen === menu.id ? "true" : "false"}
214
+ onMouseEnter={() => {
215
+ cancelScheduledClose();
216
+ props.onOpenMenu(menu.id);
217
+ }}
218
+ onMouseLeave={scheduleClose}
219
+ >
220
+ <button
221
+ className="ef-menu-button"
222
+ type="button"
223
+ data-open={props.menuOpen === menu.id ? "true" : "false"}
224
+ >
225
+ {menu.label}
226
+ </button>
227
+ {props.menuOpen === menu.id ? (
228
+ <div
229
+ className="ef-menu-popover"
230
+ data-open="true"
231
+ onMouseEnter={() => {
232
+ cancelScheduledClose();
233
+ props.onOpenMenu(menu.id);
234
+ }}
235
+ onMouseLeave={scheduleClose}
236
+ >
237
+ {menu.content}
238
+ </div>
239
+ ) : null}
240
+ </div>
241
+ ))}
242
+ </div>
243
+ );
244
+ }
245
+
246
+ if (props.variant === "user" || props.variant === "user-list") {
247
+ const [internalOpen, setInternalOpen] = useState(false);
248
+ const [avatarState, setAvatarState] = useState<"primary" | "fallback" | "none">("primary");
249
+ const ref = useRef<HTMLDivElement | null>(null);
250
+ const isOpen = props.open ?? internalOpen;
251
+
252
+ useEffect(() => {
253
+ setAvatarState("primary");
254
+ }, [props.avatarUrl, props.avatarUrlFallback]);
255
+
256
+ useEffect(() => {
257
+ if (!isOpen) return;
258
+ const handlePointer = (event: PointerEvent) => {
259
+ if (!ref.current) return;
260
+ if (ref.current.contains(event.target as Node)) return;
261
+ if (props.open === undefined) {
262
+ setInternalOpen(false);
263
+ }
264
+ props.onOpenChange?.(false);
265
+ };
266
+ window.addEventListener("pointerdown", handlePointer);
267
+ return () => window.removeEventListener("pointerdown", handlePointer);
268
+ }, [isOpen, props.open, props.onOpenChange]);
269
+
270
+ const setOpen = (next: boolean) => {
271
+ if (props.open === undefined) {
272
+ setInternalOpen(next);
273
+ }
274
+ props.onOpenChange?.(next);
275
+ };
276
+
277
+ const fallback = props.avatarFallback ?? props.name.slice(0, 1).toUpperCase();
278
+ const primaryAvatar = props.avatarUrl ?? null;
279
+ const fallbackAvatar = props.avatarUrlFallback ?? null;
280
+ const currentAvatar =
281
+ avatarState === "primary"
282
+ ? primaryAvatar
283
+ : avatarState === "fallback"
284
+ ? fallbackAvatar
285
+ : null;
286
+
287
+ const renderUserListItems = () => {
288
+ if (props.variant !== "user-list") {
289
+ return props.items.map((item, index) => {
290
+ const key = item.id ?? `${item.label}-${index}`;
291
+ if (item.variant === "theme-preview") {
292
+ return (
293
+ <Button
294
+ key={key}
295
+ type="button"
296
+ variant="primary"
297
+ className={["theme-preview", item.className].filter(Boolean).join(" ")}
298
+ onClick={() => {
299
+ item.onClick();
300
+ setOpen(false);
301
+ }}
302
+ disabled={item.disabled}
303
+ title={item.title}
304
+ >
305
+ {item.label}
306
+ </Button>
307
+ );
308
+ }
309
+ return (
310
+ <button
311
+ key={key}
312
+ className={["dropdown-item", item.className].filter(Boolean).join(" ")}
313
+ type="button"
314
+ onClick={() => {
315
+ item.onClick();
316
+ setOpen(false);
317
+ }}
318
+ disabled={item.disabled}
319
+ title={item.title}
320
+ >
321
+ {item.label}
322
+ </button>
323
+ );
324
+ });
325
+ }
326
+
327
+ if (!props.items.length) {
328
+ return (
329
+ <div className={["dropdown-empty", props.emptyClassName].filter(Boolean).join(" ")}>
330
+ {props.emptyLabel ?? "No items."}
331
+ </div>
332
+ );
333
+ }
334
+
335
+ return props.items.map((item, index) => {
336
+ const fallback = item.avatarFallback ?? item.label.slice(0, 1).toUpperCase();
337
+ const isDisabled = !!item.disabled;
338
+ return (
339
+ <div
340
+ key={item.id ?? `${item.label}-${index}`}
341
+ className={[
342
+ "dropdown-item",
343
+ "dropdown-item-rich",
344
+ item.className,
345
+ isDisabled ? "is-disabled" : "",
346
+ ]
347
+ .filter(Boolean)
348
+ .join(" ")}
349
+ role="button"
350
+ tabIndex={isDisabled ? -1 : 0}
351
+ aria-disabled={isDisabled ? "true" : undefined}
352
+ onClick={() => {
353
+ if (isDisabled) return;
354
+ item.onClick();
355
+ setOpen(false);
356
+ }}
357
+ onKeyDown={(event) => {
358
+ if (isDisabled) return;
359
+ if (event.key === "Enter" || event.key === " ") {
360
+ event.preventDefault();
361
+ item.onClick();
362
+ setOpen(false);
363
+ }
364
+ }}
365
+ title={item.title}
366
+ >
367
+ <span className="dropdown-avatar">
368
+ {item.avatarUrl ? (
369
+ <img
370
+ src={item.avatarUrl}
371
+ alt={item.label}
372
+ loading="lazy"
373
+ decoding="async"
374
+ referrerPolicy="no-referrer"
375
+ crossOrigin="anonymous"
376
+ />
377
+ ) : (
378
+ <span className="dropdown-avatar-fallback">{fallback}</span>
379
+ )}
380
+ </span>
381
+ <span className="dropdown-item-text">
382
+ <span className="dropdown-item-label">{item.label}</span>
383
+ {item.subtitle ? (
384
+ <span className="dropdown-item-subtitle">{item.subtitle}</span>
385
+ ) : null}
386
+ </span>
387
+ {item.onEdit || item.onDelete ? (
388
+ <span className="dropdown-item-actions">
389
+ {item.onEdit ? (
390
+ <Button
391
+ type="button"
392
+ variant="ghost"
393
+ className="dropdown-action"
394
+ aria-label={`Edit ${item.label}`}
395
+ onClick={(event) => {
396
+ event.stopPropagation();
397
+ item.onEdit?.();
398
+ setOpen(false);
399
+ }}
400
+ >
401
+ <IconEdit />
402
+ </Button>
403
+ ) : null}
404
+ {item.onDelete ? (
405
+ <Button
406
+ type="button"
407
+ variant="delete"
408
+ className="dropdown-action is-delete"
409
+ aria-label={`Delete ${item.label}`}
410
+ onClick={(event) => {
411
+ event.stopPropagation();
412
+ item.onDelete?.();
413
+ }}
414
+ >
415
+ <IconTrash />
416
+ </Button>
417
+ ) : null}
418
+ </span>
419
+ ) : null}
420
+ </div>
421
+ );
422
+ });
423
+ };
424
+
425
+ return (
426
+ <div className="user-section" ref={ref} data-open={isOpen ? "true" : "false"}>
427
+ <button className="user-button" onClick={() => setOpen(!isOpen)} type="button">
428
+ <span className="avatar">
429
+ {currentAvatar ? (
430
+ <img
431
+ src={currentAvatar}
432
+ alt={props.avatarAlt ?? props.name}
433
+ loading="eager"
434
+ decoding="async"
435
+ referrerPolicy="no-referrer"
436
+ crossOrigin="anonymous"
437
+ onError={() => {
438
+ if (avatarState === "primary" && fallbackAvatar) {
439
+ setAvatarState("fallback");
440
+ } else {
441
+ setAvatarState("none");
442
+ }
443
+ }}
444
+ />
445
+ ) : (
446
+ <span className="avatar-fallback">{fallback}</span>
447
+ )}
448
+ </span>
449
+ <span className="user-name">{props.name}</span>
450
+ <DefaultChevron open={isOpen} />
451
+ </button>
452
+ <div className="dropdown" data-open={isOpen ? "true" : "false"}>
453
+ {renderUserListItems()}
454
+ </div>
455
+ </div>
456
+ );
457
+ }
458
+
459
+ const {
460
+ label,
461
+ layout = "row",
462
+ value,
463
+ placeholder = "Select a saved connection",
464
+ sections,
465
+ onChange,
466
+ renderTriggerIcon,
467
+ renderItemIcon,
468
+ triggerLabel,
469
+ caret,
470
+ emptyLabel = "No saved connections.",
471
+ emptyClassName,
472
+ } = props;
473
+ const [open, setOpen] = useState(false);
474
+ const ref = useRef<HTMLDivElement | null>(null);
475
+ const options = useMemo(
476
+ () => sections.flatMap((section) => section.options),
477
+ [sections]
478
+ );
479
+ const active = options.find((item) => item.value === value) ?? null;
480
+
481
+ useEffect(() => {
482
+ if (!open) return;
483
+ const handlePointer = (event: PointerEvent) => {
484
+ if (!ref.current) return;
485
+ if (ref.current.contains(event.target as Node)) return;
486
+ setOpen(false);
487
+ };
488
+ window.addEventListener("pointerdown", handlePointer);
489
+ return () => window.removeEventListener("pointerdown", handlePointer);
490
+ }, [open]);
491
+
492
+ const handleEllipsisTooltip = (event: React.MouseEvent<HTMLElement>) => {
493
+ const target = event.currentTarget;
494
+ if (target.scrollWidth > target.clientWidth) {
495
+ target.setAttribute("title", target.textContent ?? "");
496
+ } else {
497
+ target.removeAttribute("title");
498
+ }
499
+ };
500
+
501
+ const clearEllipsisTooltip = (event: React.MouseEvent<HTMLElement>) => {
502
+ event.currentTarget.removeAttribute("title");
503
+ };
504
+
505
+ const dropdownBody = (
506
+ <div className={`bookmark-dropdown ${open ? "open" : ""}`}>
507
+ <button
508
+ className="bookmark-trigger"
509
+ onClick={() => setOpen((prev) => !prev)}
510
+ type="button"
511
+ >
512
+ {renderTriggerIcon ? <span className="bookmark-icon">{renderTriggerIcon}</span> : null}
513
+ <span className="bookmark-text">
514
+ {triggerLabel ?? (active ? active.label : placeholder)}
515
+ </span>
516
+ <span className="bookmark-caret">{caret ?? <DefaultCaret />}</span>
517
+ </button>
518
+ {open ? (
519
+ <div className="bookmark-menu">
520
+ {options.length === 0 ? (
521
+ <div className={["bookmark-empty", emptyClassName].filter(Boolean).join(" ")}>
522
+ {emptyLabel}
523
+ </div>
524
+ ) : (
525
+ sections.map((section) => (
526
+ <div key={section.label ?? "options"}>
527
+ {section.label ? <div className="bookmark-group">{section.label}</div> : null}
528
+ {section.options.map((item) => (
529
+ <button
530
+ key={item.value}
531
+ className={[
532
+ "bookmark-item",
533
+ item.value === value ? "active" : "",
534
+ item.className,
535
+ ]
536
+ .filter(Boolean)
537
+ .join(" ")}
538
+ type="button"
539
+ onClick={() => {
540
+ onChange(item.value, item);
541
+ setOpen(false);
542
+ }}
543
+ >
544
+ {renderItemIcon ? (
545
+ <span className="bookmark-icon">{renderItemIcon(item)}</span>
546
+ ) : null}
547
+ <span
548
+ className="bookmark-text"
549
+ onMouseEnter={handleEllipsisTooltip}
550
+ onMouseLeave={clearEllipsisTooltip}
551
+ >
552
+ {item.label}
553
+ </span>
554
+ </button>
555
+ ))}
556
+ </div>
557
+ ))
558
+ )}
559
+ </div>
560
+ ) : null}
561
+ </div>
562
+ );
563
+
564
+ return (
565
+ <div className={layout === "row" ? "bookmark-row" : "bookmark-field"} ref={ref}>
566
+ {layout === "row" && label ? <div className="bookmark-label">{label}</div> : null}
567
+ {dropdownBody}
568
+ </div>
569
+ );
570
+ };
@@ -0,0 +1,49 @@
1
+ .ef-floating-footer {
2
+ display: flex;
3
+ align-items: center;
4
+ justify-content: space-between;
5
+ gap: 14px;
6
+ width: min(860px, calc(100vw - 24px));
7
+ position: fixed;
8
+ left: 50%;
9
+ transform: translateX(-50%);
10
+ bottom: max(12px, env(safe-area-inset-bottom));
11
+ z-index: 120;
12
+ backdrop-filter: blur(12px);
13
+ font-family: var(--font-main, "Inter", "Segoe UI", Arial, sans-serif);
14
+ }
15
+
16
+ .ef-footer-copy {
17
+ display: grid;
18
+ gap: 2px;
19
+ }
20
+
21
+ .ef-footer-title {
22
+ font-weight: 700;
23
+ color: var(--text-strong);
24
+ letter-spacing: 0.03em;
25
+ }
26
+
27
+ .ef-footer-subtitle {
28
+ color: var(--text-muted);
29
+ font-size: 0.82rem;
30
+ }
31
+
32
+ .ef-footer-actions {
33
+ display: flex;
34
+ align-items: center;
35
+ gap: 10px;
36
+ }
37
+
38
+ @media (max-width: 760px) {
39
+ .ef-floating-footer {
40
+ flex-direction: column;
41
+ align-items: stretch;
42
+ width: calc(100vw - 14px);
43
+ bottom: max(8px, env(safe-area-inset-bottom));
44
+ }
45
+
46
+ .ef-footer-actions {
47
+ justify-content: flex-end;
48
+ }
49
+ }
@@ -0,0 +1,27 @@
1
+ import type { ReactNode } from "react";
2
+ import { Panel } from "./Panel";
3
+
4
+ type FloatingFooterProps = {
5
+ title?: string;
6
+ subtitle?: string;
7
+ actions?: ReactNode;
8
+ className?: string;
9
+ };
10
+
11
+ export const FloatingFooter = ({
12
+ title = "Quick Actions",
13
+ subtitle,
14
+ actions,
15
+ className,
16
+ }: FloatingFooterProps) => {
17
+ const classes = ["ef-floating-footer", className].filter(Boolean).join(" ");
18
+ return (
19
+ <Panel variant="full" borderWidth={2} className={classes}>
20
+ <div className="ef-footer-copy">
21
+ <div className="ef-footer-title">{title}</div>
22
+ {subtitle ? <div className="ef-footer-subtitle">{subtitle}</div> : null}
23
+ </div>
24
+ <div className="ef-footer-actions">{actions}</div>
25
+ </Panel>
26
+ );
27
+ };
@@ -0,0 +1,29 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ type FormFieldProps = {
4
+ label: string;
5
+ htmlFor?: string;
6
+ helper?: string;
7
+ error?: string;
8
+ required?: boolean;
9
+ children: ReactNode;
10
+ };
11
+
12
+ export const FormField = ({
13
+ label,
14
+ htmlFor,
15
+ helper,
16
+ error,
17
+ required,
18
+ children,
19
+ }: FormFieldProps) => (
20
+ <div className={["ef-field", error ? "has-error" : ""].filter(Boolean).join(" ")}>
21
+ <label className="ef-field-label" htmlFor={htmlFor}>
22
+ <span>{label}</span>
23
+ {required ? <span className="ef-field-required">*</span> : null}
24
+ </label>
25
+ {children}
26
+ {helper ? <div className="ef-field-helper">{helper}</div> : null}
27
+ {error ? <div className="ef-field-error">{error}</div> : null}
28
+ </div>
29
+ );