@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
@@ -82,6 +82,15 @@ export const Suggest: <T>(props: SuggestProps<T>, children: ChildrenList) => JSX
82
82
 
83
83
  useHostProps({
84
84
  'data-opened': isOpened ? '' : undefined,
85
+ tabIndex: -1,
86
+ 'data-spatial-nav-target': '',
87
+ onfocus: (ev: FocusEvent) => {
88
+ const host = ev.currentTarget as HTMLElement
89
+ const input = host.querySelector('input')
90
+ if (input) {
91
+ input.focus()
92
+ }
93
+ },
85
94
  })
86
95
  useDisposable('isLoadingSubscription', () =>
87
96
  manager.isLoading.subscribe((isLoading) => {
@@ -107,7 +116,10 @@ export const Suggest: <T>(props: SuggestProps<T>, children: ChildrenList) => JSX
107
116
  <div
108
117
  ref={wrapperRef}
109
118
  className="suggest-wrapper"
110
- onkeyup={(ev) => {
119
+ onkeydown={(ev) => {
120
+ const hasSuggestions = manager.isOpened.getValue() && manager.currentSuggestions.getValue().length > 0
121
+ if (!hasSuggestions) return
122
+
111
123
  if (ev.key === 'Enter') {
112
124
  ev.preventDefault()
113
125
  manager.selectSuggestion()
@@ -123,7 +135,8 @@ export const Suggest: <T>(props: SuggestProps<T>, children: ChildrenList) => JSX
123
135
  Math.min(manager.selectedIndex.getValue() + 1, manager.currentSuggestions.getValue().length - 1),
124
136
  )
125
137
  }
126
-
138
+ }}
139
+ oninput={(ev) => {
127
140
  void manager.getSuggestion({ injector, term: (ev.target as HTMLInputElement).value })
128
141
  }}
129
142
  >
@@ -115,6 +115,10 @@ export const Tabs = Shade<{
115
115
  color: cssVariableTheme.text.primary,
116
116
  boxShadow: `inset 0 -2px 0 ${cssVariableTheme.palette.primary.main}`,
117
117
  },
118
+ '& .shade-tab-btn:focus-visible': {
119
+ outline: cssVariableTheme.action.focusOutline,
120
+ outlineOffset: '-2px',
121
+ },
118
122
 
119
123
  // Close button (span with role="button" via event delegation)
120
124
  '& .shade-tab-close': {
@@ -69,10 +69,20 @@ export const TreeItem: <T>(props: TreeItemProps<T>, children: ChildrenList) => J
69
69
  const isSelected = selection.includes(item)
70
70
 
71
71
  useHostProps({
72
+ tabIndex: isFocused ? 0 : -1,
73
+ 'data-spatial-nav-target': '',
72
74
  role: 'treeitem',
73
75
  'aria-level': (level + 1).toString(),
74
76
  'aria-selected': isSelected.toString(),
75
77
  ...(hasChildren ? { 'aria-expanded': isExpanded.toString() } : {}),
78
+ onfocus: () => {
79
+ if (treeService.focusedItem.getValue() !== item) {
80
+ treeService.focusedItem.setValue(item)
81
+ }
82
+ if (!treeService.hasFocus.getValue()) {
83
+ treeService.hasFocus.setValue(true)
84
+ }
85
+ },
76
86
  onclick: (ev: MouseEvent) => {
77
87
  treeService.handleItemClick(item, ev)
78
88
  },
@@ -93,10 +103,15 @@ export const TreeItem: <T>(props: TreeItemProps<T>, children: ChildrenList) => J
93
103
  queueMicrotask(() => {
94
104
  const el = wrapperRef.current
95
105
  if (!el) return
106
+ const hostEl = el.closest('shade-tree-item') as HTMLElement
107
+ if (!hostEl) return
108
+
109
+ if (document.activeElement !== hostEl) {
110
+ hostEl.focus({ preventScroll: true })
111
+ }
112
+
96
113
  const scrollContainer = el.closest('shade-tree') as HTMLElement
97
114
  if (scrollContainer) {
98
- const hostEl = el.closest('shade-tree-item') as HTMLElement
99
- if (!hostEl) return
100
115
  const containerRect = scrollContainer.getBoundingClientRect()
101
116
  const itemRect = hostEl.getBoundingClientRect()
102
117
  const itemTopInContainer = itemRect.top - containerRect.top
@@ -105,12 +120,12 @@ export const TreeItem: <T>(props: TreeItemProps<T>, children: ChildrenList) => J
105
120
  if (itemTopInContainer < 0) {
106
121
  scrollContainer.scrollTo({
107
122
  top: scrollContainer.scrollTop + itemTopInContainer,
108
- behavior: 'smooth',
123
+ behavior: 'instant',
109
124
  })
110
125
  } else if (itemBottomInContainer > scrollContainer.clientHeight) {
111
126
  scrollContainer.scrollTo({
112
127
  top: scrollContainer.scrollTop + (itemBottomInContainer - scrollContainer.clientHeight),
113
- behavior: 'smooth',
128
+ behavior: 'instant',
114
129
  })
115
130
  }
116
131
  }
@@ -501,8 +501,107 @@ describe('Tree', () => {
501
501
  })
502
502
  })
503
503
 
504
+ describe('item spatial navigation attributes', () => {
505
+ it('should set data-spatial-nav-target on tree items', async () => {
506
+ await usingAsync(new Injector(), async (injector) => {
507
+ const rootElement = document.getElementById('root') as HTMLDivElement
508
+ const treeData = createTreeData()
509
+ const service = createTestService()
510
+
511
+ service.rootItems.setValue(treeData)
512
+ service.updateFlattenedNodes()
513
+
514
+ initializeShadeRoot({
515
+ injector,
516
+ rootElement,
517
+ jsxElement: (
518
+ <Tree<TestNode>
519
+ rootItems={treeData}
520
+ treeService={service}
521
+ renderItem={(node) => <span>{node.name}</span>}
522
+ />
523
+ ),
524
+ })
525
+
526
+ await flushUpdates()
527
+
528
+ const items = document.querySelectorAll('shade-tree-item')
529
+ for (const item of items) {
530
+ expect(item.hasAttribute('data-spatial-nav-target')).toBe(true)
531
+ }
532
+
533
+ service[Symbol.dispose]()
534
+ })
535
+ })
536
+
537
+ it('should set tabIndex 0 on focused item and -1 on others', async () => {
538
+ await usingAsync(new Injector(), async (injector) => {
539
+ const rootElement = document.getElementById('root') as HTMLDivElement
540
+ const treeData = createTreeData()
541
+ const service = createTestService()
542
+
543
+ service.rootItems.setValue(treeData)
544
+ service.updateFlattenedNodes()
545
+ service.focusedItem.setValue(treeData[1])
546
+
547
+ initializeShadeRoot({
548
+ injector,
549
+ rootElement,
550
+ jsxElement: (
551
+ <Tree<TestNode>
552
+ rootItems={treeData}
553
+ treeService={service}
554
+ renderItem={(node) => <span>{node.name}</span>}
555
+ />
556
+ ),
557
+ })
558
+
559
+ await flushUpdates()
560
+
561
+ const items = document.querySelectorAll<HTMLDivElement>('shade-tree-item')
562
+ expect(items[0]?.tabIndex).toBe(-1)
563
+ expect(items[1]?.tabIndex).toBe(0)
564
+
565
+ service[Symbol.dispose]()
566
+ })
567
+ })
568
+
569
+ it('should sync focusedItem on item onfocus', async () => {
570
+ await usingAsync(new Injector(), async (injector) => {
571
+ const rootElement = document.getElementById('root') as HTMLDivElement
572
+ const treeData = createTreeData()
573
+ const service = createTestService()
574
+
575
+ service.rootItems.setValue(treeData)
576
+ service.updateFlattenedNodes()
577
+
578
+ initializeShadeRoot({
579
+ injector,
580
+ rootElement,
581
+ jsxElement: (
582
+ <Tree<TestNode>
583
+ rootItems={treeData}
584
+ treeService={service}
585
+ renderItem={(node) => <span>{node.name}</span>}
586
+ />
587
+ ),
588
+ })
589
+
590
+ await flushUpdates()
591
+
592
+ const items = document.querySelectorAll('shade-tree-item')
593
+ items[1]?.dispatchEvent(new FocusEvent('focus'))
594
+
595
+ expect(service.focusedItem.getValue()).toEqual(treeData[1])
596
+ expect(service.hasFocus.getValue()).toBe(true)
597
+
598
+ service[Symbol.dispose]()
599
+ })
600
+ })
601
+ })
602
+
504
603
  describe('keyboard navigation', () => {
505
- it('should handle ArrowDown to move focus to next item', async () => {
604
+ it('should not handle ArrowDown (delegated to spatial navigation)', async () => {
506
605
  await usingAsync(new Injector(), async (injector) => {
507
606
  const rootElement = document.getElementById('root') as HTMLDivElement
508
607
  const treeData = createTreeData()
@@ -529,7 +628,7 @@ describe('Tree', () => {
529
628
 
530
629
  window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }))
531
630
 
532
- expect(service.focusedItem.getValue()).toEqual(treeData[1])
631
+ expect(service.focusedItem.getValue()).toEqual(treeData[0])
533
632
 
534
633
  service[Symbol.dispose]()
535
634
  })
@@ -5,6 +5,8 @@ import { cssVariableTheme } from '../../services/css-variable-theme.js'
5
5
  import type { TreeService } from '../../services/tree-service.js'
6
6
  import { TreeItem } from './tree-item.js'
7
7
 
8
+ let nextTreeId = 0
9
+
8
10
  export type TreeItemState = {
9
11
  isFocused: boolean
10
12
  isSelected: boolean
@@ -21,6 +23,13 @@ export type TreeProps<T> = {
21
23
  variant?: 'contained' | 'outlined'
22
24
  onItemActivate?: (item: T) => void
23
25
  onSelectionChange?: (selected: T[]) => void
26
+ /**
27
+ * Section name for spatial navigation scoping.
28
+ * Sets `data-nav-section` on the tree host so that SpatialNavigationService
29
+ * constrains arrow-key navigation within the tree.
30
+ * Auto-generated per instance when not provided.
31
+ */
32
+ navSection?: string
24
33
  } & PartialElement<HTMLDivElement>
25
34
 
26
35
  export const Tree: <T>(props: TreeProps<T>, children: ChildrenList) => JSX.Element<any> = Shade({
@@ -31,7 +40,9 @@ export const Tree: <T>(props: TreeProps<T>, children: ChildrenList) => JSX.Eleme
31
40
  width: '100%',
32
41
  overflow: 'auto',
33
42
  },
34
- render: ({ props, useDisposable, useObservable, useHostProps }) => {
43
+ render: ({ props, useDisposable, useObservable, useHostProps, useState }) => {
44
+ const [navSectionId] = useState('navSectionId', String(nextTreeId++))
45
+
35
46
  useDisposable('keydown-handler', () => {
36
47
  const listener = (ev: KeyboardEvent) => {
37
48
  props.treeService.handleKeyDown(ev)
@@ -43,8 +54,8 @@ export const Tree: <T>(props: TreeProps<T>, children: ChildrenList) => JSX.Eleme
43
54
  }
44
55
  }
45
56
  }
46
- window.addEventListener('keydown', listener)
47
- return { [Symbol.dispose]: () => window.removeEventListener('keydown', listener) }
57
+ window.addEventListener('keydown', listener, true)
58
+ return { [Symbol.dispose]: () => window.removeEventListener('keydown', listener, true) }
48
59
  })
49
60
 
50
61
  if (props.treeService.rootItems.getValue() !== props.rootItems) {
@@ -82,9 +93,16 @@ export const Tree: <T>(props: TreeProps<T>, children: ChildrenList) => JSX.Eleme
82
93
  useHostProps({
83
94
  'data-variant': props.variant || undefined,
84
95
  'data-tree-instance-id': treeInstanceId.value,
96
+ 'data-nav-section': props.navSection ?? `tree-${navSectionId}`,
85
97
  role: 'tree',
86
98
  'aria-multiselectable': 'true',
87
99
  onclick: () => props.treeService.hasFocus.setValue(true),
100
+ onfocusout: (ev: FocusEvent) => {
101
+ const hostEl = ev.currentTarget as HTMLElement
102
+ if (!ev.relatedTarget || !hostEl.contains(ev.relatedTarget as Node)) {
103
+ props.treeService.hasFocus.setValue(false)
104
+ }
105
+ },
88
106
  })
89
107
 
90
108
  const [flattenedNodes] = useObservable('flattenedNodes', props.treeService.flattenedNodes)
@@ -206,8 +206,8 @@ describe('CollectionService', () => {
206
206
  })
207
207
  })
208
208
 
209
- describe('ArrowUp key', () => {
210
- it('Should move focus to previous entry', () => {
209
+ describe('Arrow keys', () => {
210
+ it('Should move focus to the previous entry on ArrowUp', () => {
211
211
  const testEntries = createTestEntries()
212
212
  using(new CollectionService<TestEntry>({}), (service) => {
213
213
  service.data.setValue({ count: 3, entries: testEntries })
@@ -222,22 +222,22 @@ describe('CollectionService', () => {
222
222
  })
223
223
  })
224
224
 
225
- it('Should not go below index 0', () => {
225
+ it('Should not preventDefault ArrowUp at the first entry', () => {
226
226
  const testEntries = createTestEntries()
227
227
  using(new CollectionService<TestEntry>({}), (service) => {
228
228
  service.data.setValue({ count: 3, entries: testEntries })
229
229
  service.hasFocus.setValue(true)
230
230
  service.focusedEntry.setValue(testEntries[0])
231
231
 
232
- service.handleKeyDown(createKeyboardEvent('ArrowUp'))
232
+ const ev = createKeyboardEvent('ArrowUp')
233
+ service.handleKeyDown(ev)
233
234
 
235
+ expect(ev.preventDefault).not.toHaveBeenCalled()
234
236
  expect(service.focusedEntry.getValue()).toBe(testEntries[0])
235
237
  })
236
238
  })
237
- })
238
239
 
239
- describe('ArrowDown key', () => {
240
- it('Should move focus to next entry', () => {
240
+ it('Should move focus to the next entry on ArrowDown', () => {
241
241
  const testEntries = createTestEntries()
242
242
  using(new CollectionService<TestEntry>({}), (service) => {
243
243
  service.data.setValue({ count: 3, entries: testEntries })
@@ -252,60 +252,69 @@ describe('CollectionService', () => {
252
252
  })
253
253
  })
254
254
 
255
- it('Should not exceed the last entry', () => {
255
+ it('Should not preventDefault ArrowDown at the last entry', () => {
256
256
  const testEntries = createTestEntries()
257
257
  using(new CollectionService<TestEntry>({}), (service) => {
258
258
  service.data.setValue({ count: 3, entries: testEntries })
259
259
  service.hasFocus.setValue(true)
260
260
  service.focusedEntry.setValue(testEntries[2])
261
261
 
262
- service.handleKeyDown(createKeyboardEvent('ArrowDown'))
262
+ const ev = createKeyboardEvent('ArrowDown')
263
+ service.handleKeyDown(ev)
263
264
 
265
+ expect(ev.preventDefault).not.toHaveBeenCalled()
264
266
  expect(service.focusedEntry.getValue()).toBe(testEntries[2])
265
267
  })
266
268
  })
267
- })
268
269
 
269
- describe('Home key', () => {
270
- it('Should focus the first entry', () => {
270
+ it('Should not handle arrow keys when focusedEntry is undefined', () => {
271
271
  const testEntries = createTestEntries()
272
272
  using(new CollectionService<TestEntry>({}), (service) => {
273
273
  service.data.setValue({ count: 3, entries: testEntries })
274
274
  service.hasFocus.setValue(true)
275
- service.focusedEntry.setValue(testEntries[2])
275
+ service.focusedEntry.setValue(undefined)
276
276
 
277
- service.handleKeyDown(createKeyboardEvent('Home'))
277
+ const evDown = createKeyboardEvent('ArrowDown')
278
+ service.handleKeyDown(evDown)
279
+ expect(evDown.preventDefault).not.toHaveBeenCalled()
278
280
 
279
- expect(service.focusedEntry.getValue()).toBe(testEntries[0])
281
+ const evUp = createKeyboardEvent('ArrowUp')
282
+ service.handleKeyDown(evUp)
283
+ expect(evUp.preventDefault).not.toHaveBeenCalled()
280
284
  })
281
285
  })
282
286
  })
283
287
 
284
- describe('End key', () => {
285
- it('Should focus the last entry', () => {
288
+ describe('Home key', () => {
289
+ it('Should focus the first entry and preventDefault', () => {
286
290
  const testEntries = createTestEntries()
287
291
  using(new CollectionService<TestEntry>({}), (service) => {
288
292
  service.data.setValue({ count: 3, entries: testEntries })
289
293
  service.hasFocus.setValue(true)
290
- service.focusedEntry.setValue(testEntries[0])
294
+ service.focusedEntry.setValue(testEntries[2])
291
295
 
292
- service.handleKeyDown(createKeyboardEvent('End'))
296
+ const ev = createKeyboardEvent('Home')
297
+ service.handleKeyDown(ev)
293
298
 
294
- expect(service.focusedEntry.getValue()).toBe(testEntries[2])
299
+ expect(ev.preventDefault).toHaveBeenCalled()
300
+ expect(service.focusedEntry.getValue()).toBe(testEntries[0])
295
301
  })
296
302
  })
297
303
  })
298
304
 
299
- describe('Tab key', () => {
300
- it('Should toggle hasFocus', () => {
305
+ describe('End key', () => {
306
+ it('Should focus the last entry and preventDefault', () => {
301
307
  const testEntries = createTestEntries()
302
308
  using(new CollectionService<TestEntry>({}), (service) => {
303
309
  service.data.setValue({ count: 3, entries: testEntries })
304
310
  service.hasFocus.setValue(true)
311
+ service.focusedEntry.setValue(testEntries[0])
305
312
 
306
- service.handleKeyDown(createKeyboardEvent('Tab'))
313
+ const ev = createKeyboardEvent('End')
314
+ service.handleKeyDown(ev)
307
315
 
308
- expect(service.hasFocus.getValue()).toBe(false)
316
+ expect(ev.preventDefault).toHaveBeenCalled()
317
+ expect(service.focusedEntry.getValue()).toBe(testEntries[2])
309
318
  })
310
319
  })
311
320
  })
@@ -62,6 +62,19 @@ export class CollectionService<T>
62
62
 
63
63
  public focusedEntry = new ObservableValue<T | undefined>(undefined)
64
64
 
65
+ /**
66
+ * Stores the focused entry captured on pointerdown, before the focus event
67
+ * can update focusedEntry. Used as the anchor for SHIFT+click range selection.
68
+ * Call {@link setFocusAnchor} from `onpointerdown` to snapshot the anchor
69
+ * before focus shifts.
70
+ */
71
+ private focusAnchor: T | undefined = undefined
72
+
73
+ /** Snapshot the current focused entry as the anchor for SHIFT+click range selection. */
74
+ public setFocusAnchor(): void {
75
+ this.focusAnchor = this.focusedEntry.getValue()
76
+ }
77
+
65
78
  public selection = new ObservableValue<T[]>([])
66
79
 
67
80
  public searchTerm = new ObservableValue('')
@@ -107,28 +120,36 @@ export class CollectionService<T>
107
120
  }
108
121
 
109
122
  break
110
- case 'ArrowUp':
111
- ev.preventDefault()
112
- this.focusedEntry.setValue(entries[Math.max(0, entries.findIndex((e) => e === focusedEntry) - 1)])
123
+ case 'ArrowDown': {
124
+ if (focusedEntry !== undefined) {
125
+ const currentIndex = entries.indexOf(focusedEntry)
126
+ if (currentIndex >= 0 && currentIndex < entries.length - 1) {
127
+ ev.preventDefault()
128
+ this.focusedEntry.setValue(entries[currentIndex + 1])
129
+ }
130
+ }
113
131
  break
114
- case 'ArrowDown':
115
- ev.preventDefault()
116
- this.focusedEntry.setValue(
117
- entries[Math.min(entries.length - 1, entries.findIndex((e) => e === focusedEntry) + 1)],
118
- )
132
+ }
133
+ case 'ArrowUp': {
134
+ if (focusedEntry !== undefined) {
135
+ const currentIndex = entries.indexOf(focusedEntry)
136
+ if (currentIndex > 0) {
137
+ ev.preventDefault()
138
+ this.focusedEntry.setValue(entries[currentIndex - 1])
139
+ }
140
+ }
119
141
  break
142
+ }
120
143
  case 'Home': {
144
+ ev.preventDefault()
121
145
  this.focusedEntry.setValue(entries[0])
122
146
  break
123
147
  }
124
148
  case 'End': {
149
+ ev.preventDefault()
125
150
  this.focusedEntry.setValue(entries[entries.length - 1])
126
151
  break
127
152
  }
128
- case 'Tab': {
129
- this.hasFocus.setValue(!hasFocus)
130
- break
131
- }
132
153
  case 'Escape': {
133
154
  this.searchTerm.setValue('')
134
155
  this.selection.setValue([])
@@ -152,7 +173,8 @@ export class CollectionService<T>
152
173
  public handleRowClick(entry: T, ev: MouseEvent) {
153
174
  this.emit('onRowClick', entry)
154
175
  const currentSelectionValue = this.selection.getValue()
155
- const lastFocused = this.focusedEntry.getValue()
176
+ const lastFocused = this.focusAnchor ?? this.focusedEntry.getValue()
177
+ this.focusAnchor = undefined
156
178
  if (ev.ctrlKey) {
157
179
  if (currentSelectionValue.includes(entry)) {
158
180
  this.selection.setValue(currentSelectionValue.filter((s) => s !== entry))
@@ -35,6 +35,7 @@ describe('css-variable-theme', () => {
35
35
  expect(cssVariableTheme.action.selectedBackground).toBe('var(--shades-theme-action-selected-background)')
36
36
  expect(cssVariableTheme.action.activeBackground).toBe('var(--shades-theme-action-active-background)')
37
37
  expect(cssVariableTheme.action.focusRing).toBe('var(--shades-theme-action-focus-ring)')
38
+ expect(cssVariableTheme.action.focusOutline).toBe('var(--shades-theme-action-focus-outline)')
38
39
  expect(cssVariableTheme.action.disabledOpacity).toBe('var(--shades-theme-action-disabled-opacity)')
39
40
  expect(cssVariableTheme.action.backdrop).toBe('var(--shades-theme-action-backdrop)')
40
41
  expect(cssVariableTheme.action.subtleBorder).toBe('var(--shades-theme-action-subtle-border)')
@@ -75,6 +75,7 @@ export const cssVariableTheme = {
75
75
  selectedBackground: 'var(--shades-theme-action-selected-background)',
76
76
  activeBackground: 'var(--shades-theme-action-active-background)',
77
77
  focusRing: 'var(--shades-theme-action-focus-ring)',
78
+ focusOutline: 'var(--shades-theme-action-focus-outline)',
78
79
  disabledOpacity: 'var(--shades-theme-action-disabled-opacity)',
79
80
  backdrop: 'var(--shades-theme-action-backdrop)',
80
81
  subtleBorder: 'var(--shades-theme-action-subtle-border)',
@@ -176,6 +177,30 @@ export const cssVariableTheme = {
176
177
  export const buildTransition = (...specs: Array<[property: string, duration: string, easing: string]>): string =>
177
178
  specs.map(([prop, dur, ease]) => `${prop} ${dur} ${ease}`).join(', ')
178
179
 
180
+ const FOCUS_STYLES_ID = 'shades-focus-visible-styles'
181
+
182
+ /**
183
+ * Injects global `:focus-visible` styles using the theme's `focusOutline` CSS variable.
184
+ * Ensures keyboard/spatial navigation focus is visible while mouse clicks produce no outline.
185
+ * Safe to call multiple times — the style element is only created once.
186
+ */
187
+ export const injectFocusVisibleStyles = (): void => {
188
+ if (document.getElementById(FOCUS_STYLES_ID)) return
189
+
190
+ const style = document.createElement('style')
191
+ style.id = FOCUS_STYLES_ID
192
+ style.textContent = `
193
+ :focus-visible {
194
+ outline: ${cssVariableTheme.action.focusOutline};
195
+ outline-offset: 2px;
196
+ }
197
+ :focus:not(:focus-visible) {
198
+ outline: none;
199
+ }
200
+ `
201
+ document.head.appendChild(style)
202
+ }
203
+
179
204
  const extractVarName = (key: string): string => key.replace(/^var\(/, '').replace(/[,)].*/, '')
180
205
 
181
206
  export const setCssVariable = (key: string, value: string, root: HTMLElement) => {
@@ -1,5 +1,5 @@
1
1
  import { using } from '@furystack/utils'
2
- import { describe, expect, it } from 'vitest'
2
+ import { describe, expect, it, vi } from 'vitest'
3
3
  import { ListService } from './list-service.js'
4
4
 
5
5
  type TestItem = { id: number; name: string }
@@ -80,51 +80,33 @@ describe('ListService', () => {
80
80
  })
81
81
  })
82
82
 
83
- it('should handle ArrowDown to move focus to next item', () => {
83
+ it('should not handle ArrowDown (delegated to spatial navigation)', () => {
84
84
  const { service, items } = createTestService()
85
85
  using(service, () => {
86
86
  service.hasFocus.setValue(true)
87
87
  service.focusedItem.setValue(items[0])
88
88
 
89
- service.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
90
-
91
- expect(service.focusedItem.getValue()).toBe(items[1])
92
- })
93
- })
94
-
95
- it('should not move past last item on ArrowDown', () => {
96
- const { service, items } = createTestService()
97
- using(service, () => {
98
- service.hasFocus.setValue(true)
99
- service.focusedItem.setValue(items[2])
100
-
101
- service.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
102
-
103
- expect(service.focusedItem.getValue()).toBe(items[2])
104
- })
105
- })
106
-
107
- it('should handle ArrowUp to move focus to previous item', () => {
108
- const { service, items } = createTestService()
109
- using(service, () => {
110
- service.hasFocus.setValue(true)
111
- service.focusedItem.setValue(items[1])
112
-
113
- service.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
89
+ const ev = new KeyboardEvent('keydown', { key: 'ArrowDown', cancelable: true })
90
+ const preventSpy = vi.spyOn(ev, 'preventDefault')
91
+ service.handleKeyDown(ev)
114
92
 
115
93
  expect(service.focusedItem.getValue()).toBe(items[0])
94
+ expect(preventSpy).not.toHaveBeenCalled()
116
95
  })
117
96
  })
118
97
 
119
- it('should not move past first item on ArrowUp', () => {
98
+ it('should not handle ArrowUp (delegated to spatial navigation)', () => {
120
99
  const { service, items } = createTestService()
121
100
  using(service, () => {
122
101
  service.hasFocus.setValue(true)
123
- service.focusedItem.setValue(items[0])
102
+ service.focusedItem.setValue(items[1])
124
103
 
125
- service.handleKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
104
+ const ev = new KeyboardEvent('keydown', { key: 'ArrowUp', cancelable: true })
105
+ const preventSpy = vi.spyOn(ev, 'preventDefault')
106
+ service.handleKeyDown(ev)
126
107
 
127
- expect(service.focusedItem.getValue()).toBe(items[0])
108
+ expect(service.focusedItem.getValue()).toBe(items[1])
109
+ expect(preventSpy).not.toHaveBeenCalled()
128
110
  })
129
111
  })
130
112
 
@@ -232,17 +214,6 @@ describe('ListService', () => {
232
214
  })
233
215
  })
234
216
 
235
- it('should handle Tab to toggle focus', () => {
236
- const { service } = createTestService()
237
- using(service, () => {
238
- service.hasFocus.setValue(true)
239
-
240
- service.handleKeyDown(new KeyboardEvent('keydown', { key: 'Tab' }))
241
-
242
- expect(service.hasFocus.getValue()).toBe(false)
243
- })
244
- })
245
-
246
217
  it('should handle Escape to clear selection and search term', () => {
247
218
  const { service, items } = createTestService()
248
219
  using(service, () => {