@fragments-sdk/ui 0.2.3 → 0.4.0

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 (133) hide show
  1. package/fragments.json +1 -1
  2. package/package.json +9 -4
  3. package/src/components/Accordion/Accordion.fragment.tsx +186 -0
  4. package/src/components/Accordion/Accordion.module.scss +111 -0
  5. package/src/components/Accordion/index.tsx +271 -0
  6. package/src/components/Alert/Alert.fragment.tsx +66 -41
  7. package/src/components/Alert/Alert.module.scss +31 -21
  8. package/src/components/Alert/index.tsx +202 -73
  9. package/src/components/AppShell/AppShell.fragment.tsx +315 -0
  10. package/src/components/AppShell/AppShell.module.scss +213 -0
  11. package/src/components/AppShell/index.tsx +398 -0
  12. package/src/components/Avatar/index.tsx +8 -9
  13. package/src/components/Badge/Badge.module.scss +16 -10
  14. package/src/components/Badge/index.tsx +20 -6
  15. package/src/components/Box/Box.fragment.tsx +168 -0
  16. package/src/components/Box/Box.module.scss +84 -0
  17. package/src/components/Box/index.tsx +78 -0
  18. package/src/components/Button/Button.module.scss +42 -0
  19. package/src/components/Button/index.tsx +67 -33
  20. package/src/components/ButtonGroup/ButtonGroup.module.scss +37 -0
  21. package/src/components/ButtonGroup/index.tsx +40 -0
  22. package/src/components/Card/Card.fragment.tsx +51 -25
  23. package/src/components/Card/Card.module.scss +52 -5
  24. package/src/components/Card/index.tsx +154 -53
  25. package/src/components/Checkbox/Checkbox.module.scss +4 -4
  26. package/src/components/Checkbox/index.tsx +3 -4
  27. package/src/components/CodeBlock/CodeBlock.fragment.tsx +201 -0
  28. package/src/components/CodeBlock/CodeBlock.module.scss +224 -0
  29. package/src/components/CodeBlock/index.tsx +385 -0
  30. package/src/components/ColorChip/ColorChip.module.scss +165 -0
  31. package/src/components/ColorChip/index.tsx +157 -0
  32. package/src/components/ColorPicker/ColorPicker.module.scss +109 -0
  33. package/src/components/ColorPicker/index.tsx +107 -0
  34. package/src/components/Dialog/Dialog.fragment.tsx +9 -0
  35. package/src/components/Dialog/Dialog.module.scss +26 -7
  36. package/src/components/Dialog/index.tsx +12 -15
  37. package/src/components/EmptyState/EmptyState.fragment.tsx +54 -71
  38. package/src/components/EmptyState/EmptyState.module.scss +9 -9
  39. package/src/components/EmptyState/index.tsx +104 -69
  40. package/src/components/Field/Field.fragment.tsx +165 -0
  41. package/src/components/Field/Field.module.scss +31 -0
  42. package/src/components/Field/index.tsx +143 -0
  43. package/src/components/Fieldset/Fieldset.fragment.tsx +166 -0
  44. package/src/components/Fieldset/Fieldset.module.scss +22 -0
  45. package/src/components/Fieldset/index.tsx +47 -0
  46. package/src/components/Form/Form.fragment.tsx +286 -0
  47. package/src/components/Form/Form.module.scss +8 -0
  48. package/src/components/Form/index.tsx +53 -0
  49. package/src/components/Grid/Grid.fragment.tsx +17 -17
  50. package/src/components/Grid/index.tsx +6 -1
  51. package/src/components/Header/Header.fragment.tsx +192 -0
  52. package/src/components/Header/Header.module.scss +209 -0
  53. package/src/components/Header/index.tsx +363 -0
  54. package/src/components/Icon/Icon.fragment.tsx +138 -0
  55. package/src/components/Icon/Icon.module.scss +38 -0
  56. package/src/components/Icon/index.tsx +58 -0
  57. package/src/components/Image/Image.fragment.tsx +195 -0
  58. package/src/components/Image/Image.module.scss +77 -0
  59. package/src/components/Image/index.tsx +95 -0
  60. package/src/components/Input/Input.module.scss +75 -2
  61. package/src/components/Input/index.tsx +60 -21
  62. package/src/components/Link/Link.fragment.tsx +132 -0
  63. package/src/components/Link/Link.module.scss +67 -0
  64. package/src/components/Link/index.tsx +57 -0
  65. package/src/components/List/List.fragment.tsx +152 -0
  66. package/src/components/List/List.module.scss +71 -0
  67. package/src/components/List/index.tsx +106 -0
  68. package/src/components/Listbox/Listbox.fragment.tsx +191 -0
  69. package/src/components/Listbox/Listbox.module.scss +97 -0
  70. package/src/components/Listbox/index.tsx +121 -0
  71. package/src/components/Menu/Menu.fragment.tsx +9 -0
  72. package/src/components/Menu/Menu.module.scss +17 -1
  73. package/src/components/Menu/index.tsx +3 -3
  74. package/src/components/Popover/Popover.fragment.tsx +9 -0
  75. package/src/components/Popover/Popover.module.scss +33 -10
  76. package/src/components/Popover/index.tsx +9 -11
  77. package/src/components/Progress/Progress.module.scss +11 -11
  78. package/src/components/Progress/index.tsx +34 -7
  79. package/src/components/Prompt/Prompt.fragment.tsx +231 -0
  80. package/src/components/Prompt/Prompt.module.scss +243 -0
  81. package/src/components/Prompt/index.tsx +439 -0
  82. package/src/components/RadioGroup/RadioGroup.module.scss +3 -3
  83. package/src/components/RadioGroup/index.tsx +3 -4
  84. package/src/components/Select/Select.fragment.tsx +9 -0
  85. package/src/components/Select/index.tsx +6 -7
  86. package/src/components/Separator/index.tsx +7 -3
  87. package/src/components/Sidebar/Sidebar.fragment.tsx +783 -0
  88. package/src/components/Sidebar/Sidebar.module.scss +586 -0
  89. package/src/components/Sidebar/index.tsx +1013 -0
  90. package/src/components/Skeleton/Skeleton.fragment.tsx +5 -5
  91. package/src/components/Skeleton/Skeleton.module.scss +11 -0
  92. package/src/components/Slider/Slider.module.scss +87 -0
  93. package/src/components/Slider/index.tsx +88 -0
  94. package/src/components/Stack/Stack.module.scss +120 -0
  95. package/src/components/Stack/index.tsx +148 -0
  96. package/src/components/Table/Table.fragment.tsx +7 -0
  97. package/src/components/Table/Table.module.scss +57 -0
  98. package/src/components/Table/index.tsx +44 -6
  99. package/src/components/Tabs/Tabs.fragment.tsx +9 -0
  100. package/src/components/Tabs/Tabs.module.scss +25 -10
  101. package/src/components/Tabs/index.tsx +11 -8
  102. package/src/components/Text/Text.module.scss +82 -0
  103. package/src/components/Text/index.tsx +58 -0
  104. package/src/components/Textarea/index.tsx +3 -7
  105. package/src/components/Theme/Theme.fragment.tsx +128 -0
  106. package/src/components/Theme/ThemeToggle.module.scss +82 -0
  107. package/src/components/Theme/index.tsx +343 -0
  108. package/src/components/Toast/Toast.fragment.tsx +5 -5
  109. package/src/components/Toast/Toast.module.scss +16 -1
  110. package/src/components/Toast/index.tsx +27 -11
  111. package/src/components/Toggle/Toggle.module.scss +25 -10
  112. package/src/components/Toggle/index.tsx +12 -0
  113. package/src/components/ToggleGroup/ToggleGroup.module.scss +134 -0
  114. package/src/components/ToggleGroup/index.tsx +144 -0
  115. package/src/components/Tooltip/Tooltip.module.scss +4 -4
  116. package/src/components/Tooltip/index.tsx +4 -2
  117. package/src/components/VisuallyHidden/VisuallyHidden.fragment.tsx +134 -0
  118. package/src/components/VisuallyHidden/VisuallyHidden.module.scss +13 -0
  119. package/src/components/VisuallyHidden/index.tsx +29 -0
  120. package/src/index.ts +241 -3
  121. package/src/recipes/AppShell.recipe.ts +175 -0
  122. package/src/recipes/CardGrid.recipe.ts +6 -2
  123. package/src/recipes/ChatInterface.recipe.ts +87 -0
  124. package/src/recipes/CodeExamples.recipe.ts +66 -0
  125. package/src/recipes/DashboardLayout.recipe.ts +46 -12
  126. package/src/recipes/DashboardNav.recipe.ts +183 -0
  127. package/src/recipes/LoginForm.recipe.ts +8 -1
  128. package/src/recipes/SettingsPage.recipe.ts +37 -20
  129. package/src/styles/globals.scss +31 -0
  130. package/src/tokens/_index.scss +3 -0
  131. package/src/tokens/_mixins.scss +54 -1
  132. package/src/tokens/_variables.scss +429 -64
  133. package/src/utils/a11y.tsx +439 -0
@@ -0,0 +1,439 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ // ============================================
6
+ // Unique ID Generator
7
+ // ============================================
8
+
9
+ let idCounter = 0;
10
+
11
+ /**
12
+ * Generates a unique ID for ARIA relationships (labelledby, describedby, etc.)
13
+ * Falls back to React.useId if available (React 18+), otherwise uses a counter.
14
+ *
15
+ * @param prefix - Optional prefix for the ID
16
+ * @returns A unique ID string
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * const id = useId('dialog-title');
21
+ * // Returns: "dialog-title-1" or ":r0:" (React 18)
22
+ * ```
23
+ */
24
+ export function useId(prefix?: string): string {
25
+ // Use React 18's useId if available
26
+ const reactId = React.useId?.();
27
+
28
+ const [id] = React.useState(() => {
29
+ if (reactId) return reactId;
30
+ return `${prefix ? `${prefix}-` : 'fui-'}${++idCounter}`;
31
+ });
32
+
33
+ return id;
34
+ }
35
+
36
+ // ============================================
37
+ // Screen Reader Announcements
38
+ // ============================================
39
+
40
+ /**
41
+ * Hook to announce messages to screen readers via a live region.
42
+ * Creates an ARIA live region that persists for the component lifetime.
43
+ *
44
+ * @returns An object with an announce function
45
+ *
46
+ * @example
47
+ * ```tsx
48
+ * const { announce } = useAnnounce();
49
+ *
50
+ * // Polite announcement (waits for screen reader to finish)
51
+ * announce('Item added to cart');
52
+ *
53
+ * // Assertive announcement (interrupts immediately)
54
+ * announce('Error: Please fix the form', 'assertive');
55
+ * ```
56
+ */
57
+ export function useAnnounce(): {
58
+ announce: (message: string, priority?: 'polite' | 'assertive') => void;
59
+ } {
60
+ const politeRef = React.useRef<HTMLDivElement | null>(null);
61
+ const assertiveRef = React.useRef<HTMLDivElement | null>(null);
62
+
63
+ React.useEffect(() => {
64
+ // Create live regions on mount
65
+ const polite = document.createElement('div');
66
+ polite.setAttribute('aria-live', 'polite');
67
+ polite.setAttribute('aria-atomic', 'true');
68
+ polite.setAttribute('role', 'status');
69
+ Object.assign(polite.style, {
70
+ position: 'absolute',
71
+ width: '1px',
72
+ height: '1px',
73
+ padding: '0',
74
+ margin: '-1px',
75
+ overflow: 'hidden',
76
+ clip: 'rect(0, 0, 0, 0)',
77
+ whiteSpace: 'nowrap',
78
+ border: '0',
79
+ });
80
+ document.body.appendChild(polite);
81
+ politeRef.current = polite;
82
+
83
+ const assertive = document.createElement('div');
84
+ assertive.setAttribute('aria-live', 'assertive');
85
+ assertive.setAttribute('aria-atomic', 'true');
86
+ assertive.setAttribute('role', 'alert');
87
+ Object.assign(assertive.style, {
88
+ position: 'absolute',
89
+ width: '1px',
90
+ height: '1px',
91
+ padding: '0',
92
+ margin: '-1px',
93
+ overflow: 'hidden',
94
+ clip: 'rect(0, 0, 0, 0)',
95
+ whiteSpace: 'nowrap',
96
+ border: '0',
97
+ });
98
+ document.body.appendChild(assertive);
99
+ assertiveRef.current = assertive;
100
+
101
+ // Cleanup on unmount
102
+ return () => {
103
+ polite.remove();
104
+ assertive.remove();
105
+ };
106
+ }, []);
107
+
108
+ const announce = React.useCallback(
109
+ (message: string, priority: 'polite' | 'assertive' = 'polite') => {
110
+ const region = priority === 'assertive' ? assertiveRef.current : politeRef.current;
111
+ if (region) {
112
+ // Clear and re-set to ensure announcement
113
+ region.textContent = '';
114
+ // Use requestAnimationFrame to ensure the clear is processed
115
+ requestAnimationFrame(() => {
116
+ region.textContent = message;
117
+ });
118
+ }
119
+ },
120
+ []
121
+ );
122
+
123
+ return { announce };
124
+ }
125
+
126
+ // ============================================
127
+ // User Preference Detection
128
+ // ============================================
129
+
130
+ /**
131
+ * Hook to detect if the user prefers reduced motion.
132
+ * Respects the `prefers-reduced-motion` media query.
133
+ *
134
+ * @returns boolean - true if user prefers reduced motion
135
+ *
136
+ * @example
137
+ * ```tsx
138
+ * const prefersReducedMotion = usePrefersReducedMotion();
139
+ *
140
+ * <div style={{
141
+ * transition: prefersReducedMotion ? 'none' : 'transform 200ms'
142
+ * }}>
143
+ * ```
144
+ */
145
+ export function usePrefersReducedMotion(): boolean {
146
+ const [prefersReducedMotion, setPrefersReducedMotion] = React.useState(() => {
147
+ // SSR-safe default
148
+ if (typeof window === 'undefined') return false;
149
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
150
+ });
151
+
152
+ React.useEffect(() => {
153
+ const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
154
+
155
+ const handleChange = (event: MediaQueryListEvent) => {
156
+ setPrefersReducedMotion(event.matches);
157
+ };
158
+
159
+ // Modern browsers
160
+ mediaQuery.addEventListener('change', handleChange);
161
+
162
+ // Set initial value
163
+ setPrefersReducedMotion(mediaQuery.matches);
164
+
165
+ return () => {
166
+ mediaQuery.removeEventListener('change', handleChange);
167
+ };
168
+ }, []);
169
+
170
+ return prefersReducedMotion;
171
+ }
172
+
173
+ /**
174
+ * Hook to detect if the user prefers increased contrast.
175
+ * Respects the `prefers-contrast: more` media query.
176
+ *
177
+ * @returns boolean - true if user prefers high contrast
178
+ *
179
+ * @example
180
+ * ```tsx
181
+ * const prefersContrast = usePrefersContrast();
182
+ *
183
+ * <div className={prefersContrast ? styles.highContrast : undefined}>
184
+ * ```
185
+ */
186
+ export function usePrefersContrast(): boolean {
187
+ const [prefersContrast, setPrefersContrast] = React.useState(() => {
188
+ // SSR-safe default
189
+ if (typeof window === 'undefined') return false;
190
+ return window.matchMedia('(prefers-contrast: more)').matches;
191
+ });
192
+
193
+ React.useEffect(() => {
194
+ const mediaQuery = window.matchMedia('(prefers-contrast: more)');
195
+
196
+ const handleChange = (event: MediaQueryListEvent) => {
197
+ setPrefersContrast(event.matches);
198
+ };
199
+
200
+ mediaQuery.addEventListener('change', handleChange);
201
+ setPrefersContrast(mediaQuery.matches);
202
+
203
+ return () => {
204
+ mediaQuery.removeEventListener('change', handleChange);
205
+ };
206
+ }, []);
207
+
208
+ return prefersContrast;
209
+ }
210
+
211
+ // ============================================
212
+ // Focus Management
213
+ // ============================================
214
+
215
+ /**
216
+ * Hook to trap focus within a container element.
217
+ * Useful for modals, dialogs, and other overlays.
218
+ *
219
+ * @param ref - React ref to the container element
220
+ * @param active - Whether focus trap is active
221
+ *
222
+ * @example
223
+ * ```tsx
224
+ * const dialogRef = useRef<HTMLDivElement>(null);
225
+ * useFocusTrap(dialogRef, isOpen);
226
+ *
227
+ * return <div ref={dialogRef}>...</div>;
228
+ * ```
229
+ */
230
+ export function useFocusTrap(
231
+ ref: React.RefObject<HTMLElement | null>,
232
+ active: boolean
233
+ ): void {
234
+ const previousActiveElement = React.useRef<Element | null>(null);
235
+
236
+ React.useEffect(() => {
237
+ if (!active || !ref.current) return;
238
+
239
+ const container = ref.current;
240
+ previousActiveElement.current = document.activeElement;
241
+
242
+ // Focus the first focusable element
243
+ const focusableElements = getFocusableElements(container);
244
+ if (focusableElements.length > 0) {
245
+ (focusableElements[0] as HTMLElement).focus();
246
+ }
247
+
248
+ const handleKeyDown = (event: KeyboardEvent) => {
249
+ if (event.key !== 'Tab') return;
250
+
251
+ const focusable = getFocusableElements(container);
252
+ if (focusable.length === 0) return;
253
+
254
+ const firstFocusable = focusable[0] as HTMLElement;
255
+ const lastFocusable = focusable[focusable.length - 1] as HTMLElement;
256
+
257
+ if (event.shiftKey) {
258
+ // Shift + Tab: move focus backward
259
+ if (document.activeElement === firstFocusable) {
260
+ event.preventDefault();
261
+ lastFocusable.focus();
262
+ }
263
+ } else {
264
+ // Tab: move focus forward
265
+ if (document.activeElement === lastFocusable) {
266
+ event.preventDefault();
267
+ firstFocusable.focus();
268
+ }
269
+ }
270
+ };
271
+
272
+ container.addEventListener('keydown', handleKeyDown);
273
+
274
+ return () => {
275
+ container.removeEventListener('keydown', handleKeyDown);
276
+
277
+ // Restore focus when trap is deactivated
278
+ if (previousActiveElement.current instanceof HTMLElement) {
279
+ previousActiveElement.current.focus();
280
+ }
281
+ };
282
+ }, [active, ref]);
283
+ }
284
+
285
+ /**
286
+ * Get all focusable elements within a container
287
+ */
288
+ function getFocusableElements(container: HTMLElement): NodeListOf<Element> {
289
+ return container.querySelectorAll(
290
+ 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
291
+ );
292
+ }
293
+
294
+ // ============================================
295
+ // Keyboard Navigation Helpers
296
+ // ============================================
297
+
298
+ /**
299
+ * Helper to handle keyboard navigation within a group of elements.
300
+ * Supports arrow keys, Home, End, and optional type-ahead.
301
+ *
302
+ * @param event - Keyboard event
303
+ * @param items - Array of focusable items
304
+ * @param currentIndex - Current focused item index
305
+ * @param options - Navigation options
306
+ * @returns New index or undefined if no navigation occurred
307
+ *
308
+ * @example
309
+ * ```tsx
310
+ * const handleKeyDown = (event: KeyboardEvent) => {
311
+ * const newIndex = handleArrowNavigation(event, menuItems, currentIndex, {
312
+ * orientation: 'vertical',
313
+ * loop: true,
314
+ * });
315
+ * if (newIndex !== undefined) {
316
+ * setCurrentIndex(newIndex);
317
+ * }
318
+ * };
319
+ * ```
320
+ */
321
+ export function handleArrowNavigation(
322
+ event: React.KeyboardEvent | KeyboardEvent,
323
+ items: readonly unknown[],
324
+ currentIndex: number,
325
+ options: {
326
+ orientation?: 'horizontal' | 'vertical' | 'both';
327
+ loop?: boolean;
328
+ } = {}
329
+ ): number | undefined {
330
+ const { orientation = 'vertical', loop = true } = options;
331
+ const length = items.length;
332
+
333
+ if (length === 0) return undefined;
334
+
335
+ let newIndex: number | undefined;
336
+
337
+ switch (event.key) {
338
+ case 'ArrowDown':
339
+ if (orientation === 'horizontal') return undefined;
340
+ event.preventDefault();
341
+ newIndex = currentIndex + 1;
342
+ if (newIndex >= length) {
343
+ newIndex = loop ? 0 : length - 1;
344
+ }
345
+ break;
346
+
347
+ case 'ArrowUp':
348
+ if (orientation === 'horizontal') return undefined;
349
+ event.preventDefault();
350
+ newIndex = currentIndex - 1;
351
+ if (newIndex < 0) {
352
+ newIndex = loop ? length - 1 : 0;
353
+ }
354
+ break;
355
+
356
+ case 'ArrowRight':
357
+ if (orientation === 'vertical') return undefined;
358
+ event.preventDefault();
359
+ newIndex = currentIndex + 1;
360
+ if (newIndex >= length) {
361
+ newIndex = loop ? 0 : length - 1;
362
+ }
363
+ break;
364
+
365
+ case 'ArrowLeft':
366
+ if (orientation === 'vertical') return undefined;
367
+ event.preventDefault();
368
+ newIndex = currentIndex - 1;
369
+ if (newIndex < 0) {
370
+ newIndex = loop ? length - 1 : 0;
371
+ }
372
+ break;
373
+
374
+ case 'Home':
375
+ event.preventDefault();
376
+ newIndex = 0;
377
+ break;
378
+
379
+ case 'End':
380
+ event.preventDefault();
381
+ newIndex = length - 1;
382
+ break;
383
+
384
+ default:
385
+ return undefined;
386
+ }
387
+
388
+ return newIndex;
389
+ }
390
+
391
+ // ============================================
392
+ // Screen Reader Only Component
393
+ // ============================================
394
+
395
+ export interface VisuallyHiddenProps {
396
+ children: React.ReactNode;
397
+ /** If true, becomes visible when focused (useful for skip links) */
398
+ focusable?: boolean;
399
+ }
400
+
401
+ /**
402
+ * Visually hides content while keeping it accessible to screen readers.
403
+ * Use for skip links, additional context, or off-screen labels.
404
+ *
405
+ * @example
406
+ * ```tsx
407
+ * // Hidden label for icon-only button
408
+ * <button>
409
+ * <Icon name="search" />
410
+ * <VisuallyHidden>Search</VisuallyHidden>
411
+ * </button>
412
+ *
413
+ * // Skip link that shows on focus
414
+ * <VisuallyHidden focusable>
415
+ * <a href="#main">Skip to main content</a>
416
+ * </VisuallyHidden>
417
+ * ```
418
+ */
419
+ export function VisuallyHidden({ children, focusable = false }: VisuallyHiddenProps): React.ReactElement {
420
+ const style: React.CSSProperties = focusable
421
+ ? {}
422
+ : {
423
+ position: 'absolute',
424
+ width: '1px',
425
+ height: '1px',
426
+ padding: '0',
427
+ margin: '-1px',
428
+ overflow: 'hidden',
429
+ clip: 'rect(0, 0, 0, 0)',
430
+ whiteSpace: 'nowrap',
431
+ border: '0',
432
+ };
433
+
434
+ return (
435
+ <span style={style} data-visually-hidden={!focusable || undefined}>
436
+ {children}
437
+ </span>
438
+ );
439
+ }