@furystack/shades-common-components 14.0.0 → 15.0.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 (284) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/esm/components/accordion/accordion-item.d.ts.map +1 -1
  3. package/esm/components/accordion/accordion-item.js +6 -9
  4. package/esm/components/accordion/accordion-item.js.map +1 -1
  5. package/esm/components/accordion/accordion.d.ts +7 -0
  6. package/esm/components/accordion/accordion.d.ts.map +1 -1
  7. package/esm/components/accordion/accordion.js +4 -1
  8. package/esm/components/accordion/accordion.js.map +1 -1
  9. package/esm/components/accordion/accordion.spec.js +91 -50
  10. package/esm/components/accordion/accordion.spec.js.map +1 -1
  11. package/esm/components/carousel.js +1 -1
  12. package/esm/components/carousel.js.map +1 -1
  13. package/esm/components/chip.d.ts.map +1 -1
  14. package/esm/components/chip.js +4 -2
  15. package/esm/components/chip.js.map +1 -1
  16. package/esm/components/chip.spec.js +42 -0
  17. package/esm/components/chip.spec.js.map +1 -1
  18. package/esm/components/command-palette/index.d.ts.map +1 -1
  19. package/esm/components/command-palette/index.js +14 -1
  20. package/esm/components/command-palette/index.js.map +1 -1
  21. package/esm/components/command-palette/index.spec.js +78 -33
  22. package/esm/components/command-palette/index.spec.js.map +1 -1
  23. package/esm/components/data-grid/data-grid-row.d.ts.map +1 -1
  24. package/esm/components/data-grid/data-grid-row.js +18 -2
  25. package/esm/components/data-grid/data-grid-row.js.map +1 -1
  26. package/esm/components/data-grid/data-grid.d.ts +7 -0
  27. package/esm/components/data-grid/data-grid.d.ts.map +1 -1
  28. package/esm/components/data-grid/data-grid.js +28 -10
  29. package/esm/components/data-grid/data-grid.js.map +1 -1
  30. package/esm/components/data-grid/data-grid.spec.js +114 -34
  31. package/esm/components/data-grid/data-grid.spec.js.map +1 -1
  32. package/esm/components/data-grid/selection-cell.d.ts.map +1 -1
  33. package/esm/components/data-grid/selection-cell.js +1 -1
  34. package/esm/components/data-grid/selection-cell.js.map +1 -1
  35. package/esm/components/dialog.d.ts +11 -0
  36. package/esm/components/dialog.d.ts.map +1 -1
  37. package/esm/components/dialog.js +2 -2
  38. package/esm/components/dialog.js.map +1 -1
  39. package/esm/components/dialog.spec.js +54 -2
  40. package/esm/components/dialog.spec.js.map +1 -1
  41. package/esm/components/dropdown.d.ts.map +1 -1
  42. package/esm/components/dropdown.js +1 -1
  43. package/esm/components/dropdown.js.map +1 -1
  44. package/esm/components/dropdown.spec.js +8 -0
  45. package/esm/components/dropdown.spec.js.map +1 -1
  46. package/esm/components/image.d.ts.map +1 -1
  47. package/esm/components/image.js +15 -6
  48. package/esm/components/image.js.map +1 -1
  49. package/esm/components/image.spec.js +60 -0
  50. package/esm/components/image.spec.js.map +1 -1
  51. package/esm/components/inputs/checkbox.d.ts.map +1 -1
  52. package/esm/components/inputs/checkbox.js +1 -0
  53. package/esm/components/inputs/checkbox.js.map +1 -1
  54. package/esm/components/inputs/radio.d.ts.map +1 -1
  55. package/esm/components/inputs/radio.js +1 -0
  56. package/esm/components/inputs/radio.js.map +1 -1
  57. package/esm/components/inputs/slider.d.ts.map +1 -1
  58. package/esm/components/inputs/slider.js +1 -0
  59. package/esm/components/inputs/slider.js.map +1 -1
  60. package/esm/components/inputs/switch.d.ts.map +1 -1
  61. package/esm/components/inputs/switch.js +1 -0
  62. package/esm/components/inputs/switch.js.map +1 -1
  63. package/esm/components/list/list-item.d.ts.map +1 -1
  64. package/esm/components/list/list-item.js +21 -5
  65. package/esm/components/list/list-item.js.map +1 -1
  66. package/esm/components/list/list.d.ts +7 -0
  67. package/esm/components/list/list.d.ts.map +1 -1
  68. package/esm/components/list/list.js +28 -8
  69. package/esm/components/list/list.js.map +1 -1
  70. package/esm/components/list/list.spec.js +117 -23
  71. package/esm/components/list/list.spec.js.map +1 -1
  72. package/esm/components/markdown/markdown-display.d.ts.map +1 -1
  73. package/esm/components/markdown/markdown-display.js +11 -1
  74. package/esm/components/markdown/markdown-display.js.map +1 -1
  75. package/esm/components/markdown/markdown-display.spec.js +97 -0
  76. package/esm/components/markdown/markdown-display.spec.js.map +1 -1
  77. package/esm/components/markdown/markdown-editor.spec.js +87 -0
  78. package/esm/components/markdown/markdown-editor.spec.js.map +1 -1
  79. package/esm/components/menu/menu.js +1 -1
  80. package/esm/components/menu/menu.js.map +1 -1
  81. package/esm/components/modal.d.ts +10 -0
  82. package/esm/components/modal.d.ts.map +1 -1
  83. package/esm/components/modal.js +24 -4
  84. package/esm/components/modal.js.map +1 -1
  85. package/esm/components/modal.spec.js +86 -1
  86. package/esm/components/modal.spec.js.map +1 -1
  87. package/esm/components/page-layout/index.js +1 -1
  88. package/esm/components/page-layout/index.js.map +1 -1
  89. package/esm/components/page-layout/index.spec.js +14 -0
  90. package/esm/components/page-layout/index.spec.js.map +1 -1
  91. package/esm/components/rating.d.ts.map +1 -1
  92. package/esm/components/rating.js +28 -21
  93. package/esm/components/rating.js.map +1 -1
  94. package/esm/components/rating.spec.js +151 -4
  95. package/esm/components/rating.spec.js.map +1 -1
  96. package/esm/components/suggest/index.d.ts.map +1 -1
  97. package/esm/components/suggest/index.js +14 -1
  98. package/esm/components/suggest/index.js.map +1 -1
  99. package/esm/components/suggest/index.spec.js +98 -43
  100. package/esm/components/suggest/index.spec.js.map +1 -1
  101. package/esm/components/tabs.d.ts.map +1 -1
  102. package/esm/components/tabs.js +4 -0
  103. package/esm/components/tabs.js.map +1 -1
  104. package/esm/components/tree/tree-item.d.ts.map +1 -1
  105. package/esm/components/tree/tree-item.js +18 -5
  106. package/esm/components/tree/tree-item.js.map +1 -1
  107. package/esm/components/tree/tree.d.ts +7 -0
  108. package/esm/components/tree/tree.d.ts.map +1 -1
  109. package/esm/components/tree/tree.js +12 -3
  110. package/esm/components/tree/tree.js.map +1 -1
  111. package/esm/components/tree/tree.spec.js +64 -2
  112. package/esm/components/tree/tree.spec.js.map +1 -1
  113. package/esm/services/collection-service.d.ts +9 -0
  114. package/esm/services/collection-service.d.ts.map +1 -1
  115. package/esm/services/collection-service.js +33 -11
  116. package/esm/services/collection-service.js.map +1 -1
  117. package/esm/services/collection-service.spec.js +33 -24
  118. package/esm/services/collection-service.spec.js.map +1 -1
  119. package/esm/services/css-variable-theme.d.ts +7 -0
  120. package/esm/services/css-variable-theme.d.ts.map +1 -1
  121. package/esm/services/css-variable-theme.js +23 -0
  122. package/esm/services/css-variable-theme.js.map +1 -1
  123. package/esm/services/css-variable-theme.spec.js +1 -0
  124. package/esm/services/css-variable-theme.spec.js.map +1 -1
  125. package/esm/services/list-service.d.ts +9 -0
  126. package/esm/services/list-service.d.ts.map +1 -1
  127. package/esm/services/list-service.js +13 -13
  128. package/esm/services/list-service.js.map +1 -1
  129. package/esm/services/list-service.spec.js +13 -33
  130. package/esm/services/list-service.spec.js.map +1 -1
  131. package/esm/services/theme-provider-service.d.ts +3 -0
  132. package/esm/services/theme-provider-service.d.ts.map +1 -1
  133. package/esm/services/theme-provider-service.js.map +1 -1
  134. package/esm/services/tree-service.d.ts.map +1 -1
  135. package/esm/services/tree-service.js +5 -9
  136. package/esm/services/tree-service.js.map +1 -1
  137. package/esm/services/tree-service.spec.js +12 -9
  138. package/esm/services/tree-service.spec.js.map +1 -1
  139. package/esm/themes/architect-theme.d.ts +1 -0
  140. package/esm/themes/architect-theme.d.ts.map +1 -1
  141. package/esm/themes/architect-theme.js +1 -0
  142. package/esm/themes/architect-theme.js.map +1 -1
  143. package/esm/themes/auditore-theme.d.ts +1 -0
  144. package/esm/themes/auditore-theme.d.ts.map +1 -1
  145. package/esm/themes/auditore-theme.js +1 -0
  146. package/esm/themes/auditore-theme.js.map +1 -1
  147. package/esm/themes/black-mesa-theme.d.ts +1 -0
  148. package/esm/themes/black-mesa-theme.d.ts.map +1 -1
  149. package/esm/themes/black-mesa-theme.js +1 -0
  150. package/esm/themes/black-mesa-theme.js.map +1 -1
  151. package/esm/themes/chieftain-theme.d.ts +1 -0
  152. package/esm/themes/chieftain-theme.d.ts.map +1 -1
  153. package/esm/themes/chieftain-theme.js +1 -0
  154. package/esm/themes/chieftain-theme.js.map +1 -1
  155. package/esm/themes/default-dark-theme.d.ts +1 -0
  156. package/esm/themes/default-dark-theme.d.ts.map +1 -1
  157. package/esm/themes/default-dark-theme.js +1 -0
  158. package/esm/themes/default-dark-theme.js.map +1 -1
  159. package/esm/themes/default-light-theme.d.ts +1 -0
  160. package/esm/themes/default-light-theme.d.ts.map +1 -1
  161. package/esm/themes/default-light-theme.js +1 -0
  162. package/esm/themes/default-light-theme.js.map +1 -1
  163. package/esm/themes/dragonborn-theme.d.ts +1 -0
  164. package/esm/themes/dragonborn-theme.d.ts.map +1 -1
  165. package/esm/themes/dragonborn-theme.js +1 -0
  166. package/esm/themes/dragonborn-theme.js.map +1 -1
  167. package/esm/themes/hawkins-theme.d.ts +1 -0
  168. package/esm/themes/hawkins-theme.d.ts.map +1 -1
  169. package/esm/themes/hawkins-theme.js +1 -0
  170. package/esm/themes/hawkins-theme.js.map +1 -1
  171. package/esm/themes/jedi-theme.d.ts +1 -0
  172. package/esm/themes/jedi-theme.d.ts.map +1 -1
  173. package/esm/themes/jedi-theme.js +1 -0
  174. package/esm/themes/jedi-theme.js.map +1 -1
  175. package/esm/themes/neon-runner-theme.d.ts +1 -0
  176. package/esm/themes/neon-runner-theme.d.ts.map +1 -1
  177. package/esm/themes/neon-runner-theme.js +1 -0
  178. package/esm/themes/neon-runner-theme.js.map +1 -1
  179. package/esm/themes/paladin-theme.d.ts +1 -0
  180. package/esm/themes/paladin-theme.d.ts.map +1 -1
  181. package/esm/themes/paladin-theme.js +1 -0
  182. package/esm/themes/paladin-theme.js.map +1 -1
  183. package/esm/themes/plumber-theme.d.ts +1 -0
  184. package/esm/themes/plumber-theme.d.ts.map +1 -1
  185. package/esm/themes/plumber-theme.js +1 -0
  186. package/esm/themes/plumber-theme.js.map +1 -1
  187. package/esm/themes/replicant-theme.d.ts +1 -0
  188. package/esm/themes/replicant-theme.d.ts.map +1 -1
  189. package/esm/themes/replicant-theme.js +1 -0
  190. package/esm/themes/replicant-theme.js.map +1 -1
  191. package/esm/themes/sandworm-theme.d.ts +1 -0
  192. package/esm/themes/sandworm-theme.d.ts.map +1 -1
  193. package/esm/themes/sandworm-theme.js +1 -0
  194. package/esm/themes/sandworm-theme.js.map +1 -1
  195. package/esm/themes/shadow-broker-theme.d.ts +1 -0
  196. package/esm/themes/shadow-broker-theme.d.ts.map +1 -1
  197. package/esm/themes/shadow-broker-theme.js +1 -0
  198. package/esm/themes/shadow-broker-theme.js.map +1 -1
  199. package/esm/themes/sith-theme.d.ts +1 -0
  200. package/esm/themes/sith-theme.d.ts.map +1 -1
  201. package/esm/themes/sith-theme.js +1 -0
  202. package/esm/themes/sith-theme.js.map +1 -1
  203. package/esm/themes/vault-dweller-theme.d.ts +1 -0
  204. package/esm/themes/vault-dweller-theme.d.ts.map +1 -1
  205. package/esm/themes/vault-dweller-theme.js +1 -0
  206. package/esm/themes/vault-dweller-theme.js.map +1 -1
  207. package/esm/themes/wild-hunt-theme.d.ts +1 -0
  208. package/esm/themes/wild-hunt-theme.d.ts.map +1 -1
  209. package/esm/themes/wild-hunt-theme.js +1 -0
  210. package/esm/themes/wild-hunt-theme.js.map +1 -1
  211. package/esm/themes/xenomorph-theme.d.ts +1 -0
  212. package/esm/themes/xenomorph-theme.d.ts.map +1 -1
  213. package/esm/themes/xenomorph-theme.js +1 -0
  214. package/esm/themes/xenomorph-theme.js.map +1 -1
  215. package/package.json +3 -3
  216. package/src/components/accordion/accordion-item.tsx +9 -14
  217. package/src/components/accordion/accordion.spec.tsx +134 -79
  218. package/src/components/accordion/accordion.tsx +13 -1
  219. package/src/components/carousel.tsx +1 -1
  220. package/src/components/chip.spec.tsx +64 -0
  221. package/src/components/chip.tsx +4 -1
  222. package/src/components/command-palette/index.spec.tsx +95 -33
  223. package/src/components/command-palette/index.tsx +15 -3
  224. package/src/components/data-grid/data-grid-row.tsx +20 -2
  225. package/src/components/data-grid/data-grid.spec.tsx +185 -57
  226. package/src/components/data-grid/data-grid.tsx +38 -13
  227. package/src/components/data-grid/selection-cell.tsx +1 -0
  228. package/src/components/dialog.spec.tsx +77 -2
  229. package/src/components/dialog.tsx +14 -1
  230. package/src/components/dropdown.spec.tsx +9 -0
  231. package/src/components/dropdown.tsx +1 -0
  232. package/src/components/image.spec.tsx +82 -0
  233. package/src/components/image.tsx +16 -7
  234. package/src/components/inputs/checkbox.tsx +1 -0
  235. package/src/components/inputs/radio.tsx +1 -0
  236. package/src/components/inputs/slider.tsx +1 -0
  237. package/src/components/inputs/switch.tsx +1 -0
  238. package/src/components/list/list-item.tsx +22 -4
  239. package/src/components/list/list.spec.tsx +165 -32
  240. package/src/components/list/list.tsx +37 -10
  241. package/src/components/markdown/markdown-display.spec.tsx +132 -0
  242. package/src/components/markdown/markdown-display.tsx +12 -1
  243. package/src/components/markdown/markdown-editor.spec.tsx +123 -0
  244. package/src/components/menu/menu.tsx +1 -1
  245. package/src/components/modal.spec.tsx +124 -1
  246. package/src/components/modal.tsx +41 -3
  247. package/src/components/page-layout/index.spec.tsx +20 -0
  248. package/src/components/page-layout/index.tsx +1 -1
  249. package/src/components/rating.spec.tsx +199 -4
  250. package/src/components/rating.tsx +28 -22
  251. package/src/components/suggest/index.spec.tsx +147 -43
  252. package/src/components/suggest/index.tsx +15 -2
  253. package/src/components/tabs.tsx +4 -0
  254. package/src/components/tree/tree-item.tsx +19 -4
  255. package/src/components/tree/tree.spec.tsx +101 -2
  256. package/src/components/tree/tree.tsx +21 -3
  257. package/src/services/collection-service.spec.ts +33 -24
  258. package/src/services/collection-service.ts +35 -13
  259. package/src/services/css-variable-theme.spec.ts +1 -0
  260. package/src/services/css-variable-theme.ts +25 -0
  261. package/src/services/list-service.spec.ts +13 -42
  262. package/src/services/list-service.ts +15 -13
  263. package/src/services/theme-provider-service.ts +2 -0
  264. package/src/services/tree-service.spec.ts +12 -9
  265. package/src/services/tree-service.ts +5 -8
  266. package/src/themes/architect-theme.ts +1 -0
  267. package/src/themes/auditore-theme.ts +1 -0
  268. package/src/themes/black-mesa-theme.ts +1 -0
  269. package/src/themes/chieftain-theme.ts +1 -0
  270. package/src/themes/default-dark-theme.ts +1 -0
  271. package/src/themes/default-light-theme.ts +1 -0
  272. package/src/themes/dragonborn-theme.ts +1 -0
  273. package/src/themes/hawkins-theme.ts +1 -0
  274. package/src/themes/jedi-theme.ts +1 -0
  275. package/src/themes/neon-runner-theme.ts +1 -0
  276. package/src/themes/paladin-theme.ts +1 -0
  277. package/src/themes/plumber-theme.ts +1 -0
  278. package/src/themes/replicant-theme.ts +1 -0
  279. package/src/themes/sandworm-theme.ts +1 -0
  280. package/src/themes/shadow-broker-theme.ts +1 -0
  281. package/src/themes/sith-theme.ts +1 -0
  282. package/src/themes/vault-dweller-theme.ts +1 -0
  283. package/src/themes/wild-hunt-theme.ts +1 -0
  284. package/src/themes/xenomorph-theme.ts +1 -0
@@ -284,15 +284,11 @@ describe('DataGrid', () => {
284
284
  expect(headers?.length).toBe(2)
285
285
  })
286
286
  })
287
- })
288
287
 
289
- describe('focus management', () => {
290
- it('should set focus on click', async () => {
288
+ it('should render with auto-generated data-nav-section attribute', async () => {
291
289
  await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
292
290
  const rootElement = document.getElementById('root') as HTMLDivElement
293
291
 
294
- expect(service.hasFocus.getValue()).toBe(false)
295
-
296
292
  initializeShadeRoot({
297
293
  injector,
298
294
  rootElement,
@@ -302,70 +298,115 @@ describe('DataGrid', () => {
302
298
  collectionService={service}
303
299
  findOptions={findOptions}
304
300
  onFindOptionsChange={onFindOptionsChange}
305
- styles={{}}
306
- headerComponents={{}}
307
- rowComponents={{}}
308
301
  />
309
302
  ),
310
303
  })
311
304
 
312
305
  await flushUpdates()
313
306
 
314
- const grid = document.querySelector('shade-data-grid')
315
- const wrapper = grid?.querySelector('.shade-grid-wrapper') as HTMLElement
307
+ const wrapper = document.querySelector('.shade-grid-wrapper')
308
+ const navSection = wrapper?.getAttribute('data-nav-section')
309
+ expect(navSection).toBeTruthy()
310
+ expect(navSection).toMatch(/^data-grid-/)
311
+ })
312
+ })
316
313
 
317
- wrapper?.click()
314
+ it('should render with custom navSection', async () => {
315
+ await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
316
+ const rootElement = document.getElementById('root') as HTMLDivElement
318
317
 
319
- expect(service.hasFocus.getValue()).toBe(true)
318
+ initializeShadeRoot({
319
+ injector,
320
+ rootElement,
321
+ jsxElement: (
322
+ <DataGrid<TestEntry, 'id' | 'name'>
323
+ columns={['id', 'name']}
324
+ collectionService={service}
325
+ findOptions={findOptions}
326
+ onFindOptionsChange={onFindOptionsChange}
327
+ navSection="my-grid"
328
+ />
329
+ ),
330
+ })
331
+
332
+ await flushUpdates()
333
+
334
+ const wrapper = document.querySelector('.shade-grid-wrapper')
335
+ expect(wrapper?.getAttribute('data-nav-section')).toBe('my-grid')
320
336
  })
321
337
  })
338
+ })
322
339
 
323
- it('should lose focus on click outside', async () => {
340
+ describe('focus management', () => {
341
+ it('should clear hasFocus on focusout when focus leaves the grid', async () => {
324
342
  await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
325
343
  const rootElement = document.getElementById('root') as HTMLDivElement
344
+ const outsideBtn = document.createElement('button')
345
+ document.body.appendChild(outsideBtn)
326
346
 
327
347
  initializeShadeRoot({
328
348
  injector,
329
349
  rootElement,
330
350
  jsxElement: (
331
- <>
332
- <div data-testid="outside">Outside</div>
333
- <DataGrid<TestEntry, 'id' | 'name'>
334
- columns={['id', 'name']}
335
- collectionService={service}
336
- findOptions={findOptions}
337
- onFindOptionsChange={onFindOptionsChange}
338
- styles={{}}
339
- headerComponents={{}}
340
- rowComponents={{}}
341
- />
342
- </>
351
+ <DataGrid<TestEntry, 'id' | 'name'>
352
+ columns={['id', 'name']}
353
+ collectionService={service}
354
+ findOptions={findOptions}
355
+ onFindOptionsChange={onFindOptionsChange}
356
+ />
343
357
  ),
344
358
  })
345
359
 
346
360
  await flushUpdates()
361
+ await new Promise((r) => setTimeout(r, 0))
347
362
 
348
- const grid = document.querySelector('shade-data-grid')
349
- const wrapper = grid?.querySelector('.shade-grid-wrapper') as HTMLElement
350
- wrapper?.click()
351
-
352
- expect(service.hasFocus.getValue()).toBe(true)
363
+ service.hasFocus.setValue(true)
353
364
 
354
- const outside = document.querySelector('[data-testid="outside"]') as HTMLElement
355
- outside?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
365
+ const wrapper = document.querySelector('.shade-grid-wrapper') as HTMLElement
366
+ wrapper?.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outsideBtn }))
356
367
 
357
368
  expect(service.hasFocus.getValue()).toBe(false)
369
+ outsideBtn.remove()
358
370
  })
359
371
  })
360
- })
361
372
 
362
- describe('keyboard navigation', () => {
363
- it('should handle ArrowDown to move focus to next entry', async () => {
373
+ it('should clear hasFocus on focusout when focus moves outside', async () => {
364
374
  await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
365
375
  const rootElement = document.getElementById('root') as HTMLDivElement
376
+ const outsideEl = document.createElement('button')
377
+ outsideEl.textContent = 'Outside'
378
+ document.body.appendChild(outsideEl)
379
+
380
+ initializeShadeRoot({
381
+ injector,
382
+ rootElement,
383
+ jsxElement: (
384
+ <DataGrid<TestEntry, 'id' | 'name'>
385
+ columns={['id', 'name']}
386
+ collectionService={service}
387
+ findOptions={findOptions}
388
+ onFindOptionsChange={onFindOptionsChange}
389
+ />
390
+ ),
391
+ })
392
+
393
+ await flushUpdates()
394
+ await new Promise((r) => setTimeout(r, 0))
366
395
 
367
396
  service.hasFocus.setValue(true)
368
- service.focusedEntry.setValue(service.data.getValue().entries[0])
397
+
398
+ const wrapper = document.querySelector('.shade-grid-wrapper') as HTMLElement
399
+ wrapper?.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outsideEl }))
400
+
401
+ expect(service.hasFocus.getValue()).toBe(false)
402
+
403
+ outsideEl.remove()
404
+ })
405
+ })
406
+
407
+ it('should clear hasFocus on focusout when relatedTarget is null', async () => {
408
+ await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
409
+ const rootElement = document.getElementById('root') as HTMLDivElement
369
410
 
370
411
  initializeShadeRoot({
371
412
  injector,
@@ -376,28 +417,30 @@ describe('DataGrid', () => {
376
417
  collectionService={service}
377
418
  findOptions={findOptions}
378
419
  onFindOptionsChange={onFindOptionsChange}
379
- styles={{}}
380
- headerComponents={{}}
381
- rowComponents={{}}
382
420
  />
383
421
  ),
384
422
  })
385
423
 
386
424
  await flushUpdates()
425
+ await new Promise((r) => setTimeout(r, 0))
387
426
 
388
- const keydownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
389
- window.dispatchEvent(keydownEvent)
427
+ service.hasFocus.setValue(true)
390
428
 
391
- expect(service.focusedEntry.getValue()).toEqual({ id: 2, name: 'Second' })
429
+ const wrapper = document.querySelector('.shade-grid-wrapper') as HTMLElement
430
+ wrapper?.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: null }))
431
+
432
+ expect(service.hasFocus.getValue()).toBe(false)
392
433
  })
393
434
  })
435
+ })
394
436
 
395
- it('should handle ArrowUp to move focus to previous entry', async () => {
437
+ describe('keyboard navigation', () => {
438
+ it('should move focus to next entry on ArrowDown', async () => {
396
439
  await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
397
440
  const rootElement = document.getElementById('root') as HTMLDivElement
398
441
 
399
442
  service.hasFocus.setValue(true)
400
- service.focusedEntry.setValue(service.data.getValue().entries[1])
443
+ service.focusedEntry.setValue(service.data.getValue().entries[0])
401
444
 
402
445
  initializeShadeRoot({
403
446
  injector,
@@ -417,19 +460,19 @@ describe('DataGrid', () => {
417
460
 
418
461
  await flushUpdates()
419
462
 
420
- const keydownEvent = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
463
+ const keydownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
421
464
  window.dispatchEvent(keydownEvent)
422
465
 
423
- expect(service.focusedEntry.getValue()).toEqual({ id: 1, name: 'First' })
466
+ expect(service.focusedEntry.getValue()).toEqual({ id: 2, name: 'Second' })
424
467
  })
425
468
  })
426
469
 
427
- it('should handle Home to move focus to first entry', async () => {
470
+ it('should move focus to previous entry on ArrowUp', async () => {
428
471
  await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
429
472
  const rootElement = document.getElementById('root') as HTMLDivElement
430
473
 
431
474
  service.hasFocus.setValue(true)
432
- service.focusedEntry.setValue(service.data.getValue().entries[2])
475
+ service.focusedEntry.setValue(service.data.getValue().entries[1])
433
476
 
434
477
  initializeShadeRoot({
435
478
  injector,
@@ -449,19 +492,19 @@ describe('DataGrid', () => {
449
492
 
450
493
  await flushUpdates()
451
494
 
452
- const keydownEvent = new KeyboardEvent('keydown', { key: 'Home', bubbles: true })
495
+ const keydownEvent = new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
453
496
  window.dispatchEvent(keydownEvent)
454
497
 
455
498
  expect(service.focusedEntry.getValue()).toEqual({ id: 1, name: 'First' })
456
499
  })
457
500
  })
458
501
 
459
- it('should handle End to move focus to last entry', async () => {
502
+ it('should handle Home to move focus to first entry', async () => {
460
503
  await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
461
504
  const rootElement = document.getElementById('root') as HTMLDivElement
462
505
 
463
506
  service.hasFocus.setValue(true)
464
- service.focusedEntry.setValue(service.data.getValue().entries[0])
507
+ service.focusedEntry.setValue(service.data.getValue().entries[2])
465
508
 
466
509
  initializeShadeRoot({
467
510
  injector,
@@ -481,18 +524,19 @@ describe('DataGrid', () => {
481
524
 
482
525
  await flushUpdates()
483
526
 
484
- const keydownEvent = new KeyboardEvent('keydown', { key: 'End', bubbles: true })
527
+ const keydownEvent = new KeyboardEvent('keydown', { key: 'Home', bubbles: true })
485
528
  window.dispatchEvent(keydownEvent)
486
529
 
487
- expect(service.focusedEntry.getValue()).toEqual({ id: 3, name: 'Third' })
530
+ expect(service.focusedEntry.getValue()).toEqual({ id: 1, name: 'First' })
488
531
  })
489
532
  })
490
533
 
491
- it('should handle Tab to toggle focus', async () => {
534
+ it('should handle End to move focus to last entry', async () => {
492
535
  await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
493
536
  const rootElement = document.getElementById('root') as HTMLDivElement
494
537
 
495
538
  service.hasFocus.setValue(true)
539
+ service.focusedEntry.setValue(service.data.getValue().entries[0])
496
540
 
497
541
  initializeShadeRoot({
498
542
  injector,
@@ -512,10 +556,10 @@ describe('DataGrid', () => {
512
556
 
513
557
  await flushUpdates()
514
558
 
515
- const keydownEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
559
+ const keydownEvent = new KeyboardEvent('keydown', { key: 'End', bubbles: true })
516
560
  window.dispatchEvent(keydownEvent)
517
561
 
518
- expect(service.hasFocus.getValue()).toBe(false)
562
+ expect(service.focusedEntry.getValue()).toEqual({ id: 3, name: 'Third' })
519
563
  })
520
564
  })
521
565
 
@@ -694,7 +738,6 @@ describe('DataGrid', () => {
694
738
  await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
695
739
  const rootElement = document.getElementById('root') as HTMLDivElement
696
740
 
697
- service.hasFocus.setValue(false)
698
741
  service.focusedEntry.setValue(service.data.getValue().entries[0])
699
742
 
700
743
  initializeShadeRoot({
@@ -715,6 +758,8 @@ describe('DataGrid', () => {
715
758
 
716
759
  await flushUpdates()
717
760
 
761
+ service.hasFocus.setValue(false)
762
+
718
763
  const keydownEvent = new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
719
764
  window.dispatchEvent(keydownEvent)
720
765
 
@@ -934,6 +979,89 @@ describe('DataGrid', () => {
934
979
  })
935
980
  })
936
981
 
982
+ describe('row spatial navigation attributes', () => {
983
+ it('should set data-spatial-nav-target on rows', async () => {
984
+ await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
985
+ const rootElement = document.getElementById('root') as HTMLDivElement
986
+
987
+ initializeShadeRoot({
988
+ injector,
989
+ rootElement,
990
+ jsxElement: (
991
+ <DataGrid<TestEntry, 'id' | 'name'>
992
+ columns={['id', 'name']}
993
+ collectionService={service}
994
+ findOptions={findOptions}
995
+ onFindOptionsChange={onFindOptionsChange}
996
+ />
997
+ ),
998
+ })
999
+
1000
+ await flushUpdates()
1001
+
1002
+ const rows = document.querySelectorAll('shades-data-grid-row')
1003
+ for (const row of rows) {
1004
+ expect(row.hasAttribute('data-spatial-nav-target')).toBe(true)
1005
+ }
1006
+ })
1007
+ })
1008
+
1009
+ it('should set tabIndex 0 on focused row and -1 on others', async () => {
1010
+ await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
1011
+ const rootElement = document.getElementById('root') as HTMLDivElement
1012
+
1013
+ service.focusedEntry.setValue(service.data.getValue().entries[1])
1014
+
1015
+ initializeShadeRoot({
1016
+ injector,
1017
+ rootElement,
1018
+ jsxElement: (
1019
+ <DataGrid<TestEntry, 'id' | 'name'>
1020
+ columns={['id', 'name']}
1021
+ collectionService={service}
1022
+ findOptions={findOptions}
1023
+ onFindOptionsChange={onFindOptionsChange}
1024
+ />
1025
+ ),
1026
+ })
1027
+
1028
+ await flushUpdates()
1029
+
1030
+ const rows = document.querySelectorAll<HTMLTableRowElement>('shades-data-grid-row')
1031
+ expect(rows[0]?.tabIndex).toBe(-1)
1032
+ expect(rows[1]?.tabIndex).toBe(0)
1033
+ expect(rows[2]?.tabIndex).toBe(-1)
1034
+ })
1035
+ })
1036
+
1037
+ it('should sync focusedEntry on row onfocus', async () => {
1038
+ await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
1039
+ const rootElement = document.getElementById('root') as HTMLDivElement
1040
+
1041
+ initializeShadeRoot({
1042
+ injector,
1043
+ rootElement,
1044
+ jsxElement: (
1045
+ <DataGrid<TestEntry, 'id' | 'name'>
1046
+ columns={['id', 'name']}
1047
+ collectionService={service}
1048
+ findOptions={findOptions}
1049
+ onFindOptionsChange={onFindOptionsChange}
1050
+ />
1051
+ ),
1052
+ })
1053
+
1054
+ await flushUpdates()
1055
+
1056
+ const rows = document.querySelectorAll('shades-data-grid-row')
1057
+ rows[2]?.dispatchEvent(new FocusEvent('focus'))
1058
+
1059
+ expect(service.focusedEntry.getValue()).toEqual({ id: 3, name: 'Third' })
1060
+ expect(service.hasFocus.getValue()).toBe(true)
1061
+ })
1062
+ })
1063
+ })
1064
+
937
1065
  describe('keyboard listener cleanup', () => {
938
1066
  it('should remove keyboard listener when component is disconnected', async () => {
939
1067
  await withTestGrid(async ({ injector, service, findOptions, onFindOptionsChange }) => {
@@ -1,7 +1,6 @@
1
1
  import type { FindOptions } from '@furystack/core'
2
2
  import type { ChildrenList } from '@furystack/shades'
3
3
  import { createComponent, Shade } from '@furystack/shades'
4
- import { ClickAwayService } from '../../services/click-away-service.js'
5
4
  import type { CollectionService } from '../../services/collection-service.js'
6
5
  import { cssVariableTheme } from '../../services/css-variable-theme.js'
7
6
  import type { GridProps } from '../grid.js'
@@ -9,6 +8,8 @@ import { DataGridBody } from './body.js'
9
8
  import { DataGridFooter } from './footer.js'
10
9
  import { DataGridHeader } from './header.js'
11
10
 
11
+ let nextDataGridId = 0
12
+
12
13
  export type StringFilterConfig = { type: 'string' }
13
14
  export type NumberFilterConfig = { type: 'number' }
14
15
  export type BooleanFilterConfig = { type: 'boolean' }
@@ -119,6 +120,14 @@ export interface DataGridProps<T, Column extends string> {
119
120
  * @default dataGridItemsPerPage ([10, 20, 25, 50, 100, Infinity])
120
121
  */
121
122
  paginationOptions?: number[]
123
+
124
+ /**
125
+ * Section name for spatial navigation scoping.
126
+ * Sets `data-nav-section` on the grid wrapper so that SpatialNavigationService
127
+ * constrains arrow-key navigation within the grid.
128
+ * Auto-generated per instance when not provided.
129
+ */
130
+ navSection?: string
122
131
  }
123
132
 
124
133
  export const DataGrid: <T, Column extends string>(
@@ -157,25 +166,43 @@ export const DataGrid: <T, Column extends string>(
157
166
  borderRight: `1px solid ${cssVariableTheme.action.subtleBorder}`,
158
167
  },
159
168
  },
160
- render: ({ props, useDisposable, useRef, useHostProps }) => {
169
+ render: ({ props, useDisposable, useRef, useHostProps, useState }) => {
161
170
  const wrapperRef = useRef<HTMLDivElement>('gridWrapper')
171
+ const [navSectionId] = useState('navSectionId', String(nextDataGridId++))
162
172
 
163
173
  const headerFindOptions = props.findOptions as FilterableFindOptions
164
174
  const handleHeaderChange = props.onFindOptionsChange as (options: FilterableFindOptions) => void
165
175
 
166
176
  useDisposable('keydown-handler', () => {
167
177
  const listener = (ev: KeyboardEvent) => props.collectionService.handleKeyDown(ev)
168
- window.addEventListener('keydown', listener)
169
- return { [Symbol.dispose]: () => window.removeEventListener('keydown', listener) }
178
+ window.addEventListener('keydown', listener, true)
179
+ return { [Symbol.dispose]: () => window.removeEventListener('keydown', listener, true) }
170
180
  })
171
181
 
172
- useDisposable(
173
- 'clickAway',
174
- () =>
175
- new ClickAwayService(wrapperRef, () => {
182
+ useDisposable('focus-coordination', () => {
183
+ const handleFocusOut = (ev: FocusEvent) => {
184
+ const wrapper = wrapperRef.current
185
+ if (wrapper && (!ev.relatedTarget || !wrapper.contains(ev.relatedTarget as Node))) {
176
186
  props.collectionService.hasFocus.setValue(false)
177
- }),
178
- )
187
+ }
188
+ }
189
+
190
+ queueMicrotask(() => {
191
+ const wrapper = wrapperRef.current
192
+ if (wrapper) {
193
+ wrapper.addEventListener('focusout', handleFocusOut)
194
+ }
195
+ })
196
+
197
+ return {
198
+ [Symbol.dispose]: () => {
199
+ const wrapper = wrapperRef.current
200
+ if (wrapper) {
201
+ wrapper.removeEventListener('focusout', handleFocusOut)
202
+ }
203
+ },
204
+ }
205
+ })
179
206
 
180
207
  if (props.styles?.wrapper) {
181
208
  useHostProps({ style: props.styles.wrapper as Record<string, string> })
@@ -185,9 +212,7 @@ export const DataGrid: <T, Column extends string>(
185
212
  <div
186
213
  ref={wrapperRef}
187
214
  className="shade-grid-wrapper"
188
- onclick={() => {
189
- props.collectionService.hasFocus.setValue(true)
190
- }}
215
+ data-nav-section={props.navSection ?? `data-grid-${navSectionId}`}
191
216
  ariaMultiSelectable="true"
192
217
  >
193
218
  <table>
@@ -22,6 +22,7 @@ export const SelectionCell = Shade<{ entry: any; service: CollectionService<any>
22
22
 
23
23
  return (
24
24
  <input
25
+ tabIndex={-1}
25
26
  onchange={() => {
26
27
  props.service.toggleSelection(props.entry)
27
28
  }}
@@ -1,9 +1,19 @@
1
- import { createComponent } from '@furystack/shades'
2
- import { describe, expect, it, vi } from 'vitest'
1
+ import { Injector } from '@furystack/inject'
2
+ import { createComponent, flushUpdates, initializeShadeRoot, SpatialNavigationService } from '@furystack/shades'
3
+ import { usingAsync } from '@furystack/utils'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
5
  import type { DialogProps } from './dialog.js'
4
6
  import { ConfirmDialog, Dialog } from './dialog.js'
5
7
 
6
8
  describe('Dialog', () => {
9
+ beforeEach(() => {
10
+ document.body.innerHTML = '<div id="root"></div>'
11
+ })
12
+
13
+ afterEach(() => {
14
+ document.body.innerHTML = ''
15
+ })
16
+
7
17
  it('should be defined', () => {
8
18
  expect(Dialog).toBeDefined()
9
19
  expect(typeof Dialog).toBe('function')
@@ -55,6 +65,71 @@ describe('Dialog', () => {
55
65
  const el = (<Dialog isVisible={false} />) as unknown as { props: DialogProps }
56
66
  expect(el.props.isVisible).toBe(false)
57
67
  })
68
+
69
+ it('should forward trapFocus to the underlying Modal', async () => {
70
+ await usingAsync(new Injector(), async (injector) => {
71
+ const spatialNav = injector.getInstance(SpatialNavigationService)
72
+ const pushSpy = vi.spyOn(spatialNav, 'pushFocusTrap')
73
+ const rootElement = document.getElementById('root') as HTMLDivElement
74
+
75
+ initializeShadeRoot({
76
+ injector,
77
+ rootElement,
78
+ jsxElement: (
79
+ <Dialog isVisible={true} trapFocus={true} navSection="dialog-trap">
80
+ <div>Content</div>
81
+ </Dialog>
82
+ ),
83
+ })
84
+
85
+ await flushUpdates()
86
+
87
+ expect(pushSpy).toHaveBeenCalledWith('dialog-trap')
88
+ })
89
+ })
90
+
91
+ it('should forward navSection to the underlying Modal', async () => {
92
+ await usingAsync(new Injector(), async (injector) => {
93
+ const rootElement = document.getElementById('root') as HTMLDivElement
94
+
95
+ initializeShadeRoot({
96
+ injector,
97
+ rootElement,
98
+ jsxElement: (
99
+ <Dialog isVisible={true} navSection="my-dialog">
100
+ <div>Content</div>
101
+ </Dialog>
102
+ ),
103
+ })
104
+
105
+ await flushUpdates()
106
+
107
+ const backdrop = document.querySelector('.shade-backdrop')
108
+ expect(backdrop?.getAttribute('data-nav-section')).toBe('my-dialog')
109
+ })
110
+ })
111
+
112
+ it('should not push focus trap when trapFocus is false', async () => {
113
+ await usingAsync(new Injector(), async (injector) => {
114
+ const spatialNav = injector.getInstance(SpatialNavigationService)
115
+ const pushSpy = vi.spyOn(spatialNav, 'pushFocusTrap')
116
+ const rootElement = document.getElementById('root') as HTMLDivElement
117
+
118
+ initializeShadeRoot({
119
+ injector,
120
+ rootElement,
121
+ jsxElement: (
122
+ <Dialog isVisible={true} trapFocus={false}>
123
+ <div>Content</div>
124
+ </Dialog>
125
+ ),
126
+ })
127
+
128
+ await flushUpdates()
129
+
130
+ expect(pushSpy).not.toHaveBeenCalled()
131
+ })
132
+ })
58
133
  })
59
134
 
60
135
  describe('ConfirmDialog', () => {
@@ -13,6 +13,17 @@ export type DialogProps = {
13
13
  actions?: JSX.Element
14
14
  maxWidth?: string
15
15
  fullWidth?: boolean
16
+ /**
17
+ * When true, traps spatial navigation within the dialog's bounds.
18
+ * Forwarded to the underlying Modal component.
19
+ */
20
+ trapFocus?: boolean
21
+ /**
22
+ * Section name for spatial navigation scoping.
23
+ * Forwarded to the underlying Modal component.
24
+ * @default 'modal'
25
+ */
26
+ navSection?: string
16
27
  }
17
28
 
18
29
  const showAnimation = async (el: Element | null) => {
@@ -127,7 +138,7 @@ export const Dialog = Shade<DialogProps>({
127
138
  },
128
139
  },
129
140
  render: ({ props, children }) => {
130
- const { isVisible, title, onClose, actions, maxWidth = '560px', fullWidth } = props
141
+ const { isVisible, title, onClose, actions, maxWidth = '560px', fullWidth, trapFocus = true, navSection } = props
131
142
 
132
143
  const handleClose = () => {
133
144
  onClose?.()
@@ -139,6 +150,8 @@ export const Dialog = Shade<DialogProps>({
139
150
  onClose={handleClose}
140
151
  showAnimation={showAnimation}
141
152
  hideAnimation={hideAnimation}
153
+ trapFocus={trapFocus}
154
+ navSection={navSection}
142
155
  backdropStyle={{
143
156
  display: 'flex',
144
157
  alignItems: 'center',
@@ -416,6 +416,15 @@ describe('Dropdown', () => {
416
416
  })
417
417
  })
418
418
 
419
+ describe('spatial navigation', () => {
420
+ it('should have data-spatial-nav-passthrough on the backdrop', async () => {
421
+ await usingAsync(await renderDropdown({}), async ({ dropdown }) => {
422
+ const backdrop = dropdown.querySelector('.dropdown-backdrop') as HTMLElement
423
+ expect(backdrop.hasAttribute('data-spatial-nav-passthrough')).toBe(true)
424
+ })
425
+ })
426
+ })
427
+
419
428
  describe('placement', () => {
420
429
  it('should accept bottomRight placement', async () => {
421
430
  await usingAsync(await renderDropdown({ placement: 'bottomRight' }), async ({ dropdown }) => {
@@ -309,6 +309,7 @@ export const Dropdown: (props: DropdownProps, children: ChildrenList) => JSX.Ele
309
309
  <div
310
310
  ref={backdropRef}
311
311
  className="dropdown-backdrop"
312
+ data-spatial-nav-passthrough=""
312
313
  style={{
313
314
  position: 'fixed',
314
315
  top: '0',