@justin_evo/evo-ui 1.0.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.
Files changed (110) hide show
  1. package/dist/Alert/Alert.d.ts +11 -0
  2. package/dist/AutoComplete/AutoComplete.d.ts +95 -0
  3. package/dist/Badge/Badge.d.ts +23 -0
  4. package/dist/Breadcrumb/Breadcrumb.d.ts +16 -0
  5. package/dist/Button/Button.d.ts +54 -0
  6. package/dist/Card/Card.d.ts +60 -0
  7. package/dist/Checkbox/Checkbox.d.ts +16 -0
  8. package/dist/CommandPalette/CommandPalette.d.ts +17 -0
  9. package/dist/Container/Container.d.ts +10 -0
  10. package/dist/Divider/Divider.d.ts +7 -0
  11. package/dist/Form/Form.d.ts +61 -0
  12. package/dist/Grid/Grid.d.ts +23 -0
  13. package/dist/ImageCropper/ImageCropper.d.ts +111 -0
  14. package/dist/Input/Input.d.ts +12 -0
  15. package/dist/Modal/Modal.d.ts +26 -0
  16. package/dist/Nav/Nav.d.ts +63 -0
  17. package/dist/Notification/Notification.d.ts +186 -0
  18. package/dist/Pagination/Pagination.d.ts +10 -0
  19. package/dist/Radio/Radio.d.ts +20 -0
  20. package/dist/RichTextArea/RichTextArea.d.ts +70 -0
  21. package/dist/Select/Select.d.ts +44 -0
  22. package/dist/Skeleton/Skeleton.d.ts +23 -0
  23. package/dist/Stack/Stack.d.ts +16 -0
  24. package/dist/Table/Table.d.ts +77 -0
  25. package/dist/Tabs/Tabs.d.ts +28 -0
  26. package/dist/Theme/ThemeProvider.d.ts +96 -0
  27. package/dist/Theme/ThemeToggle.d.ts +22 -0
  28. package/dist/Toggle/Toggle.d.ts +11 -0
  29. package/dist/Tooltip/Tooltip.d.ts +10 -0
  30. package/dist/TopNav/TopNav.d.ts +76 -0
  31. package/dist/TreeSelect/TreeSelect.d.ts +50 -0
  32. package/dist/declarations.d.ts +6 -0
  33. package/dist/evo-ui.css +1 -0
  34. package/dist/index.cjs.js +1 -0
  35. package/dist/index.d.ts +31 -0
  36. package/dist/index.es.js +5688 -0
  37. package/package.json +52 -0
  38. package/src/Alert/Alert.tsx +49 -0
  39. package/src/AutoComplete/AutoComplete.tsx +810 -0
  40. package/src/Badge/Badge.tsx +53 -0
  41. package/src/Breadcrumb/Breadcrumb.tsx +53 -0
  42. package/src/Button/Button.tsx +125 -0
  43. package/src/Card/Card.tsx +257 -0
  44. package/src/Checkbox/Checkbox.tsx +59 -0
  45. package/src/CommandPalette/CommandPalette.tsx +185 -0
  46. package/src/Container/Container.tsx +31 -0
  47. package/src/Divider/Divider.tsx +31 -0
  48. package/src/Form/Form.tsx +185 -0
  49. package/src/Grid/Grid.tsx +66 -0
  50. package/src/ImageCropper/ImageCropper.tsx +911 -0
  51. package/src/Input/Input.tsx +74 -0
  52. package/src/Modal/Modal.tsx +77 -0
  53. package/src/Nav/Nav.tsx +626 -0
  54. package/src/Notification/Notification.tsx +1503 -0
  55. package/src/Pagination/Pagination.tsx +76 -0
  56. package/src/Radio/Radio.tsx +69 -0
  57. package/src/RichTextArea/RichTextArea.tsx +869 -0
  58. package/src/Select/Select.tsx +515 -0
  59. package/src/Skeleton/Skeleton.tsx +70 -0
  60. package/src/Stack/Stack.tsx +52 -0
  61. package/src/Table/Table.tsx +335 -0
  62. package/src/Tabs/Tabs.tsx +90 -0
  63. package/src/Theme/ThemeProvider.tsx +253 -0
  64. package/src/Theme/ThemeToggle.tsx +79 -0
  65. package/src/Toggle/Toggle.tsx +48 -0
  66. package/src/Tooltip/Tooltip.tsx +38 -0
  67. package/src/TopNav/TopNav.tsx +994 -0
  68. package/src/TreeSelect/TreeSelect.tsx +825 -0
  69. package/src/css/alert.module.scss +93 -0
  70. package/src/css/autocomplete.module.scss +416 -0
  71. package/src/css/badge.module.scss +82 -0
  72. package/src/css/base/_color.scss +159 -0
  73. package/src/css/base/_theme.scss +237 -0
  74. package/src/css/base/_variables.scss +161 -0
  75. package/src/css/breadcrumb.module.scss +50 -0
  76. package/src/css/button.module.scss +385 -0
  77. package/src/css/card.module.scss +217 -0
  78. package/src/css/checkbox.module.scss +120 -0
  79. package/src/css/commandpalette.module.scss +211 -0
  80. package/src/css/container.module.scss +18 -0
  81. package/src/css/divider.module.scss +41 -0
  82. package/src/css/form.module.scss +245 -0
  83. package/src/css/imagecropper.module.scss +397 -0
  84. package/src/css/input.module.scss +89 -0
  85. package/src/css/modal.module.scss +105 -0
  86. package/src/css/nav.module.scss +339 -0
  87. package/src/css/notification.module.scss +691 -0
  88. package/src/css/pagination.module.scss +63 -0
  89. package/src/css/radio.module.scss +89 -0
  90. package/src/css/richtextarea.module.scss +307 -0
  91. package/src/css/select.module.scss +525 -0
  92. package/src/css/skeleton.module.scss +30 -0
  93. package/src/css/table.module.scss +386 -0
  94. package/src/css/tabs.module.scss +63 -0
  95. package/src/css/theme-toggle.module.scss +83 -0
  96. package/src/css/toggle.module.scss +54 -0
  97. package/src/css/tooltip.module.scss +97 -0
  98. package/src/css/topnav.module.scss +396 -0
  99. package/src/css/treeselect.module.scss +558 -0
  100. package/src/css/utilities/_borders.scss +111 -0
  101. package/src/css/utilities/_colors.scss +66 -0
  102. package/src/css/utilities/_effects.scss +216 -0
  103. package/src/css/utilities/_layout.scss +181 -0
  104. package/src/css/utilities/_position.scss +75 -0
  105. package/src/css/utilities/_sizing.scss +138 -0
  106. package/src/css/utilities/_spacing.scss +99 -0
  107. package/src/css/utilities/_typography.scss +121 -0
  108. package/src/css/utilities/index.scss +24 -0
  109. package/src/declarations.d.ts +6 -0
  110. package/src/index.ts +60 -0
@@ -0,0 +1,335 @@
1
+ 'use client';
2
+
3
+ import React, { useMemo, useState } from 'react';
4
+ import styles from '../css/table.module.scss';
5
+
6
+ export type TableSize = 'sm' | 'md' | 'lg';
7
+ export type TableAlign = 'left' | 'center' | 'right';
8
+ export type TableSortDirection = 'asc' | 'desc';
9
+ export type TableResponsive = 'scroll' | 'stack';
10
+
11
+ export interface TableSortState {
12
+ key: string;
13
+ direction: TableSortDirection;
14
+ }
15
+
16
+ export interface TableColumn<T> {
17
+ /** Key in the data object. Supports dot notation for nested paths ('role.name'). */
18
+ key: string;
19
+ /** Display label or node for the column header. */
20
+ header: React.ReactNode;
21
+ /** Optional fixed width, e.g. '120px' or '10%'. */
22
+ width?: string;
23
+ /** Cell alignment. Defaults to 'left'. */
24
+ align?: TableAlign;
25
+ /** Enable click-to-sort on this column. */
26
+ sortable?: boolean;
27
+ /** Custom comparator. Falls back to a numeric-aware string compare. */
28
+ sortFn?: (a: T, b: T, direction: TableSortDirection) => number;
29
+ /** Custom cell renderer. Defaults to string coercion. */
30
+ render?: (value: unknown, row: T, rowIndex: number) => React.ReactNode;
31
+ /** Extra class for cells in this column. */
32
+ cellClassName?: string;
33
+ /** Extra class for the header cell. */
34
+ headerClassName?: string;
35
+ }
36
+
37
+ export interface EvoTableProps<T extends Record<string, unknown>> {
38
+ /** Column definitions. */
39
+ columns: TableColumn<T>[];
40
+ /** Row data array. */
41
+ data: T[];
42
+ /** Density variant — controls cell padding and font size. Default 'md'. */
43
+ size?: TableSize;
44
+ /** Alternating row backgrounds. */
45
+ striped?: boolean;
46
+ /** Highlight rows on hover. Default true. */
47
+ hoverable?: boolean;
48
+ /** Add vertical dividers between columns. Default false. */
49
+ bordered?: boolean;
50
+ /** Keep the header pinned while the body scrolls. */
51
+ stickyHeader?: boolean;
52
+ /** Show skeleton rows in place of data. */
53
+ loading?: boolean;
54
+ /** Skeleton row count when loading. Default 5. */
55
+ loadingRows?: number;
56
+ /** Stable key for each row. Pass a property name or a getter. */
57
+ rowKey?: keyof T | ((row: T, index: number) => React.Key);
58
+ /** Custom empty-state slot. Takes priority over `emptyText`. */
59
+ emptyState?: React.ReactNode;
60
+ /** Plain-text fallback when data is empty. */
61
+ emptyText?: string;
62
+ /** Fires on single row click. */
63
+ onRowClick?: (row: T, index: number) => void;
64
+ /** Fires on row double-click. */
65
+ onRowDoubleClick?: (row: T, index: number) => void;
66
+ /** Add custom classes to specific rows. */
67
+ getRowClassName?: (row: T, index: number) => string | undefined;
68
+ /**
69
+ * Small-viewport behaviour.
70
+ * - `scroll` (default): horizontal scroll with a thin scrollbar.
71
+ * - `stack`: under ~640px each row becomes a labelled card.
72
+ */
73
+ responsive?: TableResponsive;
74
+ /** Optional caption rendered above the table. */
75
+ caption?: React.ReactNode;
76
+ /** Controlled sort state. Pair with `onSortChange`. */
77
+ sort?: TableSortState | null;
78
+ /** Sort change callback. */
79
+ onSortChange?: (next: TableSortState | null) => void;
80
+ /** Initial sort for uncontrolled mode. */
81
+ defaultSort?: TableSortState;
82
+ className?: string;
83
+ }
84
+
85
+ function getNestedValue(obj: unknown, path: string): unknown {
86
+ return path.split('.').reduce<unknown>((acc, key) => {
87
+ if (acc !== null && acc !== undefined && typeof acc === 'object') {
88
+ return (acc as Record<string, unknown>)[key];
89
+ }
90
+ return undefined;
91
+ }, obj);
92
+ }
93
+
94
+ function defaultCompare(a: unknown, b: unknown): number {
95
+ if (a == null && b == null) return 0;
96
+ if (a == null) return -1;
97
+ if (b == null) return 1;
98
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
99
+ return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
100
+ }
101
+
102
+ function resolveRowKey<T>(
103
+ row: T,
104
+ index: number,
105
+ rowKey?: keyof T | ((row: T, i: number) => React.Key),
106
+ ): React.Key {
107
+ if (typeof rowKey === 'function') return rowKey(row, index);
108
+ if (rowKey && row && typeof row === 'object') {
109
+ const value = (row as Record<string, unknown>)[rowKey as string];
110
+ if (typeof value === 'string' || typeof value === 'number') return value;
111
+ }
112
+ return index;
113
+ }
114
+
115
+ const SortIcon = ({ direction }: { direction?: TableSortDirection }) => (
116
+ <svg
117
+ className={styles.sortIcon}
118
+ width="8"
119
+ height="12"
120
+ viewBox="0 0 8 12"
121
+ aria-hidden="true"
122
+ focusable="false"
123
+ >
124
+ <path
125
+ d="M4 0 L8 4 H0 Z"
126
+ fill="currentColor"
127
+ opacity={direction === 'asc' ? 1 : 0.3}
128
+ />
129
+ <path
130
+ d="M4 12 L0 8 H8 Z"
131
+ fill="currentColor"
132
+ opacity={direction === 'desc' ? 1 : 0.3}
133
+ />
134
+ </svg>
135
+ );
136
+
137
+ export const EvoTable = <T extends Record<string, unknown>>({
138
+ columns,
139
+ data,
140
+ size = 'md',
141
+ striped = false,
142
+ hoverable = true,
143
+ bordered = false,
144
+ stickyHeader = false,
145
+ loading = false,
146
+ loadingRows = 5,
147
+ rowKey,
148
+ emptyState,
149
+ emptyText = 'No data available.',
150
+ onRowClick,
151
+ onRowDoubleClick,
152
+ getRowClassName,
153
+ responsive = 'scroll',
154
+ caption,
155
+ sort,
156
+ onSortChange,
157
+ defaultSort,
158
+ className,
159
+ }: EvoTableProps<T>) => {
160
+ const isControlled = sort !== undefined;
161
+ const [internalSort, setInternalSort] = useState<TableSortState | null>(defaultSort ?? null);
162
+ const activeSort = isControlled ? sort : internalSort;
163
+
164
+ const handleSort = (key: string) => {
165
+ let next: TableSortState | null;
166
+ if (!activeSort || activeSort.key !== key) {
167
+ next = { key, direction: 'asc' };
168
+ } else if (activeSort.direction === 'asc') {
169
+ next = { key, direction: 'desc' };
170
+ } else {
171
+ next = null;
172
+ }
173
+ if (!isControlled) setInternalSort(next);
174
+ onSortChange?.(next);
175
+ };
176
+
177
+ const sortedData = useMemo(() => {
178
+ if (!activeSort) return data;
179
+ const col = columns.find((c) => c.key === activeSort.key);
180
+ if (!col) return data;
181
+ const direction = activeSort.direction;
182
+ const next = [...data];
183
+ if (col.sortFn) {
184
+ next.sort((a, b) => col.sortFn!(a, b, direction));
185
+ } else {
186
+ next.sort((a, b) => {
187
+ const av = getNestedValue(a, col.key);
188
+ const bv = getNestedValue(b, col.key);
189
+ const cmp = defaultCompare(av, bv);
190
+ return direction === 'asc' ? cmp : -cmp;
191
+ });
192
+ }
193
+ return next;
194
+ }, [data, columns, activeSort]);
195
+
196
+ const isClickable = Boolean(onRowClick || onRowDoubleClick);
197
+
198
+ const wrapperClass = [
199
+ styles.wrapper,
200
+ styles[`size-${size}`],
201
+ striped && styles.striped,
202
+ hoverable && styles.hoverable,
203
+ bordered && styles.bordered,
204
+ stickyHeader && styles.stickyHeader,
205
+ responsive === 'stack' && styles.responsiveStack,
206
+ isClickable && styles.clickable,
207
+ className,
208
+ ]
209
+ .filter(Boolean)
210
+ .join(' ');
211
+
212
+ const renderHeader = (col: TableColumn<T>) => {
213
+ const align = col.align ?? 'left';
214
+ const isSorted = activeSort?.key === col.key;
215
+ const direction = isSorted ? activeSort!.direction : undefined;
216
+ const ariaSort: React.AriaAttributes['aria-sort'] = isSorted
217
+ ? direction === 'asc'
218
+ ? 'ascending'
219
+ : 'descending'
220
+ : col.sortable
221
+ ? 'none'
222
+ : undefined;
223
+
224
+ const thClass = [styles.th, styles[`align-${align}`], col.headerClassName]
225
+ .filter(Boolean)
226
+ .join(' ');
227
+
228
+ return (
229
+ <th
230
+ key={col.key}
231
+ scope="col"
232
+ className={thClass}
233
+ style={col.width ? { width: col.width } : undefined}
234
+ aria-sort={ariaSort}
235
+ >
236
+ {col.sortable ? (
237
+ <button
238
+ type="button"
239
+ className={[styles.sortBtn, isSorted ? styles.sortActive : '']
240
+ .filter(Boolean)
241
+ .join(' ')}
242
+ onClick={() => handleSort(col.key)}
243
+ >
244
+ <span>{col.header}</span>
245
+ <SortIcon direction={direction} />
246
+ </button>
247
+ ) : (
248
+ col.header
249
+ )}
250
+ </th>
251
+ );
252
+ };
253
+
254
+ const renderBody = () => {
255
+ if (loading) {
256
+ return Array.from({ length: Math.max(1, loadingRows) }).map((_, i) => (
257
+ <tr key={`evo-skeleton-${i}`} className={styles.tr}>
258
+ {columns.map((col) => (
259
+ <td
260
+ key={col.key}
261
+ className={[styles.td, styles[`align-${col.align ?? 'left'}`]]
262
+ .filter(Boolean)
263
+ .join(' ')}
264
+ data-label={typeof col.header === 'string' ? col.header : col.key}
265
+ >
266
+ <span className={styles.skeleton} />
267
+ </td>
268
+ ))}
269
+ </tr>
270
+ ));
271
+ }
272
+
273
+ if (sortedData.length === 0) {
274
+ return (
275
+ <tr className={styles.emptyRow}>
276
+ <td colSpan={columns.length} className={styles.empty}>
277
+ {emptyState ?? <span className={styles.emptyText}>{emptyText}</span>}
278
+ </td>
279
+ </tr>
280
+ );
281
+ }
282
+
283
+ return sortedData.map((row, rowIndex) => {
284
+ const trClass = [styles.tr, getRowClassName?.(row, rowIndex)]
285
+ .filter(Boolean)
286
+ .join(' ');
287
+
288
+ return (
289
+ <tr
290
+ key={resolveRowKey(row, rowIndex, rowKey)}
291
+ className={trClass}
292
+ onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}
293
+ onDoubleClick={
294
+ onRowDoubleClick ? () => onRowDoubleClick(row, rowIndex) : undefined
295
+ }
296
+ >
297
+ {columns.map((col) => {
298
+ const value = getNestedValue(row, col.key);
299
+ const align = col.align ?? 'left';
300
+ const tdClass = [styles.td, styles[`align-${align}`], col.cellClassName]
301
+ .filter(Boolean)
302
+ .join(' ');
303
+ return (
304
+ <td
305
+ key={col.key}
306
+ className={tdClass}
307
+ data-label={typeof col.header === 'string' ? col.header : col.key}
308
+ >
309
+ {col.render
310
+ ? col.render(value, row, rowIndex)
311
+ : value == null
312
+ ? ''
313
+ : String(value)}
314
+ </td>
315
+ );
316
+ })}
317
+ </tr>
318
+ );
319
+ });
320
+ };
321
+
322
+ return (
323
+ <div className={wrapperClass}>
324
+ <div className={styles.scroll}>
325
+ <table className={styles.table}>
326
+ {caption && <caption className={styles.caption}>{caption}</caption>}
327
+ <thead className={styles.thead}>
328
+ <tr>{columns.map(renderHeader)}</tr>
329
+ </thead>
330
+ <tbody className={styles.tbody}>{renderBody()}</tbody>
331
+ </table>
332
+ </div>
333
+ </div>
334
+ );
335
+ };
@@ -0,0 +1,90 @@
1
+ import React, { createContext, useContext, useState } from 'react';
2
+ import styles from '../css/tabs.module.scss';
3
+
4
+ interface TabsContextType {
5
+ active: string;
6
+ setActive: (id: string) => void;
7
+ }
8
+
9
+ const TabsContext = createContext<TabsContextType | null>(null);
10
+
11
+ const useTabsCtx = () => {
12
+ const ctx = useContext(TabsContext);
13
+ if (!ctx) throw new Error('Tab components must be used within EvoTabs');
14
+ return ctx;
15
+ };
16
+
17
+ interface EvoTabsProps {
18
+ children: React.ReactNode;
19
+ defaultTab?: string;
20
+ className?: string;
21
+ }
22
+
23
+ interface EvoTabsListProps {
24
+ children: React.ReactNode;
25
+ className?: string;
26
+ }
27
+
28
+ interface EvoTabProps {
29
+ id: string;
30
+ children: React.ReactNode;
31
+ disabled?: boolean;
32
+ className?: string;
33
+ }
34
+
35
+ interface EvoTabsPanelProps {
36
+ id: string;
37
+ children: React.ReactNode;
38
+ className?: string;
39
+ }
40
+
41
+ const EvoTabsList = ({ children, className = '' }: EvoTabsListProps) => (
42
+ <div className={`${styles.tabList} ${className}`} role="tablist">
43
+ {children}
44
+ </div>
45
+ );
46
+
47
+ const EvoTab = ({ id, children, disabled = false, className = '' }: EvoTabProps) => {
48
+ const { active, setActive } = useTabsCtx();
49
+ return (
50
+ <button
51
+ role="tab"
52
+ aria-selected={active === id}
53
+ disabled={disabled}
54
+ className={[
55
+ styles.tab,
56
+ active === id ? styles.active : '',
57
+ disabled ? styles.disabled : '',
58
+ className,
59
+ ]
60
+ .filter(Boolean)
61
+ .join(' ')}
62
+ onClick={() => setActive(id)}
63
+ >
64
+ {children}
65
+ </button>
66
+ );
67
+ };
68
+
69
+ const EvoTabsPanel = ({ id, children, className = '' }: EvoTabsPanelProps) => {
70
+ const { active } = useTabsCtx();
71
+ if (active !== id) return null;
72
+ return (
73
+ <div role="tabpanel" className={`${styles.tabPanel} ${className}`}>
74
+ {children}
75
+ </div>
76
+ );
77
+ };
78
+
79
+ export const EvoTabs = ({ children, defaultTab = '', className = '' }: EvoTabsProps) => {
80
+ const [active, setActive] = useState(defaultTab);
81
+ return (
82
+ <TabsContext.Provider value={{ active, setActive }}>
83
+ <div className={`${styles.tabs} ${className}`}>{children}</div>
84
+ </TabsContext.Provider>
85
+ );
86
+ };
87
+
88
+ EvoTabs.List = EvoTabsList;
89
+ EvoTabs.Tab = EvoTab;
90
+ EvoTabs.Panel = EvoTabsPanel;
@@ -0,0 +1,253 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type ReactNode,
10
+ } from 'react';
11
+
12
+ /**
13
+ * The three theme modes EvoUI supports.
14
+ * - `'light'` / `'dark'` — force the theme regardless of OS preference.
15
+ * - `'system'` — follow the user's OS-level color-scheme preference.
16
+ */
17
+ export type EvoTheme = 'light' | 'dark' | 'system';
18
+
19
+ /**
20
+ * The actually-applied theme after resolving `'system'` against
21
+ * `window.matchMedia('(prefers-color-scheme: dark)')`. Always either
22
+ * `'light'` or `'dark'` — never `'system'`.
23
+ */
24
+ export type EvoResolvedTheme = 'light' | 'dark';
25
+
26
+ export interface EvoThemeContextValue {
27
+ /** The user-selected mode (may be `'system'`). */
28
+ theme: EvoTheme;
29
+ /** The mode that is actually painted right now (`'light'` or `'dark'`). */
30
+ resolvedTheme: EvoResolvedTheme;
31
+ /** Switch to a specific mode. */
32
+ setTheme: (theme: EvoTheme) => void;
33
+ /** Convenience: flip between light and dark (treats `'system'` as its resolved value). */
34
+ toggleTheme: () => void;
35
+ }
36
+
37
+ const ThemeContext = createContext<EvoThemeContextValue | null>(null);
38
+
39
+ export interface EvoThemeProviderProps {
40
+ /** Subtree to provide the theme to. */
41
+ children: ReactNode;
42
+ /**
43
+ * Initial theme used before any persisted value is read.
44
+ * @default 'system'
45
+ */
46
+ defaultTheme?: EvoTheme;
47
+ /**
48
+ * localStorage key used to persist the user's choice across reloads.
49
+ * Pass `null` to disable persistence entirely.
50
+ * @default 'evo-ui-theme'
51
+ */
52
+ storageKey?: string | null;
53
+ /**
54
+ * HTML attribute written to the document root. Most apps want
55
+ * `'data-theme'`; pass `'class'` to instead toggle `light` / `dark`
56
+ * as className (useful if you're sharing tokens with Tailwind).
57
+ * @default 'data-theme'
58
+ */
59
+ attribute?: 'data-theme' | 'class';
60
+ /**
61
+ * Animate color transitions when the theme flips.
62
+ * Automatically disabled for users with `prefers-reduced-motion`.
63
+ * @default true
64
+ */
65
+ enableTransitions?: boolean;
66
+ /**
67
+ * Element to apply the theme attribute to.
68
+ * @default document.documentElement
69
+ */
70
+ target?: HTMLElement;
71
+ }
72
+
73
+ const STORAGE_KEY_DEFAULT = 'evo-ui-theme';
74
+
75
+ function getSystemTheme(): EvoResolvedTheme {
76
+ if (typeof window === 'undefined' || !window.matchMedia) return 'light';
77
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
78
+ }
79
+
80
+ function readStoredTheme(key: string | null): EvoTheme | null {
81
+ if (!key || typeof window === 'undefined') return null;
82
+ try {
83
+ const value = window.localStorage.getItem(key);
84
+ if (value === 'light' || value === 'dark' || value === 'system') return value;
85
+ } catch {
86
+ // localStorage can throw in private mode / sandboxed iframes.
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Provides EvoUI theming context to descendant components.
93
+ *
94
+ * @example
95
+ * ```tsx
96
+ * import { EvoThemeProvider } from '@justin_evo/evo-ui';
97
+ *
98
+ * <EvoThemeProvider defaultTheme="system">
99
+ * <App />
100
+ * </EvoThemeProvider>
101
+ * ```
102
+ */
103
+ export const EvoThemeProvider = ({
104
+ children,
105
+ defaultTheme = 'system',
106
+ storageKey = STORAGE_KEY_DEFAULT,
107
+ attribute = 'data-theme',
108
+ enableTransitions = true,
109
+ target,
110
+ }: EvoThemeProviderProps) => {
111
+ const [theme, setThemeState] = useState<EvoTheme>(() => {
112
+ return readStoredTheme(storageKey) ?? defaultTheme;
113
+ });
114
+
115
+ const [resolvedTheme, setResolvedTheme] = useState<EvoResolvedTheme>(() => {
116
+ const initial = readStoredTheme(storageKey) ?? defaultTheme;
117
+ return initial === 'system' ? getSystemTheme() : initial;
118
+ });
119
+
120
+ const isFirstApply = useRef(true);
121
+
122
+ const applyToDOM = useCallback(
123
+ (resolved: EvoResolvedTheme) => {
124
+ if (typeof document === 'undefined') return;
125
+ const el = target ?? document.documentElement;
126
+
127
+ // Enable transitions only AFTER the first paint, so the page
128
+ // doesn't fade in from the wrong colors on initial load.
129
+ if (enableTransitions && !isFirstApply.current) {
130
+ el.setAttribute('data-theme-transition', 'true');
131
+ window.clearTimeout((el as any).__evoThemeTimer);
132
+ (el as any).__evoThemeTimer = window.setTimeout(() => {
133
+ el.removeAttribute('data-theme-transition');
134
+ }, 250);
135
+ }
136
+
137
+ if (attribute === 'class') {
138
+ el.classList.remove('light', 'dark');
139
+ el.classList.add(resolved);
140
+ // Always set data-theme too so our CSS variables resolve.
141
+ el.setAttribute('data-theme', resolved);
142
+ } else {
143
+ el.setAttribute('data-theme', resolved);
144
+ }
145
+
146
+ isFirstApply.current = false;
147
+ },
148
+ [attribute, enableTransitions, target],
149
+ );
150
+
151
+ // Apply theme to DOM whenever resolvedTheme changes.
152
+ useEffect(() => {
153
+ applyToDOM(resolvedTheme);
154
+ }, [resolvedTheme, applyToDOM]);
155
+
156
+ // Recompute resolvedTheme when theme changes.
157
+ useEffect(() => {
158
+ setResolvedTheme(theme === 'system' ? getSystemTheme() : theme);
159
+ }, [theme]);
160
+
161
+ // When the user is on 'system', listen for OS-level changes.
162
+ useEffect(() => {
163
+ if (theme !== 'system' || typeof window === 'undefined' || !window.matchMedia) return;
164
+ const mql = window.matchMedia('(prefers-color-scheme: dark)');
165
+ const handler = (e: MediaQueryListEvent) => {
166
+ setResolvedTheme(e.matches ? 'dark' : 'light');
167
+ };
168
+ mql.addEventListener('change', handler);
169
+ return () => mql.removeEventListener('change', handler);
170
+ }, [theme]);
171
+
172
+ const setTheme = useCallback(
173
+ (next: EvoTheme) => {
174
+ setThemeState(next);
175
+ if (storageKey && typeof window !== 'undefined') {
176
+ try {
177
+ window.localStorage.setItem(storageKey, next);
178
+ } catch {
179
+ // Storage might be unavailable — fail silently.
180
+ }
181
+ }
182
+ },
183
+ [storageKey],
184
+ );
185
+
186
+ const toggleTheme = useCallback(() => {
187
+ setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
188
+ }, [resolvedTheme, setTheme]);
189
+
190
+ const value = useMemo<EvoThemeContextValue>(
191
+ () => ({ theme, resolvedTheme, setTheme, toggleTheme }),
192
+ [theme, resolvedTheme, setTheme, toggleTheme],
193
+ );
194
+
195
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
196
+ };
197
+
198
+ /**
199
+ * Read and update the EvoUI theme.
200
+ *
201
+ * Must be called from a descendant of `<EvoThemeProvider>`. If used
202
+ * outside the provider, returns a no-op object with `resolvedTheme`
203
+ * set to whatever is currently on `document.documentElement`.
204
+ *
205
+ * @example
206
+ * ```tsx
207
+ * const { resolvedTheme, setTheme, toggleTheme } = useEvoTheme();
208
+ * <button onClick={toggleTheme}>
209
+ * {resolvedTheme === 'dark' ? '☀️' : '🌙'}
210
+ * </button>
211
+ * ```
212
+ */
213
+ export const useEvoTheme = (): EvoThemeContextValue => {
214
+ const ctx = useContext(ThemeContext);
215
+ if (ctx) return ctx;
216
+
217
+ // Graceful fallback if hook is called without a provider —
218
+ // common in standalone widgets where the host app sets theme manually.
219
+ const fallbackResolved: EvoResolvedTheme =
220
+ typeof document !== 'undefined' &&
221
+ document.documentElement.getAttribute('data-theme') === 'dark'
222
+ ? 'dark'
223
+ : 'light';
224
+
225
+ return {
226
+ theme: fallbackResolved,
227
+ resolvedTheme: fallbackResolved,
228
+ setTheme: () => {
229
+ if (typeof document !== 'undefined') {
230
+ // eslint-disable-next-line no-console
231
+ console.warn(
232
+ '[evo-ui] useEvoTheme called without <EvoThemeProvider>. ' +
233
+ 'Wrap your app in <EvoThemeProvider> to enable setTheme().',
234
+ );
235
+ }
236
+ },
237
+ toggleTheme: () => {},
238
+ };
239
+ };
240
+
241
+ /**
242
+ * Inline script that applies the persisted theme before React hydrates,
243
+ * preventing the white-flash on first paint in dark mode. Drop into your
244
+ * `<head>` (or Next.js `<Script strategy="beforeInteractive">`):
245
+ *
246
+ * @example
247
+ * ```html
248
+ * <script dangerouslySetInnerHTML={{ __html: getEvoThemeScript() }} />
249
+ * ```
250
+ */
251
+ export const getEvoThemeScript = (storageKey: string = STORAGE_KEY_DEFAULT): string => {
252
+ return `(function(){try{var s=localStorage.getItem('${storageKey}');var t=s||'system';var r=t==='system'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):t;document.documentElement.setAttribute('data-theme',r);}catch(e){}})();`;
253
+ };