@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
@@ -345,6 +345,88 @@ describe('Image component', () => {
345
345
  })
346
346
  })
347
347
 
348
+ it('should be focusable when preview is enabled', async () => {
349
+ await usingAsync(new Injector(), async (injector) => {
350
+ const rootElement = document.getElementById('root') as HTMLDivElement
351
+
352
+ initializeShadeRoot({
353
+ injector,
354
+ rootElement,
355
+ jsxElement: <Image src="https://example.com/photo.jpg" preview />,
356
+ })
357
+
358
+ await flushUpdates()
359
+
360
+ const imageComponent = document.querySelector('shade-image') as HTMLElement
361
+ expect(imageComponent.getAttribute('tabindex')).toBe('0')
362
+ })
363
+ })
364
+
365
+ it('should not be focusable when preview is disabled', async () => {
366
+ await usingAsync(new Injector(), async (injector) => {
367
+ const rootElement = document.getElementById('root') as HTMLDivElement
368
+
369
+ initializeShadeRoot({
370
+ injector,
371
+ rootElement,
372
+ jsxElement: <Image src="https://example.com/photo.jpg" />,
373
+ })
374
+
375
+ await flushUpdates()
376
+
377
+ const imageComponent = document.querySelector('shade-image') as HTMLElement
378
+ expect(imageComponent.hasAttribute('tabindex')).toBe(false)
379
+ })
380
+ })
381
+
382
+ it('should open lightbox when pressing Enter on a preview-enabled image', async () => {
383
+ await usingAsync(new Injector(), async (injector) => {
384
+ const rootElement = document.getElementById('root') as HTMLDivElement
385
+
386
+ initializeShadeRoot({
387
+ injector,
388
+ rootElement,
389
+ jsxElement: <Image src="https://example.com/photo.jpg" alt="My photo" preview />,
390
+ })
391
+
392
+ await flushUpdates()
393
+
394
+ const imageComponent = document.querySelector('shade-image') as HTMLElement
395
+ imageComponent.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }))
396
+
397
+ await flushUpdates()
398
+
399
+ const lightbox = document.querySelector('.lightbox-backdrop')
400
+ expect(lightbox).not.toBeNull()
401
+
402
+ lightbox?.remove()
403
+ })
404
+ })
405
+
406
+ it('should open lightbox when pressing Space on a preview-enabled image', async () => {
407
+ await usingAsync(new Injector(), async (injector) => {
408
+ const rootElement = document.getElementById('root') as HTMLDivElement
409
+
410
+ initializeShadeRoot({
411
+ injector,
412
+ rootElement,
413
+ jsxElement: <Image src="https://example.com/photo.jpg" alt="My photo" preview />,
414
+ })
415
+
416
+ await flushUpdates()
417
+
418
+ const imageComponent = document.querySelector('shade-image') as HTMLElement
419
+ imageComponent.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true }))
420
+
421
+ await flushUpdates()
422
+
423
+ const lightbox = document.querySelector('.lightbox-backdrop')
424
+ expect(lightbox).not.toBeNull()
425
+
426
+ lightbox?.remove()
427
+ })
428
+ })
429
+
348
430
  it('should store src and alt as data attributes', async () => {
349
431
  await usingAsync(new Injector(), async (injector) => {
350
432
  const rootElement = document.getElementById('root') as HTMLDivElement
@@ -438,13 +438,6 @@ export const Image = Shade<ImageProps>({
438
438
  style: styleOverrides,
439
439
  } = props
440
440
 
441
- useHostProps({
442
- 'data-preview': preview ? '' : undefined,
443
- 'data-src': src,
444
- 'data-alt': alt,
445
- ...(styleOverrides ? { style: styleOverrides as Record<string, string> } : {}),
446
- })
447
-
448
441
  const [hasError, setHasError] = useState('hasError', false)
449
442
 
450
443
  const handleClick = () => {
@@ -466,6 +459,22 @@ export const Image = Shade<ImageProps>({
466
459
  createLightbox(src, alt)
467
460
  }
468
461
 
462
+ useHostProps({
463
+ 'data-preview': preview ? '' : undefined,
464
+ 'data-src': src,
465
+ 'data-alt': alt,
466
+ tabIndex: preview ? 0 : undefined,
467
+ onkeydown: preview
468
+ ? (ev: KeyboardEvent) => {
469
+ if (ev.key === 'Enter' || ev.key === ' ') {
470
+ ev.preventDefault()
471
+ handleClick()
472
+ }
473
+ }
474
+ : undefined,
475
+ ...(styleOverrides ? { style: styleOverrides as Record<string, string> } : {}),
476
+ })
477
+
469
478
  return (
470
479
  <div ref={imageHostRef} style={{ display: 'contents' }}>
471
480
  <img
@@ -95,6 +95,7 @@ export const Checkbox = Shade<CheckboxProps>({
95
95
  },
96
96
 
97
97
  '& input[type="checkbox"]:focus-visible': {
98
+ outline: 'none',
98
99
  boxShadow: cssVariableTheme.action.focusRing,
99
100
  },
100
101
 
@@ -87,6 +87,7 @@ export const Radio = Shade<RadioProps>({
87
87
  },
88
88
 
89
89
  '& input[type="radio"]:focus-visible': {
90
+ outline: 'none',
90
91
  boxShadow: cssVariableTheme.action.focusRing,
91
92
  },
92
93
 
@@ -293,6 +293,7 @@ export const Slider = Shade<SliderProps>({
293
293
  },
294
294
 
295
295
  '& .slider-thumb:focus-visible': {
296
+ outline: 'none',
296
297
  boxShadow: '0 0 0 4px color-mix(in srgb, var(--slider-color) 30%, transparent)',
297
298
  },
298
299
 
@@ -154,6 +154,7 @@ export const Switch = Shade<SwitchProps>({
154
154
 
155
155
  // Focus state
156
156
  '& input[type="checkbox"]:focus-visible + .switch-track': {
157
+ outline: 'none',
157
158
  boxShadow: cssVariableTheme.action.focusRing,
158
159
  },
159
160
 
@@ -46,8 +46,21 @@ export const ListItem: <T>(props: ListItemProps<T>, children: ChildrenList) => J
46
46
  const isSelected = selection.includes(item)
47
47
 
48
48
  useHostProps({
49
+ tabIndex: isFocused ? 0 : -1,
50
+ 'data-spatial-nav-target': '',
49
51
  role: 'option',
50
52
  'aria-selected': isSelected.toString(),
53
+ onpointerdown: () => {
54
+ listService.setFocusAnchor()
55
+ },
56
+ onfocus: () => {
57
+ if (listService.focusedItem.getValue() !== item) {
58
+ listService.focusedItem.setValue(item)
59
+ }
60
+ if (!listService.hasFocus.getValue()) {
61
+ listService.hasFocus.setValue(true)
62
+ }
63
+ },
51
64
  onclick: (ev: MouseEvent) => {
52
65
  listService.handleItemClick(item, ev)
53
66
  },
@@ -65,11 +78,16 @@ export const ListItem: <T>(props: ListItemProps<T>, children: ChildrenList) => J
65
78
  queueMicrotask(() => {
66
79
  const el = wrapperRef.current
67
80
  if (!el) return
81
+ const hostEl = el.closest('shade-list-item') as HTMLElement
82
+ if (!hostEl) return
83
+
84
+ if (document.activeElement !== hostEl) {
85
+ hostEl.focus({ preventScroll: true })
86
+ }
87
+
68
88
  const scrollContainer = el.closest('shade-list') as HTMLElement
69
89
  if (scrollContainer) {
70
90
  const containerRect = scrollContainer.getBoundingClientRect()
71
- const hostEl = el.closest('shade-list-item') as HTMLElement
72
- if (!hostEl) return
73
91
  const itemRect = hostEl.getBoundingClientRect()
74
92
  const itemTopInContainer = itemRect.top - containerRect.top
75
93
  const itemBottomInContainer = itemRect.bottom - containerRect.top
@@ -77,12 +95,12 @@ export const ListItem: <T>(props: ListItemProps<T>, children: ChildrenList) => J
77
95
  if (itemTopInContainer < 0) {
78
96
  scrollContainer.scrollTo({
79
97
  top: scrollContainer.scrollTop + itemTopInContainer,
80
- behavior: 'smooth',
98
+ behavior: 'instant',
81
99
  })
82
100
  } else if (itemBottomInContainer > scrollContainer.clientHeight) {
83
101
  scrollContainer.scrollTo({
84
102
  top: scrollContainer.scrollTop + (itemBottomInContainer - scrollContainer.clientHeight),
85
- behavior: 'smooth',
103
+ behavior: 'instant',
86
104
  })
87
105
  }
88
106
  }
@@ -252,7 +252,7 @@ describe('List', () => {
252
252
  })
253
253
  })
254
254
 
255
- it('should lose focus on click outside', async () => {
255
+ it('should lose focus on focusout to an outside element', async () => {
256
256
  await usingAsync(new Injector(), async (injector) => {
257
257
  const rootElement = document.getElementById('root') as HTMLDivElement
258
258
  const service = createTestService()
@@ -262,7 +262,7 @@ describe('List', () => {
262
262
  rootElement,
263
263
  jsxElement: (
264
264
  <>
265
- <div data-testid="outside">Outside</div>
265
+ <button data-testid="outside">Outside</button>
266
266
  <List<TestItem> items={testItems} listService={service} renderItem={(item) => <span>{item.name}</span>} />
267
267
  </>
268
268
  ),
@@ -277,7 +277,7 @@ describe('List', () => {
277
277
  expect(service.hasFocus.getValue()).toBe(true)
278
278
 
279
279
  const outside = document.querySelector('[data-testid="outside"]') as HTMLElement
280
- outside?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
280
+ wrapper?.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outside }))
281
281
 
282
282
  expect(service.hasFocus.getValue()).toBe(false)
283
283
 
@@ -336,6 +336,87 @@ describe('List', () => {
336
336
  service[Symbol.dispose]()
337
337
  })
338
338
  })
339
+
340
+ it('should not initialize focusedItem on wrapper focusin (items handle focus individually)', async () => {
341
+ await usingAsync(new Injector(), async (injector) => {
342
+ const rootElement = document.getElementById('root') as HTMLDivElement
343
+ const service = createTestService()
344
+
345
+ initializeShadeRoot({
346
+ injector,
347
+ rootElement,
348
+ jsxElement: (
349
+ <List<TestItem> items={testItems} listService={service} renderItem={(item) => <span>{item.name}</span>} />
350
+ ),
351
+ })
352
+
353
+ await flushUpdates()
354
+ await new Promise((r) => setTimeout(r, 0))
355
+
356
+ expect(service.hasFocus.getValue()).toBe(false)
357
+ expect(service.focusedItem.getValue()).toBeUndefined()
358
+
359
+ service[Symbol.dispose]()
360
+ })
361
+ })
362
+
363
+ it('should clear hasFocus on focusout when focus moves outside', async () => {
364
+ await usingAsync(new Injector(), async (injector) => {
365
+ const rootElement = document.getElementById('root') as HTMLDivElement
366
+ const service = createTestService()
367
+ const outsideEl = document.createElement('button')
368
+ outsideEl.textContent = 'Outside'
369
+ document.body.appendChild(outsideEl)
370
+
371
+ initializeShadeRoot({
372
+ injector,
373
+ rootElement,
374
+ jsxElement: (
375
+ <List<TestItem> items={testItems} listService={service} renderItem={(item) => <span>{item.name}</span>} />
376
+ ),
377
+ })
378
+
379
+ await flushUpdates()
380
+ await new Promise((r) => setTimeout(r, 0))
381
+
382
+ service.hasFocus.setValue(true)
383
+
384
+ const wrapper = document.querySelector('.shade-list-wrapper') as HTMLElement
385
+ wrapper?.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: outsideEl }))
386
+
387
+ expect(service.hasFocus.getValue()).toBe(false)
388
+
389
+ outsideEl.remove()
390
+ service[Symbol.dispose]()
391
+ })
392
+ })
393
+
394
+ it('should clear hasFocus on focusout when relatedTarget is null', async () => {
395
+ await usingAsync(new Injector(), async (injector) => {
396
+ const rootElement = document.getElementById('root') as HTMLDivElement
397
+ const service = createTestService()
398
+
399
+ initializeShadeRoot({
400
+ injector,
401
+ rootElement,
402
+ jsxElement: (
403
+ <List<TestItem> items={testItems} listService={service} renderItem={(item) => <span>{item.name}</span>} />
404
+ ),
405
+ })
406
+
407
+ await flushUpdates()
408
+ await new Promise((r) => setTimeout(r, 0))
409
+
410
+ service.hasFocus.setValue(true)
411
+
412
+ const wrapper = document.querySelector('.shade-list-wrapper') as HTMLElement
413
+ wrapper?.dispatchEvent(new FocusEvent('focusout', { bubbles: true, relatedTarget: null }))
414
+
415
+ expect(service.hasFocus.getValue()).toBe(false)
416
+
417
+ service[Symbol.dispose]()
418
+ })
419
+ })
339
420
  })
340
421
 
341
422
  describe('selection', () => {
@@ -426,7 +507,7 @@ describe('List', () => {
426
507
  })
427
508
 
428
509
  describe('keyboard navigation', () => {
429
- it('should handle ArrowDown to move focus to next item', async () => {
510
+ it('should not handle ArrowDown (delegated to spatial navigation)', async () => {
430
511
  await usingAsync(new Injector(), async (injector) => {
431
512
  const rootElement = document.getElementById('root') as HTMLDivElement
432
513
  const service = createTestService()
@@ -446,13 +527,13 @@ describe('List', () => {
446
527
 
447
528
  window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
448
529
 
449
- expect(service.focusedItem.getValue()).toEqual(testItems[1])
530
+ expect(service.focusedItem.getValue()).toEqual(testItems[0])
450
531
 
451
532
  service[Symbol.dispose]()
452
533
  })
453
534
  })
454
535
 
455
- it('should handle ArrowUp to move focus to previous item', async () => {
536
+ it('should not handle ArrowUp (delegated to spatial navigation)', async () => {
456
537
  await usingAsync(new Injector(), async (injector) => {
457
538
  const rootElement = document.getElementById('root') as HTMLDivElement
458
539
  const service = createTestService()
@@ -472,7 +553,7 @@ describe('List', () => {
472
553
 
473
554
  window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
474
555
 
475
- expect(service.focusedItem.getValue()).toEqual(testItems[0])
556
+ expect(service.focusedItem.getValue()).toEqual(testItems[1])
476
557
 
477
558
  service[Symbol.dispose]()
478
559
  })
@@ -666,31 +747,6 @@ describe('List', () => {
666
747
  })
667
748
  })
668
749
 
669
- it('should handle Tab to toggle focus', async () => {
670
- await usingAsync(new Injector(), async (injector) => {
671
- const rootElement = document.getElementById('root') as HTMLDivElement
672
- const service = createTestService()
673
-
674
- service.hasFocus.setValue(true)
675
-
676
- initializeShadeRoot({
677
- injector,
678
- rootElement,
679
- jsxElement: (
680
- <List<TestItem> items={testItems} listService={service} renderItem={(item) => <span>{item.name}</span>} />
681
- ),
682
- })
683
-
684
- await flushUpdates()
685
-
686
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }))
687
-
688
- expect(service.hasFocus.getValue()).toBe(false)
689
-
690
- service[Symbol.dispose]()
691
- })
692
- })
693
-
694
750
  it('should not handle keyboard when not focused', async () => {
695
751
  await usingAsync(new Injector(), async (injector) => {
696
752
  const rootElement = document.getElementById('root') as HTMLDivElement
@@ -956,6 +1012,83 @@ describe('List', () => {
956
1012
  })
957
1013
  })
958
1014
 
1015
+ describe('item spatial navigation attributes', () => {
1016
+ it('should set data-spatial-nav-target on list items', async () => {
1017
+ await usingAsync(new Injector(), async (injector) => {
1018
+ const rootElement = document.getElementById('root') as HTMLDivElement
1019
+ const service = createTestService()
1020
+
1021
+ initializeShadeRoot({
1022
+ injector,
1023
+ rootElement,
1024
+ jsxElement: (
1025
+ <List<TestItem> items={testItems} listService={service} renderItem={(item) => <span>{item.name}</span>} />
1026
+ ),
1027
+ })
1028
+
1029
+ await flushUpdates()
1030
+
1031
+ const items = document.querySelectorAll('shade-list-item')
1032
+ for (const item of items) {
1033
+ expect(item.hasAttribute('data-spatial-nav-target')).toBe(true)
1034
+ }
1035
+
1036
+ service[Symbol.dispose]()
1037
+ })
1038
+ })
1039
+
1040
+ it('should set tabIndex 0 on focused item and -1 on others', async () => {
1041
+ await usingAsync(new Injector(), async (injector) => {
1042
+ const rootElement = document.getElementById('root') as HTMLDivElement
1043
+ const service = createTestService()
1044
+
1045
+ service.focusedItem.setValue(testItems[1])
1046
+
1047
+ initializeShadeRoot({
1048
+ injector,
1049
+ rootElement,
1050
+ jsxElement: (
1051
+ <List<TestItem> items={testItems} listService={service} renderItem={(item) => <span>{item.name}</span>} />
1052
+ ),
1053
+ })
1054
+
1055
+ await flushUpdates()
1056
+
1057
+ const items = document.querySelectorAll<HTMLDivElement>('shade-list-item')
1058
+ expect(items[0]?.tabIndex).toBe(-1)
1059
+ expect(items[1]?.tabIndex).toBe(0)
1060
+ expect(items[2]?.tabIndex).toBe(-1)
1061
+
1062
+ service[Symbol.dispose]()
1063
+ })
1064
+ })
1065
+
1066
+ it('should sync focusedItem on item onfocus', async () => {
1067
+ await usingAsync(new Injector(), async (injector) => {
1068
+ const rootElement = document.getElementById('root') as HTMLDivElement
1069
+ const service = createTestService()
1070
+
1071
+ initializeShadeRoot({
1072
+ injector,
1073
+ rootElement,
1074
+ jsxElement: (
1075
+ <List<TestItem> items={testItems} listService={service} renderItem={(item) => <span>{item.name}</span>} />
1076
+ ),
1077
+ })
1078
+
1079
+ await flushUpdates()
1080
+
1081
+ const items = document.querySelectorAll('shade-list-item')
1082
+ items[2]?.dispatchEvent(new FocusEvent('focus'))
1083
+
1084
+ expect(service.focusedItem.getValue()).toEqual(testItems[2])
1085
+ expect(service.hasFocus.getValue()).toBe(true)
1086
+
1087
+ service[Symbol.dispose]()
1088
+ })
1089
+ })
1090
+ })
1091
+
959
1092
  describe('keyboard listener cleanup', () => {
960
1093
  it('should remove keyboard listener when component is disconnected', async () => {
961
1094
  await usingAsync(new Injector(), async (injector) => {
@@ -1,11 +1,12 @@
1
1
  import type { ChildrenList, PartialElement } from '@furystack/shades'
2
2
  import { createComponent, Shade } from '@furystack/shades'
3
- import { ClickAwayService } from '../../services/click-away-service.js'
4
3
  import { cssVariableTheme } from '../../services/css-variable-theme.js'
5
4
  import type { ListService } from '../../services/list-service.js'
6
5
  import { Pagination } from '../pagination.js'
7
6
  import { ListItem } from './list-item.js'
8
7
 
8
+ let nextListId = 0
9
+
9
10
  export type ListItemState = {
10
11
  isFocused: boolean
11
12
  isSelected: boolean
@@ -31,6 +32,13 @@ export type ListProps<T> = {
31
32
  onSelectionChange?: (selected: T[]) => void
32
33
  /** Optional pagination configuration. When provided, items are sliced and a Pagination control is rendered. */
33
34
  pagination?: ListPaginationProps
35
+ /**
36
+ * Section name for spatial navigation scoping.
37
+ * Sets `data-nav-section` on the list wrapper so that SpatialNavigationService
38
+ * constrains arrow-key navigation within the list.
39
+ * Auto-generated per instance when not provided.
40
+ */
41
+ navSection?: string
34
42
  } & PartialElement<HTMLDivElement>
35
43
 
36
44
  export const List: <T>(props: ListProps<T>, children: ChildrenList) => JSX.Element<any> = Shade({
@@ -46,8 +54,9 @@ export const List: <T>(props: ListProps<T>, children: ChildrenList) => JSX.Eleme
46
54
  padding: '8px 0',
47
55
  },
48
56
  },
49
- render: ({ props, useDisposable, useHostProps, useRef }) => {
57
+ render: ({ props, useDisposable, useHostProps, useRef, useState }) => {
50
58
  const wrapperRef = useRef<HTMLDivElement>('listWrapper')
59
+ const [navSectionId] = useState('navSectionId', String(nextListId++))
51
60
 
52
61
  useDisposable('keydown-handler', () => {
53
62
  const listener = (ev: KeyboardEvent) => {
@@ -60,8 +69,8 @@ export const List: <T>(props: ListProps<T>, children: ChildrenList) => JSX.Eleme
60
69
  }
61
70
  }
62
71
  }
63
- window.addEventListener('keydown', listener)
64
- return { [Symbol.dispose]: () => window.removeEventListener('keydown', listener) }
72
+ window.addEventListener('keydown', listener, true)
73
+ return { [Symbol.dispose]: () => window.removeEventListener('keydown', listener, true) }
65
74
  })
66
75
 
67
76
  const { pagination } = props
@@ -79,13 +88,30 @@ export const List: <T>(props: ListProps<T>, children: ChildrenList) => JSX.Eleme
79
88
 
80
89
  props.listService.items.setValue(visibleItems)
81
90
 
82
- useDisposable(
83
- 'clickAway',
84
- () =>
85
- new ClickAwayService(wrapperRef, () => {
91
+ useDisposable('focus-coordination', () => {
92
+ const handleFocusOut = (ev: FocusEvent) => {
93
+ const wrapper = wrapperRef.current
94
+ if (wrapper && (!ev.relatedTarget || !wrapper.contains(ev.relatedTarget as Node))) {
86
95
  props.listService.hasFocus.setValue(false)
87
- }),
88
- )
96
+ }
97
+ }
98
+
99
+ queueMicrotask(() => {
100
+ const wrapper = wrapperRef.current
101
+ if (wrapper) {
102
+ wrapper.addEventListener('focusout', handleFocusOut)
103
+ }
104
+ })
105
+
106
+ return {
107
+ [Symbol.dispose]: () => {
108
+ const wrapper = wrapperRef.current
109
+ if (wrapper) {
110
+ wrapper.removeEventListener('focusout', handleFocusOut)
111
+ }
112
+ },
113
+ }
114
+ })
89
115
 
90
116
  if (props.onSelectionChange) {
91
117
  const { onSelectionChange } = props
@@ -107,6 +133,7 @@ export const List: <T>(props: ListProps<T>, children: ChildrenList) => JSX.Eleme
107
133
  role="listbox"
108
134
  ariaMultiSelectable="true"
109
135
  className="shade-list-wrapper"
136
+ data-nav-section={props.navSection ?? `list-${navSectionId}`}
110
137
  onclick={() => props.listService.hasFocus.setValue(true)}
111
138
  >
112
139
  {visibleItems.map((item) => (