@12-apps/ui 6.22.0 → 6.23.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 (251) hide show
  1. package/NATIVE.md +1 -1
  2. package/components-catalog.md +7 -0
  3. package/dist/_internal/components/data-display/AsyncStateContainer/AsyncStateContainer.js +3 -3
  4. package/dist/_internal/components/data-display/Avatar/Avatar.js +2 -2
  5. package/dist/_internal/components/data-display/CardKit/use-remove-confirm.js +1 -1
  6. package/dist/_internal/components/data-display/CardKit/use-row-confirm.js +1 -1
  7. package/dist/_internal/components/data-display/Carousel/Carousel.js +2 -2
  8. package/dist/_internal/components/data-display/Chart/Chart.js +4 -4
  9. package/dist/_internal/components/data-display/Chart/SpecChart.js +5 -5
  10. package/dist/_internal/components/data-display/Chart/chart-context.js +2 -2
  11. package/dist/_internal/components/data-display/Chart/chart-renderers.js +3 -3
  12. package/dist/_internal/components/data-display/Chip/Chip.js +3 -3
  13. package/dist/_internal/components/data-display/Chip/Chip.styles.js +2 -2
  14. package/dist/_internal/components/data-display/DataGrid/DataGrid.js +1 -1
  15. package/dist/_internal/components/data-display/DataViews/DataViewsGrid.js +26 -26
  16. package/dist/_internal/components/data-display/DataViews/DataViewsTableBase.js +30 -30
  17. package/dist/_internal/components/data-display/DataViews/ManageViewsDialog.js +1 -1
  18. package/dist/_internal/components/data-display/DataViews/data-views-category-pill.js +5 -5
  19. package/dist/_internal/components/data-display/DataViews/data-views-filter-panel.js +10 -10
  20. package/dist/_internal/components/data-display/DataViews/data-views-grid-bodies.js +1 -1
  21. package/dist/_internal/components/data-display/DataViews/data-views-grid-parts.js +22 -22
  22. package/dist/_internal/components/data-display/DataViews/data-views-inline-bar.js +10 -10
  23. package/dist/_internal/components/data-display/DataViews/data-views-more-fields.js +3 -3
  24. package/dist/_internal/components/data-display/DataViews/data-views-more-filters.js +4 -4
  25. package/dist/_internal/components/data-display/DataViews/data-views-pagination.js +3 -3
  26. package/dist/_internal/components/data-display/DataViews/data-views-range-pill.js +2 -2
  27. package/dist/_internal/components/data-display/DataViews/data-views-table-parts.js +3 -3
  28. package/dist/_internal/components/data-display/DataViews/use-data-views-state.js +3 -3
  29. package/dist/_internal/components/data-display/FleetMap/FleetBody.js +31 -0
  30. package/dist/_internal/components/data-display/FleetMap/FleetCanvas.js +23 -0
  31. package/dist/_internal/components/data-display/FleetMap/FleetCanvas.js.map +1 -0
  32. package/dist/_internal/components/data-display/FleetMap/FleetMap.fixtures.js +57 -0
  33. package/dist/_internal/components/data-display/FleetMap/FleetMap.fixtures.js.map +1 -0
  34. package/dist/_internal/components/data-display/FleetMap/FleetMap.helpers.js +14 -0
  35. package/dist/_internal/components/data-display/FleetMap/FleetMap.helpers.js.map +1 -0
  36. package/dist/_internal/components/data-display/FleetMap/FleetMap.hooks.js +9 -0
  37. package/dist/_internal/components/data-display/FleetMap/FleetMap.hooks.js.map +1 -0
  38. package/dist/_internal/components/data-display/FleetMap/FleetMap.js +46 -0
  39. package/dist/_internal/components/data-display/FleetMap/FleetMap.js.map +1 -0
  40. package/dist/_internal/components/data-display/FleetMap/FleetMap.types.js +1 -0
  41. package/dist/_internal/components/data-display/FleetMap/FleetMap.types.js.map +1 -0
  42. package/dist/_internal/components/data-display/FleetMap/FleetRoster.js +14 -0
  43. package/dist/_internal/components/data-display/FleetMap/FleetRoster.js.map +1 -0
  44. package/dist/_internal/components/data-display/HoverCard/HoverCard.js +2 -2
  45. package/dist/_internal/components/data-display/Lightbox/Lightbox.js +2 -2
  46. package/dist/_internal/components/data-display/MapPreview/MapLayers.js +2 -2
  47. package/dist/_internal/components/data-display/MapPreview/MapPreview.js +6 -6
  48. package/dist/_internal/components/data-display/Sheet/Sheet.hooks.js +2 -2
  49. package/dist/_internal/components/data-display/Sheet/Sheet.js +3 -3
  50. package/dist/_internal/components/data-display/Table/Table.js +3 -3
  51. package/dist/_internal/components/data-display/index.js +82 -62
  52. package/dist/_internal/components/feedback/Modal/Modal.js +2 -2
  53. package/dist/_internal/components/feedback/StackedModal/StackedModal.js +2 -2
  54. package/dist/_internal/components/feedback/index.js +14 -14
  55. package/dist/_internal/components/form/AddressAutocomplete/AddressAutocomplete.hooks.js +3 -3
  56. package/dist/_internal/components/form/AddressAutocomplete/AddressAutocomplete.js +5 -5
  57. package/dist/_internal/components/form/AddressAutocomplete/AddressAutocompleteField.js +2 -2
  58. package/dist/_internal/components/form/CategorySelect/CategoryPanel.js +2 -2
  59. package/dist/_internal/components/form/CategorySelect/CategorySelect.js +4 -4
  60. package/dist/_internal/components/form/Command/Command.js +2 -2
  61. package/dist/_internal/components/form/DateRangePicker/DateRangePicker.js +3 -3
  62. package/dist/_internal/components/form/PhoneInput/PhoneInput.hooks.js +2 -2
  63. package/dist/_internal/components/form/PhoneInput/PhoneInput.js +3 -3
  64. package/dist/_internal/components/form/index.js +30 -30
  65. package/dist/_internal/components/form/total-form/Fields/CategoryField.js +5 -5
  66. package/dist/_internal/components/form/total-form/Fields/index.js +9 -9
  67. package/dist/_internal/components/index.js +152 -133
  68. package/dist/_internal/components/layout/SettingsLayout/SettingsLayout.js +2 -2
  69. package/dist/_internal/components/layout/index.js +20 -20
  70. package/dist/_internal/components/navigation/AppHeader/AppHeader.details.js +4 -4
  71. package/dist/_internal/components/navigation/Pagination/Pagination.js +2 -2
  72. package/dist/_internal/components/navigation/index.js +12 -12
  73. package/dist/_internal/components/utility/index.js +4 -4
  74. package/dist/_internal/pt-BR.data-display.js +2 -2
  75. package/dist/_internal/user-avatar/user-avatar.js +3 -3
  76. package/dist/charts.js +5 -5
  77. package/dist/chunk-2RQINU5T.js +41 -0
  78. package/dist/chunk-2RQINU5T.js.map +1 -0
  79. package/dist/{chunk-VLHGBFR6.js → chunk-2UGJIKVA.js} +2 -2
  80. package/dist/{chunk-WMALDOF5.js → chunk-4G3FGCWX.js} +4 -4
  81. package/dist/{chunk-ZKR5B6YJ.js → chunk-4LRFGB3K.js} +2 -2
  82. package/dist/{chunk-ZOUWHGV3.js → chunk-4MGFUPQT.js} +4 -4
  83. package/dist/{chunk-VQAM2XZV.js → chunk-4XZXLB4I.js} +3 -3
  84. package/dist/chunk-5V4UPOWK.js +36 -0
  85. package/dist/chunk-5V4UPOWK.js.map +1 -0
  86. package/dist/{chunk-VIK3HAUQ.js → chunk-73NJCGQG.js} +2 -2
  87. package/dist/{chunk-PFV6RL4Q.js → chunk-ACFGEZ4Q.js} +3 -3
  88. package/dist/{chunk-U2YJCNRU.js → chunk-AJGYHDA5.js} +4 -4
  89. package/dist/{chunk-HKV4MQ2W.js → chunk-BLBOYCOV.js} +5 -5
  90. package/dist/{chunk-QZBUGUOY.js → chunk-BZTFPSWT.js} +4 -4
  91. package/dist/{chunk-7TZ6OC5T.js → chunk-CBTY36TU.js} +2 -2
  92. package/dist/chunk-CDCTLPMD.js +1 -0
  93. package/dist/chunk-CDCTLPMD.js.map +1 -0
  94. package/dist/chunk-CF3FAAPC.js +79 -0
  95. package/dist/chunk-CF3FAAPC.js.map +1 -0
  96. package/dist/{chunk-PMS3H3OO.js → chunk-DF2FFYZ7.js} +4 -4
  97. package/dist/{chunk-FPWB5ILF.js → chunk-DRHIIUDO.js} +5 -5
  98. package/dist/{chunk-WLQTHPMX.js → chunk-EJQW2IYW.js} +2 -2
  99. package/dist/{chunk-XFUNJYFT.js → chunk-EPNUVSNK.js} +7 -7
  100. package/dist/{chunk-MSUSZLRE.js → chunk-EWT3WMVY.js} +5 -5
  101. package/dist/chunk-F7VBGS4C.js +64 -0
  102. package/dist/chunk-F7VBGS4C.js.map +1 -0
  103. package/dist/{chunk-Q75SJ35X.js → chunk-GMTM6EN7.js} +3 -3
  104. package/dist/{chunk-6NZTYZP5.js → chunk-GVA5CYKP.js} +2 -2
  105. package/dist/{chunk-VR5HDUBK.js → chunk-HP2ODXNA.js} +2 -2
  106. package/dist/{chunk-7BYFC2MR.js → chunk-I7HYGIEJ.js} +6 -6
  107. package/dist/{chunk-UVGRRI7N.js → chunk-IPVYEPXO.js} +4 -4
  108. package/dist/{chunk-5ZTUP65G.js → chunk-JM64OEU6.js} +5 -5
  109. package/dist/{chunk-QFJRB27Q.js → chunk-KHWCX4LI.js} +2 -2
  110. package/dist/{chunk-TLMLEC4F.js → chunk-KW7H5O3K.js} +6 -6
  111. package/dist/{chunk-UU3QAKG6.js → chunk-L4U23SBX.js} +4 -4
  112. package/dist/chunk-MQNKRIGC.js +205 -0
  113. package/dist/chunk-MQNKRIGC.js.map +1 -0
  114. package/dist/{chunk-QWDEOITU.js → chunk-MYGNIFZC.js} +2 -2
  115. package/dist/{chunk-2MMEIN4T.js → chunk-N2TUX4A5.js} +4 -4
  116. package/dist/{chunk-KTK4A3IF.js → chunk-OIKQO73C.js} +7 -7
  117. package/dist/{chunk-REMK55JI.js → chunk-OLZTGVBT.js} +4 -4
  118. package/dist/{chunk-ZIYMBR7K.js → chunk-OTH7H3IP.js} +2 -2
  119. package/dist/{chunk-J4W4TQPC.js → chunk-QJRDDVZ2.js} +5 -5
  120. package/dist/{chunk-VNDOQEJ7.js → chunk-QRX5BYUQ.js} +4 -4
  121. package/dist/{chunk-XT7HMNBW.js → chunk-R6SNYC3D.js} +5 -5
  122. package/dist/{chunk-W2UTDN4T.js → chunk-RBGOGTT2.js} +4 -4
  123. package/dist/chunk-RQWZYD6H.js +1 -0
  124. package/dist/chunk-RQWZYD6H.js.map +1 -0
  125. package/dist/{chunk-EXJBPKGW.js → chunk-SHDIZSN5.js} +5 -5
  126. package/dist/{chunk-23FCBIUN.js → chunk-TNVO2DTB.js} +8 -8
  127. package/dist/{chunk-AGSBIUPV.js → chunk-TPLKJHNN.js} +4 -4
  128. package/dist/{chunk-U2AC4AQX.js → chunk-TU5EIKJA.js} +4 -4
  129. package/dist/chunk-TWQYDKCA.js +113 -0
  130. package/dist/chunk-TWQYDKCA.js.map +1 -0
  131. package/dist/{chunk-BGYLL467.js → chunk-TXHJX2NG.js} +4 -4
  132. package/dist/{chunk-E46PG577.js → chunk-URXT5TOY.js} +4 -4
  133. package/dist/{chunk-5NKUYCZD.js → chunk-VON4BFK4.js} +4 -4
  134. package/dist/{chunk-FL54G7PV.js → chunk-WIT3Y7JT.js} +2 -2
  135. package/dist/{chunk-DVUPIVI2.js → chunk-WQA4LIIK.js} +2 -2
  136. package/dist/{chunk-2UYR2OOE.js → chunk-YRUCIKPO.js} +4 -4
  137. package/dist/{chunk-QKIJ6YXZ.js → chunk-ZA4U5GDU.js} +4 -4
  138. package/dist/{chunk-OXQP3N6X.js → chunk-ZTZ6S5Q6.js} +4 -4
  139. package/dist/data-display/AsyncStateContainer.js +3 -3
  140. package/dist/data-display/Avatar.js +2 -2
  141. package/dist/data-display/CardKit.js +8 -8
  142. package/dist/data-display/Carousel.js +2 -2
  143. package/dist/data-display/Chart.js +5 -5
  144. package/dist/data-display/Chip.js +3 -3
  145. package/dist/data-display/DataGrid.js +1 -1
  146. package/dist/data-display/DataViews.js +33 -33
  147. package/dist/data-display/FleetMap.js +54 -0
  148. package/dist/data-display/FleetMap.js.map +1 -0
  149. package/dist/data-display/HoverCard.js +2 -2
  150. package/dist/data-display/Lightbox.js +2 -2
  151. package/dist/data-display/MapPreview.js +6 -6
  152. package/dist/data-display/Sheet.js +3 -3
  153. package/dist/data-display/Table.js +3 -3
  154. package/dist/feedback/ConfirmAction.js +3 -3
  155. package/dist/feedback/Modal.js +2 -2
  156. package/dist/feedback/StackedModal.js +2 -2
  157. package/dist/form/AddressAutocomplete.js +5 -5
  158. package/dist/form/CategorySelect.js +4 -4
  159. package/dist/form/Command.js +2 -2
  160. package/dist/form/DateRangePicker.js +5 -5
  161. package/dist/form/PhoneInput.js +3 -3
  162. package/dist/form/total-form.js +9 -9
  163. package/dist/layout/SettingsLayout.js +2 -2
  164. package/dist/locales.js +4 -4
  165. package/dist/navigation/AppHeader.js +6 -6
  166. package/dist/navigation/Pagination.js +2 -2
  167. package/dist/pt-BR.js +4 -4
  168. package/dist/types/components/data-display/FleetMap/FleetBody.d.ts +22 -0
  169. package/dist/types/components/data-display/FleetMap/FleetBody.d.ts.map +1 -0
  170. package/dist/types/components/data-display/FleetMap/FleetCanvas.d.ts +50 -0
  171. package/dist/types/components/data-display/FleetMap/FleetCanvas.d.ts.map +1 -0
  172. package/dist/types/components/data-display/FleetMap/FleetMap.d.ts +32 -0
  173. package/dist/types/components/data-display/FleetMap/FleetMap.d.ts.map +1 -0
  174. package/dist/types/components/data-display/FleetMap/FleetMap.fixtures.d.ts +23 -0
  175. package/dist/types/components/data-display/FleetMap/FleetMap.fixtures.d.ts.map +1 -0
  176. package/dist/types/components/data-display/FleetMap/FleetMap.helpers.d.ts +45 -0
  177. package/dist/types/components/data-display/FleetMap/FleetMap.helpers.d.ts.map +1 -0
  178. package/dist/types/components/data-display/FleetMap/FleetMap.hooks.d.ts +54 -0
  179. package/dist/types/components/data-display/FleetMap/FleetMap.hooks.d.ts.map +1 -0
  180. package/dist/types/components/data-display/FleetMap/FleetMap.types.d.ts +145 -0
  181. package/dist/types/components/data-display/FleetMap/FleetMap.types.d.ts.map +1 -0
  182. package/dist/types/components/data-display/FleetMap/FleetRoster.d.ts +15 -0
  183. package/dist/types/components/data-display/FleetMap/FleetRoster.d.ts.map +1 -0
  184. package/dist/types/components/data-display/FleetMap/index.d.ts +5 -0
  185. package/dist/types/components/data-display/FleetMap/index.d.ts.map +1 -0
  186. package/dist/types/components/data-display/index.d.ts +1 -0
  187. package/dist/types/components/data-display/index.d.ts.map +1 -1
  188. package/dist/user-avatar.js +3 -3
  189. package/package.json +5 -1
  190. package/src/components/data-display/FleetMap/FleetBody.tsx +96 -0
  191. package/src/components/data-display/FleetMap/FleetCanvas.tsx +80 -0
  192. package/src/components/data-display/FleetMap/FleetMap.fixtures.ts +70 -0
  193. package/src/components/data-display/FleetMap/FleetMap.helpers.ts +85 -0
  194. package/src/components/data-display/FleetMap/FleetMap.hooks.ts +164 -0
  195. package/src/components/data-display/FleetMap/FleetMap.md +182 -0
  196. package/src/components/data-display/FleetMap/FleetMap.tsx +169 -0
  197. package/src/components/data-display/FleetMap/FleetMap.types.ts +148 -0
  198. package/src/components/data-display/FleetMap/FleetRoster.tsx +321 -0
  199. package/src/components/data-display/FleetMap/index.ts +9 -0
  200. package/src/components/data-display/FleetMap/tests.md +79 -0
  201. package/src/components/data-display/FleetMap/track.md +50 -0
  202. package/src/components/data-display/index.ts +1 -0
  203. package/src/components/feedback/ConfirmAction/ConfirmAction.md +10 -4
  204. package/dist/chunk-X3HL5C6H.js +0 -1
  205. /package/dist/{chunk-X3HL5C6H.js.map → _internal/components/data-display/FleetMap/FleetBody.js.map} +0 -0
  206. /package/dist/{chunk-VLHGBFR6.js.map → chunk-2UGJIKVA.js.map} +0 -0
  207. /package/dist/{chunk-WMALDOF5.js.map → chunk-4G3FGCWX.js.map} +0 -0
  208. /package/dist/{chunk-ZKR5B6YJ.js.map → chunk-4LRFGB3K.js.map} +0 -0
  209. /package/dist/{chunk-ZOUWHGV3.js.map → chunk-4MGFUPQT.js.map} +0 -0
  210. /package/dist/{chunk-VQAM2XZV.js.map → chunk-4XZXLB4I.js.map} +0 -0
  211. /package/dist/{chunk-VIK3HAUQ.js.map → chunk-73NJCGQG.js.map} +0 -0
  212. /package/dist/{chunk-PFV6RL4Q.js.map → chunk-ACFGEZ4Q.js.map} +0 -0
  213. /package/dist/{chunk-U2YJCNRU.js.map → chunk-AJGYHDA5.js.map} +0 -0
  214. /package/dist/{chunk-HKV4MQ2W.js.map → chunk-BLBOYCOV.js.map} +0 -0
  215. /package/dist/{chunk-QZBUGUOY.js.map → chunk-BZTFPSWT.js.map} +0 -0
  216. /package/dist/{chunk-7TZ6OC5T.js.map → chunk-CBTY36TU.js.map} +0 -0
  217. /package/dist/{chunk-PMS3H3OO.js.map → chunk-DF2FFYZ7.js.map} +0 -0
  218. /package/dist/{chunk-FPWB5ILF.js.map → chunk-DRHIIUDO.js.map} +0 -0
  219. /package/dist/{chunk-WLQTHPMX.js.map → chunk-EJQW2IYW.js.map} +0 -0
  220. /package/dist/{chunk-XFUNJYFT.js.map → chunk-EPNUVSNK.js.map} +0 -0
  221. /package/dist/{chunk-MSUSZLRE.js.map → chunk-EWT3WMVY.js.map} +0 -0
  222. /package/dist/{chunk-Q75SJ35X.js.map → chunk-GMTM6EN7.js.map} +0 -0
  223. /package/dist/{chunk-6NZTYZP5.js.map → chunk-GVA5CYKP.js.map} +0 -0
  224. /package/dist/{chunk-VR5HDUBK.js.map → chunk-HP2ODXNA.js.map} +0 -0
  225. /package/dist/{chunk-7BYFC2MR.js.map → chunk-I7HYGIEJ.js.map} +0 -0
  226. /package/dist/{chunk-UVGRRI7N.js.map → chunk-IPVYEPXO.js.map} +0 -0
  227. /package/dist/{chunk-5ZTUP65G.js.map → chunk-JM64OEU6.js.map} +0 -0
  228. /package/dist/{chunk-QFJRB27Q.js.map → chunk-KHWCX4LI.js.map} +0 -0
  229. /package/dist/{chunk-TLMLEC4F.js.map → chunk-KW7H5O3K.js.map} +0 -0
  230. /package/dist/{chunk-UU3QAKG6.js.map → chunk-L4U23SBX.js.map} +0 -0
  231. /package/dist/{chunk-QWDEOITU.js.map → chunk-MYGNIFZC.js.map} +0 -0
  232. /package/dist/{chunk-2MMEIN4T.js.map → chunk-N2TUX4A5.js.map} +0 -0
  233. /package/dist/{chunk-KTK4A3IF.js.map → chunk-OIKQO73C.js.map} +0 -0
  234. /package/dist/{chunk-REMK55JI.js.map → chunk-OLZTGVBT.js.map} +0 -0
  235. /package/dist/{chunk-ZIYMBR7K.js.map → chunk-OTH7H3IP.js.map} +0 -0
  236. /package/dist/{chunk-J4W4TQPC.js.map → chunk-QJRDDVZ2.js.map} +0 -0
  237. /package/dist/{chunk-VNDOQEJ7.js.map → chunk-QRX5BYUQ.js.map} +0 -0
  238. /package/dist/{chunk-XT7HMNBW.js.map → chunk-R6SNYC3D.js.map} +0 -0
  239. /package/dist/{chunk-W2UTDN4T.js.map → chunk-RBGOGTT2.js.map} +0 -0
  240. /package/dist/{chunk-EXJBPKGW.js.map → chunk-SHDIZSN5.js.map} +0 -0
  241. /package/dist/{chunk-23FCBIUN.js.map → chunk-TNVO2DTB.js.map} +0 -0
  242. /package/dist/{chunk-AGSBIUPV.js.map → chunk-TPLKJHNN.js.map} +0 -0
  243. /package/dist/{chunk-U2AC4AQX.js.map → chunk-TU5EIKJA.js.map} +0 -0
  244. /package/dist/{chunk-BGYLL467.js.map → chunk-TXHJX2NG.js.map} +0 -0
  245. /package/dist/{chunk-E46PG577.js.map → chunk-URXT5TOY.js.map} +0 -0
  246. /package/dist/{chunk-5NKUYCZD.js.map → chunk-VON4BFK4.js.map} +0 -0
  247. /package/dist/{chunk-FL54G7PV.js.map → chunk-WIT3Y7JT.js.map} +0 -0
  248. /package/dist/{chunk-DVUPIVI2.js.map → chunk-WQA4LIIK.js.map} +0 -0
  249. /package/dist/{chunk-2UYR2OOE.js.map → chunk-YRUCIKPO.js.map} +0 -0
  250. /package/dist/{chunk-QKIJ6YXZ.js.map → chunk-ZA4U5GDU.js.map} +0 -0
  251. /package/dist/{chunk-OXQP3N6X.js.map → chunk-ZTZ6S5Q6.js.map} +0 -0
@@ -0,0 +1,169 @@
1
+ import Box from '@mui/material/Box/index.js';
2
+ import Typography from '@mui/material/Typography/index.js';
3
+ import { useTheme } from '@mui/material/styles/index.js';
4
+ import React from 'react';
5
+
6
+ import { SR_ONLY_SX } from '../../form/Label/Label.styles';
7
+ import { EmptyState } from '../EmptyState/EmptyState';
8
+
9
+ import { FleetBody } from './FleetBody';
10
+ import { useFleetMap } from './FleetMap.hooks';
11
+ import type { FleetMapProps } from './FleetMap.types';
12
+
13
+ /**
14
+ * How long a fix stays `live`, and then `lagging`, when the caller says nothing.
15
+ *
16
+ * Ninety seconds and five minutes: sized for a phone reporting every twenty
17
+ * seconds, which is the densest cadence a battery tolerates all day. They are
18
+ * DEFAULTS and not a rule — a fleet on five-minute trackers passes its own, and
19
+ * the props exist so it can.
20
+ */
21
+ const DEFAULT_LAGGING_AFTER_SECONDS = 90;
22
+ const DEFAULT_STALE_AFTER_SECONDS = 300;
23
+
24
+ /** The map's own default height. Overridable, and never a hardcoded viewport. */
25
+ const DEFAULT_HEIGHT = '420px';
26
+
27
+ /**
28
+ * The reload announcement, and nothing else.
29
+ *
30
+ * `aria-busy` marks the region stale for assistive technology but UTTERS
31
+ * nothing, and the skeletons beside it are `aria-hidden` — so a screen-reader
32
+ * user got silence while the roster reloaded. This is the utterance.
33
+ *
34
+ * Optional by design: the sentence is in the reader's language, which only the
35
+ * caller knows, and inventing an English one in a component whose whole premise
36
+ * is that every word arrives through `copy` would be the bug it exists to
37
+ * avoid. No `copy.loading`, no region.
38
+ *
39
+ * ## The node outlives the text, and that is the whole trick
40
+ *
41
+ * A live region has to be in the DOM BEFORE its content changes; a screen
42
+ * reader watches an existing region for mutations rather than announcing one
43
+ * that appears already populated. Mounting region-and-text together in a single
44
+ * commit is the classic silent case — it satisfies a DOM assertion while
45
+ * announcing nothing. So the region mounts as soon as the caller offers the
46
+ * copy and merely EMPTIES when idle, which is what `CepField`, `UploadButton`
47
+ * and `DateRangePickerFields` all do.
48
+ */
49
+ function FleetStatus({
50
+ announcement,
51
+ loading,
52
+ testId,
53
+ }: {
54
+ announcement: string | undefined;
55
+ loading: boolean;
56
+ testId: string;
57
+ }): React.JSX.Element | null {
58
+ // No copy, no region at all — the caller has not asked for an announcement.
59
+ if (announcement === undefined) return null;
60
+ return (
61
+ <Box role="status" aria-live="polite" data-testid={`${testId}-status`} sx={SR_ONLY_SX}>
62
+ {loading ? announcement : ''}
63
+ </Box>
64
+ );
65
+ }
66
+
67
+ /**
68
+ * FleetMap — where a set of tracked units is right now, as a map beside the
69
+ * roster that reads it.
70
+ *
71
+ * Product-free by construction: it draws labelled dots with a freshness, and
72
+ * every word — the heading, the three freshness states, how a duration and a
73
+ * distance are written — arrives through {@link FleetMapProps.copy}. Couriers,
74
+ * service vans and field engineers are all the same picture.
75
+ *
76
+ * ## Three decisions worth knowing before changing it
77
+ *
78
+ * **The roster is the accessible representation, not a sidebar.** A map is a
79
+ * picture and a screen reader cannot read one, so the list carries the
80
+ * information: everything a sighted user gets from a pin — who, how recently,
81
+ * what they are carrying — is on the row. The map is a NAMED region rather than
82
+ * an `aria-hidden` one, because its controls are focusable and hiding a
83
+ * focusable subtree is the `aria-hidden-focus` violation.
84
+ *
85
+ * **Nothing here formats.** `lastSeen` and `accuracy` are functions on the copy
86
+ * object, the same line `StatCard` holds for its `value`: a duration and a
87
+ * distance are locale rules, and a component that wrote "2 min ago" would have
88
+ * made English the only language it could ever render.
89
+ *
90
+ * **An empty fleet is an empty STATE, not an empty map.** A map with no pins is
91
+ * indistinguishable from a map that failed to load, and the difference is the
92
+ * whole question a dispatcher is asking.
93
+ */
94
+ export const FleetMap: React.FC<FleetMapProps> = React.memo(
95
+ ({
96
+ units,
97
+ copy,
98
+ // NO default. `undefined` is what tells the hook the caller does not own
99
+ // the selection; defaulting it to `null` here made every render look
100
+ // controlled and the uncontrolled path unreachable.
101
+ selectedId,
102
+ onSelect,
103
+ laggingAfterSeconds = DEFAULT_LAGGING_AFTER_SECONDS,
104
+ staleAfterSeconds = DEFAULT_STALE_AFTER_SECONDS,
105
+ height = DEFAULT_HEIGHT,
106
+ loading = false,
107
+ className,
108
+ dataTestId,
109
+ }) => {
110
+ const theme = useTheme();
111
+ const testId = dataTestId || 'fleet-map';
112
+ const headingId = React.useId();
113
+ const { ordered, active, centre, select, markers, onKeyDown } = useFleetMap(
114
+ units,
115
+ selectedId,
116
+ onSelect,
117
+ );
118
+
119
+ return (
120
+ <Box
121
+ role="region"
122
+ aria-labelledby={headingId}
123
+ className={className}
124
+ data-testid={testId}
125
+ sx={{ display: 'flex', flexDirection: 'column', gap: theme.spacing(1.5), minWidth: 0 }}
126
+ >
127
+ <Typography
128
+ id={headingId}
129
+ variant="subtitle1"
130
+ component="h2"
131
+ data-testid={`${testId}-title`}
132
+ sx={{ fontWeight: theme.typography.fontWeightMedium }}
133
+ >
134
+ {copy.title}
135
+ </Typography>
136
+
137
+ <FleetStatus announcement={copy.loading} loading={loading} testId={testId} />
138
+
139
+ {!loading && ordered.length === 0 ? (
140
+ <EmptyState
141
+ variant="minimal"
142
+ title={copy.emptyTitle}
143
+ description={copy.emptyDescription}
144
+ dataTestId={`${testId}-empty`}
145
+ />
146
+ ) : (
147
+ <FleetBody
148
+ ordered={ordered}
149
+ centre={centre}
150
+ markers={markers}
151
+ select={select}
152
+ onKeyDown={onKeyDown}
153
+ copy={copy}
154
+ selectedId={active}
155
+ laggingAfterSeconds={laggingAfterSeconds}
156
+ staleAfterSeconds={staleAfterSeconds}
157
+ height={height}
158
+ loading={loading}
159
+ testId={testId}
160
+ />
161
+ )}
162
+ </Box>
163
+ );
164
+ },
165
+ );
166
+
167
+ FleetMap.displayName = 'FleetMap';
168
+
169
+ export default FleetMap;
@@ -0,0 +1,148 @@
1
+ import type { MapPreviewCopy } from '../../../copy';
2
+
3
+ /**
4
+ * One tracked unit's newest position, as the caller resolved it.
5
+ *
6
+ * Deliberately says nothing about WHAT is being tracked. A courier, a service
7
+ * van, a field engineer — the component draws a labelled dot with a freshness,
8
+ * and every word that names the domain arrives through {@link FleetMapCopy}.
9
+ */
10
+ export interface FleetUnit {
11
+ id: string;
12
+ /** The name a human reads, on the pin and in the roster. */
13
+ label: string;
14
+ latitude: number;
15
+ longitude: number;
16
+ /**
17
+ * The fix's accuracy radius in metres, when the platform reported one.
18
+ *
19
+ * Kept because it is what separates a rooftop GPS fix from a cell-tower guess
20
+ * three blocks wide, and a map that draws both identically tells its reader
21
+ * something false.
22
+ */
23
+ accuracyM?: number | null;
24
+ /**
25
+ * Seconds since the fix was ACCEPTED, on the server's clock.
26
+ *
27
+ * The caller computes it, and the distinction matters: a device whose own
28
+ * clock is an hour slow would otherwise report itself as an hour stale while
29
+ * reporting perfectly.
30
+ */
31
+ staleSeconds: number;
32
+ /** A short pre-formatted badge beside the name, e.g. `"2 entregas"`. */
33
+ badge?: string;
34
+ }
35
+
36
+ /** How current a unit's newest fix is, derived from the thresholds below. */
37
+ export type FleetFreshness = 'live' | 'lagging' | 'stale';
38
+
39
+ /**
40
+ * Every word the board prints, and the two it FORMATS.
41
+ *
42
+ * `lastSeen` and `accuracy` are functions rather than strings because a
43
+ * duration and a distance are locale rules, and this package holds the same
44
+ * line `StatCard` does: presentation only, the caller owns the formatting. A
45
+ * `lastSeenMinutes: string` template would have made "há 2 min" the only
46
+ * shape any consumer could ever render.
47
+ */
48
+ export interface FleetMapCopy {
49
+ /** The panel's heading. */
50
+ title: string;
51
+ /** What the roster says when nobody is reporting. */
52
+ emptyTitle: string;
53
+ emptyDescription?: string;
54
+ /** The roster list's accessible name — it is the map's readable half. */
55
+ rosterLabel: string;
56
+ /**
57
+ * What a screen reader hears while the roster reloads, e.g. "Atualizando a
58
+ * frota". OPTIONAL, and silence is the honest default: `aria-busy` marks the
59
+ * region stale without uttering anything, the skeletons are `aria-hidden`,
60
+ * and this component cannot invent the sentence in the reader's language.
61
+ * Set it and the panel announces politely; leave it and it stays quiet.
62
+ *
63
+ * Announced on a TRANSITION into loading. A board mounted already loading is
64
+ * silent — as any live region is at mount, since there is no change for a
65
+ * reader to observe.
66
+ */
67
+ loading?: string;
68
+ /**
69
+ * The map region's accessible name.
70
+ *
71
+ * The map is NOT `aria-hidden`, and that is deliberate rather than an
72
+ * oversight: it carries focusable controls (zoom, centre, map type), and
73
+ * `aria-hidden` over a focusable subtree is the `aria-hidden-focus`
74
+ * violation — a keyboard user tabs into a region a screen reader insists is
75
+ * not there. So it is a NAMED region a reader can skip past instead, and the
76
+ * roster beside it carries every fact a pin does.
77
+ *
78
+ * Skipping it does not silence it: `MapPreview` carries its own
79
+ * `aria-live="polite"` and announces its centre in English on every
80
+ * re-centre. See `FleetCanvas` for why that is stated rather than fixed here.
81
+ */
82
+ mapLabel: string;
83
+ /** The three freshness words, as a reader sees them on a row. */
84
+ freshness: Record<FleetFreshness, string>;
85
+ /** `(seconds) => "há 2 min"`. */
86
+ lastSeen: (staleSeconds: number) => string;
87
+ /** `(metres) => "±12 m"`. A unit with no accuracy renders none. */
88
+ accuracy: (metres: number) => string;
89
+ /** The map control bar's own six names. */
90
+ map: MapPreviewCopy;
91
+ }
92
+
93
+ /** Props for the {@link FleetMap} live board. */
94
+ export interface FleetMapProps {
95
+ /** Everyone currently reporting. An empty array renders the empty state. */
96
+ units: readonly FleetUnit[];
97
+ copy: FleetMapCopy;
98
+ /**
99
+ * Which unit is highlighted: the map centres on it and the roster marks it
100
+ * selected. `null` selects nobody and centres on the whole fleet.
101
+ *
102
+ * PASSING IT AT ALL IS THE DECISION. Present on the first render — `null`
103
+ * included — and the board is controlled for life: it renders what you pass
104
+ * and never moves the selection itself. Absent on the first render and it
105
+ * keeps its own, reporting each change through {@link onSelect}. The mode is
106
+ * latched on that first render, the way React latches an `<input>`, so the
107
+ * component cannot change its own semantics halfway through a session.
108
+ *
109
+ * A controlled board reads `undefined` as "nobody is selected", so
110
+ * `useState<string>()` in the caller behaves: clearing it clears the roster.
111
+ */
112
+ selectedId?: string | null;
113
+ /**
114
+ * A click on a row or a pin, and each arrow-key move. Fires in both modes, so
115
+ * an uncontrolled board can still be observed.
116
+ *
117
+ * Passing {@link selectedId} WITHOUT this is a selection nothing can move:
118
+ * the arrow keys then decline to act and leave the page to scroll, rather
119
+ * than swallowing a keypress that would do nothing.
120
+ */
121
+ onSelect?: (id: string) => void;
122
+ /**
123
+ * Seconds after which a unit stops reading as `live`, and then as `lagging`.
124
+ *
125
+ * Both carry a GENERIC default (90s and 300s, sized for a phone reporting
126
+ * every twenty seconds) and no domain one, because the answer is entirely a
127
+ * property of the fleet's own ping cadence: a phone reporting every twenty
128
+ * seconds is late at ninety, and a tracker reporting every five minutes is
129
+ * not. A component that picked one would be picking it for every consumer.
130
+ */
131
+ laggingAfterSeconds?: number;
132
+ staleAfterSeconds?: number;
133
+ /** Map height, any CSS length. */
134
+ height?: string;
135
+ /**
136
+ * While true the map and roster are marked `aria-busy`, and the roster renders
137
+ * skeletons IF no units have landed yet — a poll over a populated roster keeps
138
+ * the list, because unmounting it throws away the focus inside it.
139
+ *
140
+ * It only ANNOUNCES if {@link FleetMapCopy.loading} is set — `aria-busy` is a
141
+ * state, not an utterance. The panel root is deliberately not the busy
142
+ * element, so that announcement sits outside the busy subtree.
143
+ */
144
+ loading?: boolean;
145
+ className?: string;
146
+ /** Test id for the panel root; every child id is derived from it. */
147
+ dataTestId?: string;
148
+ }
@@ -0,0 +1,321 @@
1
+ import Box from '@mui/material/Box/index.js';
2
+ import Stack from '@mui/material/Stack/index.js';
3
+ import Typography from '@mui/material/Typography/index.js';
4
+ import { useTheme, type SxProps, type Theme } from '@mui/material/styles/index.js';
5
+ import React from 'react';
6
+
7
+ import { Skeleton } from '../../layout/Skeleton/Skeleton';
8
+
9
+ import { freshnessOf } from './FleetMap.helpers';
10
+ import type { FleetFreshness, FleetMapCopy, FleetUnit } from './FleetMap.types';
11
+
12
+ /**
13
+ * The roster beside the map — and the ACCESSIBLE half of this component.
14
+ *
15
+ * A map is a picture of positions and a screen reader cannot read a picture, so
16
+ * the panel's information is carried by this list. That is not a consolation
17
+ * prize: the list answers the questions a dispatcher actually asks — who is
18
+ * reporting, how recently, and what are they carrying — and it answers them in
19
+ * an order the map cannot express.
20
+ *
21
+ * The map beside it is a NAMED LANDMARK rather than an `aria-hidden` one, so a
22
+ * reader can skip it in one gesture — see `FleetCanvas`, which carries the
23
+ * reason it must not be hidden.
24
+ */
25
+
26
+ /** Each freshness draws from the semantic palette, never a hardcoded hex. */
27
+ function freshnessColor(freshness: FleetFreshness, theme: Theme): string {
28
+ if (freshness === 'live') return theme.palette.success.main;
29
+ if (freshness === 'lagging') return theme.palette.warning.main;
30
+ return theme.palette.text.disabled;
31
+ }
32
+
33
+ /**
34
+ * A row's two lines of text — the name, and the one-line summary under it.
35
+ *
36
+ * Split out of `FleetRow` to keep that function under the size gate, and the
37
+ * seam is the natural one: everything here is READ, nothing is interactive.
38
+ */
39
+ function RowText({
40
+ unit,
41
+ copy,
42
+ freshness,
43
+ testId,
44
+ }: Pick<RowProps, 'unit' | 'copy' | 'freshness' | 'testId'>): React.JSX.Element {
45
+ const theme = useTheme();
46
+
47
+ // Dropped rather than rendered empty: a phone that reports no accuracy radius
48
+ // would otherwise print a trailing separator with nothing after it.
49
+ const meta = [
50
+ copy.freshness[freshness],
51
+ copy.lastSeen(unit.staleSeconds),
52
+ unit.accuracyM == null ? null : copy.accuracy(unit.accuracyM),
53
+ ]
54
+ .filter((part): part is string => Boolean(part))
55
+ .join(' · ');
56
+
57
+ return (
58
+ <Stack sx={{ minWidth: 0, flexGrow: 1 }}>
59
+ <Typography
60
+ variant="body2"
61
+ component="p"
62
+ data-testid={`${testId}-label`}
63
+ sx={{ fontWeight: theme.typography.fontWeightMedium, overflowWrap: 'anywhere' }}
64
+ >
65
+ {unit.label}
66
+ </Typography>
67
+ <Typography
68
+ variant="caption"
69
+ component="p"
70
+ data-testid={`${testId}-meta`}
71
+ sx={{ color: theme.palette.text.secondary }}
72
+ >
73
+ {meta}
74
+ </Typography>
75
+ </Stack>
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Keep the selected row visible INSIDE the roster.
81
+ *
82
+ * The roster caps its own height, so a dispatcher arrowing through a fleet of
83
+ * thirty otherwise walks the selection below the fold with nothing moving —
84
+ * `aria-activedescendant` follows, and a SIGHTED keyboard user sees no feedback
85
+ * at all.
86
+ *
87
+ * Watches the row's POSITION as well as its selectedness, because the roster
88
+ * re-sorts on every poll: a rider who stays selected while their staleness
89
+ * moves them down the list would otherwise slide out of view with the effect
90
+ * never re-running.
91
+ *
92
+ * Arithmetic rather than `scrollIntoView({ block: 'nearest' })` because that
93
+ * walks every scrollable ancestor, the document included, and `nearest` only
94
+ * spares an ancestor the row is already visible in — which is exactly false
95
+ * when the panel is off screen. Re-running on every re-sort, it would yank the
96
+ * whole page back to the map on a timer, and drag it there on mount for any
97
+ * board rendered below the fold with a selection already set.
98
+ */
99
+ function useKeptInView(
100
+ selected: boolean,
101
+ position: number,
102
+ ): React.RefObject<HTMLDivElement | null> {
103
+ const row = React.useRef<HTMLDivElement>(null);
104
+
105
+ React.useEffect(() => {
106
+ const box = row.current;
107
+ const list = box?.parentElement;
108
+ if (!selected || !box || !list) return;
109
+ const top = box.offsetTop - list.offsetTop;
110
+ if (top < list.scrollTop) {
111
+ list.scrollTop = top;
112
+ } else if (top + box.offsetHeight > list.scrollTop + list.clientHeight) {
113
+ list.scrollTop = top + box.offsetHeight - list.clientHeight;
114
+ }
115
+ }, [selected, position]);
116
+
117
+ return row;
118
+ }
119
+
120
+ /**
121
+ * A row's own box, selected or not.
122
+ *
123
+ * Out of line because the hover rule needs the explanation more than the JSX
124
+ * has room for: hover must never WEAKEN the selection. Written flat as
125
+ * `'&:hover': { backgroundColor: action.hover }`, the pseudo-class wins on
126
+ * specificity and repaints the selected row with the hover tint — measured at
127
+ * `rgba(0,0,0,0.08)` idle against `rgba(0,0,0,0.04)` hovered, so pointing at
128
+ * the selected row visually DESELECTED it and made it indistinguishable from
129
+ * any other row under the cursor.
130
+ */
131
+ function rowSx(theme: Theme, selected: boolean): SxProps<Theme> {
132
+ const resting = selected ? theme.palette.action.selected : 'transparent';
133
+ return {
134
+ display: 'flex',
135
+ alignItems: 'center',
136
+ gap: theme.spacing(1.5),
137
+ padding: theme.spacing(1, 1.5),
138
+ // `1`, not `theme.shape.borderRadius / 4`. MUI's `sx` reads a NUMBER here as
139
+ // a multiple of `theme.shape.borderRadius`, so dividing the theme value out
140
+ // and letting `sx` multiply it back in is quadratic: measured 4px on the
141
+ // default theme — which is why this looked right — and 36px on a host that
142
+ // sets its own radius to 12. In a package whose whole premise is being
143
+ // re-themed, only the multiple is portable.
144
+ borderRadius: 1,
145
+ cursor: 'pointer',
146
+ backgroundColor: resting,
147
+ '&:hover': {
148
+ backgroundColor: selected ? theme.palette.action.selected : theme.palette.action.hover,
149
+ },
150
+ };
151
+ }
152
+
153
+ interface RowProps {
154
+ unit: FleetUnit;
155
+ copy: FleetMapCopy;
156
+ freshness: FleetFreshness;
157
+ selected: boolean;
158
+ onSelect: (id: string) => void;
159
+ /** The row's DOM id, which `aria-activedescendant` points at. */
160
+ optionId: string;
161
+ /** Where the row sits in the re-sorted roster — see the scroll effect. */
162
+ position: number;
163
+ testId: string;
164
+ }
165
+
166
+ function FleetRow({
167
+ unit,
168
+ copy,
169
+ freshness,
170
+ selected,
171
+ onSelect,
172
+ optionId,
173
+ position,
174
+ testId,
175
+ }: RowProps): React.JSX.Element {
176
+ const theme = useTheme();
177
+ const color = freshnessColor(freshness, theme);
178
+ const row = useKeptInView(selected, position);
179
+
180
+ return (
181
+ <Box
182
+ ref={row}
183
+ role="option"
184
+ aria-selected={selected}
185
+ id={optionId}
186
+ data-testid={testId}
187
+ data-freshness={freshness}
188
+ onClick={() => onSelect(unit.id)}
189
+ sx={rowSx(theme, selected)}
190
+ >
191
+ {/* The dot repeats what the freshness word beside it already says, so it
192
+ is hidden rather than read twice — and colour is never the only
193
+ carrier of the state. */}
194
+ <Box
195
+ aria-hidden="true"
196
+ data-testid={`${testId}-dot`}
197
+ sx={{
198
+ width: 10,
199
+ height: 10,
200
+ borderRadius: '50%',
201
+ flexShrink: 0,
202
+ backgroundColor: color,
203
+ }}
204
+ />
205
+ <RowText unit={unit} copy={copy} freshness={freshness} testId={testId} />
206
+ {unit.badge && (
207
+ <Typography
208
+ variant="caption"
209
+ component="span"
210
+ data-testid={`${testId}-badge`}
211
+ sx={{ color: theme.palette.text.secondary, flexShrink: 0 }}
212
+ >
213
+ {unit.badge}
214
+ </Typography>
215
+ )}
216
+ </Box>
217
+ );
218
+ }
219
+
220
+ /**
221
+ * What stands in for the roster before any unit has landed.
222
+ *
223
+ * `aria-hidden`, because three grey bars are not information — the panel's
224
+ * `aria-busy` and its optional announcement carry that. The radius is the same
225
+ * MULTIPLE a real row uses rather than a raw pixel count: `Skeleton` forwards
226
+ * the number into `sx`, where it scales with `theme.shape.borderRadius`, so a
227
+ * literal `4` drew 16px against the rows' 4px on the default theme and 48px
228
+ * against 12px on a re-themed host.
229
+ */
230
+ function FirstLoad({ testId }: { testId: string }): React.JSX.Element {
231
+ return (
232
+ <Stack spacing={1} data-testid={`${testId}-skeleton`} aria-hidden="true">
233
+ {[0, 1, 2].map((row) => (
234
+ <Skeleton key={row} variant="rectangular" height={44} borderRadius={1} />
235
+ ))}
236
+ </Stack>
237
+ );
238
+ }
239
+
240
+ export interface FleetRosterProps {
241
+ units: readonly FleetUnit[];
242
+ copy: FleetMapCopy;
243
+ selectedId: string | null | undefined;
244
+ onSelect: (id: string) => void;
245
+ laggingAfterSeconds: number;
246
+ staleAfterSeconds: number;
247
+ loading: boolean;
248
+ testId: string;
249
+ onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void;
250
+ }
251
+
252
+ export function FleetRoster({
253
+ units,
254
+ copy,
255
+ selectedId,
256
+ onSelect,
257
+ laggingAfterSeconds,
258
+ staleAfterSeconds,
259
+ loading,
260
+ testId,
261
+ onKeyDown,
262
+ }: FleetRosterProps): React.JSX.Element {
263
+ const theme = useTheme();
264
+ // Generated, not derived from `dataTestId`: two boards on one page filtered
265
+ // from the same fleet would otherwise emit duplicate DOM ids and an ambiguous
266
+ // `aria-activedescendant`. Same reason the heading uses one.
267
+ const rowIdPrefix = React.useId();
268
+ const optionId = (id: string): string => `${rowIdPrefix}-${id}`;
269
+
270
+ // The selection is CONTROLLED, so it can name a unit that has since dropped
271
+ // out of the freshness window — a courier ending their shift mid-poll. The
272
+ // attribute must then be absent rather than pointing at an element that no
273
+ // longer renders, which announces nothing and fails an axe audit. `mapCentre`
274
+ // and `nextSelection` both handle the same case; this is the third place.
275
+ const active = units.some((unit) => unit.id === selectedId) ? selectedId : null;
276
+
277
+ // Only when there is nothing to show yet. Unmounting a populated listbox on
278
+ // every poll destroys the focus inside it, so a dispatcher arrowing through
279
+ // the roster is thrown back to the top of the page each time the data
280
+ // refreshes — and the rows they were reading vanish and return.
281
+ if (loading && units.length === 0) return <FirstLoad testId={testId} />;
282
+
283
+ return (
284
+ <Box
285
+ role="listbox"
286
+ // A single tab stop with arrow keys inside, which is the listbox pattern:
287
+ // a dispatcher tabbing past a fleet of thirty should not have to press it
288
+ // thirty times to reach the map.
289
+ tabIndex={0}
290
+ aria-label={copy.rosterLabel}
291
+ aria-activedescendant={active ? optionId(active) : undefined}
292
+ data-testid={`${testId}-roster`}
293
+ onKeyDown={onKeyDown}
294
+ sx={{
295
+ display: 'flex',
296
+ flexDirection: 'column',
297
+ gap: theme.spacing(0.5),
298
+ overflowY: 'auto',
299
+ minWidth: 0,
300
+ '&:focus-visible': {
301
+ outline: `2px solid ${theme.palette.primary.main}`,
302
+ outlineOffset: 2,
303
+ },
304
+ }}
305
+ >
306
+ {units.map((unit, index) => (
307
+ <FleetRow
308
+ key={unit.id}
309
+ unit={unit}
310
+ copy={copy}
311
+ freshness={freshnessOf(unit.staleSeconds, laggingAfterSeconds, staleAfterSeconds)}
312
+ selected={unit.id === active}
313
+ onSelect={onSelect}
314
+ optionId={optionId(unit.id)}
315
+ position={index}
316
+ testId={`${testId}-${unit.id}`}
317
+ />
318
+ ))}
319
+ </Box>
320
+ );
321
+ }
@@ -0,0 +1,9 @@
1
+ export { FleetMap } from './FleetMap';
2
+ export { freshnessOf, mapCentre, nextSelection, rosterOrder } from './FleetMap.helpers';
3
+ export type { LatLng } from './FleetMap.helpers';
4
+ export type {
5
+ FleetFreshness,
6
+ FleetMapCopy,
7
+ FleetMapProps,
8
+ FleetUnit,
9
+ } from './FleetMap.types';