@imjp/writenex-astro 0.1.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 (141) hide show
  1. package/README.md +539 -0
  2. package/dist/chunk-5PM6EQE5.js +151 -0
  3. package/dist/chunk-5PM6EQE5.js.map +1 -0
  4. package/dist/chunk-7XU5X6CW.js +1331 -0
  5. package/dist/chunk-7XU5X6CW.js.map +1 -0
  6. package/dist/chunk-AAOQHQPU.js +574 -0
  7. package/dist/chunk-AAOQHQPU.js.map +1 -0
  8. package/dist/chunk-CF2XXJFF.js +1410 -0
  9. package/dist/chunk-CF2XXJFF.js.map +1 -0
  10. package/dist/chunk-CRPZUUDU.js +52 -0
  11. package/dist/chunk-CRPZUUDU.js.map +1 -0
  12. package/dist/chunk-CYLDJ3HZ.js +310 -0
  13. package/dist/chunk-CYLDJ3HZ.js.map +1 -0
  14. package/dist/chunk-KIKIPIFA.js +1 -0
  15. package/dist/chunk-KIKIPIFA.js.map +1 -0
  16. package/dist/chunk-XNTQTTJU.js +145 -0
  17. package/dist/chunk-XNTQTTJU.js.map +1 -0
  18. package/dist/client/index.css +2 -0
  19. package/dist/client/index.css.map +1 -0
  20. package/dist/client/index.js +375 -0
  21. package/dist/client/index.js.map +1 -0
  22. package/dist/client/styles.css +584 -0
  23. package/dist/client/variables.css +304 -0
  24. package/dist/config/index.d.ts +54 -0
  25. package/dist/config/index.js +38 -0
  26. package/dist/config/index.js.map +1 -0
  27. package/dist/config-BmEdBDo_.d.ts +220 -0
  28. package/dist/content-BWR52vD-.d.ts +64 -0
  29. package/dist/discovery/index.d.ts +310 -0
  30. package/dist/discovery/index.js +38 -0
  31. package/dist/discovery/index.js.map +1 -0
  32. package/dist/errors-C0iYiDTv.d.ts +107 -0
  33. package/dist/filesystem/index.d.ts +1292 -0
  34. package/dist/filesystem/index.js +203 -0
  35. package/dist/filesystem/index.js.map +1 -0
  36. package/dist/image-FP7w5ZIs.d.ts +47 -0
  37. package/dist/index.d.ts +64 -0
  38. package/dist/index.js +151 -0
  39. package/dist/index.js.map +1 -0
  40. package/dist/loader-55LWCXHA.js +12 -0
  41. package/dist/loader-55LWCXHA.js.map +1 -0
  42. package/dist/loader-CrdnaAWR.d.ts +327 -0
  43. package/dist/server/index.d.ts +357 -0
  44. package/dist/server/index.js +37 -0
  45. package/dist/server/index.js.map +1 -0
  46. package/package.json +94 -0
  47. package/src/client/App.tsx +900 -0
  48. package/src/client/components/ConfigPanel/ConfigPanel.css +553 -0
  49. package/src/client/components/ConfigPanel/ConfigPanel.tsx +396 -0
  50. package/src/client/components/ConfigPanel/index.ts +6 -0
  51. package/src/client/components/CreateContentModal/CreateContentModal.css +327 -0
  52. package/src/client/components/CreateContentModal/CreateContentModal.tsx +216 -0
  53. package/src/client/components/CreateContentModal/index.ts +7 -0
  54. package/src/client/components/Editor/Editor.css +885 -0
  55. package/src/client/components/Editor/Editor.tsx +484 -0
  56. package/src/client/components/Editor/ImageDialog.css +344 -0
  57. package/src/client/components/Editor/ImageDialog.tsx +367 -0
  58. package/src/client/components/Editor/LinkDialog.css +326 -0
  59. package/src/client/components/Editor/LinkDialog.tsx +332 -0
  60. package/src/client/components/Editor/index.ts +6 -0
  61. package/src/client/components/FrontmatterForm/FrontmatterForm.css +468 -0
  62. package/src/client/components/FrontmatterForm/FrontmatterForm.tsx +914 -0
  63. package/src/client/components/FrontmatterForm/index.ts +7 -0
  64. package/src/client/components/Header/Header.css +300 -0
  65. package/src/client/components/Header/Header.tsx +300 -0
  66. package/src/client/components/Header/index.ts +7 -0
  67. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.css +239 -0
  68. package/src/client/components/KeyboardShortcuts/KeyboardShortcuts.tsx +151 -0
  69. package/src/client/components/KeyboardShortcuts/index.ts +6 -0
  70. package/src/client/components/LazyEditor.tsx +75 -0
  71. package/src/client/components/LiveRegion/LiveRegion.css +19 -0
  72. package/src/client/components/LiveRegion/LiveRegion.tsx +60 -0
  73. package/src/client/components/LiveRegion/index.ts +7 -0
  74. package/src/client/components/SearchReplace/SearchReplacePanel.css +300 -0
  75. package/src/client/components/SearchReplace/SearchReplacePanel.tsx +332 -0
  76. package/src/client/components/SearchReplace/index.ts +7 -0
  77. package/src/client/components/SelectCollectionModal/SelectCollectionModal.css +308 -0
  78. package/src/client/components/SelectCollectionModal/SelectCollectionModal.tsx +223 -0
  79. package/src/client/components/SelectCollectionModal/index.ts +7 -0
  80. package/src/client/components/Sidebar/Sidebar.css +570 -0
  81. package/src/client/components/Sidebar/Sidebar.tsx +617 -0
  82. package/src/client/components/Sidebar/index.ts +7 -0
  83. package/src/client/components/SkipLink/SkipLink.css +51 -0
  84. package/src/client/components/SkipLink/SkipLink.tsx +67 -0
  85. package/src/client/components/SkipLink/index.ts +7 -0
  86. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.css +233 -0
  87. package/src/client/components/UnsavedChangesModal/UnsavedChangesModal.tsx +160 -0
  88. package/src/client/components/UnsavedChangesModal/index.ts +1 -0
  89. package/src/client/components/VersionHistory/DiffViewer.css +430 -0
  90. package/src/client/components/VersionHistory/DiffViewer.tsx +383 -0
  91. package/src/client/components/VersionHistory/VersionActions.css +318 -0
  92. package/src/client/components/VersionHistory/VersionActions.tsx +277 -0
  93. package/src/client/components/VersionHistory/VersionHistoryPanel.css +369 -0
  94. package/src/client/components/VersionHistory/VersionHistoryPanel.tsx +469 -0
  95. package/src/client/components/VersionHistory/index.ts +9 -0
  96. package/src/client/context/ApiContext.tsx +154 -0
  97. package/src/client/context/ThemeContext.tsx +172 -0
  98. package/src/client/hooks/useAnnounce.ts +201 -0
  99. package/src/client/hooks/useApi.ts +374 -0
  100. package/src/client/hooks/useArrowNavigation.ts +286 -0
  101. package/src/client/hooks/useAutosave.ts +241 -0
  102. package/src/client/hooks/useFocusTrap.ts +178 -0
  103. package/src/client/hooks/useKeyboardShortcuts.ts +203 -0
  104. package/src/client/hooks/useSearch.ts +206 -0
  105. package/src/client/hooks/useVersionHistory.ts +451 -0
  106. package/src/client/index.tsx +70 -0
  107. package/src/client/styles.css +584 -0
  108. package/src/client/utils/focus.ts +57 -0
  109. package/src/client/utils/openInEditor.ts +130 -0
  110. package/src/client/variables.css +304 -0
  111. package/src/config/defaults.ts +109 -0
  112. package/src/config/index.ts +32 -0
  113. package/src/config/loader.ts +174 -0
  114. package/src/config/schema.ts +161 -0
  115. package/src/core/constants.ts +39 -0
  116. package/src/core/errors.ts +739 -0
  117. package/src/core/index.ts +11 -0
  118. package/src/discovery/collections.ts +216 -0
  119. package/src/discovery/index.ts +33 -0
  120. package/src/discovery/patterns.ts +702 -0
  121. package/src/discovery/schema.ts +453 -0
  122. package/src/filesystem/images.ts +798 -0
  123. package/src/filesystem/index.ts +107 -0
  124. package/src/filesystem/reader.ts +452 -0
  125. package/src/filesystem/version-config.ts +390 -0
  126. package/src/filesystem/versions.ts +1339 -0
  127. package/src/filesystem/watcher.ts +226 -0
  128. package/src/filesystem/writer.ts +540 -0
  129. package/src/index.ts +61 -0
  130. package/src/integration.ts +228 -0
  131. package/src/server/assets.ts +254 -0
  132. package/src/server/cache.ts +355 -0
  133. package/src/server/index.ts +33 -0
  134. package/src/server/middleware.ts +209 -0
  135. package/src/server/routes.ts +1428 -0
  136. package/src/types/api.ts +61 -0
  137. package/src/types/config.ts +134 -0
  138. package/src/types/content.ts +64 -0
  139. package/src/types/image.ts +48 -0
  140. package/src/types/index.ts +58 -0
  141. package/src/types/version.ts +117 -0
@@ -0,0 +1,374 @@
1
+ /**
2
+ * @fileoverview API hooks for Writenex client
3
+ *
4
+ * Custom React hooks for interacting with the Writenex API.
5
+ * These hooks use the shared API client from ApiContext when available,
6
+ * falling back to creating a new client for standalone usage.
7
+ *
8
+ * @module @writenex/astro/client/hooks/useApi
9
+ */
10
+
11
+ import { useState, useCallback, useMemo } from "react";
12
+
13
+ /**
14
+ * Collection data from API
15
+ */
16
+ export interface Collection {
17
+ name: string;
18
+ path: string;
19
+ filePattern: string;
20
+ count: number;
21
+ schema?: Record<string, unknown>;
22
+ previewUrl?: string;
23
+ }
24
+
25
+ /**
26
+ * Content summary for listing
27
+ */
28
+ export interface ContentSummary {
29
+ id: string;
30
+ path: string;
31
+ title: string;
32
+ pubDate?: string;
33
+ draft?: boolean;
34
+ excerpt?: string;
35
+ }
36
+
37
+ /**
38
+ * Full content item
39
+ */
40
+ export interface ContentItem {
41
+ id: string;
42
+ path: string;
43
+ frontmatter: Record<string, unknown>;
44
+ body: string;
45
+ raw: string;
46
+ }
47
+
48
+ /**
49
+ * API client configuration
50
+ */
51
+ interface ApiConfig {
52
+ apiBase: string;
53
+ }
54
+
55
+ /**
56
+ * Image configuration from API
57
+ */
58
+ export interface ImageConfig {
59
+ strategy: "colocated" | "public" | "custom";
60
+ publicPath?: string;
61
+ storagePath?: string;
62
+ }
63
+
64
+ /**
65
+ * Editor configuration from API
66
+ */
67
+ export interface EditorConfig {
68
+ autosave?: boolean;
69
+ autosaveInterval?: number;
70
+ }
71
+
72
+ /**
73
+ * Writenex configuration from API
74
+ */
75
+ export interface WritenexClientConfig {
76
+ images?: ImageConfig;
77
+ editor?: EditorConfig;
78
+ /** Astro's trailingSlash setting for preview URLs */
79
+ trailingSlash?: "always" | "never" | "ignore";
80
+ }
81
+
82
+ /**
83
+ * Config path response from API
84
+ */
85
+ export interface ConfigPathResponse {
86
+ configPath: string | null;
87
+ projectRoot: string;
88
+ hasConfigFile: boolean;
89
+ }
90
+
91
+ /**
92
+ * Create API client functions
93
+ */
94
+ export function createApiClient(config: ApiConfig) {
95
+ const { apiBase } = config;
96
+
97
+ // Extract basePath from apiBase (remove /api suffix)
98
+ const basePath = apiBase.replace(/\/api$/, "");
99
+
100
+ return {
101
+ /** Base path for the Writenex editor (without /api) */
102
+ basePath,
103
+ /**
104
+ * Fetch configuration
105
+ */
106
+ async getConfig(): Promise<WritenexClientConfig> {
107
+ const response = await fetch(`${apiBase}/config`);
108
+ if (!response.ok) {
109
+ throw new Error("Failed to fetch config");
110
+ }
111
+ return response.json();
112
+ },
113
+
114
+ /**
115
+ * Fetch config file path for opening in editor
116
+ */
117
+ async getConfigPath(): Promise<ConfigPathResponse> {
118
+ const response = await fetch(`${apiBase}/config/path`);
119
+ if (!response.ok) {
120
+ throw new Error("Failed to fetch config path");
121
+ }
122
+ return response.json();
123
+ },
124
+
125
+ /**
126
+ * Fetch all collections
127
+ */
128
+ async getCollections(): Promise<Collection[]> {
129
+ const response = await fetch(`${apiBase}/collections`);
130
+ if (!response.ok) {
131
+ throw new Error("Failed to fetch collections");
132
+ }
133
+ const data = await response.json();
134
+ return data.collections;
135
+ },
136
+
137
+ /**
138
+ * Fetch content list for a collection
139
+ */
140
+ async getContentList(
141
+ collection: string,
142
+ options?: {
143
+ includeDrafts?: boolean;
144
+ sort?: string;
145
+ order?: "asc" | "desc";
146
+ }
147
+ ): Promise<ContentSummary[]> {
148
+ const params = new URLSearchParams();
149
+ if (options?.includeDrafts) params.set("draft", "true");
150
+ if (options?.sort) params.set("sort", options.sort);
151
+ if (options?.order) params.set("order", options.order);
152
+
153
+ const url = `${apiBase}/content/${collection}${params.toString() ? `?${params}` : ""}`;
154
+ const response = await fetch(url);
155
+ if (!response.ok) {
156
+ throw new Error("Failed to fetch content list");
157
+ }
158
+ const data = await response.json();
159
+ return data.items;
160
+ },
161
+
162
+ /**
163
+ * Fetch single content item
164
+ */
165
+ async getContent(collection: string, id: string): Promise<ContentItem> {
166
+ const response = await fetch(`${apiBase}/content/${collection}/${id}`);
167
+ if (!response.ok) {
168
+ throw new Error("Failed to fetch content");
169
+ }
170
+ return response.json();
171
+ },
172
+
173
+ /**
174
+ * Create new content
175
+ */
176
+ async createContent(
177
+ collection: string,
178
+ data: {
179
+ frontmatter: Record<string, unknown>;
180
+ body: string;
181
+ slug?: string;
182
+ }
183
+ ): Promise<{
184
+ success: boolean;
185
+ id?: string;
186
+ path?: string;
187
+ error?: string;
188
+ }> {
189
+ const response = await fetch(`${apiBase}/content/${collection}`, {
190
+ method: "POST",
191
+ headers: { "Content-Type": "application/json" },
192
+ body: JSON.stringify(data),
193
+ });
194
+ return response.json();
195
+ },
196
+
197
+ /**
198
+ * Update content
199
+ */
200
+ async updateContent(
201
+ collection: string,
202
+ id: string,
203
+ data: { frontmatter?: Record<string, unknown>; body?: string }
204
+ ): Promise<{ success: boolean; error?: string }> {
205
+ const response = await fetch(`${apiBase}/content/${collection}/${id}`, {
206
+ method: "PUT",
207
+ headers: { "Content-Type": "application/json" },
208
+ body: JSON.stringify(data),
209
+ });
210
+ return response.json();
211
+ },
212
+
213
+ /**
214
+ * Delete content
215
+ */
216
+ async deleteContent(
217
+ collection: string,
218
+ id: string
219
+ ): Promise<{ success: boolean; error?: string }> {
220
+ const response = await fetch(`${apiBase}/content/${collection}/${id}`, {
221
+ method: "DELETE",
222
+ });
223
+ return response.json();
224
+ },
225
+
226
+ /**
227
+ * Upload image
228
+ */
229
+ async uploadImage(
230
+ file: File,
231
+ collection: string,
232
+ contentId: string
233
+ ): Promise<{
234
+ success: boolean;
235
+ path?: string;
236
+ url?: string;
237
+ error?: string;
238
+ }> {
239
+ const formData = new FormData();
240
+ formData.append("file", file);
241
+ formData.append("collection", collection);
242
+ formData.append("contentId", contentId);
243
+
244
+ const response = await fetch(`${apiBase}/images`, {
245
+ method: "POST",
246
+ body: formData,
247
+ });
248
+ return response.json();
249
+ },
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Hook for using the API client
255
+ *
256
+ * Creates a memoized API client instance that persists across re-renders.
257
+ * For shared usage across the app, consider using ApiProvider and useSharedApi instead.
258
+ */
259
+ export function useApi(apiBase: string) {
260
+ const client = useMemo(() => createApiClient({ apiBase }), [apiBase]);
261
+ return client;
262
+ }
263
+
264
+ /** API client type */
265
+ export type ApiClient = ReturnType<typeof createApiClient>;
266
+
267
+ /**
268
+ * Hook for fetching collections
269
+ *
270
+ * @param apiBaseOrClient - Either an API base URL string or a pre-created API client
271
+ */
272
+ export function useCollections(apiBaseOrClient: string | ApiClient) {
273
+ const client = useMemo(() => {
274
+ if (typeof apiBaseOrClient === "string") {
275
+ return createApiClient({ apiBase: apiBaseOrClient });
276
+ }
277
+ return apiBaseOrClient;
278
+ }, [apiBaseOrClient]);
279
+ const [collections, setCollections] = useState<Collection[]>([]);
280
+ const [loading, setLoading] = useState(true);
281
+ const [error, setError] = useState<string | null>(null);
282
+
283
+ const refresh = useCallback(async () => {
284
+ setLoading(true);
285
+ setError(null);
286
+ try {
287
+ const data = await client.getCollections();
288
+ setCollections(data);
289
+ } catch (err) {
290
+ setError(
291
+ err instanceof Error ? err.message : "Failed to fetch collections"
292
+ );
293
+ } finally {
294
+ setLoading(false);
295
+ }
296
+ }, [client]);
297
+
298
+ return { collections, loading, error, refresh };
299
+ }
300
+
301
+ /**
302
+ * Hook for fetching content list
303
+ *
304
+ * @param apiBaseOrClient - Either an API base URL string or a pre-created API client
305
+ * @param collection - Collection name to fetch content from
306
+ */
307
+ export function useContentList(
308
+ apiBaseOrClient: string | ApiClient,
309
+ collection: string | null
310
+ ) {
311
+ const client = useMemo(() => {
312
+ if (typeof apiBaseOrClient === "string") {
313
+ return createApiClient({ apiBase: apiBaseOrClient });
314
+ }
315
+ return apiBaseOrClient;
316
+ }, [apiBaseOrClient]);
317
+ const [items, setItems] = useState<ContentSummary[]>([]);
318
+ const [loading, setLoading] = useState(false);
319
+ const [error, setError] = useState<string | null>(null);
320
+
321
+ const refresh = useCallback(async () => {
322
+ if (!collection) {
323
+ setItems([]);
324
+ return;
325
+ }
326
+
327
+ setLoading(true);
328
+ setError(null);
329
+ try {
330
+ const data = await client.getContentList(collection, {
331
+ includeDrafts: true,
332
+ });
333
+ setItems(data);
334
+ } catch (err) {
335
+ setError(err instanceof Error ? err.message : "Failed to fetch content");
336
+ } finally {
337
+ setLoading(false);
338
+ }
339
+ }, [client, collection]);
340
+
341
+ return { items, loading, error, refresh };
342
+ }
343
+
344
+ /**
345
+ * Hook for fetching configuration
346
+ *
347
+ * @param apiBaseOrClient - Either an API base URL string or a pre-created API client
348
+ */
349
+ export function useConfig(apiBaseOrClient: string | ApiClient) {
350
+ const client = useMemo(() => {
351
+ if (typeof apiBaseOrClient === "string") {
352
+ return createApiClient({ apiBase: apiBaseOrClient });
353
+ }
354
+ return apiBaseOrClient;
355
+ }, [apiBaseOrClient]);
356
+ const [config, setConfig] = useState<WritenexClientConfig | null>(null);
357
+ const [loading, setLoading] = useState(true);
358
+ const [error, setError] = useState<string | null>(null);
359
+
360
+ const refresh = useCallback(async () => {
361
+ setLoading(true);
362
+ setError(null);
363
+ try {
364
+ const data = await client.getConfig();
365
+ setConfig(data);
366
+ } catch (err) {
367
+ setError(err instanceof Error ? err.message : "Failed to fetch config");
368
+ } finally {
369
+ setLoading(false);
370
+ }
371
+ }, [client]);
372
+
373
+ return { config, loading, error, refresh };
374
+ }
@@ -0,0 +1,286 @@
1
+ /**
2
+ * @fileoverview Arrow key navigation hook for list accessibility
3
+ *
4
+ * This hook provides arrow key navigation for lists and tab panels,
5
+ * supporting both vertical (ArrowUp/ArrowDown) and horizontal
6
+ * (ArrowLeft/ArrowRight) navigation patterns.
7
+ *
8
+ * ## Features:
9
+ * - Vertical arrow key navigation for lists
10
+ * - Horizontal arrow key navigation for tabs
11
+ * - Loop and non-loop modes
12
+ * - Enter key for selection
13
+ * - Home/End key support
14
+ *
15
+ * @module @writenex/astro/client/hooks/useArrowNavigation
16
+ */
17
+
18
+ import { useCallback, useEffect, useRef } from "react";
19
+
20
+ /**
21
+ * Navigation orientation
22
+ */
23
+ export type NavigationOrientation = "vertical" | "horizontal";
24
+
25
+ /**
26
+ * Options for useArrowNavigation hook
27
+ */
28
+ export interface UseArrowNavigationOptions {
29
+ /** List of item IDs or refs */
30
+ items: string[] | React.RefObject<HTMLElement | null>[];
31
+ /** Current focused index */
32
+ currentIndex: number;
33
+ /** Callback when index changes */
34
+ onIndexChange: (index: number) => void;
35
+ /** Callback when Enter is pressed on an item */
36
+ onSelect?: (index: number) => void;
37
+ /** Whether navigation is vertical (default) or horizontal */
38
+ orientation?: NavigationOrientation;
39
+ /** Whether to loop at boundaries (default: true) */
40
+ loop?: boolean;
41
+ /** Whether the navigation is enabled (default: true) */
42
+ enabled?: boolean;
43
+ }
44
+
45
+ /**
46
+ * Return value from useArrowNavigation hook
47
+ */
48
+ export interface UseArrowNavigationReturn {
49
+ /** Ref to attach to the container element */
50
+ containerRef: React.RefObject<HTMLElement | null>;
51
+ /** Handle keydown event (for manual attachment) */
52
+ handleKeyDown: (event: React.KeyboardEvent) => void;
53
+ /** Move focus to next item */
54
+ focusNext: () => void;
55
+ /** Move focus to previous item */
56
+ focusPrevious: () => void;
57
+ /** Move focus to first item */
58
+ focusFirst: () => void;
59
+ /** Move focus to last item */
60
+ focusLast: () => void;
61
+ }
62
+
63
+ /**
64
+ * Get the element at a given index from items array
65
+ */
66
+ function getElementAtIndex(
67
+ items: string[] | React.RefObject<HTMLElement | null>[],
68
+ index: number
69
+ ): HTMLElement | null {
70
+ if (index < 0 || index >= items.length) return null;
71
+
72
+ const item = items[index];
73
+
74
+ // Check if it's a ref
75
+ if (item && typeof item === "object" && "current" in item) {
76
+ return item.current;
77
+ }
78
+
79
+ // It's an ID string
80
+ if (typeof item === "string") {
81
+ return document.getElementById(item);
82
+ }
83
+
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Hook for arrow key navigation in lists and tab panels
89
+ *
90
+ * This hook implements the WAI-ARIA keyboard navigation patterns
91
+ * for lists and tabs, allowing users to navigate using arrow keys.
92
+ *
93
+ * @param options - Navigation configuration options
94
+ * @returns Object containing container ref and navigation functions
95
+ *
96
+ * @example
97
+ * ```tsx
98
+ * function CollectionList({ collections, selectedIndex, onSelect }) {
99
+ * const { containerRef, handleKeyDown } = useArrowNavigation({
100
+ * items: collections.map(c => c.id),
101
+ * currentIndex: selectedIndex,
102
+ * onIndexChange: setSelectedIndex,
103
+ * onSelect: (index) => onSelect(collections[index]),
104
+ * orientation: 'vertical',
105
+ * loop: true,
106
+ * });
107
+ *
108
+ * return (
109
+ * <ul ref={containerRef} role="listbox" onKeyDown={handleKeyDown}>
110
+ * {collections.map((collection, index) => (
111
+ * <li
112
+ * key={collection.id}
113
+ * id={collection.id}
114
+ * role="option"
115
+ * aria-selected={index === selectedIndex}
116
+ * tabIndex={index === selectedIndex ? 0 : -1}
117
+ * >
118
+ * {collection.name}
119
+ * </li>
120
+ * ))}
121
+ * </ul>
122
+ * );
123
+ * }
124
+ * ```
125
+ */
126
+ export function useArrowNavigation(
127
+ options: UseArrowNavigationOptions
128
+ ): UseArrowNavigationReturn {
129
+ const {
130
+ items,
131
+ currentIndex,
132
+ onIndexChange,
133
+ onSelect,
134
+ orientation = "vertical",
135
+ loop = true,
136
+ enabled = true,
137
+ } = options;
138
+
139
+ const containerRef = useRef<HTMLElement | null>(null);
140
+
141
+ /**
142
+ * Calculate the next index based on direction
143
+ */
144
+ const getNextIndex = useCallback(
145
+ (direction: "next" | "previous"): number => {
146
+ if (items.length === 0) return -1;
147
+
148
+ if (direction === "next") {
149
+ const nextIndex = currentIndex + 1;
150
+ if (nextIndex >= items.length) {
151
+ return loop ? 0 : currentIndex;
152
+ }
153
+ return nextIndex;
154
+ } else {
155
+ const prevIndex = currentIndex - 1;
156
+ if (prevIndex < 0) {
157
+ return loop ? items.length - 1 : currentIndex;
158
+ }
159
+ return prevIndex;
160
+ }
161
+ },
162
+ [items.length, currentIndex, loop]
163
+ );
164
+
165
+ /**
166
+ * Move focus to next item
167
+ */
168
+ const focusNext = useCallback(() => {
169
+ const nextIndex = getNextIndex("next");
170
+ if (nextIndex !== currentIndex) {
171
+ onIndexChange(nextIndex);
172
+ const element = getElementAtIndex(items, nextIndex);
173
+ element?.focus();
174
+ }
175
+ }, [getNextIndex, currentIndex, onIndexChange, items]);
176
+
177
+ /**
178
+ * Move focus to previous item
179
+ */
180
+ const focusPrevious = useCallback(() => {
181
+ const prevIndex = getNextIndex("previous");
182
+ if (prevIndex !== currentIndex) {
183
+ onIndexChange(prevIndex);
184
+ const element = getElementAtIndex(items, prevIndex);
185
+ element?.focus();
186
+ }
187
+ }, [getNextIndex, currentIndex, onIndexChange, items]);
188
+
189
+ /**
190
+ * Move focus to first item
191
+ */
192
+ const focusFirst = useCallback(() => {
193
+ if (items.length === 0) return;
194
+ onIndexChange(0);
195
+ const element = getElementAtIndex(items, 0);
196
+ element?.focus();
197
+ }, [items, onIndexChange]);
198
+
199
+ /**
200
+ * Move focus to last item
201
+ */
202
+ const focusLast = useCallback(() => {
203
+ if (items.length === 0) return;
204
+ const lastIndex = items.length - 1;
205
+ onIndexChange(lastIndex);
206
+ const element = getElementAtIndex(items, lastIndex);
207
+ element?.focus();
208
+ }, [items, onIndexChange]);
209
+
210
+ /**
211
+ * Handle keydown event
212
+ */
213
+ const handleKeyDown = useCallback(
214
+ (event: React.KeyboardEvent) => {
215
+ if (!enabled || items.length === 0) return;
216
+
217
+ const isVertical = orientation === "vertical";
218
+ const nextKey = isVertical ? "ArrowDown" : "ArrowRight";
219
+ const prevKey = isVertical ? "ArrowUp" : "ArrowLeft";
220
+
221
+ switch (event.key) {
222
+ case nextKey:
223
+ event.preventDefault();
224
+ focusNext();
225
+ break;
226
+
227
+ case prevKey:
228
+ event.preventDefault();
229
+ focusPrevious();
230
+ break;
231
+
232
+ case "Home":
233
+ event.preventDefault();
234
+ focusFirst();
235
+ break;
236
+
237
+ case "End":
238
+ event.preventDefault();
239
+ focusLast();
240
+ break;
241
+
242
+ case "Enter":
243
+ case " ":
244
+ if (onSelect && currentIndex >= 0) {
245
+ event.preventDefault();
246
+ onSelect(currentIndex);
247
+ }
248
+ break;
249
+ }
250
+ },
251
+ [
252
+ enabled,
253
+ items.length,
254
+ orientation,
255
+ focusNext,
256
+ focusPrevious,
257
+ focusFirst,
258
+ focusLast,
259
+ onSelect,
260
+ currentIndex,
261
+ ]
262
+ );
263
+
264
+ // Focus the current item when index changes externally
265
+ useEffect(() => {
266
+ if (!enabled || currentIndex < 0) return;
267
+
268
+ const element = getElementAtIndex(items, currentIndex);
269
+ if (element && document.activeElement !== element) {
270
+ // Only focus if the container or one of its children has focus
271
+ const container = containerRef.current;
272
+ if (container && container.contains(document.activeElement)) {
273
+ element.focus();
274
+ }
275
+ }
276
+ }, [enabled, currentIndex, items]);
277
+
278
+ return {
279
+ containerRef,
280
+ handleKeyDown,
281
+ focusNext,
282
+ focusPrevious,
283
+ focusFirst,
284
+ focusLast,
285
+ };
286
+ }