@ecoma-io/loom 0.3.0 → 0.4.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 (398) hide show
  1. package/dist/index.js +38 -6
  2. package/dist/packages/blocks/app-header/src/AppHeader.js.map +1 -1
  3. package/dist/packages/blocks/app-header/src/AppHeader.vue.d.ts.map +1 -1
  4. package/dist/packages/blocks/app-header/src/AppHeader.vue_vue_type_script_setup_true_lang.js +1 -0
  5. package/dist/packages/blocks/app-header/src/AppHeader.vue_vue_type_script_setup_true_lang.js.map +1 -1
  6. package/dist/packages/blocks/desktop-app-shell/src/DesktopAppShell.vue.d.ts +3 -3
  7. package/dist/packages/blocks/metric-card/src/MetricCard.js.map +1 -1
  8. package/dist/packages/blocks/metric-card/src/MetricCard.vue.d.ts +22 -4
  9. package/dist/packages/blocks/metric-card/src/MetricCard.vue.d.ts.map +1 -1
  10. package/dist/packages/blocks/metric-card/src/MetricCard.vue_vue_type_script_setup_true_lang.js +38 -15
  11. package/dist/packages/blocks/metric-card/src/MetricCard.vue_vue_type_script_setup_true_lang.js.map +1 -1
  12. package/dist/packages/blocks/title-bar/src/TitleBar.vue.d.ts +3 -3
  13. package/dist/packages/blocks/toast-stack/src/ToastStack.vue.d.ts +3 -3
  14. package/dist/packages/composition/scroll-reel/src/ScrollReel.js +2 -2
  15. package/dist/packages/composition/scroll-reel/src/ScrollReel.js.map +1 -1
  16. package/dist/packages/composition/scroll-reel/src/ScrollReel.vue.d.ts +26 -0
  17. package/dist/packages/composition/scroll-reel/src/ScrollReel.vue_vue_type_script_setup_true_lang.js +27 -7
  18. package/dist/packages/composition/scroll-reel/src/ScrollReel.vue_vue_type_script_setup_true_lang.js.map +1 -1
  19. package/dist/packages/composition/scroll-reel/src/index.d.ts +2 -1
  20. package/dist/packages/composition/scroll-reel/src/index.js +2 -1
  21. package/dist/packages/core/src/index.d.ts +1 -1
  22. package/dist/packages/core/src/index.d.ts.map +1 -1
  23. package/dist/packages/core/src/index.js +2 -2
  24. package/dist/packages/core/src/motion.d.ts +13 -0
  25. package/dist/packages/core/src/motion.d.ts.map +1 -1
  26. package/dist/packages/core/src/motion.js +20 -13
  27. package/dist/packages/core/src/motion.js.map +1 -1
  28. package/dist/packages/core/src/theme.d.ts +9 -0
  29. package/dist/packages/core/src/theme.d.ts.map +1 -1
  30. package/dist/packages/core/src/theme.js +36 -10
  31. package/dist/packages/core/src/theme.js.map +1 -1
  32. package/dist/packages/labels/src/ancestor-disabled.d.ts +5 -1
  33. package/dist/packages/labels/src/ancestor-disabled.d.ts.map +1 -1
  34. package/dist/packages/labels/src/ancestor-disabled.js +5 -1
  35. package/dist/packages/labels/src/ancestor-disabled.js.map +1 -1
  36. package/dist/packages/labels/src/component-shapes.d.ts +73 -0
  37. package/dist/packages/labels/src/component-shapes.d.ts.map +1 -1
  38. package/dist/packages/labels/src/date-labels.d.ts +8 -6
  39. package/dist/packages/labels/src/date-labels.d.ts.map +1 -1
  40. package/dist/packages/labels/src/date-labels.js +8 -6
  41. package/dist/packages/labels/src/date-labels.js.map +1 -1
  42. package/dist/packages/labels/src/index.d.ts +2 -2
  43. package/dist/packages/labels/src/index.d.ts.map +1 -1
  44. package/dist/packages/labels/src/label-registry.d.ts +7 -1
  45. package/dist/packages/labels/src/label-registry.d.ts.map +1 -1
  46. package/dist/packages/layouts/reading/src/Reading.js.map +1 -1
  47. package/dist/packages/layouts/reading/src/Reading.vue.d.ts +16 -15
  48. package/dist/packages/layouts/reading/src/Reading.vue.d.ts.map +1 -1
  49. package/dist/packages/layouts/reading/src/Reading.vue_vue_type_script_setup_true_lang.js.map +1 -1
  50. package/dist/packages/layouts/reading/src/index.d.ts +1 -0
  51. package/dist/packages/layouts/reading/src/index.d.ts.map +1 -1
  52. package/dist/packages/loom/src/a11y.d.ts +1 -1
  53. package/dist/packages/loom/src/index.d.ts +191 -174
  54. package/dist/packages/loom/src/theme.d.ts +3 -0
  55. package/dist/packages/primitives/alert/src/Alert.js +7 -0
  56. package/dist/packages/primitives/alert/src/Alert.js.map +1 -0
  57. package/dist/packages/primitives/alert/src/Alert.vue.d.ts +73 -0
  58. package/dist/packages/primitives/alert/src/Alert.vue_vue_type_script_setup_true_lang.js +127 -0
  59. package/dist/packages/primitives/alert/src/Alert.vue_vue_type_script_setup_true_lang.js.map +1 -0
  60. package/dist/packages/primitives/alert/src/index.d.ts +4 -0
  61. package/dist/packages/primitives/alert/src/index.js +3 -0
  62. package/dist/packages/primitives/alert-dialog/src/AlertDialog.js.map +1 -1
  63. package/dist/packages/primitives/alert-dialog/src/AlertDialog.vue.d.ts +2 -2
  64. package/dist/packages/primitives/alert-dialog/src/AlertDialog.vue_vue_type_script_setup_true_lang.js +2 -2
  65. package/dist/packages/primitives/alert-dialog/src/AlertDialog.vue_vue_type_script_setup_true_lang.js.map +1 -1
  66. package/dist/packages/primitives/alert-dialog/src/index.d.ts +1 -1
  67. package/dist/packages/primitives/avatar-group/src/AvatarGroup.vue.d.ts +3 -3
  68. package/dist/packages/primitives/avatar-group/src/index.d.ts +1 -1
  69. package/dist/packages/primitives/badge/src/Badge.js.map +1 -1
  70. package/dist/packages/primitives/badge/src/Badge.vue.d.ts.map +1 -1
  71. package/dist/packages/primitives/badge/src/Badge.vue_vue_type_script_setup_true_lang.js +1 -1
  72. package/dist/packages/primitives/badge/src/Badge.vue_vue_type_script_setup_true_lang.js.map +1 -1
  73. package/dist/packages/primitives/breadcrumb/src/Breadcrumb.js +2 -2
  74. package/dist/packages/primitives/breadcrumb/src/Breadcrumb.js.map +1 -1
  75. package/dist/packages/primitives/breadcrumb/src/Breadcrumb.vue.d.ts +28 -0
  76. package/dist/packages/primitives/breadcrumb/src/Breadcrumb.vue_vue_type_script_setup_true_lang.js +16 -5
  77. package/dist/packages/primitives/breadcrumb/src/Breadcrumb.vue_vue_type_script_setup_true_lang.js.map +1 -1
  78. package/dist/packages/primitives/breadcrumb/src/index.d.ts +2 -1
  79. package/dist/packages/primitives/breadcrumb/src/index.js +2 -1
  80. package/dist/packages/primitives/button/src/Button.js.map +1 -1
  81. package/dist/packages/primitives/button/src/Button.vue.d.ts.map +1 -1
  82. package/dist/packages/primitives/button/src/Button.vue_vue_type_script_setup_true_lang.js.map +1 -1
  83. package/dist/packages/primitives/button/src/index.js +2 -2
  84. package/dist/packages/primitives/card/src/Card.js +7 -0
  85. package/dist/packages/primitives/card/src/Card.js.map +1 -0
  86. package/dist/packages/primitives/card/src/Card.vue.d.ts +72 -0
  87. package/dist/packages/primitives/card/src/Card.vue_vue_type_script_setup_true_lang.js +65 -0
  88. package/dist/packages/primitives/card/src/Card.vue_vue_type_script_setup_true_lang.js.map +1 -0
  89. package/dist/packages/primitives/card/src/index.d.ts +3 -0
  90. package/dist/packages/primitives/card/src/index.d.ts.map +1 -0
  91. package/dist/packages/primitives/card/src/index.js +2 -0
  92. package/dist/packages/primitives/carousel/src/Carousel.js +7 -0
  93. package/dist/packages/primitives/carousel/src/Carousel.js.map +1 -0
  94. package/dist/packages/primitives/carousel/src/Carousel.vue.d.ts +34 -0
  95. package/dist/packages/primitives/carousel/src/Carousel.vue_vue_type_script_setup_true_lang.js +175 -0
  96. package/dist/packages/primitives/carousel/src/Carousel.vue_vue_type_script_setup_true_lang.js.map +1 -0
  97. package/dist/packages/primitives/carousel/src/index.d.ts +3 -0
  98. package/dist/packages/primitives/carousel/src/index.js +3 -0
  99. package/dist/packages/primitives/checkbox/src/Checkbox.js.map +1 -1
  100. package/dist/packages/primitives/checkbox/src/Checkbox.vue.d.ts +1 -1
  101. package/dist/packages/primitives/checkbox/src/Checkbox.vue.d.ts.map +1 -1
  102. package/dist/packages/primitives/checkbox/src/Checkbox.vue_vue_type_script_setup_true_lang.js +3 -3
  103. package/dist/packages/primitives/checkbox/src/Checkbox.vue_vue_type_script_setup_true_lang.js.map +1 -1
  104. package/dist/packages/primitives/chip/src/Chip.js.map +1 -1
  105. package/dist/packages/primitives/chip/src/Chip.vue.d.ts.map +1 -1
  106. package/dist/packages/primitives/chip/src/Chip.vue_vue_type_script_setup_true_lang.js +1 -1
  107. package/dist/packages/primitives/chip/src/Chip.vue_vue_type_script_setup_true_lang.js.map +1 -1
  108. package/dist/packages/primitives/collapse/src/Collapse.js +7 -0
  109. package/dist/packages/primitives/collapse/src/Collapse.js.map +1 -0
  110. package/dist/packages/primitives/collapse/src/Collapse.vue.d.ts +56 -0
  111. package/dist/packages/primitives/collapse/src/Collapse.vue.d.ts.map +1 -0
  112. package/dist/packages/primitives/collapse/src/Collapse.vue_vue_type_script_setup_true_lang.js +81 -0
  113. package/dist/packages/primitives/collapse/src/Collapse.vue_vue_type_script_setup_true_lang.js.map +1 -0
  114. package/dist/packages/primitives/collapse/src/index.d.ts +2 -0
  115. package/dist/packages/primitives/collapse/src/index.d.ts.map +1 -0
  116. package/dist/packages/primitives/collapse/src/index.js +2 -0
  117. package/dist/packages/primitives/color-picker/src/ColorPicker.js.map +1 -1
  118. package/dist/packages/primitives/color-picker/src/ColorPicker.vue.d.ts +4 -4
  119. package/dist/packages/primitives/color-picker/src/ColorPicker.vue_vue_type_script_setup_true_lang.js +2 -2
  120. package/dist/packages/primitives/color-picker/src/ColorPicker.vue_vue_type_script_setup_true_lang.js.map +1 -1
  121. package/dist/packages/primitives/color-picker/src/index.d.ts +1 -1
  122. package/dist/packages/primitives/combobox/src/Combobox.js.map +1 -1
  123. package/dist/packages/primitives/combobox/src/Combobox.vue.d.ts +2 -2
  124. package/dist/packages/primitives/combobox/src/Combobox.vue_vue_type_script_setup_true_lang.js +2 -2
  125. package/dist/packages/primitives/combobox/src/Combobox.vue_vue_type_script_setup_true_lang.js.map +1 -1
  126. package/dist/packages/primitives/combobox/src/index.d.ts +1 -1
  127. package/dist/packages/primitives/context-menu/src/ContextMenu.js.map +1 -1
  128. package/dist/packages/primitives/context-menu/src/ContextMenu.vue.d.ts +12 -0
  129. package/dist/packages/primitives/context-menu/src/ContextMenu.vue.d.ts.map +1 -1
  130. package/dist/packages/primitives/context-menu/src/ContextMenu.vue_vue_type_script_setup_true_lang.js +7 -3
  131. package/dist/packages/primitives/context-menu/src/ContextMenu.vue_vue_type_script_setup_true_lang.js.map +1 -1
  132. package/dist/packages/primitives/date-picker/src/DatePicker.js.map +1 -1
  133. package/dist/packages/primitives/date-picker/src/DatePicker.vue.d.ts +2 -2
  134. package/dist/packages/primitives/date-picker/src/DatePicker.vue_vue_type_script_setup_true_lang.js +1 -1
  135. package/dist/packages/primitives/date-picker/src/DatePicker.vue_vue_type_script_setup_true_lang.js.map +1 -1
  136. package/dist/packages/primitives/date-range-picker/src/DateRangePicker.js.map +1 -1
  137. package/dist/packages/primitives/date-range-picker/src/DateRangePicker.vue.d.ts +3 -3
  138. package/dist/packages/primitives/date-range-picker/src/DateRangePicker.vue_vue_type_script_setup_true_lang.js +1 -1
  139. package/dist/packages/primitives/date-range-picker/src/DateRangePicker.vue_vue_type_script_setup_true_lang.js.map +1 -1
  140. package/dist/packages/primitives/date-range-picker/src/index.d.ts +1 -1
  141. package/dist/packages/primitives/date-time-picker/src/DateTimePicker.js.map +1 -1
  142. package/dist/packages/primitives/date-time-picker/src/DateTimePicker.vue.d.ts +2 -2
  143. package/dist/packages/primitives/date-time-picker/src/DateTimePicker.vue_vue_type_script_setup_true_lang.js +1 -1
  144. package/dist/packages/primitives/date-time-picker/src/DateTimePicker.vue_vue_type_script_setup_true_lang.js.map +1 -1
  145. package/dist/packages/primitives/date-time-range-picker/src/DateTimeRangePicker.js.map +1 -1
  146. package/dist/packages/primitives/date-time-range-picker/src/DateTimeRangePicker.vue.d.ts +3 -3
  147. package/dist/packages/primitives/date-time-range-picker/src/DateTimeRangePicker.vue_vue_type_script_setup_true_lang.js +1 -1
  148. package/dist/packages/primitives/date-time-range-picker/src/DateTimeRangePicker.vue_vue_type_script_setup_true_lang.js.map +1 -1
  149. package/dist/packages/primitives/date-time-range-picker/src/index.d.ts +1 -1
  150. package/dist/packages/primitives/dialog/src/Dialog.js.map +1 -1
  151. package/dist/packages/primitives/dialog/src/Dialog.vue.d.ts +12 -4
  152. package/dist/packages/primitives/dialog/src/Dialog.vue_vue_type_script_setup_true_lang.js +1 -1
  153. package/dist/packages/primitives/dialog/src/Dialog.vue_vue_type_script_setup_true_lang.js.map +1 -1
  154. package/dist/packages/primitives/dialog/src/index.d.ts +1 -1
  155. package/dist/packages/primitives/drawer/src/Drawer.js.map +1 -1
  156. package/dist/packages/primitives/drawer/src/Drawer.vue.d.ts +2 -2
  157. package/dist/packages/primitives/drawer/src/Drawer.vue_vue_type_script_setup_true_lang.js +2 -2
  158. package/dist/packages/primitives/drawer/src/Drawer.vue_vue_type_script_setup_true_lang.js.map +1 -1
  159. package/dist/packages/primitives/drawer/src/index.d.ts +1 -1
  160. package/dist/packages/primitives/dropdown-menu/src/DropdownMenu.js.map +1 -1
  161. package/dist/packages/primitives/dropdown-menu/src/DropdownMenu.vue.d.ts +26 -0
  162. package/dist/packages/primitives/dropdown-menu/src/DropdownMenu.vue.d.ts.map +1 -1
  163. package/dist/packages/primitives/dropdown-menu/src/DropdownMenu.vue_vue_type_script_setup_true_lang.js +10 -4
  164. package/dist/packages/primitives/dropdown-menu/src/DropdownMenu.vue_vue_type_script_setup_true_lang.js.map +1 -1
  165. package/dist/packages/primitives/editable/src/Editable.js.map +1 -1
  166. package/dist/packages/primitives/editable/src/Editable.vue.d.ts +2 -2
  167. package/dist/packages/primitives/editable/src/Editable.vue_vue_type_script_setup_true_lang.js +1 -1
  168. package/dist/packages/primitives/editable/src/Editable.vue_vue_type_script_setup_true_lang.js.map +1 -1
  169. package/dist/packages/primitives/editable/src/index.d.ts +1 -1
  170. package/dist/packages/primitives/file-upload/src/FileUpload.js.map +1 -1
  171. package/dist/packages/primitives/file-upload/src/FileUpload.vue.d.ts +2 -2
  172. package/dist/packages/primitives/file-upload/src/FileUpload.vue_vue_type_script_setup_true_lang.js +1 -1
  173. package/dist/packages/primitives/file-upload/src/FileUpload.vue_vue_type_script_setup_true_lang.js.map +1 -1
  174. package/dist/packages/primitives/file-upload/src/index.d.ts +1 -1
  175. package/dist/packages/primitives/hover-card/src/HoverCard.js.map +1 -1
  176. package/dist/packages/primitives/hover-card/src/HoverCard.vue.d.ts.map +1 -1
  177. package/dist/packages/primitives/hover-card/src/HoverCard.vue_vue_type_script_setup_true_lang.js +1 -1
  178. package/dist/packages/primitives/hover-card/src/HoverCard.vue_vue_type_script_setup_true_lang.js.map +1 -1
  179. package/dist/packages/primitives/icon-button/src/IconButton.js.map +1 -1
  180. package/dist/packages/primitives/icon-button/src/IconButton.vue.d.ts +10 -4
  181. package/dist/packages/primitives/icon-button/src/IconButton.vue.d.ts.map +1 -1
  182. package/dist/packages/primitives/icon-button/src/IconButton.vue_vue_type_script_setup_true_lang.js +18 -6
  183. package/dist/packages/primitives/icon-button/src/IconButton.vue_vue_type_script_setup_true_lang.js.map +1 -1
  184. package/dist/packages/primitives/indicator/src/Indicator.vue.d.ts +2 -2
  185. package/dist/packages/primitives/indicator/src/index.d.ts +1 -1
  186. package/dist/packages/primitives/kbd/src/Kbd.js +7 -0
  187. package/dist/packages/primitives/kbd/src/Kbd.js.map +1 -0
  188. package/dist/packages/primitives/kbd/src/Kbd.vue.d.ts +30 -0
  189. package/dist/packages/primitives/kbd/src/Kbd.vue.d.ts.map +1 -0
  190. package/dist/packages/primitives/kbd/src/Kbd.vue_vue_type_script_setup_true_lang.js +29 -0
  191. package/dist/packages/primitives/kbd/src/Kbd.vue_vue_type_script_setup_true_lang.js.map +1 -0
  192. package/dist/packages/primitives/kbd/src/index.d.ts +3 -0
  193. package/dist/packages/primitives/kbd/src/index.d.ts.map +1 -0
  194. package/dist/packages/primitives/kbd/src/index.js +3 -0
  195. package/dist/packages/primitives/link/src/Link.js.map +1 -1
  196. package/dist/packages/primitives/link/src/Link.vue.d.ts.map +1 -1
  197. package/dist/packages/primitives/link/src/Link.vue_vue_type_script_setup_true_lang.js +3 -3
  198. package/dist/packages/primitives/link/src/Link.vue_vue_type_script_setup_true_lang.js.map +1 -1
  199. package/dist/packages/primitives/list/src/List.js +7 -0
  200. package/dist/packages/primitives/list/src/List.js.map +1 -0
  201. package/dist/packages/primitives/list/src/List.vue.d.ts +22 -0
  202. package/dist/packages/primitives/list/src/List.vue.d.ts.map +1 -0
  203. package/dist/packages/primitives/list/src/List.vue_vue_type_script_setup_true_lang.js +27 -0
  204. package/dist/packages/primitives/list/src/List.vue_vue_type_script_setup_true_lang.js.map +1 -0
  205. package/dist/packages/primitives/list/src/ListItem.js +7 -0
  206. package/dist/packages/primitives/list/src/ListItem.js.map +1 -0
  207. package/dist/packages/primitives/list/src/ListItem.vue.d.ts +75 -0
  208. package/dist/packages/primitives/list/src/ListItem.vue.d.ts.map +1 -0
  209. package/dist/packages/primitives/list/src/ListItem.vue_vue_type_script_setup_true_lang.js +101 -0
  210. package/dist/packages/primitives/list/src/ListItem.vue_vue_type_script_setup_true_lang.js.map +1 -0
  211. package/dist/packages/primitives/list/src/index.d.ts +4 -0
  212. package/dist/packages/primitives/list/src/index.d.ts.map +1 -0
  213. package/dist/packages/primitives/list/src/index.js +3 -0
  214. package/dist/packages/primitives/menubar/src/Menubar.js.map +1 -1
  215. package/dist/packages/primitives/menubar/src/Menubar.vue.d.ts +20 -6
  216. package/dist/packages/primitives/menubar/src/Menubar.vue.d.ts.map +1 -1
  217. package/dist/packages/primitives/menubar/src/Menubar.vue_vue_type_script_setup_true_lang.js +178 -64
  218. package/dist/packages/primitives/menubar/src/Menubar.vue_vue_type_script_setup_true_lang.js.map +1 -1
  219. package/dist/packages/primitives/number-field/src/NumberField.js.map +1 -1
  220. package/dist/packages/primitives/number-field/src/NumberField.vue.d.ts +2 -2
  221. package/dist/packages/primitives/number-field/src/NumberField.vue_vue_type_script_setup_true_lang.js +1 -0
  222. package/dist/packages/primitives/number-field/src/NumberField.vue_vue_type_script_setup_true_lang.js.map +1 -1
  223. package/dist/packages/primitives/number-field/src/index.d.ts +1 -1
  224. package/dist/packages/primitives/otp-input/src/OtpInput.vue.d.ts +2 -2
  225. package/dist/packages/primitives/otp-input/src/index.d.ts +1 -1
  226. package/dist/packages/primitives/pagination/src/Pagination.js.map +1 -1
  227. package/dist/packages/primitives/pagination/src/Pagination.vue.d.ts +3 -3
  228. package/dist/packages/primitives/pagination/src/Pagination.vue_vue_type_script_setup_true_lang.js +1 -1
  229. package/dist/packages/primitives/pagination/src/Pagination.vue_vue_type_script_setup_true_lang.js.map +1 -1
  230. package/dist/packages/primitives/pagination/src/index.d.ts +1 -1
  231. package/dist/packages/primitives/popover/src/Popover.js.map +1 -1
  232. package/dist/packages/primitives/popover/src/Popover.vue.d.ts.map +1 -1
  233. package/dist/packages/primitives/popover/src/Popover.vue_vue_type_script_setup_true_lang.js +1 -1
  234. package/dist/packages/primitives/popover/src/Popover.vue_vue_type_script_setup_true_lang.js.map +1 -1
  235. package/dist/packages/primitives/progress/src/Progress.vue.d.ts +2 -2
  236. package/dist/packages/primitives/progress/src/index.d.ts +1 -1
  237. package/dist/packages/primitives/radial-progress/src/RadialProgress.vue.d.ts +2 -2
  238. package/dist/packages/primitives/radial-progress/src/index.d.ts +1 -1
  239. package/dist/packages/primitives/radio-group/src/RadioGroup.js.map +1 -1
  240. package/dist/packages/primitives/radio-group/src/RadioGroup.vue.d.ts.map +1 -1
  241. package/dist/packages/primitives/radio-group/src/RadioGroup.vue_vue_type_script_setup_true_lang.js +1 -1
  242. package/dist/packages/primitives/radio-group/src/RadioGroup.vue_vue_type_script_setup_true_lang.js.map +1 -1
  243. package/dist/packages/primitives/rating/src/Rating.vue.d.ts +2 -2
  244. package/dist/packages/primitives/rating/src/index.d.ts +1 -1
  245. package/dist/packages/primitives/segmented-control/src/SegmentedControl.js.map +1 -1
  246. package/dist/packages/primitives/segmented-control/src/SegmentedControl.vue.d.ts.map +1 -1
  247. package/dist/packages/primitives/segmented-control/src/SegmentedControl.vue_vue_type_script_setup_true_lang.js +2 -3
  248. package/dist/packages/primitives/segmented-control/src/SegmentedControl.vue_vue_type_script_setup_true_lang.js.map +1 -1
  249. package/dist/packages/primitives/select/src/Select.js.map +1 -1
  250. package/dist/packages/primitives/select/src/Select.vue.d.ts.map +1 -1
  251. package/dist/packages/primitives/select/src/Select.vue_vue_type_script_setup_true_lang.js +1 -1
  252. package/dist/packages/primitives/select/src/Select.vue_vue_type_script_setup_true_lang.js.map +1 -1
  253. package/dist/packages/primitives/slider/src/Slider.js.map +1 -1
  254. package/dist/packages/primitives/slider/src/Slider.vue.d.ts +6 -3
  255. package/dist/packages/primitives/slider/src/Slider.vue.d.ts.map +1 -1
  256. package/dist/packages/primitives/slider/src/Slider.vue_vue_type_script_setup_true_lang.js +15 -10
  257. package/dist/packages/primitives/slider/src/Slider.vue_vue_type_script_setup_true_lang.js.map +1 -1
  258. package/dist/packages/primitives/stepper/src/Stepper.vue.d.ts +2 -2
  259. package/dist/packages/primitives/stepper/src/index.d.ts +1 -1
  260. package/dist/packages/primitives/switch/src/Switch.js.map +1 -1
  261. package/dist/packages/primitives/switch/src/Switch.vue.d.ts +1 -1
  262. package/dist/packages/primitives/switch/src/Switch.vue.d.ts.map +1 -1
  263. package/dist/packages/primitives/switch/src/Switch.vue_vue_type_script_setup_true_lang.js +3 -3
  264. package/dist/packages/primitives/switch/src/Switch.vue_vue_type_script_setup_true_lang.js.map +1 -1
  265. package/dist/packages/primitives/table/src/Table.js +7 -0
  266. package/dist/packages/primitives/table/src/Table.js.map +1 -0
  267. package/dist/packages/primitives/table/src/Table.vue.d.ts +45 -0
  268. package/dist/packages/primitives/table/src/Table.vue_vue_type_script_setup_true_lang.js +72 -0
  269. package/dist/packages/primitives/table/src/Table.vue_vue_type_script_setup_true_lang.js.map +1 -0
  270. package/dist/packages/primitives/table/src/TableCell.js +7 -0
  271. package/dist/packages/primitives/table/src/TableCell.js.map +1 -0
  272. package/dist/packages/primitives/table/src/TableCell.vue.d.ts +19 -0
  273. package/dist/packages/primitives/table/src/TableCell.vue.d.ts.map +1 -0
  274. package/dist/packages/primitives/table/src/TableCell.vue_vue_type_script_setup_true_lang.js +17 -0
  275. package/dist/packages/primitives/table/src/TableCell.vue_vue_type_script_setup_true_lang.js.map +1 -0
  276. package/dist/packages/primitives/table/src/TableHead.js +7 -0
  277. package/dist/packages/primitives/table/src/TableHead.js.map +1 -0
  278. package/dist/packages/primitives/table/src/TableHead.vue.d.ts +56 -0
  279. package/dist/packages/primitives/table/src/TableHead.vue_vue_type_script_setup_true_lang.js +79 -0
  280. package/dist/packages/primitives/table/src/TableHead.vue_vue_type_script_setup_true_lang.js.map +1 -0
  281. package/dist/packages/primitives/table/src/TableRow.js +7 -0
  282. package/dist/packages/primitives/table/src/TableRow.js.map +1 -0
  283. package/dist/packages/primitives/table/src/TableRow.vue.d.ts +39 -0
  284. package/dist/packages/primitives/table/src/TableRow.vue.d.ts.map +1 -0
  285. package/dist/packages/primitives/table/src/TableRow.vue_vue_type_script_setup_true_lang.js +61 -0
  286. package/dist/packages/primitives/table/src/TableRow.vue_vue_type_script_setup_true_lang.js.map +1 -0
  287. package/dist/packages/primitives/table/src/index.d.ts +10 -0
  288. package/dist/packages/primitives/table/src/index.js +8 -0
  289. package/dist/packages/primitives/tabs/src/Tabs.js.map +1 -1
  290. package/dist/packages/primitives/tabs/src/Tabs.vue.d.ts.map +1 -1
  291. package/dist/packages/primitives/tabs/src/Tabs.vue_vue_type_script_setup_true_lang.js +1 -1
  292. package/dist/packages/primitives/tabs/src/Tabs.vue_vue_type_script_setup_true_lang.js.map +1 -1
  293. package/dist/packages/primitives/tags-input/src/TagsInput.js.map +1 -1
  294. package/dist/packages/primitives/tags-input/src/TagsInput.vue.d.ts +2 -2
  295. package/dist/packages/primitives/tags-input/src/TagsInput.vue_vue_type_script_setup_true_lang.js +1 -1
  296. package/dist/packages/primitives/tags-input/src/TagsInput.vue_vue_type_script_setup_true_lang.js.map +1 -1
  297. package/dist/packages/primitives/tags-input/src/index.d.ts +1 -1
  298. package/dist/packages/primitives/text-field/src/TextField.js.map +1 -1
  299. package/dist/packages/primitives/text-field/src/TextField.vue.d.ts +2 -2
  300. package/dist/packages/primitives/text-field/src/TextField.vue_vue_type_script_setup_true_lang.js +1 -1
  301. package/dist/packages/primitives/text-field/src/TextField.vue_vue_type_script_setup_true_lang.js.map +1 -1
  302. package/dist/packages/primitives/text-field/src/index.d.ts +1 -1
  303. package/dist/packages/primitives/text-field/src/index.js +2 -2
  304. package/dist/packages/primitives/textarea/src/Textarea.js.map +1 -1
  305. package/dist/packages/primitives/textarea/src/Textarea.vue.d.ts +2 -2
  306. package/dist/packages/primitives/textarea/src/Textarea.vue_vue_type_script_setup_true_lang.js +1 -1
  307. package/dist/packages/primitives/textarea/src/Textarea.vue_vue_type_script_setup_true_lang.js.map +1 -1
  308. package/dist/packages/primitives/textarea/src/index.d.ts +1 -1
  309. package/dist/packages/primitives/textarea/src/index.js +2 -2
  310. package/dist/packages/primitives/time-picker/src/TimePicker.vue.d.ts +2 -2
  311. package/dist/packages/primitives/timeline/src/Timeline.js +7 -0
  312. package/dist/packages/primitives/timeline/src/Timeline.js.map +1 -0
  313. package/dist/packages/primitives/timeline/src/Timeline.vue.d.ts +37 -0
  314. package/dist/packages/primitives/timeline/src/Timeline.vue_vue_type_script_setup_true_lang.js +44 -0
  315. package/dist/packages/primitives/timeline/src/Timeline.vue_vue_type_script_setup_true_lang.js.map +1 -0
  316. package/dist/packages/primitives/timeline/src/TimelineItem.js +7 -0
  317. package/dist/packages/primitives/timeline/src/TimelineItem.js.map +1 -0
  318. package/dist/packages/primitives/timeline/src/TimelineItem.vue.d.ts +50 -0
  319. package/dist/packages/primitives/timeline/src/TimelineItem.vue_vue_type_script_setup_true_lang.js +96 -0
  320. package/dist/packages/primitives/timeline/src/TimelineItem.vue_vue_type_script_setup_true_lang.js.map +1 -0
  321. package/dist/packages/primitives/timeline/src/index.d.ts +5 -0
  322. package/dist/packages/primitives/timeline/src/index.js +5 -0
  323. package/dist/packages/primitives/toast/src/Toast.vue.d.ts +2 -2
  324. package/dist/packages/primitives/toast/src/ToastItem.js.map +1 -1
  325. package/dist/packages/primitives/toast/src/ToastItem.vue.d.ts +2 -2
  326. package/dist/packages/primitives/toast/src/ToastItem.vue_vue_type_script_setup_true_lang.js +2 -2
  327. package/dist/packages/primitives/toast/src/ToastItem.vue_vue_type_script_setup_true_lang.js.map +1 -1
  328. package/dist/packages/primitives/toast/src/index.d.ts +1 -1
  329. package/dist/packages/primitives/tooltip/src/Tooltip.js.map +1 -1
  330. package/dist/packages/primitives/tooltip/src/Tooltip.vue.d.ts.map +1 -1
  331. package/dist/packages/primitives/tooltip/src/Tooltip.vue_vue_type_script_setup_true_lang.js +1 -1
  332. package/dist/packages/primitives/tooltip/src/Tooltip.vue_vue_type_script_setup_true_lang.js.map +1 -1
  333. package/dist/packages/primitives/window-controls/src/WindowControls.vue.d.ts +2 -2
  334. package/dist/packages/primitives/window-controls/src/index.d.ts +1 -1
  335. package/dist/styles/global.css +59 -0
  336. package/dist/styles/theme.css +13 -0
  337. package/dist/theme.js +3 -0
  338. package/package.json +13 -7
  339. package/dist/packages/blocks/desktop-app-shell/src/DesktopAppShell.vue.d.ts.map +0 -1
  340. package/dist/packages/blocks/title-bar/src/TitleBar.vue.d.ts.map +0 -1
  341. package/dist/packages/blocks/toast-stack/src/ToastStack.vue.d.ts.map +0 -1
  342. package/dist/packages/composition/scroll-reel/src/ScrollReel.vue.d.ts.map +0 -1
  343. package/dist/packages/composition/scroll-reel/src/index.d.ts.map +0 -1
  344. package/dist/packages/loom/src/a11y.d.ts.map +0 -1
  345. package/dist/packages/loom/src/index.d.ts.map +0 -1
  346. package/dist/packages/primitives/alert-dialog/src/AlertDialog.vue.d.ts.map +0 -1
  347. package/dist/packages/primitives/alert-dialog/src/index.d.ts.map +0 -1
  348. package/dist/packages/primitives/avatar-group/src/AvatarGroup.vue.d.ts.map +0 -1
  349. package/dist/packages/primitives/avatar-group/src/index.d.ts.map +0 -1
  350. package/dist/packages/primitives/breadcrumb/src/Breadcrumb.vue.d.ts.map +0 -1
  351. package/dist/packages/primitives/breadcrumb/src/index.d.ts.map +0 -1
  352. package/dist/packages/primitives/color-picker/src/ColorPicker.vue.d.ts.map +0 -1
  353. package/dist/packages/primitives/color-picker/src/index.d.ts.map +0 -1
  354. package/dist/packages/primitives/combobox/src/Combobox.vue.d.ts.map +0 -1
  355. package/dist/packages/primitives/combobox/src/index.d.ts.map +0 -1
  356. package/dist/packages/primitives/date-picker/src/DatePicker.vue.d.ts.map +0 -1
  357. package/dist/packages/primitives/date-range-picker/src/DateRangePicker.vue.d.ts.map +0 -1
  358. package/dist/packages/primitives/date-range-picker/src/index.d.ts.map +0 -1
  359. package/dist/packages/primitives/date-time-picker/src/DateTimePicker.vue.d.ts.map +0 -1
  360. package/dist/packages/primitives/date-time-range-picker/src/DateTimeRangePicker.vue.d.ts.map +0 -1
  361. package/dist/packages/primitives/date-time-range-picker/src/index.d.ts.map +0 -1
  362. package/dist/packages/primitives/dialog/src/Dialog.vue.d.ts.map +0 -1
  363. package/dist/packages/primitives/dialog/src/index.d.ts.map +0 -1
  364. package/dist/packages/primitives/drawer/src/Drawer.vue.d.ts.map +0 -1
  365. package/dist/packages/primitives/drawer/src/index.d.ts.map +0 -1
  366. package/dist/packages/primitives/editable/src/Editable.vue.d.ts.map +0 -1
  367. package/dist/packages/primitives/editable/src/index.d.ts.map +0 -1
  368. package/dist/packages/primitives/file-upload/src/FileUpload.vue.d.ts.map +0 -1
  369. package/dist/packages/primitives/file-upload/src/index.d.ts.map +0 -1
  370. package/dist/packages/primitives/indicator/src/Indicator.vue.d.ts.map +0 -1
  371. package/dist/packages/primitives/indicator/src/index.d.ts.map +0 -1
  372. package/dist/packages/primitives/number-field/src/NumberField.vue.d.ts.map +0 -1
  373. package/dist/packages/primitives/number-field/src/index.d.ts.map +0 -1
  374. package/dist/packages/primitives/otp-input/src/OtpInput.vue.d.ts.map +0 -1
  375. package/dist/packages/primitives/otp-input/src/index.d.ts.map +0 -1
  376. package/dist/packages/primitives/pagination/src/Pagination.vue.d.ts.map +0 -1
  377. package/dist/packages/primitives/pagination/src/index.d.ts.map +0 -1
  378. package/dist/packages/primitives/progress/src/Progress.vue.d.ts.map +0 -1
  379. package/dist/packages/primitives/progress/src/index.d.ts.map +0 -1
  380. package/dist/packages/primitives/radial-progress/src/RadialProgress.vue.d.ts.map +0 -1
  381. package/dist/packages/primitives/radial-progress/src/index.d.ts.map +0 -1
  382. package/dist/packages/primitives/rating/src/Rating.vue.d.ts.map +0 -1
  383. package/dist/packages/primitives/rating/src/index.d.ts.map +0 -1
  384. package/dist/packages/primitives/stepper/src/Stepper.vue.d.ts.map +0 -1
  385. package/dist/packages/primitives/stepper/src/index.d.ts.map +0 -1
  386. package/dist/packages/primitives/tags-input/src/TagsInput.vue.d.ts.map +0 -1
  387. package/dist/packages/primitives/tags-input/src/index.d.ts.map +0 -1
  388. package/dist/packages/primitives/text-field/src/TextField.vue.d.ts.map +0 -1
  389. package/dist/packages/primitives/text-field/src/index.d.ts.map +0 -1
  390. package/dist/packages/primitives/textarea/src/Textarea.vue.d.ts.map +0 -1
  391. package/dist/packages/primitives/textarea/src/index.d.ts.map +0 -1
  392. package/dist/packages/primitives/time-picker/src/TimePicker.vue.d.ts.map +0 -1
  393. package/dist/packages/primitives/toast/src/Toast.vue.d.ts.map +0 -1
  394. package/dist/packages/primitives/toast/src/ToastItem.vue.d.ts.map +0 -1
  395. package/dist/packages/primitives/toast/src/index.d.ts.map +0 -1
  396. package/dist/packages/primitives/window-controls/src/WindowControls.vue.d.ts.map +0 -1
  397. package/dist/packages/primitives/window-controls/src/index.d.ts.map +0 -1
  398. package/dist/styles/theme.contrast.test.ts +0 -460
@@ -1 +1 @@
1
- {"version":3,"file":"Editable.js","names":[],"sources":["../../../../../packages/primitives/editable/src/Editable.vue"],"sourcesContent":["<script lang=\"ts\">\nimport type { EditableLabels } from \"@ecoma-io/loom-labels\";\n\n/**\n\n/**\n * Loom's English, co-located with the component so it tree-shakes with it: a\n * consumer who never imports Editable ships none of these bytes.\n *\n * Exported so a host can build a partial vocabulary against the real thing\n * rather than a transcription of it.\n */\nexport const EDITABLE_LABELS: EditableLabels = {\n edit: \"Edit\",\n input: \"Editable text\",\n submit: \"Save\",\n cancel: \"Cancel\",\n restored: ({ value }) => `Empty value discarded, ${value} restored`,\n};\n\n/**\n * What a pointer has to do to open the editor. The keyboard is deliberately\n * not on this axis — Enter and Space open it in all three, because a mode a\n * reader cannot reach by keyboard is a control they cannot use.\n *\n * • `click` — one click on the text opens it. The default: it is the whole\n * promise of edit-in-place, and the only mode a touch reader can perform.\n * • `dblclick` — for a row or a cell where a single click already means\n * something else, selecting it.\n * • `focus` — landing on it opens it, so Tab walks straight into the\n * editor. For a column of fields being filled in order, and the mode that\n * makes Escape's restore-and-hand-focus-back path load-bearing.\n */\nexport type EditableActivationMode = \"click\" | \"dblclick\" | \"focus\";\n\n/**\n * What commits the edit.\n *\n * • `both` — Enter, or focus leaving the control. The default: a reader who\n * typed a value and clicked away meant to keep it.\n * • `enter` — Enter alone; leaving discards, which suits an edit that is a\n * deliberate act rather than an incidental one.\n * • `blur` — leaving alone, for a control inside something that has its own\n * Enter.\n *\n * Escape abandons under all three.\n */\nexport type EditableSubmitMode = \"blur\" | \"enter\" | \"both\";\n</script>\n\n<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch, type ComponentPublicInstance } from \"vue\";\nimport {\n EditableRoot,\n EditableArea,\n EditablePreview,\n EditableInput,\n EditableSubmitTrigger,\n EditableCancelTrigger,\n} from \"reka-ui\";\nimport { Check, Pencil, X } from \"@lucide/vue\";\nimport { buttonVariants } from \"@ecoma-io/loom-button\";\nimport { cn } from \"@ecoma-io/loom-core\";\nimport { optional } from \"@ecoma-io/loom-core\";\nimport { useSplitAttrs } from \"@ecoma-io/loom-core\";\nimport { useFieldControl } from \"@ecoma-io/loom-labels\";\nimport { useLabels, type LabelOverrides } from \"@ecoma-io/loom-labels\";\n\n/**\n * Editable — text that becomes an input where it stands: a record's title, a\n * name on a card, a cell in a table. It is the control that makes an\n * operations screen feel direct, because the value is edited where it is read\n * rather than in a dialog opened to hold one field.\n *\n * Reach for a [TextField](../TextField/TextField.vue) instead whenever the\n * value is being *entered* rather than corrected — a form is a set of empty\n * boxes and should look like one. This is for a value that already exists,\n * that is usually right, and that is occasionally wrong.\n *\n * **It takes no `size`, and that is the point.** Every other control in the\n * library picks a height off the shared scale; this one inherits the\n * typography around it, so an editable page title stays the size of a page\n * title and an editable table cell stays the size of a cell. What it pins\n * instead is that the two states are the same size as each other: the preview\n * and the editor are the only two children of one padded box, both single-line\n * and both inheriting that box's type, so the swap cannot move the row below\n * it. The box carries a transparent border at rest and a visible one while\n * editing — the same border width either way, for the same reason.\n *\n * **Escape restores and hands focus back.** Ending an edit hides the editor,\n * and a browser drops focus to `<body>` when the focused node goes away, so\n * without the hand-off a reader who abandoned an edit would lose their place\n * in the document. With `activationMode: \"focus\"` that hand-off would then\n * re-open the editor it just closed, which is why activation is Loom's rather\n * than Reka's here — the returning focus is suppressed exactly once, until the\n * reader leaves and comes back deliberately.\n *\n * **The model moves on submit, not on keystrokes.** `update:modelValue` fires\n * once, with the committed value, alongside `submit`; `cancel` fires when an\n * edit is abandoned. A control whose whole purpose is that an edit can be\n * taken back cannot publish every intermediate character as though it were\n * the value.\n *\n * Inside a [Field](../Field/Field.vue) it wires itself: the row's id, the id\n * of its hint or error line, `required`, `invalid`, `disabled`, `readonly` and\n * the name the value is submitted under all arrive through `useFieldControl()`.\n * Every prop below still wins over the row when it is set, in both directions\n * — which is why the four booleans default to `undefined` rather than `false`.\n *\n * `readonly` and `disabled` are different states, so the box has **three**\n * resting appearances rather than two. Read-only is a value on show: still\n * focusable, still copyable, still submitted, and lifted onto a fill with its\n * text at full strength. Disabled is unavailable, and drained a step further in\n * fill, in text colour and in border weight together. Three channels rather\n * than one is the point — see `areaClass`.\n *\n * Built on Reka UI's Editable, which owns the state machine — what is showing,\n * what the editor holds before it is committed, and what a dismiss means under\n * each submit mode.\n */\nconst props = withDefaults(\n defineProps<{\n /** The committed value. Pair with `@update:modelValue` for `v-model`. */\n modelValue?: string;\n /** Shown in place of an empty value, in both the preview and the editor. */\n placeholder?: string;\n /** What a pointer does to open the editor. The keyboard opens it in every mode. */\n activationMode?: EditableActivationMode;\n /** What commits the edit. Escape abandons it under all three. */\n submitMode?: EditableSubmitMode;\n /**\n * Sizes the control to its own text and stacks the two states in one grid\n * cell, for an editable sitting inline in a sentence rather than in a\n * column. It measures the committed value, not what is being typed.\n */\n autoResize?: boolean;\n /** The longest value the editor will accept, in characters. */\n maxLength?: number;\n /**\n * Lets a submitted empty value through. Off by default: an empty submit\n * restores the previous value and reports `cancel` instead, because the\n * damage is asymmetric — a value wrongly kept is on screen and can be\n * edited again, a title wrongly emptied is gone.\n */\n allowEmpty?: boolean;\n /** Unavailable: the preview drops out of the tab order and refuses to open. Unset defers to a wrapping Field. */\n disabled?: boolean | undefined;\n /** Shows the value without allowing an edit, while staying focusable. Unset defers to a wrapping Field. */\n readonly?: boolean | undefined;\n /** Error state: paints the destructive border and ring, and sets `aria-invalid`. Unset defers to a wrapping Field's `error`. */\n invalid?: boolean | undefined;\n /** Marks the value mandatory to assistive tech (`aria-required`). Unset defers to a wrapping Field. */\n required?: boolean | undefined;\n /** The field name for a native form post or `FormData`. Unset defers to a wrapping Field. */\n name?: string;\n /** The editor's accessible name, when no visible element already labels it. */\n ariaLabel?: string;\n /**\n * Names for everything this control says out loud, as any subset of\n * `EditableLabels` — the rest stay as the host's `provideLoomLabels`\n * vocabulary left them, and then as Loom's English.\n *\n * This is the per-instance correction, not the place to localise an\n * application: the case it exists for is `edit`, where a page of\n * editables reads better as \"Rename\" or \"Correct\" than as the same verb\n * five times.\n */\n labels?: LabelOverrides<EditableLabels>;\n }>(),\n {\n activationMode: \"click\",\n submitMode: \"both\",\n autoResize: false,\n allowEmpty: false,\n // Not `false`, for any of the four. `false` is a caller saying \"this value\n // is fine / available / editable even though its row is not\"; absent is a\n // caller saying nothing, and only `undefined` survives to mean that past\n // Vue's absent-Boolean-casts-to-false rule — see TextField.vue, which\n // carries the full reasoning.\n disabled: undefined,\n readonly: undefined,\n invalid: undefined,\n required: undefined,\n },\n);\n\nconst emit = defineEmits<{\n /** The committed value. Fires on submit only, never per keystroke. */\n \"update:modelValue\": [value: string];\n /** The edit was committed, with the value that was accepted. */\n submit: [value: string];\n /** The edit was abandoned — Escape, the cancel button, or a refused empty submit. */\n cancel: [];\n}>();\n\ndefineOptions({ inheritAttrs: false });\n\n// `class` lands on the root rather than on the bordered box inside it: the\n// root is what participates in the caller's layout, and it holds the box and\n// the two commit controls beside it, so a caller's `w-64` has to size all\n// three. Everything else — `id`, `aria-describedby`, `aria-labelledby`,\n// `data-testid`, `autocomplete` — describes the *editor*, and lands there.\nconst { attrs, rest: inputAttrs } = useSplitAttrs();\n\n// `id` and `aria-describedby` reach a bare control as fallthrough attrs rather\n// than props, so they are read off `attrs` and handed in as this caller's own\n// values — a caller who sets either still beats the row, and the row's\n// description is merged into theirs rather than replacing it.\nconst field = useFieldControl(() => ({\n id: attrs.id as string | undefined,\n describedBy: attrs[\"aria-describedby\"] as string | undefined,\n name: props.name,\n disabled: props.disabled,\n readonly: props.readonly,\n invalid: props.invalid,\n required: props.required,\n}));\n\n// `text`, not `labels`: the prop of that name is one of the three sources this\n// resolves, and a template reading the raw prop would be reading the overrides\n// rather than the answer.\nconst text = useLabels(\"editable\", EDITABLE_LABELS, () => props.labels);\n\n// The value as this component knows it, which is also what Reka is fed. Two\n// things need it. A defined `model-value` is what puts Reka in controlled\n// mode, and controlled is what lets a submit be *refused* — Reka's own commit\n// then only announces the new value rather than adopting it, so the preview\n// keeps showing the old one with nothing to undo. And a host using this\n// without `v-model` still needs the value to move, which is what the\n// optimistic write in `onSubmit` is for.\nconst value = ref(props.modelValue ?? \"\");\nwatch(\n () => props.modelValue,\n (next) => {\n // `undefined` is a host that never controlled the value, not a host\n // clearing it — adopting it would blank the box on any unrelated re-render.\n if (next !== undefined) value.value = next;\n },\n);\n\n// Mirrors Reka's own `isEditing`, off the state event rather than out of the\n// slot, because the class bindings and the activation guard both need it in\n// the script half as well as in the template.\nconst editing = ref(false);\n\nconst previewRef = ref<ComponentPublicInstance | null>(null);\nconst rootRef = ref<ComponentPublicInstance | null>(null);\n\n// Reka's parts are components, so the only handle on their DOM node is `$el`,\n// which Vue types as `any`. Narrowing through `unknown` keeps that `any` from\n// spreading into everything it touches — and answers the SSR case honestly,\n// where there is no element at all.\nfunction elementOf(instance: ComponentPublicInstance | null): HTMLElement | null {\n const el = instance?.$el as unknown;\n return el instanceof HTMLElement ? el : null;\n}\n\n// Set only when focus was actually handed back to the preview, and cleared the\n// moment the reader leaves it. Without it, `activationMode: \"focus\"` would\n// read that hand-off as a fresh arrival and re-open the editor the reader just\n// escaped from — a control that cannot be abandoned, which is precisely the\n// trap Escape exists to avoid.\nlet suppressFocusActivation = false;\n\nfunction activate(edit: () => void): void {\n if (field.disabled || field.readonly || editing.value) return;\n edit();\n}\n\n// Reka's own activation is switched off in the template\n// (`activation-mode=\"none\"`) and rebuilt in these three handlers, rather than\n// passed through. Two things need that. Reka has no click mode at all and no\n// keyboard path on its preview, so two of the three modes this component\n// publishes would be unreachable without a pointer. And its focus mode cannot\n// tell a reader arriving on the control from the focus this component hands\n// back after an Escape — which would re-open the editor that Escape just\n// abandoned, forever.\n//\n// A `<button>` turns Enter and Space into a click carrying `detail === 0`,\n// which is what lets one handler serve the keyboard in every activation mode:\n// `dblclick` cannot be typed, and `focus` must not be the only way in for a\n// reader who arrived on the control without meaning to edit yet. `detail` is\n// the same signal Pagination reads to tell a synthesised click from a pointer\n// one.\nfunction onPreviewClick(event: MouseEvent, edit: () => void): void {\n if (props.activationMode === \"dblclick\" && event.detail !== 0) return;\n activate(edit);\n}\n\nfunction onPreviewDoubleClick(edit: () => void): void {\n if (props.activationMode !== \"dblclick\") return;\n activate(edit);\n}\n\nfunction onPreviewFocus(edit: () => void): void {\n if (props.activationMode !== \"focus\" || suppressFocusActivation) return;\n activate(edit);\n}\n\nfunction onPreviewBlur(): void {\n suppressFocusActivation = false;\n}\n\n// The editor is hidden the instant an edit ends, and a browser drops focus to\n// `<body>` when the focused node goes away — so a reader who pressed Escape or\n// Enter would be left with no position in the document at all.\n//\n// Only focus that was actually lost is rescued. A submit caused by clicking\n// somewhere else has already put the reader where they meant to be, and\n// stealing focus back from them would be the worse bug; the same reasoning,\n// and the same `<body>` check, as Pagination's edge-control hand-off. jsdom\n// leaves focus on the hidden node rather than dropping it, and a click on the\n// commit button leaves it on a button that is about to be hidden too, so\n// anything still inside this control counts as lost.\nfunction restoreFocus(): void {\n void nextTick(() => {\n const preview = elementOf(previewRef.value);\n const root = elementOf(rootRef.value);\n if (preview === null) return;\n\n const active = document.activeElement;\n const lost = active === null || active === document.body || (root?.contains(active) ?? false);\n if (!lost) return;\n\n suppressFocusActivation = true;\n preview.focus();\n });\n}\n\nfunction onState(state: \"edit\" | \"submit\" | \"cancel\"): void {\n editing.value = state === \"edit\";\n if (state === \"edit\") return;\n if (state === \"cancel\") emit(\"cancel\");\n restoreFocus();\n}\n\n// The refusal, held for one announcement rather than derived, so the live\n// region says something only when a submit was actually turned away — and is\n// cleared when the next edit opens, so a second identical refusal is still a\n// change to the region and still gets announced.\nconst restored = ref(\"\");\nwatch(editing, (isEditing) => {\n if (isEditing) restored.value = \"\";\n});\n\n// Reka's `submit` is an announcement rather than a request — in controlled\n// mode it has already told us what it would like the value to be and left the\n// deciding here, which is what makes the empty case answerable at all.\nfunction onSubmit(submitted: string | null | undefined): void {\n const next = submitted ?? \"\";\n\n // An empty submit is the one genuinely ambiguous outcome: a reader who\n // cleared the box and pressed Enter may have meant to blank the value, and\n // under `submitMode: \"blur\"` may have meant nothing at all. Loom restores\n // rather than clears, and `allowEmpty` is how a field that can legitimately\n // be emptied says so. `restored` announces it, because a refusal that only\n // shows up as text snapping back is a refusal a screen reader never hears.\n if (next === \"\" && !props.allowEmpty) {\n restored.value = text.value.restored({ value: value.value });\n emit(\"cancel\");\n return;\n }\n\n // Optimistic only while uncontrolled. With a `modelValue` prop the host owns\n // the value, and echoing it here would show a value they may be about to\n // reject — the prop watcher above is what applies the one they accept.\n if (props.modelValue === undefined) value.value = next;\n emit(\"update:modelValue\", next);\n emit(\"submit\", next);\n}\n\n// A read-only preview is not a control, so it is not rendered as one: a\n// `<button>` that refuses to act is worse than plain text. It keeps a\n// `tabindex` of its own instead — a read-only value is one a reader may reach\n// and copy, the same contract TextField's read-only input keeps — while the\n// button state drops Reka's hardcoded `tabindex=\"0\"` entirely, because a\n// `<button>` is focusable without it and a *disabled* one carrying it is\n// focusable when it should not be.\nconst previewAs = computed(() => (field.readonly ? \"span\" : \"button\"));\n\nconst rootProps = computed(() => optional({ name: field.name, maxLength: props.maxLength }));\n\n// `name` is dropped from the bag before it reaches the editor. Reka already\n// renders a hidden input under that name for any Editable inside a `<form>`,\n// and an editor carrying it as well would post the field twice — once with the\n// committed value and once with whatever the box held. Removed from a copy\n// rather than listed positively, so a key added to the resolved bag later\n// reaches this input without anyone remembering to come back here.\nconst editorAttrs = computed(() => {\n const aria: Record<string, unknown> = { ...field.attrs };\n delete aria.name;\n return aria;\n});\n\n// `aria-label` outranks a `<label for>` in the accessible-name order, so a\n// generic fallback would silently replace the name a Field row already gives\n// the editor. It is supplied only where nothing else names it at all — which\n// is also the state Reka would otherwise fill with \"editable input\".\nconst editorLabel = computed<string | undefined>(() => {\n if (props.ariaLabel !== undefined) return props.ariaLabel;\n if (field.id !== undefined) return undefined;\n if (attrs[\"aria-label\"] !== undefined || attrs[\"aria-labelledby\"] !== undefined) return undefined;\n return text.value.input;\n});\n\nconst areaClass = computed(() =>\n cn(\n // One box, two children. The border is present in both states and only\n // changes colour, the padding never changes, and the floor height keeps an\n // empty value from collapsing the row it sits in — between them, nothing\n // below this control moves when it switches.\n \"flex min-h-9 min-w-0 items-center rounded-md border border-transparent px-2 py-1\",\n // A control that sizes to its own text cannot also fill its parent: under\n // `autoResize` Reka makes this box an `inline-grid` measured by the two\n // states stacked inside it, and a width of 100% would measure the row\n // instead.\n props.autoResize ? \"w-auto\" : \"w-full\",\n \"text-inherit transition-[color,background-color,border-color,box-shadow] duration-fast ease-out\",\n // Rim-lit at rest, the weave blooms on focus (Signature law), and it blooms\n // for the preview as much as for the editor: at rest the preview is the\n // focusable node, and a control a reader can tab to has to show it.\n \"focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-ring\",\n !field.invalid && \"focus-within:shadow-halo\",\n editing.value && \"border-input bg-background\",\n // The affordance for a pointer, since the resting state is deliberately\n // just text: the box lights under the cursor and the caret says the text\n // can be typed into. Never the only signal — the preview announces itself,\n // and carries a glyph.\n !editing.value && !field.disabled && !field.readonly && \"cursor-text hover:bg-subtle\",\n // Lifted rather than dimmed, exactly as a read-only text field is: the\n // value is the whole reason the control is on the page, so it stays at\n // full contrast — 13.45:1 over this fill — whether or not it can be\n // changed. The rim comes up with it, from the resting `transparent` to\n // `input`: at rest this control is deliberately just text, so without a\n // border there is nothing but hue to say the value is on show, and the box\n // was already reserving the width for one.\n field.readonly && \"border-input bg-subtle\",\n // Drained, not faded, and a step further down all three channels: the fill\n // to `muted`, the text to `muted-foreground` (4.67:1 over it) and the rim\n // to the lighter `border`. Half-opacity text on this box measures under\n // 4.5:1, and a value nobody can read is worse than one nobody can edit.\n //\n // Read-only and disabled used to share `bg-muted` and part on the text\n // colour alone — a distinction made in hue and nothing else, which is the\n // one a reader with a colour deficiency does not receive.\n field.disabled && \"cursor-not-allowed border-border bg-muted text-muted-foreground\",\n // The same appearance, reached from the other direction: a\n // `<fieldset disabled>` above this control disables the preview `<button>`\n // and the `<input>` behind it natively — both are on the list a fieldset\n // reaches — and never touches `field.disabled`, so the rule above cannot\n // fire and the value rendered byte-identical to an editable one.\n //\n // `in-[fieldset:disabled]:` reads the attribute that did the disabling, so\n // the fill and the native inertness cannot disagree. See TextField.vue for\n // why it is preferred to a `has-[:disabled]:` rule reading this box's own\n // contents.\n \"in-[fieldset:disabled]:cursor-not-allowed in-[fieldset:disabled]:bg-muted in-[fieldset:disabled]:text-muted-foreground\",\n // Gated for the reason the rule below is ordered: Tailwind emits the\n // ancestor half of `in-*` inside `:where()`, so this carries no more\n // specificity than `border-destructive` and would win on emission order\n // alone.\n !field.invalid && \"in-[fieldset:disabled]:border-border\",\n // Last of the rules naming a border colour: `cn()` resolves one by\n // whichever class it saw last, so a control both in error and unavailable\n // would lose its destructive rim if this sat above them.\n field.invalid && \"border-destructive focus-within:outline-destructive\",\n ),\n);\n\nconst triggerClass = cn(\n buttonVariants({ variant: \"ghost\", size: \"icon-sm\" }),\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n);\n</script>\n\n<template>\n <EditableRoot\n ref=\"rootRef\"\n v-slot=\"{ edit }\"\n v-bind=\"rootProps\"\n :model-value=\"value\"\n :placeholder=\"placeholder ?? ''\"\n activation-mode=\"none\"\n :disabled=\"field.disabled\"\n :readonly=\"field.readonly\"\n :required=\"field.required\"\n :submit-mode=\"submitMode\"\n :auto-resize=\"autoResize\"\n :data-editing=\"editing || undefined\"\n :data-invalid=\"field.invalid || undefined\"\n :data-readonly=\"field.readonly || undefined\"\n :data-disabled=\"field.disabled || undefined\"\n :class=\"\n cn(\n 'inline-flex items-center gap-1',\n autoResize ? 'w-auto align-baseline' : 'w-full',\n attrs.class as string,\n )\n \"\n @update:state=\"onState\"\n @submit=\"onSubmit\"\n >\n <EditableArea :class=\"areaClass\">\n <!-- The preview is the control, and Reka's `EditableEditTrigger` is\n deliberately not rendered beside it: a second button doing the same\n job costs a second Tab stop on one value, and its click handler\n fires in every activation mode, which would make `dblclick` and\n `focus` lies. A real `<button>` rather than a `role`, so Enter and\n Space are the browser's job and the affordance survives\n forced-colors mode. -->\n <EditablePreview\n ref=\"previewRef\"\n :as=\"previewAs\"\n :type=\"field.readonly ? undefined : 'button'\"\n :tabindex=\"field.readonly ? 0 : undefined\"\n :disabled=\"!field.readonly && field.disabled ? true : undefined\"\n :aria-describedby=\"field.attrs['aria-describedby']\"\n :aria-invalid=\"field.attrs['aria-invalid']\"\n class=\"flex min-w-0 flex-1 items-center gap-1.5 text-start outline-none\"\n @click=\"(event: MouseEvent) => onPreviewClick(event, edit)\"\n @dblclick=\"() => onPreviewDoubleClick(edit)\"\n @focus=\"() => onPreviewFocus(edit)\"\n @blur=\"onPreviewBlur\"\n >\n <!-- Single-line and truncated, because the editor it swaps with is a\n single-line input: a preview that wrapped would be a taller box\n than the thing replacing it. -->\n <span :class=\"cn('truncate', !value && 'text-muted-foreground')\">\n {{ value || placeholder }}\n </span>\n <template v-if=\"!field.readonly\">\n <!-- The affordance for a sighted reader, and the counterpart to the\n span below it: the resting state is text, so something has to\n say it is more than text. Hidden from assistive tech, which is\n told the same thing in words. -->\n <Pencil class=\"h-3.5 w-3.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <!-- The whole accessibility problem of edit-in-place, in one span.\n The value reads first so it stays the start of the accessible\n name (WCAG 2.5.3, and what a voice-control reader says out\n loud); the verb follows, so the text announces as something\n that can be acted on rather than as prose. -->\n <span class=\"sr-only\">{{ text.edit }}</span>\n </template>\n </EditablePreview>\n\n <EditableInput\n v-bind=\"{ ...inputAttrs, ...editorAttrs }\"\n :aria-label=\"editorLabel\"\n class=\"min-w-0 flex-1 bg-transparent text-inherit outline-none placeholder:text-muted-foreground\"\n />\n </EditableArea>\n\n <!-- Both triggers hide themselves while the preview is showing, so there\n is nothing to guard here. They exist for the reader who has no way to\n know that Enter commits and Escape abandons — an invisible keyboard\n contract is not an affordance. -->\n <EditableSubmitTrigger :aria-label=\"text.submit\" :class=\"triggerClass\">\n <Check class=\"h-4 w-4\" aria-hidden=\"true\" />\n </EditableSubmitTrigger>\n <EditableCancelTrigger :aria-label=\"text.cancel\" :class=\"triggerClass\">\n <X class=\"h-4 w-4\" aria-hidden=\"true\" />\n </EditableCancelTrigger>\n\n <!-- A refused empty submit changes the screen without moving focus or\n opening anything, which a screen reader would otherwise never\n announce. Empty until there is something to say, so it never repeats\n itself on an unrelated re-render. -->\n <span role=\"status\" class=\"sr-only\">{{ restored }}</span>\n </EditableRoot>\n</template>\n"],"mappings":""}
1
+ {"version":3,"file":"Editable.js","names":[],"sources":["../../../../../packages/primitives/editable/src/Editable.vue"],"sourcesContent":["<script lang=\"ts\">\nimport type { EditableLabels } from \"@ecoma-io/loom-labels\";\n\n/**\n\n/**\n * Loom's English, co-located with the component so it tree-shakes with it: a\n * consumer who never imports Editable ships none of these bytes.\n *\n * Exported so a host can build a partial vocabulary against the real thing\n * rather than a transcription of it.\n */\nexport const EDITABLE_LABELS: EditableLabels = {\n edit: \"Edit\",\n input: \"Editable text\",\n submit: \"Save\",\n cancel: \"Cancel\",\n restored: ({ value }) => `Empty value discarded, ${value} restored`,\n};\n\n/**\n * What a pointer has to do to open the editor. The keyboard is deliberately\n * not on this axis — Enter and Space open it in all three, because a mode a\n * reader cannot reach by keyboard is a control they cannot use.\n *\n * • `click` — one click on the text opens it. The default: it is the whole\n * promise of edit-in-place, and the only mode a touch reader can perform.\n * • `dblclick` — for a row or a cell where a single click already means\n * something else, selecting it.\n * • `focus` — landing on it opens it, so Tab walks straight into the\n * editor. For a column of fields being filled in order, and the mode that\n * makes Escape's restore-and-hand-focus-back path load-bearing.\n */\nexport type EditableActivationMode = \"click\" | \"dblclick\" | \"focus\";\n\n/**\n * What commits the edit.\n *\n * • `both` — Enter, or focus leaving the control. The default: a reader who\n * typed a value and clicked away meant to keep it.\n * • `enter` — Enter alone; leaving discards, which suits an edit that is a\n * deliberate act rather than an incidental one.\n * • `blur` — leaving alone, for a control inside something that has its own\n * Enter.\n *\n * Escape abandons under all three.\n */\nexport type EditableSubmitMode = \"blur\" | \"enter\" | \"both\";\n</script>\n\n<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch, type ComponentPublicInstance } from \"vue\";\nimport {\n EditableRoot,\n EditableArea,\n EditablePreview,\n EditableInput,\n EditableSubmitTrigger,\n EditableCancelTrigger,\n} from \"reka-ui\";\nimport { Check, Pencil, X } from \"@lucide/vue\";\nimport { buttonVariants } from \"@ecoma-io/loom-button\";\nimport { cn } from \"@ecoma-io/loom-core\";\nimport { optional } from \"@ecoma-io/loom-core\";\nimport { useSplitAttrs } from \"@ecoma-io/loom-core\";\nimport { useFieldControl } from \"@ecoma-io/loom-labels\";\nimport { useLabels, type LabelOverrides } from \"@ecoma-io/loom-labels\";\n\n/**\n * Editable — text that becomes an input where it stands: a record's title, a\n * name on a card, a cell in a table. It is the control that makes an\n * operations screen feel direct, because the value is edited where it is read\n * rather than in a dialog opened to hold one field.\n *\n * Reach for a [TextField](../TextField/TextField.vue) instead whenever the\n * value is being *entered* rather than corrected — a form is a set of empty\n * boxes and should look like one. This is for a value that already exists,\n * that is usually right, and that is occasionally wrong.\n *\n * **It takes no `size`, and that is the point.** Every other control in the\n * library picks a height off the shared scale; this one inherits the\n * typography around it, so an editable page title stays the size of a page\n * title and an editable table cell stays the size of a cell. What it pins\n * instead is that the two states are the same size as each other: the preview\n * and the editor are the only two children of one padded box, both single-line\n * and both inheriting that box's type, so the swap cannot move the row below\n * it. The box carries a transparent border at rest and a visible one while\n * editing — the same border width either way, for the same reason.\n *\n * **Escape restores and hands focus back.** Ending an edit hides the editor,\n * and a browser drops focus to `<body>` when the focused node goes away, so\n * without the hand-off a reader who abandoned an edit would lose their place\n * in the document. With `activationMode: \"focus\"` that hand-off would then\n * re-open the editor it just closed, which is why activation is Loom's rather\n * than Reka's here — the returning focus is suppressed exactly once, until the\n * reader leaves and comes back deliberately.\n *\n * **The model moves on submit, not on keystrokes.** `update:modelValue` fires\n * once, with the committed value, alongside `submit`; `cancel` fires when an\n * edit is abandoned. A control whose whole purpose is that an edit can be\n * taken back cannot publish every intermediate character as though it were\n * the value.\n *\n * Inside a [Field](../Field/Field.vue) it wires itself: the row's id, the id\n * of its hint or error line, `required`, `invalid`, `disabled`, `readonly` and\n * the name the value is submitted under all arrive through `useFieldControl()`.\n * Every prop below still wins over the row when it is set, in both directions\n * — which is why the four booleans default to `undefined` rather than `false`.\n *\n * `readonly` and `disabled` are different states, so the box has **three**\n * resting appearances rather than two. Read-only is a value on show: still\n * focusable, still copyable, still submitted, and lifted onto a fill with its\n * text at full strength. Disabled is unavailable, and drained a step further in\n * fill, in text colour and in border weight together. Three channels rather\n * than one is the point — see `areaClass`.\n *\n * Built on Reka UI's Editable, which owns the state machine — what is showing,\n * what the editor holds before it is committed, and what a dismiss means under\n * each submit mode.\n */\nconst props = withDefaults(\n defineProps<{\n /** The committed value. Pair with `@update:modelValue` for `v-model`. */\n modelValue?: string;\n /** Shown in place of an empty value, in both the preview and the editor. */\n placeholder?: string;\n /** What a pointer does to open the editor. The keyboard opens it in every mode. */\n activationMode?: EditableActivationMode;\n /** What commits the edit. Escape abandons it under all three. */\n submitMode?: EditableSubmitMode;\n /**\n * Sizes the control to its own text and stacks the two states in one grid\n * cell, for an editable sitting inline in a sentence rather than in a\n * column. It measures the committed value, not what is being typed.\n */\n autoResize?: boolean;\n /** The longest value the editor will accept, in characters. */\n maxLength?: number;\n /**\n * Lets a submitted empty value through. Off by default: an empty submit\n * restores the previous value and reports `cancel` instead, because the\n * damage is asymmetric — a value wrongly kept is on screen and can be\n * edited again, a title wrongly emptied is gone.\n */\n allowEmpty?: boolean;\n /** Unavailable: the preview drops out of the tab order and refuses to open. Unset defers to a wrapping Field. */\n disabled?: boolean | undefined;\n /** Shows the value without allowing an edit, while staying focusable. Unset defers to a wrapping Field. */\n readonly?: boolean | undefined;\n /** Error state: paints the destructive border and ring, and sets `aria-invalid`. Unset defers to a wrapping Field's `error`. */\n invalid?: boolean | undefined;\n /** Marks the value mandatory to assistive tech (`aria-required`). Unset defers to a wrapping Field. */\n required?: boolean | undefined;\n /** The field name for a native form post or `FormData`. Unset defers to a wrapping Field. */\n name?: string;\n /** The editor's accessible name, when no visible element already labels it. */\n ariaLabel?: string;\n /**\n * Names for everything this control says out loud, as any subset of\n * `EditableLabels` — the rest stay as the host's `provideLoomLabels`\n * vocabulary left them, and then as Loom's English.\n *\n * This is the per-instance correction, not the place to localise an\n * application: the case it exists for is `edit`, where a page of\n * editables reads better as \"Rename\" or \"Correct\" than as the same verb\n * five times.\n */\n labels?: LabelOverrides<EditableLabels>;\n }>(),\n {\n activationMode: \"click\",\n submitMode: \"both\",\n autoResize: false,\n allowEmpty: false,\n // Not `false`, for any of the four. `false` is a caller saying \"this value\n // is fine / available / editable even though its row is not\"; absent is a\n // caller saying nothing, and only `undefined` survives to mean that past\n // Vue's absent-Boolean-casts-to-false rule — see TextField.vue, which\n // carries the full reasoning.\n disabled: undefined,\n readonly: undefined,\n invalid: undefined,\n required: undefined,\n },\n);\n\nconst emit = defineEmits<{\n /** The committed value. Fires on submit only, never per keystroke. */\n \"update:modelValue\": [value: string];\n /** The edit was committed, with the value that was accepted. */\n submit: [value: string];\n /** The edit was abandoned — Escape, the cancel button, or a refused empty submit. */\n cancel: [];\n}>();\n\ndefineOptions({ inheritAttrs: false });\n\n// `class` lands on the root rather than on the bordered box inside it: the\n// root is what participates in the caller's layout, and it holds the box and\n// the two commit controls beside it, so a caller's `w-64` has to size all\n// three. Everything else — `id`, `aria-describedby`, `aria-labelledby`,\n// `data-testid`, `autocomplete` — describes the *editor*, and lands there.\nconst { attrs, rest: inputAttrs } = useSplitAttrs();\n\n// `id` and `aria-describedby` reach a bare control as fallthrough attrs rather\n// than props, so they are read off `attrs` and handed in as this caller's own\n// values — a caller who sets either still beats the row, and the row's\n// description is merged into theirs rather than replacing it.\nconst field = useFieldControl(() => ({\n id: attrs.id as string | undefined,\n describedBy: attrs[\"aria-describedby\"] as string | undefined,\n name: props.name,\n disabled: props.disabled,\n readonly: props.readonly,\n invalid: props.invalid,\n required: props.required,\n}));\n\n// `text`, not `labels`: the prop of that name is one of the three sources this\n// resolves, and a template reading the raw prop would be reading the overrides\n// rather than the answer.\nconst text = useLabels(\"editable\", EDITABLE_LABELS, () => props.labels);\n\n// The value as this component knows it, which is also what Reka is fed. Two\n// things need it. A defined `model-value` is what puts Reka in controlled\n// mode, and controlled is what lets a submit be *refused* — Reka's own commit\n// then only announces the new value rather than adopting it, so the preview\n// keeps showing the old one with nothing to undo. And a host using this\n// without `v-model` still needs the value to move, which is what the\n// optimistic write in `onSubmit` is for.\nconst value = ref(props.modelValue ?? \"\");\nwatch(\n () => props.modelValue,\n (next) => {\n // `undefined` is a host that never controlled the value, not a host\n // clearing it — adopting it would blank the box on any unrelated re-render.\n if (next !== undefined) value.value = next;\n },\n);\n\n// Mirrors Reka's own `isEditing`, off the state event rather than out of the\n// slot, because the class bindings and the activation guard both need it in\n// the script half as well as in the template.\nconst editing = ref(false);\n\nconst previewRef = ref<ComponentPublicInstance | null>(null);\nconst rootRef = ref<ComponentPublicInstance | null>(null);\n\n// Reka's parts are components, so the only handle on their DOM node is `$el`,\n// which Vue types as `any`. Narrowing through `unknown` keeps that `any` from\n// spreading into everything it touches — and answers the SSR case honestly,\n// where there is no element at all.\nfunction elementOf(instance: ComponentPublicInstance | null): HTMLElement | null {\n const el = instance?.$el as unknown;\n return el instanceof HTMLElement ? el : null;\n}\n\n// Set only when focus was actually handed back to the preview, and cleared the\n// moment the reader leaves it. Without it, `activationMode: \"focus\"` would\n// read that hand-off as a fresh arrival and re-open the editor the reader just\n// escaped from — a control that cannot be abandoned, which is precisely the\n// trap Escape exists to avoid.\nlet suppressFocusActivation = false;\n\nfunction activate(edit: () => void): void {\n if (field.disabled || field.readonly || editing.value) return;\n edit();\n}\n\n// Reka's own activation is switched off in the template\n// (`activation-mode=\"none\"`) and rebuilt in these three handlers, rather than\n// passed through. Two things need that. Reka has no click mode at all and no\n// keyboard path on its preview, so two of the three modes this component\n// publishes would be unreachable without a pointer. And its focus mode cannot\n// tell a reader arriving on the control from the focus this component hands\n// back after an Escape — which would re-open the editor that Escape just\n// abandoned, forever.\n//\n// A `<button>` turns Enter and Space into a click carrying `detail === 0`,\n// which is what lets one handler serve the keyboard in every activation mode:\n// `dblclick` cannot be typed, and `focus` must not be the only way in for a\n// reader who arrived on the control without meaning to edit yet. `detail` is\n// the same signal Pagination reads to tell a synthesised click from a pointer\n// one.\nfunction onPreviewClick(event: MouseEvent, edit: () => void): void {\n if (props.activationMode === \"dblclick\" && event.detail !== 0) return;\n activate(edit);\n}\n\nfunction onPreviewDoubleClick(edit: () => void): void {\n if (props.activationMode !== \"dblclick\") return;\n activate(edit);\n}\n\nfunction onPreviewFocus(edit: () => void): void {\n if (props.activationMode !== \"focus\" || suppressFocusActivation) return;\n activate(edit);\n}\n\nfunction onPreviewBlur(): void {\n suppressFocusActivation = false;\n}\n\n// The editor is hidden the instant an edit ends, and a browser drops focus to\n// `<body>` when the focused node goes away — so a reader who pressed Escape or\n// Enter would be left with no position in the document at all.\n//\n// Only focus that was actually lost is rescued. A submit caused by clicking\n// somewhere else has already put the reader where they meant to be, and\n// stealing focus back from them would be the worse bug; the same reasoning,\n// and the same `<body>` check, as Pagination's edge-control hand-off. jsdom\n// leaves focus on the hidden node rather than dropping it, and a click on the\n// commit button leaves it on a button that is about to be hidden too, so\n// anything still inside this control counts as lost.\nfunction restoreFocus(): void {\n void nextTick(() => {\n const preview = elementOf(previewRef.value);\n const root = elementOf(rootRef.value);\n if (preview === null) return;\n\n const active = document.activeElement;\n const lost = active === null || active === document.body || (root?.contains(active) ?? false);\n if (!lost) return;\n\n suppressFocusActivation = true;\n preview.focus();\n });\n}\n\nfunction onState(state: \"edit\" | \"submit\" | \"cancel\"): void {\n editing.value = state === \"edit\";\n if (state === \"edit\") return;\n if (state === \"cancel\") emit(\"cancel\");\n restoreFocus();\n}\n\n// The refusal, held for one announcement rather than derived, so the live\n// region says something only when a submit was actually turned away — and is\n// cleared when the next edit opens, so a second identical refusal is still a\n// change to the region and still gets announced.\nconst restored = ref(\"\");\nwatch(editing, (isEditing) => {\n if (isEditing) restored.value = \"\";\n});\n\n// Reka's `submit` is an announcement rather than a request — in controlled\n// mode it has already told us what it would like the value to be and left the\n// deciding here, which is what makes the empty case answerable at all.\nfunction onSubmit(submitted: string | null | undefined): void {\n const next = submitted ?? \"\";\n\n // An empty submit is the one genuinely ambiguous outcome: a reader who\n // cleared the box and pressed Enter may have meant to blank the value, and\n // under `submitMode: \"blur\"` may have meant nothing at all. Loom restores\n // rather than clears, and `allowEmpty` is how a field that can legitimately\n // be emptied says so. `restored` announces it, because a refusal that only\n // shows up as text snapping back is a refusal a screen reader never hears.\n if (next === \"\" && !props.allowEmpty) {\n restored.value = text.value.restored({ value: value.value });\n emit(\"cancel\");\n return;\n }\n\n // Optimistic only while uncontrolled. With a `modelValue` prop the host owns\n // the value, and echoing it here would show a value they may be about to\n // reject — the prop watcher above is what applies the one they accept.\n if (props.modelValue === undefined) value.value = next;\n emit(\"update:modelValue\", next);\n emit(\"submit\", next);\n}\n\n// A read-only preview is not a control, so it is not rendered as one: a\n// `<button>` that refuses to act is worse than plain text. It keeps a\n// `tabindex` of its own instead — a read-only value is one a reader may reach\n// and copy, the same contract TextField's read-only input keeps — while the\n// button state drops Reka's hardcoded `tabindex=\"0\"` entirely, because a\n// `<button>` is focusable without it and a *disabled* one carrying it is\n// focusable when it should not be.\nconst previewAs = computed(() => (field.readonly ? \"span\" : \"button\"));\n\nconst rootProps = computed(() => optional({ name: field.name, maxLength: props.maxLength }));\n\n// `name` is dropped from the bag before it reaches the editor. Reka already\n// renders a hidden input under that name for any Editable inside a `<form>`,\n// and an editor carrying it as well would post the field twice — once with the\n// committed value and once with whatever the box held. Removed from a copy\n// rather than listed positively, so a key added to the resolved bag later\n// reaches this input without anyone remembering to come back here.\nconst editorAttrs = computed(() => {\n const aria: Record<string, unknown> = { ...field.attrs };\n delete aria.name;\n return aria;\n});\n\n// `aria-label` outranks a `<label for>` in the accessible-name order, so a\n// generic fallback would silently replace the name a Field row already gives\n// the editor. It is supplied only where nothing else names it at all — which\n// is also the state Reka would otherwise fill with \"editable input\".\nconst editorLabel = computed<string | undefined>(() => {\n if (props.ariaLabel !== undefined) return props.ariaLabel;\n if (field.id !== undefined) return undefined;\n if (attrs[\"aria-label\"] !== undefined || attrs[\"aria-labelledby\"] !== undefined) return undefined;\n return text.value.input;\n});\n\nconst areaClass = computed(() =>\n cn(\n // One box, two children. The border is present in both states and only\n // changes colour, the padding never changes, and the floor height keeps an\n // empty value from collapsing the row it sits in — between them, nothing\n // below this control moves when it switches.\n \"flex min-h-9 min-w-0 items-center rounded-md border border-transparent px-2 py-1\",\n // A control that sizes to its own text cannot also fill its parent: under\n // `autoResize` Reka makes this box an `inline-grid` measured by the two\n // states stacked inside it, and a width of 100% would measure the row\n // instead.\n props.autoResize ? \"w-auto\" : \"w-full\",\n \"text-inherit transition-[color,background-color,border-color,box-shadow] duration-fast ease-out\",\n // Rim-lit at rest, the weave blooms on focus (Signature law), and it blooms\n // for the preview as much as for the editor: at rest the preview is the\n // focusable node, and a control a reader can tab to has to show it.\n \"focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-ring\",\n !field.invalid && \"focus-within:shadow-halo\",\n editing.value && \"border-input bg-background\",\n // The affordance for a pointer, since the resting state is deliberately\n // just text: the box lights under the cursor and the caret says the text\n // can be typed into. Never the only signal — the preview announces itself,\n // and carries a glyph.\n !editing.value && !field.disabled && !field.readonly && \"cursor-text hover:bg-subtle\",\n // Lifted rather than dimmed, exactly as a read-only text field is: the\n // value is the whole reason the control is on the page, so it stays at\n // full contrast — 13.45:1 over this fill — whether or not it can be\n // changed. The rim comes up with it, from the resting `transparent` to\n // `input`: at rest this control is deliberately just text, so without a\n // border there is nothing but hue to say the value is on show, and the box\n // was already reserving the width for one.\n field.readonly && \"border-input bg-subtle\",\n // Drained, not faded, and a step further down all three channels: the fill\n // to `muted`, the text to `muted-foreground` (4.67:1 over it) and the rim\n // to the lighter `border`. Half-opacity text on this box measures under\n // 4.5:1, and a value nobody can read is worse than one nobody can edit.\n //\n // Read-only and disabled used to share `bg-muted` and part on the text\n // colour alone — a distinction made in hue and nothing else, which is the\n // one a reader with a colour deficiency does not receive.\n field.disabled && \"cursor-not-allowed border-border bg-muted text-muted-foreground\",\n // The same appearance, reached from the other direction: a\n // `<fieldset disabled>` above this control disables the preview `<button>`\n // and the `<input>` behind it natively — both are on the list a fieldset\n // reaches — and never touches `field.disabled`, so the rule above cannot\n // fire and the value rendered byte-identical to an editable one.\n //\n // `in-[fieldset:disabled]:` reads the attribute that did the disabling, so\n // the fill and the native inertness cannot disagree. See TextField.vue for\n // why it is preferred to a `has-[:disabled]:` rule reading this box's own\n // contents.\n \"in-[fieldset:disabled]:cursor-not-allowed in-[fieldset:disabled]:bg-muted in-[fieldset:disabled]:text-muted-foreground\",\n // Gated for the reason the rule below is ordered: Tailwind emits the\n // ancestor half of `in-*` inside `:where()`, so this carries no more\n // specificity than `border-destructive` and would win on emission order\n // alone.\n !field.invalid && \"in-[fieldset:disabled]:border-border\",\n // Last of the rules naming a border colour: `cn()` resolves one by\n // whichever class it saw last, so a control both in error and unavailable\n // would lose its destructive rim if this sat above them.\n field.invalid && \"border-destructive focus-within:outline-destructive\",\n ),\n);\n\n// The submit/cancel triggers are glyph-only, so the drained treatment they\n// take is the transparent control's: the neutral well under a glyph that is\n// already `text-muted-foreground`. `opacity-50` used to fade that glyph below\n// contrast — the failure Button records for its own dim — and the native\n// `disabled` attribute these Reka triggers carry already tells assistive tech.\nconst triggerClass = cn(\n buttonVariants({ variant: \"ghost\", size: \"icon-sm\" }),\n \"disabled:cursor-not-allowed disabled:bg-muted disabled:shadow-none\",\n);\n</script>\n\n<template>\n <EditableRoot\n ref=\"rootRef\"\n v-slot=\"{ edit }\"\n v-bind=\"rootProps\"\n :model-value=\"value\"\n :placeholder=\"placeholder ?? ''\"\n activation-mode=\"none\"\n :disabled=\"field.disabled\"\n :readonly=\"field.readonly\"\n :required=\"field.required\"\n :submit-mode=\"submitMode\"\n :auto-resize=\"autoResize\"\n :data-editing=\"editing || undefined\"\n :data-invalid=\"field.invalid || undefined\"\n :data-readonly=\"field.readonly || undefined\"\n :data-disabled=\"field.disabled || undefined\"\n :class=\"\n cn(\n 'inline-flex items-center gap-1',\n autoResize ? 'w-auto align-baseline' : 'w-full',\n attrs.class as string,\n )\n \"\n @update:state=\"onState\"\n @submit=\"onSubmit\"\n >\n <EditableArea :class=\"areaClass\">\n <!-- The preview is the control, and Reka's `EditableEditTrigger` is\n deliberately not rendered beside it: a second button doing the same\n job costs a second Tab stop on one value, and its click handler\n fires in every activation mode, which would make `dblclick` and\n `focus` lies. A real `<button>` rather than a `role`, so Enter and\n Space are the browser's job and the affordance survives\n forced-colors mode. -->\n <EditablePreview\n ref=\"previewRef\"\n :as=\"previewAs\"\n :type=\"field.readonly ? undefined : 'button'\"\n :tabindex=\"field.readonly ? 0 : undefined\"\n :disabled=\"!field.readonly && field.disabled ? true : undefined\"\n :aria-describedby=\"field.attrs['aria-describedby']\"\n :aria-invalid=\"field.attrs['aria-invalid']\"\n class=\"flex min-w-0 flex-1 items-center gap-1.5 text-start outline-none\"\n @click=\"(event: MouseEvent) => onPreviewClick(event, edit)\"\n @dblclick=\"() => onPreviewDoubleClick(edit)\"\n @focus=\"() => onPreviewFocus(edit)\"\n @blur=\"onPreviewBlur\"\n >\n <!-- Single-line and truncated, because the editor it swaps with is a\n single-line input: a preview that wrapped would be a taller box\n than the thing replacing it. -->\n <span :class=\"cn('truncate', !value && 'text-muted-foreground')\">\n {{ value || placeholder }}\n </span>\n <template v-if=\"!field.readonly\">\n <!-- The affordance for a sighted reader, and the counterpart to the\n span below it: the resting state is text, so something has to\n say it is more than text. Hidden from assistive tech, which is\n told the same thing in words. -->\n <Pencil class=\"h-3.5 w-3.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <!-- The whole accessibility problem of edit-in-place, in one span.\n The value reads first so it stays the start of the accessible\n name (WCAG 2.5.3, and what a voice-control reader says out\n loud); the verb follows, so the text announces as something\n that can be acted on rather than as prose. -->\n <span class=\"sr-only\">{{ text.edit }}</span>\n </template>\n </EditablePreview>\n\n <EditableInput\n v-bind=\"{ ...inputAttrs, ...editorAttrs }\"\n :aria-label=\"editorLabel\"\n class=\"min-w-0 flex-1 bg-transparent text-inherit outline-none placeholder:text-muted-foreground\"\n />\n </EditableArea>\n\n <!-- Both triggers hide themselves while the preview is showing, so there\n is nothing to guard here. They exist for the reader who has no way to\n know that Enter commits and Escape abandons — an invisible keyboard\n contract is not an affordance. -->\n <EditableSubmitTrigger :aria-label=\"text.submit\" :class=\"triggerClass\">\n <Check class=\"h-4 w-4\" aria-hidden=\"true\" />\n </EditableSubmitTrigger>\n <EditableCancelTrigger :aria-label=\"text.cancel\" :class=\"triggerClass\">\n <X class=\"h-4 w-4\" aria-hidden=\"true\" />\n </EditableCancelTrigger>\n\n <!-- A refused empty submit changes the screen without moving focus or\n opening anything, which a screen reader would otherwise never\n announce. Empty until there is something to say, so it never repeats\n itself on an unrelated re-render. -->\n <span role=\"status\" class=\"sr-only\">{{ restored }}</span>\n </EditableRoot>\n</template>\n"],"mappings":""}
@@ -1,5 +1,5 @@
1
- import { type LabelOverrides } from "@ecoma-io/loom-labels";
2
- import type { EditableLabels } from "@ecoma-io/loom-labels";
1
+ import { type LabelOverrides } from "../../../labels/src/index";
2
+ import type { EditableLabels } from "../../../labels/src/index";
3
3
  /**
4
4
 
5
5
  /**
@@ -171,7 +171,7 @@ var Editable_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defineC
171
171
  const triggerClass = cn(buttonVariants({
172
172
  variant: "ghost",
173
173
  size: "icon-sm"
174
- }), "disabled:cursor-not-allowed disabled:opacity-50");
174
+ }), "disabled:cursor-not-allowed disabled:bg-muted disabled:shadow-none");
175
175
  return (_ctx, _cache) => {
176
176
  return openBlock(), createBlock(unref(EditableRoot), mergeProps({
177
177
  ref_key: "rootRef",
@@ -1 +1 @@
1
- {"version":3,"file":"Editable.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../../../../packages/primitives/editable/src/Editable.vue"],"sourcesContent":["<script lang=\"ts\">\nimport type { EditableLabels } from \"@ecoma-io/loom-labels\";\n\n/**\n\n/**\n * Loom's English, co-located with the component so it tree-shakes with it: a\n * consumer who never imports Editable ships none of these bytes.\n *\n * Exported so a host can build a partial vocabulary against the real thing\n * rather than a transcription of it.\n */\nexport const EDITABLE_LABELS: EditableLabels = {\n edit: \"Edit\",\n input: \"Editable text\",\n submit: \"Save\",\n cancel: \"Cancel\",\n restored: ({ value }) => `Empty value discarded, ${value} restored`,\n};\n\n/**\n * What a pointer has to do to open the editor. The keyboard is deliberately\n * not on this axis — Enter and Space open it in all three, because a mode a\n * reader cannot reach by keyboard is a control they cannot use.\n *\n * • `click` — one click on the text opens it. The default: it is the whole\n * promise of edit-in-place, and the only mode a touch reader can perform.\n * • `dblclick` — for a row or a cell where a single click already means\n * something else, selecting it.\n * • `focus` — landing on it opens it, so Tab walks straight into the\n * editor. For a column of fields being filled in order, and the mode that\n * makes Escape's restore-and-hand-focus-back path load-bearing.\n */\nexport type EditableActivationMode = \"click\" | \"dblclick\" | \"focus\";\n\n/**\n * What commits the edit.\n *\n * • `both` — Enter, or focus leaving the control. The default: a reader who\n * typed a value and clicked away meant to keep it.\n * • `enter` — Enter alone; leaving discards, which suits an edit that is a\n * deliberate act rather than an incidental one.\n * • `blur` — leaving alone, for a control inside something that has its own\n * Enter.\n *\n * Escape abandons under all three.\n */\nexport type EditableSubmitMode = \"blur\" | \"enter\" | \"both\";\n</script>\n\n<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch, type ComponentPublicInstance } from \"vue\";\nimport {\n EditableRoot,\n EditableArea,\n EditablePreview,\n EditableInput,\n EditableSubmitTrigger,\n EditableCancelTrigger,\n} from \"reka-ui\";\nimport { Check, Pencil, X } from \"@lucide/vue\";\nimport { buttonVariants } from \"@ecoma-io/loom-button\";\nimport { cn } from \"@ecoma-io/loom-core\";\nimport { optional } from \"@ecoma-io/loom-core\";\nimport { useSplitAttrs } from \"@ecoma-io/loom-core\";\nimport { useFieldControl } from \"@ecoma-io/loom-labels\";\nimport { useLabels, type LabelOverrides } from \"@ecoma-io/loom-labels\";\n\n/**\n * Editable — text that becomes an input where it stands: a record's title, a\n * name on a card, a cell in a table. It is the control that makes an\n * operations screen feel direct, because the value is edited where it is read\n * rather than in a dialog opened to hold one field.\n *\n * Reach for a [TextField](../TextField/TextField.vue) instead whenever the\n * value is being *entered* rather than corrected — a form is a set of empty\n * boxes and should look like one. This is for a value that already exists,\n * that is usually right, and that is occasionally wrong.\n *\n * **It takes no `size`, and that is the point.** Every other control in the\n * library picks a height off the shared scale; this one inherits the\n * typography around it, so an editable page title stays the size of a page\n * title and an editable table cell stays the size of a cell. What it pins\n * instead is that the two states are the same size as each other: the preview\n * and the editor are the only two children of one padded box, both single-line\n * and both inheriting that box's type, so the swap cannot move the row below\n * it. The box carries a transparent border at rest and a visible one while\n * editing — the same border width either way, for the same reason.\n *\n * **Escape restores and hands focus back.** Ending an edit hides the editor,\n * and a browser drops focus to `<body>` when the focused node goes away, so\n * without the hand-off a reader who abandoned an edit would lose their place\n * in the document. With `activationMode: \"focus\"` that hand-off would then\n * re-open the editor it just closed, which is why activation is Loom's rather\n * than Reka's here — the returning focus is suppressed exactly once, until the\n * reader leaves and comes back deliberately.\n *\n * **The model moves on submit, not on keystrokes.** `update:modelValue` fires\n * once, with the committed value, alongside `submit`; `cancel` fires when an\n * edit is abandoned. A control whose whole purpose is that an edit can be\n * taken back cannot publish every intermediate character as though it were\n * the value.\n *\n * Inside a [Field](../Field/Field.vue) it wires itself: the row's id, the id\n * of its hint or error line, `required`, `invalid`, `disabled`, `readonly` and\n * the name the value is submitted under all arrive through `useFieldControl()`.\n * Every prop below still wins over the row when it is set, in both directions\n * — which is why the four booleans default to `undefined` rather than `false`.\n *\n * `readonly` and `disabled` are different states, so the box has **three**\n * resting appearances rather than two. Read-only is a value on show: still\n * focusable, still copyable, still submitted, and lifted onto a fill with its\n * text at full strength. Disabled is unavailable, and drained a step further in\n * fill, in text colour and in border weight together. Three channels rather\n * than one is the point — see `areaClass`.\n *\n * Built on Reka UI's Editable, which owns the state machine — what is showing,\n * what the editor holds before it is committed, and what a dismiss means under\n * each submit mode.\n */\nconst props = withDefaults(\n defineProps<{\n /** The committed value. Pair with `@update:modelValue` for `v-model`. */\n modelValue?: string;\n /** Shown in place of an empty value, in both the preview and the editor. */\n placeholder?: string;\n /** What a pointer does to open the editor. The keyboard opens it in every mode. */\n activationMode?: EditableActivationMode;\n /** What commits the edit. Escape abandons it under all three. */\n submitMode?: EditableSubmitMode;\n /**\n * Sizes the control to its own text and stacks the two states in one grid\n * cell, for an editable sitting inline in a sentence rather than in a\n * column. It measures the committed value, not what is being typed.\n */\n autoResize?: boolean;\n /** The longest value the editor will accept, in characters. */\n maxLength?: number;\n /**\n * Lets a submitted empty value through. Off by default: an empty submit\n * restores the previous value and reports `cancel` instead, because the\n * damage is asymmetric — a value wrongly kept is on screen and can be\n * edited again, a title wrongly emptied is gone.\n */\n allowEmpty?: boolean;\n /** Unavailable: the preview drops out of the tab order and refuses to open. Unset defers to a wrapping Field. */\n disabled?: boolean | undefined;\n /** Shows the value without allowing an edit, while staying focusable. Unset defers to a wrapping Field. */\n readonly?: boolean | undefined;\n /** Error state: paints the destructive border and ring, and sets `aria-invalid`. Unset defers to a wrapping Field's `error`. */\n invalid?: boolean | undefined;\n /** Marks the value mandatory to assistive tech (`aria-required`). Unset defers to a wrapping Field. */\n required?: boolean | undefined;\n /** The field name for a native form post or `FormData`. Unset defers to a wrapping Field. */\n name?: string;\n /** The editor's accessible name, when no visible element already labels it. */\n ariaLabel?: string;\n /**\n * Names for everything this control says out loud, as any subset of\n * `EditableLabels` — the rest stay as the host's `provideLoomLabels`\n * vocabulary left them, and then as Loom's English.\n *\n * This is the per-instance correction, not the place to localise an\n * application: the case it exists for is `edit`, where a page of\n * editables reads better as \"Rename\" or \"Correct\" than as the same verb\n * five times.\n */\n labels?: LabelOverrides<EditableLabels>;\n }>(),\n {\n activationMode: \"click\",\n submitMode: \"both\",\n autoResize: false,\n allowEmpty: false,\n // Not `false`, for any of the four. `false` is a caller saying \"this value\n // is fine / available / editable even though its row is not\"; absent is a\n // caller saying nothing, and only `undefined` survives to mean that past\n // Vue's absent-Boolean-casts-to-false rule — see TextField.vue, which\n // carries the full reasoning.\n disabled: undefined,\n readonly: undefined,\n invalid: undefined,\n required: undefined,\n },\n);\n\nconst emit = defineEmits<{\n /** The committed value. Fires on submit only, never per keystroke. */\n \"update:modelValue\": [value: string];\n /** The edit was committed, with the value that was accepted. */\n submit: [value: string];\n /** The edit was abandoned — Escape, the cancel button, or a refused empty submit. */\n cancel: [];\n}>();\n\ndefineOptions({ inheritAttrs: false });\n\n// `class` lands on the root rather than on the bordered box inside it: the\n// root is what participates in the caller's layout, and it holds the box and\n// the two commit controls beside it, so a caller's `w-64` has to size all\n// three. Everything else — `id`, `aria-describedby`, `aria-labelledby`,\n// `data-testid`, `autocomplete` — describes the *editor*, and lands there.\nconst { attrs, rest: inputAttrs } = useSplitAttrs();\n\n// `id` and `aria-describedby` reach a bare control as fallthrough attrs rather\n// than props, so they are read off `attrs` and handed in as this caller's own\n// values — a caller who sets either still beats the row, and the row's\n// description is merged into theirs rather than replacing it.\nconst field = useFieldControl(() => ({\n id: attrs.id as string | undefined,\n describedBy: attrs[\"aria-describedby\"] as string | undefined,\n name: props.name,\n disabled: props.disabled,\n readonly: props.readonly,\n invalid: props.invalid,\n required: props.required,\n}));\n\n// `text`, not `labels`: the prop of that name is one of the three sources this\n// resolves, and a template reading the raw prop would be reading the overrides\n// rather than the answer.\nconst text = useLabels(\"editable\", EDITABLE_LABELS, () => props.labels);\n\n// The value as this component knows it, which is also what Reka is fed. Two\n// things need it. A defined `model-value` is what puts Reka in controlled\n// mode, and controlled is what lets a submit be *refused* — Reka's own commit\n// then only announces the new value rather than adopting it, so the preview\n// keeps showing the old one with nothing to undo. And a host using this\n// without `v-model` still needs the value to move, which is what the\n// optimistic write in `onSubmit` is for.\nconst value = ref(props.modelValue ?? \"\");\nwatch(\n () => props.modelValue,\n (next) => {\n // `undefined` is a host that never controlled the value, not a host\n // clearing it — adopting it would blank the box on any unrelated re-render.\n if (next !== undefined) value.value = next;\n },\n);\n\n// Mirrors Reka's own `isEditing`, off the state event rather than out of the\n// slot, because the class bindings and the activation guard both need it in\n// the script half as well as in the template.\nconst editing = ref(false);\n\nconst previewRef = ref<ComponentPublicInstance | null>(null);\nconst rootRef = ref<ComponentPublicInstance | null>(null);\n\n// Reka's parts are components, so the only handle on their DOM node is `$el`,\n// which Vue types as `any`. Narrowing through `unknown` keeps that `any` from\n// spreading into everything it touches — and answers the SSR case honestly,\n// where there is no element at all.\nfunction elementOf(instance: ComponentPublicInstance | null): HTMLElement | null {\n const el = instance?.$el as unknown;\n return el instanceof HTMLElement ? el : null;\n}\n\n// Set only when focus was actually handed back to the preview, and cleared the\n// moment the reader leaves it. Without it, `activationMode: \"focus\"` would\n// read that hand-off as a fresh arrival and re-open the editor the reader just\n// escaped from — a control that cannot be abandoned, which is precisely the\n// trap Escape exists to avoid.\nlet suppressFocusActivation = false;\n\nfunction activate(edit: () => void): void {\n if (field.disabled || field.readonly || editing.value) return;\n edit();\n}\n\n// Reka's own activation is switched off in the template\n// (`activation-mode=\"none\"`) and rebuilt in these three handlers, rather than\n// passed through. Two things need that. Reka has no click mode at all and no\n// keyboard path on its preview, so two of the three modes this component\n// publishes would be unreachable without a pointer. And its focus mode cannot\n// tell a reader arriving on the control from the focus this component hands\n// back after an Escape — which would re-open the editor that Escape just\n// abandoned, forever.\n//\n// A `<button>` turns Enter and Space into a click carrying `detail === 0`,\n// which is what lets one handler serve the keyboard in every activation mode:\n// `dblclick` cannot be typed, and `focus` must not be the only way in for a\n// reader who arrived on the control without meaning to edit yet. `detail` is\n// the same signal Pagination reads to tell a synthesised click from a pointer\n// one.\nfunction onPreviewClick(event: MouseEvent, edit: () => void): void {\n if (props.activationMode === \"dblclick\" && event.detail !== 0) return;\n activate(edit);\n}\n\nfunction onPreviewDoubleClick(edit: () => void): void {\n if (props.activationMode !== \"dblclick\") return;\n activate(edit);\n}\n\nfunction onPreviewFocus(edit: () => void): void {\n if (props.activationMode !== \"focus\" || suppressFocusActivation) return;\n activate(edit);\n}\n\nfunction onPreviewBlur(): void {\n suppressFocusActivation = false;\n}\n\n// The editor is hidden the instant an edit ends, and a browser drops focus to\n// `<body>` when the focused node goes away — so a reader who pressed Escape or\n// Enter would be left with no position in the document at all.\n//\n// Only focus that was actually lost is rescued. A submit caused by clicking\n// somewhere else has already put the reader where they meant to be, and\n// stealing focus back from them would be the worse bug; the same reasoning,\n// and the same `<body>` check, as Pagination's edge-control hand-off. jsdom\n// leaves focus on the hidden node rather than dropping it, and a click on the\n// commit button leaves it on a button that is about to be hidden too, so\n// anything still inside this control counts as lost.\nfunction restoreFocus(): void {\n void nextTick(() => {\n const preview = elementOf(previewRef.value);\n const root = elementOf(rootRef.value);\n if (preview === null) return;\n\n const active = document.activeElement;\n const lost = active === null || active === document.body || (root?.contains(active) ?? false);\n if (!lost) return;\n\n suppressFocusActivation = true;\n preview.focus();\n });\n}\n\nfunction onState(state: \"edit\" | \"submit\" | \"cancel\"): void {\n editing.value = state === \"edit\";\n if (state === \"edit\") return;\n if (state === \"cancel\") emit(\"cancel\");\n restoreFocus();\n}\n\n// The refusal, held for one announcement rather than derived, so the live\n// region says something only when a submit was actually turned away — and is\n// cleared when the next edit opens, so a second identical refusal is still a\n// change to the region and still gets announced.\nconst restored = ref(\"\");\nwatch(editing, (isEditing) => {\n if (isEditing) restored.value = \"\";\n});\n\n// Reka's `submit` is an announcement rather than a request — in controlled\n// mode it has already told us what it would like the value to be and left the\n// deciding here, which is what makes the empty case answerable at all.\nfunction onSubmit(submitted: string | null | undefined): void {\n const next = submitted ?? \"\";\n\n // An empty submit is the one genuinely ambiguous outcome: a reader who\n // cleared the box and pressed Enter may have meant to blank the value, and\n // under `submitMode: \"blur\"` may have meant nothing at all. Loom restores\n // rather than clears, and `allowEmpty` is how a field that can legitimately\n // be emptied says so. `restored` announces it, because a refusal that only\n // shows up as text snapping back is a refusal a screen reader never hears.\n if (next === \"\" && !props.allowEmpty) {\n restored.value = text.value.restored({ value: value.value });\n emit(\"cancel\");\n return;\n }\n\n // Optimistic only while uncontrolled. With a `modelValue` prop the host owns\n // the value, and echoing it here would show a value they may be about to\n // reject — the prop watcher above is what applies the one they accept.\n if (props.modelValue === undefined) value.value = next;\n emit(\"update:modelValue\", next);\n emit(\"submit\", next);\n}\n\n// A read-only preview is not a control, so it is not rendered as one: a\n// `<button>` that refuses to act is worse than plain text. It keeps a\n// `tabindex` of its own instead — a read-only value is one a reader may reach\n// and copy, the same contract TextField's read-only input keeps — while the\n// button state drops Reka's hardcoded `tabindex=\"0\"` entirely, because a\n// `<button>` is focusable without it and a *disabled* one carrying it is\n// focusable when it should not be.\nconst previewAs = computed(() => (field.readonly ? \"span\" : \"button\"));\n\nconst rootProps = computed(() => optional({ name: field.name, maxLength: props.maxLength }));\n\n// `name` is dropped from the bag before it reaches the editor. Reka already\n// renders a hidden input under that name for any Editable inside a `<form>`,\n// and an editor carrying it as well would post the field twice — once with the\n// committed value and once with whatever the box held. Removed from a copy\n// rather than listed positively, so a key added to the resolved bag later\n// reaches this input without anyone remembering to come back here.\nconst editorAttrs = computed(() => {\n const aria: Record<string, unknown> = { ...field.attrs };\n delete aria.name;\n return aria;\n});\n\n// `aria-label` outranks a `<label for>` in the accessible-name order, so a\n// generic fallback would silently replace the name a Field row already gives\n// the editor. It is supplied only where nothing else names it at all — which\n// is also the state Reka would otherwise fill with \"editable input\".\nconst editorLabel = computed<string | undefined>(() => {\n if (props.ariaLabel !== undefined) return props.ariaLabel;\n if (field.id !== undefined) return undefined;\n if (attrs[\"aria-label\"] !== undefined || attrs[\"aria-labelledby\"] !== undefined) return undefined;\n return text.value.input;\n});\n\nconst areaClass = computed(() =>\n cn(\n // One box, two children. The border is present in both states and only\n // changes colour, the padding never changes, and the floor height keeps an\n // empty value from collapsing the row it sits in — between them, nothing\n // below this control moves when it switches.\n \"flex min-h-9 min-w-0 items-center rounded-md border border-transparent px-2 py-1\",\n // A control that sizes to its own text cannot also fill its parent: under\n // `autoResize` Reka makes this box an `inline-grid` measured by the two\n // states stacked inside it, and a width of 100% would measure the row\n // instead.\n props.autoResize ? \"w-auto\" : \"w-full\",\n \"text-inherit transition-[color,background-color,border-color,box-shadow] duration-fast ease-out\",\n // Rim-lit at rest, the weave blooms on focus (Signature law), and it blooms\n // for the preview as much as for the editor: at rest the preview is the\n // focusable node, and a control a reader can tab to has to show it.\n \"focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-ring\",\n !field.invalid && \"focus-within:shadow-halo\",\n editing.value && \"border-input bg-background\",\n // The affordance for a pointer, since the resting state is deliberately\n // just text: the box lights under the cursor and the caret says the text\n // can be typed into. Never the only signal — the preview announces itself,\n // and carries a glyph.\n !editing.value && !field.disabled && !field.readonly && \"cursor-text hover:bg-subtle\",\n // Lifted rather than dimmed, exactly as a read-only text field is: the\n // value is the whole reason the control is on the page, so it stays at\n // full contrast — 13.45:1 over this fill — whether or not it can be\n // changed. The rim comes up with it, from the resting `transparent` to\n // `input`: at rest this control is deliberately just text, so without a\n // border there is nothing but hue to say the value is on show, and the box\n // was already reserving the width for one.\n field.readonly && \"border-input bg-subtle\",\n // Drained, not faded, and a step further down all three channels: the fill\n // to `muted`, the text to `muted-foreground` (4.67:1 over it) and the rim\n // to the lighter `border`. Half-opacity text on this box measures under\n // 4.5:1, and a value nobody can read is worse than one nobody can edit.\n //\n // Read-only and disabled used to share `bg-muted` and part on the text\n // colour alone — a distinction made in hue and nothing else, which is the\n // one a reader with a colour deficiency does not receive.\n field.disabled && \"cursor-not-allowed border-border bg-muted text-muted-foreground\",\n // The same appearance, reached from the other direction: a\n // `<fieldset disabled>` above this control disables the preview `<button>`\n // and the `<input>` behind it natively — both are on the list a fieldset\n // reaches — and never touches `field.disabled`, so the rule above cannot\n // fire and the value rendered byte-identical to an editable one.\n //\n // `in-[fieldset:disabled]:` reads the attribute that did the disabling, so\n // the fill and the native inertness cannot disagree. See TextField.vue for\n // why it is preferred to a `has-[:disabled]:` rule reading this box's own\n // contents.\n \"in-[fieldset:disabled]:cursor-not-allowed in-[fieldset:disabled]:bg-muted in-[fieldset:disabled]:text-muted-foreground\",\n // Gated for the reason the rule below is ordered: Tailwind emits the\n // ancestor half of `in-*` inside `:where()`, so this carries no more\n // specificity than `border-destructive` and would win on emission order\n // alone.\n !field.invalid && \"in-[fieldset:disabled]:border-border\",\n // Last of the rules naming a border colour: `cn()` resolves one by\n // whichever class it saw last, so a control both in error and unavailable\n // would lose its destructive rim if this sat above them.\n field.invalid && \"border-destructive focus-within:outline-destructive\",\n ),\n);\n\nconst triggerClass = cn(\n buttonVariants({ variant: \"ghost\", size: \"icon-sm\" }),\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n);\n</script>\n\n<template>\n <EditableRoot\n ref=\"rootRef\"\n v-slot=\"{ edit }\"\n v-bind=\"rootProps\"\n :model-value=\"value\"\n :placeholder=\"placeholder ?? ''\"\n activation-mode=\"none\"\n :disabled=\"field.disabled\"\n :readonly=\"field.readonly\"\n :required=\"field.required\"\n :submit-mode=\"submitMode\"\n :auto-resize=\"autoResize\"\n :data-editing=\"editing || undefined\"\n :data-invalid=\"field.invalid || undefined\"\n :data-readonly=\"field.readonly || undefined\"\n :data-disabled=\"field.disabled || undefined\"\n :class=\"\n cn(\n 'inline-flex items-center gap-1',\n autoResize ? 'w-auto align-baseline' : 'w-full',\n attrs.class as string,\n )\n \"\n @update:state=\"onState\"\n @submit=\"onSubmit\"\n >\n <EditableArea :class=\"areaClass\">\n <!-- The preview is the control, and Reka's `EditableEditTrigger` is\n deliberately not rendered beside it: a second button doing the same\n job costs a second Tab stop on one value, and its click handler\n fires in every activation mode, which would make `dblclick` and\n `focus` lies. A real `<button>` rather than a `role`, so Enter and\n Space are the browser's job and the affordance survives\n forced-colors mode. -->\n <EditablePreview\n ref=\"previewRef\"\n :as=\"previewAs\"\n :type=\"field.readonly ? undefined : 'button'\"\n :tabindex=\"field.readonly ? 0 : undefined\"\n :disabled=\"!field.readonly && field.disabled ? true : undefined\"\n :aria-describedby=\"field.attrs['aria-describedby']\"\n :aria-invalid=\"field.attrs['aria-invalid']\"\n class=\"flex min-w-0 flex-1 items-center gap-1.5 text-start outline-none\"\n @click=\"(event: MouseEvent) => onPreviewClick(event, edit)\"\n @dblclick=\"() => onPreviewDoubleClick(edit)\"\n @focus=\"() => onPreviewFocus(edit)\"\n @blur=\"onPreviewBlur\"\n >\n <!-- Single-line and truncated, because the editor it swaps with is a\n single-line input: a preview that wrapped would be a taller box\n than the thing replacing it. -->\n <span :class=\"cn('truncate', !value && 'text-muted-foreground')\">\n {{ value || placeholder }}\n </span>\n <template v-if=\"!field.readonly\">\n <!-- The affordance for a sighted reader, and the counterpart to the\n span below it: the resting state is text, so something has to\n say it is more than text. Hidden from assistive tech, which is\n told the same thing in words. -->\n <Pencil class=\"h-3.5 w-3.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <!-- The whole accessibility problem of edit-in-place, in one span.\n The value reads first so it stays the start of the accessible\n name (WCAG 2.5.3, and what a voice-control reader says out\n loud); the verb follows, so the text announces as something\n that can be acted on rather than as prose. -->\n <span class=\"sr-only\">{{ text.edit }}</span>\n </template>\n </EditablePreview>\n\n <EditableInput\n v-bind=\"{ ...inputAttrs, ...editorAttrs }\"\n :aria-label=\"editorLabel\"\n class=\"min-w-0 flex-1 bg-transparent text-inherit outline-none placeholder:text-muted-foreground\"\n />\n </EditableArea>\n\n <!-- Both triggers hide themselves while the preview is showing, so there\n is nothing to guard here. They exist for the reader who has no way to\n know that Enter commits and Escape abandons — an invisible keyboard\n contract is not an affordance. -->\n <EditableSubmitTrigger :aria-label=\"text.submit\" :class=\"triggerClass\">\n <Check class=\"h-4 w-4\" aria-hidden=\"true\" />\n </EditableSubmitTrigger>\n <EditableCancelTrigger :aria-label=\"text.cancel\" :class=\"triggerClass\">\n <X class=\"h-4 w-4\" aria-hidden=\"true\" />\n </EditableCancelTrigger>\n\n <!-- A refused empty submit changes the screen without moving focus or\n opening anything, which a screen reader would otherwise never\n announce. Empty until there is something to say, so it never repeats\n itself on an unrelated re-render. -->\n <span role=\"status\" class=\"sr-only\">{{ restored }}</span>\n </EditableRoot>\n</template>\n"],"mappings":"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"}
1
+ {"version":3,"file":"Editable.vue_vue_type_script_setup_true_lang.js","names":[],"sources":["../../../../../packages/primitives/editable/src/Editable.vue"],"sourcesContent":["<script lang=\"ts\">\nimport type { EditableLabels } from \"@ecoma-io/loom-labels\";\n\n/**\n\n/**\n * Loom's English, co-located with the component so it tree-shakes with it: a\n * consumer who never imports Editable ships none of these bytes.\n *\n * Exported so a host can build a partial vocabulary against the real thing\n * rather than a transcription of it.\n */\nexport const EDITABLE_LABELS: EditableLabels = {\n edit: \"Edit\",\n input: \"Editable text\",\n submit: \"Save\",\n cancel: \"Cancel\",\n restored: ({ value }) => `Empty value discarded, ${value} restored`,\n};\n\n/**\n * What a pointer has to do to open the editor. The keyboard is deliberately\n * not on this axis — Enter and Space open it in all three, because a mode a\n * reader cannot reach by keyboard is a control they cannot use.\n *\n * • `click` — one click on the text opens it. The default: it is the whole\n * promise of edit-in-place, and the only mode a touch reader can perform.\n * • `dblclick` — for a row or a cell where a single click already means\n * something else, selecting it.\n * • `focus` — landing on it opens it, so Tab walks straight into the\n * editor. For a column of fields being filled in order, and the mode that\n * makes Escape's restore-and-hand-focus-back path load-bearing.\n */\nexport type EditableActivationMode = \"click\" | \"dblclick\" | \"focus\";\n\n/**\n * What commits the edit.\n *\n * • `both` — Enter, or focus leaving the control. The default: a reader who\n * typed a value and clicked away meant to keep it.\n * • `enter` — Enter alone; leaving discards, which suits an edit that is a\n * deliberate act rather than an incidental one.\n * • `blur` — leaving alone, for a control inside something that has its own\n * Enter.\n *\n * Escape abandons under all three.\n */\nexport type EditableSubmitMode = \"blur\" | \"enter\" | \"both\";\n</script>\n\n<script setup lang=\"ts\">\nimport { computed, nextTick, ref, watch, type ComponentPublicInstance } from \"vue\";\nimport {\n EditableRoot,\n EditableArea,\n EditablePreview,\n EditableInput,\n EditableSubmitTrigger,\n EditableCancelTrigger,\n} from \"reka-ui\";\nimport { Check, Pencil, X } from \"@lucide/vue\";\nimport { buttonVariants } from \"@ecoma-io/loom-button\";\nimport { cn } from \"@ecoma-io/loom-core\";\nimport { optional } from \"@ecoma-io/loom-core\";\nimport { useSplitAttrs } from \"@ecoma-io/loom-core\";\nimport { useFieldControl } from \"@ecoma-io/loom-labels\";\nimport { useLabels, type LabelOverrides } from \"@ecoma-io/loom-labels\";\n\n/**\n * Editable — text that becomes an input where it stands: a record's title, a\n * name on a card, a cell in a table. It is the control that makes an\n * operations screen feel direct, because the value is edited where it is read\n * rather than in a dialog opened to hold one field.\n *\n * Reach for a [TextField](../TextField/TextField.vue) instead whenever the\n * value is being *entered* rather than corrected — a form is a set of empty\n * boxes and should look like one. This is for a value that already exists,\n * that is usually right, and that is occasionally wrong.\n *\n * **It takes no `size`, and that is the point.** Every other control in the\n * library picks a height off the shared scale; this one inherits the\n * typography around it, so an editable page title stays the size of a page\n * title and an editable table cell stays the size of a cell. What it pins\n * instead is that the two states are the same size as each other: the preview\n * and the editor are the only two children of one padded box, both single-line\n * and both inheriting that box's type, so the swap cannot move the row below\n * it. The box carries a transparent border at rest and a visible one while\n * editing — the same border width either way, for the same reason.\n *\n * **Escape restores and hands focus back.** Ending an edit hides the editor,\n * and a browser drops focus to `<body>` when the focused node goes away, so\n * without the hand-off a reader who abandoned an edit would lose their place\n * in the document. With `activationMode: \"focus\"` that hand-off would then\n * re-open the editor it just closed, which is why activation is Loom's rather\n * than Reka's here — the returning focus is suppressed exactly once, until the\n * reader leaves and comes back deliberately.\n *\n * **The model moves on submit, not on keystrokes.** `update:modelValue` fires\n * once, with the committed value, alongside `submit`; `cancel` fires when an\n * edit is abandoned. A control whose whole purpose is that an edit can be\n * taken back cannot publish every intermediate character as though it were\n * the value.\n *\n * Inside a [Field](../Field/Field.vue) it wires itself: the row's id, the id\n * of its hint or error line, `required`, `invalid`, `disabled`, `readonly` and\n * the name the value is submitted under all arrive through `useFieldControl()`.\n * Every prop below still wins over the row when it is set, in both directions\n * — which is why the four booleans default to `undefined` rather than `false`.\n *\n * `readonly` and `disabled` are different states, so the box has **three**\n * resting appearances rather than two. Read-only is a value on show: still\n * focusable, still copyable, still submitted, and lifted onto a fill with its\n * text at full strength. Disabled is unavailable, and drained a step further in\n * fill, in text colour and in border weight together. Three channels rather\n * than one is the point — see `areaClass`.\n *\n * Built on Reka UI's Editable, which owns the state machine — what is showing,\n * what the editor holds before it is committed, and what a dismiss means under\n * each submit mode.\n */\nconst props = withDefaults(\n defineProps<{\n /** The committed value. Pair with `@update:modelValue` for `v-model`. */\n modelValue?: string;\n /** Shown in place of an empty value, in both the preview and the editor. */\n placeholder?: string;\n /** What a pointer does to open the editor. The keyboard opens it in every mode. */\n activationMode?: EditableActivationMode;\n /** What commits the edit. Escape abandons it under all three. */\n submitMode?: EditableSubmitMode;\n /**\n * Sizes the control to its own text and stacks the two states in one grid\n * cell, for an editable sitting inline in a sentence rather than in a\n * column. It measures the committed value, not what is being typed.\n */\n autoResize?: boolean;\n /** The longest value the editor will accept, in characters. */\n maxLength?: number;\n /**\n * Lets a submitted empty value through. Off by default: an empty submit\n * restores the previous value and reports `cancel` instead, because the\n * damage is asymmetric — a value wrongly kept is on screen and can be\n * edited again, a title wrongly emptied is gone.\n */\n allowEmpty?: boolean;\n /** Unavailable: the preview drops out of the tab order and refuses to open. Unset defers to a wrapping Field. */\n disabled?: boolean | undefined;\n /** Shows the value without allowing an edit, while staying focusable. Unset defers to a wrapping Field. */\n readonly?: boolean | undefined;\n /** Error state: paints the destructive border and ring, and sets `aria-invalid`. Unset defers to a wrapping Field's `error`. */\n invalid?: boolean | undefined;\n /** Marks the value mandatory to assistive tech (`aria-required`). Unset defers to a wrapping Field. */\n required?: boolean | undefined;\n /** The field name for a native form post or `FormData`. Unset defers to a wrapping Field. */\n name?: string;\n /** The editor's accessible name, when no visible element already labels it. */\n ariaLabel?: string;\n /**\n * Names for everything this control says out loud, as any subset of\n * `EditableLabels` — the rest stay as the host's `provideLoomLabels`\n * vocabulary left them, and then as Loom's English.\n *\n * This is the per-instance correction, not the place to localise an\n * application: the case it exists for is `edit`, where a page of\n * editables reads better as \"Rename\" or \"Correct\" than as the same verb\n * five times.\n */\n labels?: LabelOverrides<EditableLabels>;\n }>(),\n {\n activationMode: \"click\",\n submitMode: \"both\",\n autoResize: false,\n allowEmpty: false,\n // Not `false`, for any of the four. `false` is a caller saying \"this value\n // is fine / available / editable even though its row is not\"; absent is a\n // caller saying nothing, and only `undefined` survives to mean that past\n // Vue's absent-Boolean-casts-to-false rule — see TextField.vue, which\n // carries the full reasoning.\n disabled: undefined,\n readonly: undefined,\n invalid: undefined,\n required: undefined,\n },\n);\n\nconst emit = defineEmits<{\n /** The committed value. Fires on submit only, never per keystroke. */\n \"update:modelValue\": [value: string];\n /** The edit was committed, with the value that was accepted. */\n submit: [value: string];\n /** The edit was abandoned — Escape, the cancel button, or a refused empty submit. */\n cancel: [];\n}>();\n\ndefineOptions({ inheritAttrs: false });\n\n// `class` lands on the root rather than on the bordered box inside it: the\n// root is what participates in the caller's layout, and it holds the box and\n// the two commit controls beside it, so a caller's `w-64` has to size all\n// three. Everything else — `id`, `aria-describedby`, `aria-labelledby`,\n// `data-testid`, `autocomplete` — describes the *editor*, and lands there.\nconst { attrs, rest: inputAttrs } = useSplitAttrs();\n\n// `id` and `aria-describedby` reach a bare control as fallthrough attrs rather\n// than props, so they are read off `attrs` and handed in as this caller's own\n// values — a caller who sets either still beats the row, and the row's\n// description is merged into theirs rather than replacing it.\nconst field = useFieldControl(() => ({\n id: attrs.id as string | undefined,\n describedBy: attrs[\"aria-describedby\"] as string | undefined,\n name: props.name,\n disabled: props.disabled,\n readonly: props.readonly,\n invalid: props.invalid,\n required: props.required,\n}));\n\n// `text`, not `labels`: the prop of that name is one of the three sources this\n// resolves, and a template reading the raw prop would be reading the overrides\n// rather than the answer.\nconst text = useLabels(\"editable\", EDITABLE_LABELS, () => props.labels);\n\n// The value as this component knows it, which is also what Reka is fed. Two\n// things need it. A defined `model-value` is what puts Reka in controlled\n// mode, and controlled is what lets a submit be *refused* — Reka's own commit\n// then only announces the new value rather than adopting it, so the preview\n// keeps showing the old one with nothing to undo. And a host using this\n// without `v-model` still needs the value to move, which is what the\n// optimistic write in `onSubmit` is for.\nconst value = ref(props.modelValue ?? \"\");\nwatch(\n () => props.modelValue,\n (next) => {\n // `undefined` is a host that never controlled the value, not a host\n // clearing it — adopting it would blank the box on any unrelated re-render.\n if (next !== undefined) value.value = next;\n },\n);\n\n// Mirrors Reka's own `isEditing`, off the state event rather than out of the\n// slot, because the class bindings and the activation guard both need it in\n// the script half as well as in the template.\nconst editing = ref(false);\n\nconst previewRef = ref<ComponentPublicInstance | null>(null);\nconst rootRef = ref<ComponentPublicInstance | null>(null);\n\n// Reka's parts are components, so the only handle on their DOM node is `$el`,\n// which Vue types as `any`. Narrowing through `unknown` keeps that `any` from\n// spreading into everything it touches — and answers the SSR case honestly,\n// where there is no element at all.\nfunction elementOf(instance: ComponentPublicInstance | null): HTMLElement | null {\n const el = instance?.$el as unknown;\n return el instanceof HTMLElement ? el : null;\n}\n\n// Set only when focus was actually handed back to the preview, and cleared the\n// moment the reader leaves it. Without it, `activationMode: \"focus\"` would\n// read that hand-off as a fresh arrival and re-open the editor the reader just\n// escaped from — a control that cannot be abandoned, which is precisely the\n// trap Escape exists to avoid.\nlet suppressFocusActivation = false;\n\nfunction activate(edit: () => void): void {\n if (field.disabled || field.readonly || editing.value) return;\n edit();\n}\n\n// Reka's own activation is switched off in the template\n// (`activation-mode=\"none\"`) and rebuilt in these three handlers, rather than\n// passed through. Two things need that. Reka has no click mode at all and no\n// keyboard path on its preview, so two of the three modes this component\n// publishes would be unreachable without a pointer. And its focus mode cannot\n// tell a reader arriving on the control from the focus this component hands\n// back after an Escape — which would re-open the editor that Escape just\n// abandoned, forever.\n//\n// A `<button>` turns Enter and Space into a click carrying `detail === 0`,\n// which is what lets one handler serve the keyboard in every activation mode:\n// `dblclick` cannot be typed, and `focus` must not be the only way in for a\n// reader who arrived on the control without meaning to edit yet. `detail` is\n// the same signal Pagination reads to tell a synthesised click from a pointer\n// one.\nfunction onPreviewClick(event: MouseEvent, edit: () => void): void {\n if (props.activationMode === \"dblclick\" && event.detail !== 0) return;\n activate(edit);\n}\n\nfunction onPreviewDoubleClick(edit: () => void): void {\n if (props.activationMode !== \"dblclick\") return;\n activate(edit);\n}\n\nfunction onPreviewFocus(edit: () => void): void {\n if (props.activationMode !== \"focus\" || suppressFocusActivation) return;\n activate(edit);\n}\n\nfunction onPreviewBlur(): void {\n suppressFocusActivation = false;\n}\n\n// The editor is hidden the instant an edit ends, and a browser drops focus to\n// `<body>` when the focused node goes away — so a reader who pressed Escape or\n// Enter would be left with no position in the document at all.\n//\n// Only focus that was actually lost is rescued. A submit caused by clicking\n// somewhere else has already put the reader where they meant to be, and\n// stealing focus back from them would be the worse bug; the same reasoning,\n// and the same `<body>` check, as Pagination's edge-control hand-off. jsdom\n// leaves focus on the hidden node rather than dropping it, and a click on the\n// commit button leaves it on a button that is about to be hidden too, so\n// anything still inside this control counts as lost.\nfunction restoreFocus(): void {\n void nextTick(() => {\n const preview = elementOf(previewRef.value);\n const root = elementOf(rootRef.value);\n if (preview === null) return;\n\n const active = document.activeElement;\n const lost = active === null || active === document.body || (root?.contains(active) ?? false);\n if (!lost) return;\n\n suppressFocusActivation = true;\n preview.focus();\n });\n}\n\nfunction onState(state: \"edit\" | \"submit\" | \"cancel\"): void {\n editing.value = state === \"edit\";\n if (state === \"edit\") return;\n if (state === \"cancel\") emit(\"cancel\");\n restoreFocus();\n}\n\n// The refusal, held for one announcement rather than derived, so the live\n// region says something only when a submit was actually turned away — and is\n// cleared when the next edit opens, so a second identical refusal is still a\n// change to the region and still gets announced.\nconst restored = ref(\"\");\nwatch(editing, (isEditing) => {\n if (isEditing) restored.value = \"\";\n});\n\n// Reka's `submit` is an announcement rather than a request — in controlled\n// mode it has already told us what it would like the value to be and left the\n// deciding here, which is what makes the empty case answerable at all.\nfunction onSubmit(submitted: string | null | undefined): void {\n const next = submitted ?? \"\";\n\n // An empty submit is the one genuinely ambiguous outcome: a reader who\n // cleared the box and pressed Enter may have meant to blank the value, and\n // under `submitMode: \"blur\"` may have meant nothing at all. Loom restores\n // rather than clears, and `allowEmpty` is how a field that can legitimately\n // be emptied says so. `restored` announces it, because a refusal that only\n // shows up as text snapping back is a refusal a screen reader never hears.\n if (next === \"\" && !props.allowEmpty) {\n restored.value = text.value.restored({ value: value.value });\n emit(\"cancel\");\n return;\n }\n\n // Optimistic only while uncontrolled. With a `modelValue` prop the host owns\n // the value, and echoing it here would show a value they may be about to\n // reject — the prop watcher above is what applies the one they accept.\n if (props.modelValue === undefined) value.value = next;\n emit(\"update:modelValue\", next);\n emit(\"submit\", next);\n}\n\n// A read-only preview is not a control, so it is not rendered as one: a\n// `<button>` that refuses to act is worse than plain text. It keeps a\n// `tabindex` of its own instead — a read-only value is one a reader may reach\n// and copy, the same contract TextField's read-only input keeps — while the\n// button state drops Reka's hardcoded `tabindex=\"0\"` entirely, because a\n// `<button>` is focusable without it and a *disabled* one carrying it is\n// focusable when it should not be.\nconst previewAs = computed(() => (field.readonly ? \"span\" : \"button\"));\n\nconst rootProps = computed(() => optional({ name: field.name, maxLength: props.maxLength }));\n\n// `name` is dropped from the bag before it reaches the editor. Reka already\n// renders a hidden input under that name for any Editable inside a `<form>`,\n// and an editor carrying it as well would post the field twice — once with the\n// committed value and once with whatever the box held. Removed from a copy\n// rather than listed positively, so a key added to the resolved bag later\n// reaches this input without anyone remembering to come back here.\nconst editorAttrs = computed(() => {\n const aria: Record<string, unknown> = { ...field.attrs };\n delete aria.name;\n return aria;\n});\n\n// `aria-label` outranks a `<label for>` in the accessible-name order, so a\n// generic fallback would silently replace the name a Field row already gives\n// the editor. It is supplied only where nothing else names it at all — which\n// is also the state Reka would otherwise fill with \"editable input\".\nconst editorLabel = computed<string | undefined>(() => {\n if (props.ariaLabel !== undefined) return props.ariaLabel;\n if (field.id !== undefined) return undefined;\n if (attrs[\"aria-label\"] !== undefined || attrs[\"aria-labelledby\"] !== undefined) return undefined;\n return text.value.input;\n});\n\nconst areaClass = computed(() =>\n cn(\n // One box, two children. The border is present in both states and only\n // changes colour, the padding never changes, and the floor height keeps an\n // empty value from collapsing the row it sits in — between them, nothing\n // below this control moves when it switches.\n \"flex min-h-9 min-w-0 items-center rounded-md border border-transparent px-2 py-1\",\n // A control that sizes to its own text cannot also fill its parent: under\n // `autoResize` Reka makes this box an `inline-grid` measured by the two\n // states stacked inside it, and a width of 100% would measure the row\n // instead.\n props.autoResize ? \"w-auto\" : \"w-full\",\n \"text-inherit transition-[color,background-color,border-color,box-shadow] duration-fast ease-out\",\n // Rim-lit at rest, the weave blooms on focus (Signature law), and it blooms\n // for the preview as much as for the editor: at rest the preview is the\n // focusable node, and a control a reader can tab to has to show it.\n \"focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-ring\",\n !field.invalid && \"focus-within:shadow-halo\",\n editing.value && \"border-input bg-background\",\n // The affordance for a pointer, since the resting state is deliberately\n // just text: the box lights under the cursor and the caret says the text\n // can be typed into. Never the only signal — the preview announces itself,\n // and carries a glyph.\n !editing.value && !field.disabled && !field.readonly && \"cursor-text hover:bg-subtle\",\n // Lifted rather than dimmed, exactly as a read-only text field is: the\n // value is the whole reason the control is on the page, so it stays at\n // full contrast — 13.45:1 over this fill — whether or not it can be\n // changed. The rim comes up with it, from the resting `transparent` to\n // `input`: at rest this control is deliberately just text, so without a\n // border there is nothing but hue to say the value is on show, and the box\n // was already reserving the width for one.\n field.readonly && \"border-input bg-subtle\",\n // Drained, not faded, and a step further down all three channels: the fill\n // to `muted`, the text to `muted-foreground` (4.67:1 over it) and the rim\n // to the lighter `border`. Half-opacity text on this box measures under\n // 4.5:1, and a value nobody can read is worse than one nobody can edit.\n //\n // Read-only and disabled used to share `bg-muted` and part on the text\n // colour alone — a distinction made in hue and nothing else, which is the\n // one a reader with a colour deficiency does not receive.\n field.disabled && \"cursor-not-allowed border-border bg-muted text-muted-foreground\",\n // The same appearance, reached from the other direction: a\n // `<fieldset disabled>` above this control disables the preview `<button>`\n // and the `<input>` behind it natively — both are on the list a fieldset\n // reaches — and never touches `field.disabled`, so the rule above cannot\n // fire and the value rendered byte-identical to an editable one.\n //\n // `in-[fieldset:disabled]:` reads the attribute that did the disabling, so\n // the fill and the native inertness cannot disagree. See TextField.vue for\n // why it is preferred to a `has-[:disabled]:` rule reading this box's own\n // contents.\n \"in-[fieldset:disabled]:cursor-not-allowed in-[fieldset:disabled]:bg-muted in-[fieldset:disabled]:text-muted-foreground\",\n // Gated for the reason the rule below is ordered: Tailwind emits the\n // ancestor half of `in-*` inside `:where()`, so this carries no more\n // specificity than `border-destructive` and would win on emission order\n // alone.\n !field.invalid && \"in-[fieldset:disabled]:border-border\",\n // Last of the rules naming a border colour: `cn()` resolves one by\n // whichever class it saw last, so a control both in error and unavailable\n // would lose its destructive rim if this sat above them.\n field.invalid && \"border-destructive focus-within:outline-destructive\",\n ),\n);\n\n// The submit/cancel triggers are glyph-only, so the drained treatment they\n// take is the transparent control's: the neutral well under a glyph that is\n// already `text-muted-foreground`. `opacity-50` used to fade that glyph below\n// contrast — the failure Button records for its own dim — and the native\n// `disabled` attribute these Reka triggers carry already tells assistive tech.\nconst triggerClass = cn(\n buttonVariants({ variant: \"ghost\", size: \"icon-sm\" }),\n \"disabled:cursor-not-allowed disabled:bg-muted disabled:shadow-none\",\n);\n</script>\n\n<template>\n <EditableRoot\n ref=\"rootRef\"\n v-slot=\"{ edit }\"\n v-bind=\"rootProps\"\n :model-value=\"value\"\n :placeholder=\"placeholder ?? ''\"\n activation-mode=\"none\"\n :disabled=\"field.disabled\"\n :readonly=\"field.readonly\"\n :required=\"field.required\"\n :submit-mode=\"submitMode\"\n :auto-resize=\"autoResize\"\n :data-editing=\"editing || undefined\"\n :data-invalid=\"field.invalid || undefined\"\n :data-readonly=\"field.readonly || undefined\"\n :data-disabled=\"field.disabled || undefined\"\n :class=\"\n cn(\n 'inline-flex items-center gap-1',\n autoResize ? 'w-auto align-baseline' : 'w-full',\n attrs.class as string,\n )\n \"\n @update:state=\"onState\"\n @submit=\"onSubmit\"\n >\n <EditableArea :class=\"areaClass\">\n <!-- The preview is the control, and Reka's `EditableEditTrigger` is\n deliberately not rendered beside it: a second button doing the same\n job costs a second Tab stop on one value, and its click handler\n fires in every activation mode, which would make `dblclick` and\n `focus` lies. A real `<button>` rather than a `role`, so Enter and\n Space are the browser's job and the affordance survives\n forced-colors mode. -->\n <EditablePreview\n ref=\"previewRef\"\n :as=\"previewAs\"\n :type=\"field.readonly ? undefined : 'button'\"\n :tabindex=\"field.readonly ? 0 : undefined\"\n :disabled=\"!field.readonly && field.disabled ? true : undefined\"\n :aria-describedby=\"field.attrs['aria-describedby']\"\n :aria-invalid=\"field.attrs['aria-invalid']\"\n class=\"flex min-w-0 flex-1 items-center gap-1.5 text-start outline-none\"\n @click=\"(event: MouseEvent) => onPreviewClick(event, edit)\"\n @dblclick=\"() => onPreviewDoubleClick(edit)\"\n @focus=\"() => onPreviewFocus(edit)\"\n @blur=\"onPreviewBlur\"\n >\n <!-- Single-line and truncated, because the editor it swaps with is a\n single-line input: a preview that wrapped would be a taller box\n than the thing replacing it. -->\n <span :class=\"cn('truncate', !value && 'text-muted-foreground')\">\n {{ value || placeholder }}\n </span>\n <template v-if=\"!field.readonly\">\n <!-- The affordance for a sighted reader, and the counterpart to the\n span below it: the resting state is text, so something has to\n say it is more than text. Hidden from assistive tech, which is\n told the same thing in words. -->\n <Pencil class=\"h-3.5 w-3.5 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <!-- The whole accessibility problem of edit-in-place, in one span.\n The value reads first so it stays the start of the accessible\n name (WCAG 2.5.3, and what a voice-control reader says out\n loud); the verb follows, so the text announces as something\n that can be acted on rather than as prose. -->\n <span class=\"sr-only\">{{ text.edit }}</span>\n </template>\n </EditablePreview>\n\n <EditableInput\n v-bind=\"{ ...inputAttrs, ...editorAttrs }\"\n :aria-label=\"editorLabel\"\n class=\"min-w-0 flex-1 bg-transparent text-inherit outline-none placeholder:text-muted-foreground\"\n />\n </EditableArea>\n\n <!-- Both triggers hide themselves while the preview is showing, so there\n is nothing to guard here. They exist for the reader who has no way to\n know that Enter commits and Escape abandons — an invisible keyboard\n contract is not an affordance. -->\n <EditableSubmitTrigger :aria-label=\"text.submit\" :class=\"triggerClass\">\n <Check class=\"h-4 w-4\" aria-hidden=\"true\" />\n </EditableSubmitTrigger>\n <EditableCancelTrigger :aria-label=\"text.cancel\" :class=\"triggerClass\">\n <X class=\"h-4 w-4\" aria-hidden=\"true\" />\n </EditableCancelTrigger>\n\n <!-- A refused empty submit changes the screen without moving focus or\n opening anything, which a screen reader would otherwise never\n announce. Empty until there is something to say, so it never repeats\n itself on an unrelated re-render. -->\n <span role=\"status\" class=\"sr-only\">{{ restored }}</span>\n </EditableRoot>\n</template>\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
1
  export { default, EDITABLE_LABELS } from "./Editable.vue";
2
2
  export type { EditableActivationMode, EditableSubmitMode } from "./Editable.vue";
3
- export type { EditableLabels } from "@ecoma-io/loom-labels";
3
+ export type { EditableLabels } from "../../../labels/src/index";
4
4
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"FileUpload.js","names":[],"sources":["../../../../../packages/primitives/file-upload/src/FileUpload.vue"],"sourcesContent":["<script lang=\"ts\">\nimport type { FileUploadLabels, FileUploadRejection } from \"@ecoma-io/loom-labels\";\n\nconst BYTE_UNITS = [\"B\", \"KB\", \"MB\", \"GB\"] as const;\n\n/**\n * A byte count as a person reads it.\n *\n * Binary steps with the short decimal names, which is what every operating\n * system file dialog beside this control shows — a reader comparing \"1.4 MB\"\n * here against \"1.4 MB\" in their own file browser should see the same number,\n * and the pedantically-correct \"1.5 MB\" (or \"1.4 MiB\") only reads as a bug.\n * One decimal below ten and none above it, so the size column stays the same\n * width down the list.\n *\n * Deliberately not exported: it is a formatter this control needs, not a\n * public utility, and the moment a host imports it from here the wording of a\n * file size becomes part of this component's API.\n */\nfunction formatBytes(bytes: number): string {\n let value = Math.max(bytes, 0);\n let step = 0;\n while (value >= 1024 && step < BYTE_UNITS.length - 1) {\n value /= 1024;\n step += 1;\n }\n const rounded = step > 0 && value < 10 ? Math.round(value * 10) / 10 : Math.round(value);\n return `${String(rounded)} ${BYTE_UNITS[step] ?? \"B\"}`;\n}\n\n/**\n * Every refusal names the file, because the reader is looking at a list of\n * several and \"that file is too large\" identifies none of them.\n */\nfunction describeRejection(rejection: FileUploadRejection, limit: string): string {\n const name = rejection.file.name;\n switch (rejection.reason) {\n case \"too-large\":\n return `${name} is larger than ${limit}.`;\n case \"type\":\n return `${name} is not an accepted file type.`;\n case \"duplicate\":\n return `${name} has already been chosen.`;\n case \"too-many\":\n return `${name} was not added — only one file can be chosen.`;\n }\n}\n\n/**\n * Loom's English, co-located with the component so it tree-shakes with it, and\n * exported so a host can build a partial vocabulary against the real thing.\n */\nexport const FILE_UPLOAD_LABELS: FileUploadLabels = {\n zone: ({ multiple }) =>\n multiple ? \"Choose files or drag them here\" : \"Choose a file or drag it here\",\n hint: ({ maxSize }) => `Up to ${formatBytes(maxSize)}`,\n size: ({ bytes }) => formatBytes(bytes),\n remove: ({ file }) => `Remove ${file.name}`,\n rejected: ({ rejections, maxSize }) => {\n const limit = maxSize === undefined ? \"the size limit\" : formatBytes(maxSize);\n return rejections.map((rejection) => describeRejection(rejection, limit)).join(\" \");\n },\n};\n</script>\n\n<script setup lang=\"ts\">\nimport { computed, ref, useId, useTemplateRef } from \"vue\";\nimport { CloudUpload, File as FileIcon, TriangleAlert, X } from \"@lucide/vue\";\nimport { cn } from \"@ecoma-io/loom-core\";\nimport { listStaggerDelay } from \"@ecoma-io/loom-core\";\nimport { useSplitAttrs } from \"@ecoma-io/loom-core\";\nimport { useAncestorDisabled } from \"@ecoma-io/loom-labels\";\nimport { useFieldControl } from \"@ecoma-io/loom-labels\";\nimport { useLabels, type LabelOverrides } from \"@ecoma-io/loom-labels\";\n\n/**\n * FileUpload — choosing files, and nothing else. A drop zone that is also a\n * real file dialog, plus the list of what has been chosen so far, with each\n * row removable.\n *\n * **It chooses; it does not upload.** There is no progress bar here and no\n * request: the host reads `modelValue`, sends it wherever it goes, and renders\n * `Progress` (or `Spinner`, when there is no percentage) for that half. Two\n * controls rather than one, because the upload's states — retrying, partial,\n * failed on the server — belong to whatever is doing the uploading, and a\n * control that owned both would have to guess at all of them.\n *\n * **Drag and drop is a shortcut, never the path.** The zone is a `<label>`\n * wrapped around a real `<input type=\"file\">`, visually hidden by the clip\n * technique rather than by `display: none` or `hidden` — both of which take\n * the input out of the tab order and leave a control only a pointer can reach.\n * Tab lands on the input, Space or Enter opens the system file dialog, and the\n * zone draws the focus ring on its behalf because a 1px input cannot show one.\n * A `div` with a click handler in place of that input is the standard defect\n * in this control, and it is invisible to everyone testing with a mouse.\n *\n * Files arriving while `multiple` is set are appended to the list rather than\n * replacing it: a reader who drops two files and then a third means three, and\n * the native input's replace-everything behaviour is the surprise. Without\n * `multiple` the newest choice replaces the old one, exactly as the input does.\n *\n * Inside a [Field](../Field/Field.vue) it wires itself through\n * `useFieldControl()`: the row's id lands on the input (and on the `<label>`'s\n * `for`, so the zone keeps activating it), and the row's `name`, `required`,\n * `invalid` and the id of its hint or error line arrive with it. The row's\n * description is **merged** with this control's own refusal message rather than\n * replacing it — a file turned away for being too large has to keep saying so.\n *\n * There is no `readonly`, and a row's is ignored rather than approximated.\n * `readonly` is inert on `<input type=\"file\">` in every browser, for the reason\n * it is inert on a checkbox: there is no text to protect, only a dialog to\n * open. A zone that may be read but not added to is a disabled zone above a\n * list — and the list, which is where the value actually is, stays readable\n * either way.\n */\nconst props = withDefaults(\n defineProps<{\n /** The chosen files. Leave it unbound to let the control keep its own list. */\n modelValue?: File[] | undefined;\n /** What the file dialog offers and what a drop is checked against — the native `accept` syntax: `.csv`, `image/png`, `image/*`, comma-separated. */\n accept?: string;\n /** Allows more than one file, and appends each new choice to the list instead of replacing it. */\n multiple?: boolean;\n /** Largest file accepted, in bytes. A file over it is refused and named in the message. */\n maxSize?: number;\n /** Unavailable: dims the zone, refuses the dialog, the drop and every remove button. Unset defers to a wrapping Field. */\n disabled?: boolean | undefined;\n /** Error state: paints the destructive border and ring, and sets `aria-invalid` on the input. Unset defers to a wrapping Field's `error`. */\n invalid?: boolean | undefined;\n /** The zone's own line of copy, which is also the input's accessible name. Defaults to `labels.zone`, whose wording matches `multiple`. */\n label?: string;\n /** The smaller line under it. Defaults to `labels.hint` against the `maxSize` limit, when there is one. */\n hint?: string;\n /**\n * Everything this control says that is not a file name, as any subset of\n * `FileUploadLabels` — the rest stay as the host's `provideLoomLabels`\n * vocabulary left them, and then as Loom's English.\n *\n * This is the per-instance correction, not the place to localise an\n * application. `label` and `hint` are the shorter way to reach the two\n * keys of the same name for one zone; this is what reaches the refusal\n * message, the remove buttons and the size column.\n */\n labels?: LabelOverrides<FileUploadLabels>;\n }>(),\n {\n // Not `[]`. An empty array is a concrete value, so it would say \"the host\n // owns this list\" — and a host that binds nothing would then watch every\n // file it chose vanish on the next render. `undefined` is what leaves the\n // control managing its own, the same distinction `optional()` exists to\n // preserve for the Reka-backed primitives.\n modelValue: undefined,\n multiple: false,\n // Not `false`, for either. `false` is a caller saying \"this zone is fine /\n // enabled even though its row is not\"; absent is a caller saying nothing,\n // and only `undefined` survives to mean that past Vue's\n // absent-Boolean-casts-to-false rule — see TextField.vue, which carries the\n // full reasoning.\n disabled: undefined,\n invalid: undefined,\n },\n);\n\nconst emit = defineEmits<{\n /** The full list after the change — files added, or one removed. Never a partial list. */\n \"update:modelValue\": [files: File[]];\n /** Every file refused by this one interaction, each with the rule that refused it. Fires once per interaction, not once per file. */\n reject: [rejections: FileUploadRejection[]];\n}>();\n\n// `text`, not `labels`: the prop of that name is one of the three sources this\n// resolves, and a template reading the raw prop would be reading the overrides\n// rather than the answer.\nconst text = useLabels(\"fileUpload\", FILE_UPLOAD_LABELS, () => props.labels);\n\n// `class` sizes the whole control, so it lands on the root; everything else a\n// caller passes — `name`, `id`, `data-testid`, `aria-describedby` — describes\n// the real `<input type=\"file\">` and lands there. Splitting them is the only\n// way to route both correctly: the root is not a form control and the input is\n// not the box a caller lays out.\ndefineOptions({ inheritAttrs: false });\nconst { attrs, rest: inputAttrs } = useSplitAttrs();\n\n// The label's `for` needs the input's id, and a caller passing their own id —\n// or a Field row publishing one — must not end up with a label pointing at\n// nothing. `inputId` is resolved from `field` at the foot of this block.\nconst generatedId = useId();\nconst messageId = useId();\n\nconst ownFiles = ref<File[]>([]);\nconst files = computed(() => props.modelValue ?? ownFiles.value);\n\nconst rejections = ref<FileUploadRejection[]>([]);\n\n/**\n * A stable key per file, held against the File object itself.\n *\n * Keying the rows by index instead re-keys every row after the one removed, so\n * removing the first of five replays the entrance animation on the other four\n * — the list appears to reload because one row left it. A WeakMap keys by\n * identity, holds nothing alive, and survives the list being reordered.\n */\nconst rowIds = new WeakMap<File, number>();\nlet nextRowId = 0;\nfunction rowId(file: File): number {\n let id = rowIds.get(file);\n if (id === undefined) {\n id = nextRowId++;\n rowIds.set(file, id);\n }\n return id;\n}\n\n/** Name, size and mtime — what the file system itself calls the same file. */\nfunction signature(file: File): string {\n return `${file.name}:${String(file.size)}:${String(file.lastModified)}`;\n}\n\n/**\n * The same test the native input applies to `accept`, applied again to a drop\n * — the browser enforces it in the file dialog and not at all on a drag, so a\n * zone that only sets the attribute accepts anything dropped on it.\n *\n * `file.type` is the browser's guess from the extension and it is empty for\n * anything it does not recognise, which is why an `accept` written as MIME\n * types alone turns away files it meant to take. Naming both — `.csv,text/csv`\n * — is the form that works, and it is what the documentation shows.\n */\nfunction accepts(file: File): boolean {\n const accept = props.accept?.trim();\n if (accept === undefined || accept.length === 0) return true;\n const name = file.name.toLowerCase();\n const type = file.type.toLowerCase();\n return accept.split(\",\").some((raw) => {\n const pattern = raw.trim().toLowerCase();\n if (pattern.length === 0) return false;\n if (pattern.startsWith(\".\")) return name.endsWith(pattern);\n if (pattern.endsWith(\"/*\")) return type.startsWith(pattern.slice(0, -1));\n return type === pattern;\n });\n}\n\nfunction commit(next: File[]): void {\n if (props.modelValue === undefined) ownFiles.value = next;\n emit(\"update:modelValue\", next);\n}\n\n/** Runs the offered files past every rule, keeping what passes and reporting the rest. */\nfunction add(incoming: File[]): void {\n if (field.disabled) return;\n\n const current = files.value;\n const kept = props.multiple ? [...current] : [];\n const seen = new Set(kept.map(signature));\n const refused: FileUploadRejection[] = [];\n const limit = props.maxSize;\n\n for (const file of incoming) {\n if (!props.multiple && kept.length > 0) {\n refused.push({ file, reason: \"too-many\" });\n } else if (limit !== undefined && file.size > limit) {\n refused.push({ file, reason: \"too-large\" });\n } else if (!accepts(file)) {\n refused.push({ file, reason: \"type\" });\n } else if (seen.has(signature(file))) {\n refused.push({ file, reason: \"duplicate\" });\n } else {\n seen.add(signature(file));\n kept.push(file);\n }\n }\n\n rejections.value = refused;\n if (refused.length > 0) emit(\"reject\", refused);\n\n const unchanged = kept.length === current.length && kept.every((file, i) => file === current[i]);\n if (!unchanged) commit(kept);\n}\n\nfunction remove(index: number): void {\n if (field.disabled) return;\n // The message named files from the last selection. Once the reader edits the\n // list themselves it no longer describes anything on screen, and an error\n // sitting under a list somebody is actively fixing reads as an error about\n // that list.\n rejections.value = [];\n commit(files.value.filter((_, i) => i !== index));\n}\n\nfunction onSelect(event: Event): void {\n const input = event.target as HTMLInputElement;\n add(Array.from(input.files ?? []));\n // Cleared so choosing the same file a second time still fires `change`. The\n // input compares the new selection against its own value and stays silent\n // when they match — which, after a file has been removed from the list, is a\n // control that refuses to take back the file the reader just took out.\n input.value = \"\";\n}\n\n/**\n * Enters minus leaves, rather than a boolean.\n *\n * `dragleave` fires on the zone every time the pointer crosses onto one of its\n * own children, so an enter/leave pair drops the highlight and picks it up\n * again as the reader drags across the icon and the copy — the zone flickers\n * under the cursor at the exact moment it is meant to be saying \"let go here\".\n * A child's `dragenter` always lands before the parent's `dragleave`, so the\n * count never reaches zero until the pointer has genuinely left. `relatedTarget`\n * answers the same question in one line, but it is not reliably populated for a\n * drag that began outside the document — which is every drag this zone sees.\n */\nconst dragDepth = ref(0);\nconst dragging = computed(() => dragDepth.value > 0 && !field.disabled);\n\nfunction onDragEnter(): void {\n dragDepth.value += 1;\n}\n\nfunction onDragLeave(): void {\n dragDepth.value = Math.max(dragDepth.value - 1, 0);\n}\n\nfunction onDragOver(event: DragEvent): void {\n // Without this the browser keeps its default handling, which for a file is\n // \"navigate to it\" — the drop never reaches the zone and the page is gone.\n // Setting dropEffect is what makes the cursor say copy rather than move.\n event.preventDefault();\n if (event.dataTransfer) event.dataTransfer.dropEffect = field.disabled ? \"none\" : \"copy\";\n}\n\nfunction onDrop(event: DragEvent): void {\n event.preventDefault();\n // Reset rather than decrement: the drop consumes the drag, and no matching\n // `dragleave` follows it.\n dragDepth.value = 0;\n add(Array.from(event.dataTransfer?.files ?? []));\n}\n\n// Nothing here listens on `window` or `document`. A page-wide drag handler\n// would be the usual way to stop a file dropped *beside* the zone from\n// navigating the browser away, but a library primitive that installs one\n// silently governs every other drop target on the host's page. That decision\n// is the host's, so the listeners a component cannot tear down are the ones it\n// never registers.\n\n// The two content props sit *above* the resolved label rather than replacing\n// it: `label` is the wording for one zone — \"Drop last month's export here\" —\n// and the label is what every other zone says. Read through `text.` here and\n// not resolved above, so a language switch after mount repaints the copy.\nconst zoneLabel = computed(() => props.label ?? text.value.zone({ multiple: props.multiple }));\n\nconst zoneHint = computed(() => {\n if (props.hint !== undefined) return props.hint;\n // The limit is handed over as a byte count, never as `formatBytes` output: a\n // host that writes `1,5 Mo` or Eastern Arabic digits cannot get there from a\n // string this component already decided the shape of.\n return props.maxSize === undefined ? undefined : text.value.hint({ maxSize: props.maxSize });\n});\n\n// One call over the whole list rather than one per refusal joined with a\n// space. The joiner was the fragment: which character separates two sentences\n// is a property of the script, and a language that would rather count the\n// refused files than list them has no way to say so from a per-file message.\nconst message = computed(() =>\n rejections.value.length === 0\n ? \"\"\n : text.value.rejected({ rejections: rejections.value, maxSize: props.maxSize }),\n);\n\n/**\n * The drop is the reason this control cannot be left to the platform.\n *\n * `<fieldset disabled>` makes the nested `<input type=\"file\">` inert, which\n * closes the click and the keyboard, and it makes each file's remove button\n * inert too. It does nothing at all to the `drop` handler, because that sits on\n * a `<label>` — not a form control, and never disabled by a fieldset. `add()`\n * bails on `field.disabled`, which is exactly the value a disabled fieldset\n * does not reach, so a zone inside a disabled group accepted a dragged file\n * while refusing every other way in.\n *\n * Read off the DOM rather than taken from the Field context, which publishes no\n * `disabled` on purpose — see `../../lib/ancestor-disabled.ts`.\n */\nconst root = useTemplateRef<HTMLElement>(\"root\");\nconst groupDisabled = useAncestorDisabled(() => root.value);\n\n// The Field wiring sits here, below `message`, because it reads it: this is the\n// one control in the family that already had something of its own to say about\n// itself, so its `describedBy` is a *merge* and not a hand-over. Its refusal\n// message reads first — a file turned away for being too large is the most\n// specific thing there is to say — then whatever the caller pointed at, then\n// the row's hint or error, which `useFieldControl` appends. Replacing rather\n// than merging at either end is how a rejection stops being announced.\n//\n// The condition is `message` itself, not `rejections`, so the id can never\n// point at a `<p>` the template has not rendered.\n//\n// `name` and `required` are absent from the object because this component has\n// no prop for either: the row's answer stands unopposed, and its `name` reaches\n// the real `<input type=\"file\">`, which is what a `<form>` posts. `readonly` is\n// absent for the reason the docblock gives.\nconst field = useFieldControl(() => ({\n id: attrs.id as string | undefined,\n describedBy: [message.value.length > 0 ? messageId : undefined, attrs[\"aria-describedby\"]]\n .filter((id): id is string => typeof id === \"string\" && id.length > 0)\n .join(\" \"),\n // The fieldset beats the prop in both directions, exactly as it does for the\n // `<input type=\"file\">` inside this very control.\n disabled: groupDisabled.value ? true : props.disabled,\n invalid: props.invalid,\n}));\n\n// `field.id` is the caller's own id, then the row's; the generated one is the\n// floor beneath both, because the `<label for>` and the input have to agree on\n// an id whether or not anybody supplied one.\nconst inputId = computed(() => field.id ?? generatedId);\n</script>\n\n<template>\n <div ref=\"root\" :class=\"cn('flex w-full flex-col', attrs.class as string)\">\n <!-- The drag handlers sit on a <label>, which the rule reads as a static\n element — and it is usually right to: a box with a drop handler and\n nothing else is a control no keyboard can reach. Here the keyboard\n path is the real <input type=\"file\"> nested inside, which the label\n both names and activates, so the drop adds a pointer shortcut to a\n control that already works without one. -->\n <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->\n <label\n :for=\"inputId\"\n :data-dragging=\"dragging || undefined\"\n :data-invalid=\"field.invalid || undefined\"\n :data-disabled=\"field.disabled || undefined\"\n :class=\"\n cn(\n // `group` so the two lines of copy below can name their own disabled\n // colour off this element's state. A colour set here would only be\n // *inherited* by them, and both declare a colour of their own — an\n // inherited value loses to any declaration, however weak.\n 'group flex flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input bg-background px-6 py-8 text-center',\n // Border and fill are the drag feedback, so they carry the transition\n // — fast, because a zone that lights up after the pointer has already\n // crossed it is answering a question the reader stopped asking.\n 'transition-[color,background-color,border-color,box-shadow] duration-fast ease-out',\n // Focus lives on the clipped input, which is 1px and can show a reader\n // nothing. The zone wears the ring on its behalf, the same\n // focus-within arrangement TextField uses for its own inner input.\n 'focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-ring',\n !field.invalid && 'focus-within:shadow-halo',\n // The dash going solid is what keeps the drag state off colour alone.\n 'data-[dragging]:border-solid data-[dragging]:border-primary data-[dragging]:bg-primary-muted',\n // Drained, not dimmed. The zone is a large blank region whose whole\n // purpose is the two lines of text in the middle of it, and\n // `opacity-50` took the headline to 3.06:1 and the hint below it to\n // 2.05:1 — the reader least able to guess what a drop zone wants is\n // the one it stopped telling. The fill falls to the neutral well\n // instead, which is where every other unavailable surface in the\n // library goes, and both lines land at 4.67:1 over it. The dash stays,\n // because it is what marks this out as a drop target even while\n // nothing may be dropped on it; only its colour slackens, from\n // `input` to the lighter `border`, so the state is told in a weight\n // as well as in two fills rather than in hue alone.\n field.disabled\n ? 'cursor-not-allowed border-border bg-muted'\n : 'cursor-pointer hover:bg-subtle',\n // Last of the rules naming a border colour, and the order is\n // load-bearing: `cn()` resolves one by whichever class it saw last,\n // so a zone both refusing a file and unavailable would lose its\n // destructive rim to the disabled one if this sat above it.\n field.invalid && 'border-destructive focus-within:outline-destructive',\n )\n \"\n @dragenter=\"onDragEnter\"\n @dragleave=\"onDragLeave\"\n @dragover=\"onDragOver\"\n @drop=\"onDrop\"\n >\n <!-- `field.attrs` spreads **after** the fallthrough attrs, and that\n position is the contract (see TextField.vue): the bag has already\n resolved the caller's own id and merged their description with this\n zone's refusal message and the row's hint, and it drops every key\n that resolved to nothing, so it can only add. The `:aria-invalid` and\n `:aria-describedby` bindings that used to sit here went with it — an\n individual attribute written after a `v-bind` wins, so either one\n kept would overwrite the resolved value with the raw prop.\n\n `:id` is the deliberate exception, and not that failure: `inputId` is\n the bag's own id with the generated one beneath it, so it can only\n ever restate what the bag holds or supply the id the `<label for>`\n needs when the bag holds none. -->\n <input\n v-bind=\"{ ...inputAttrs, ...field.attrs }\"\n :id=\"inputId\"\n type=\"file\"\n :accept=\"accept\"\n :multiple=\"multiple\"\n :disabled=\"field.disabled\"\n class=\"sr-only\"\n @change=\"onSelect\"\n />\n <!-- The medallion, as EmptyState draws it: a 20px glyph alone in a large\n blank region has nothing holding it and reads as a stray mark. -->\n <span\n aria-hidden=\"true\"\n class=\"grid h-10 w-10 place-items-center rounded-full border border-border bg-subtle text-muted-foreground\"\n >\n <CloudUpload class=\"h-5 w-5\" />\n </span>\n <!-- The disabled colour is declared here rather than inherited from the\n zone, for the reason the `group` on it records: this span sets\n `text-foreground` itself, and an inherited value never beats a\n declared one. The hint below needs no such rule — it is already on\n the muted colour, which holds 4.67:1 over the drained fill. -->\n <span class=\"text-sm text-foreground group-data-[disabled]:text-muted-foreground\">{{\n zoneLabel\n }}</span>\n <span v-if=\"zoneHint\" class=\"text-small text-muted-foreground\">{{ zoneHint }}</span>\n </label>\n\n <!-- The region is in the DOM from the first render, empty. A live region\n mounted at the same moment as its text is a region assistive tech was\n not yet watching, so the first refusal — the one that matters most —\n is the one it announces least reliably. -->\n <div aria-live=\"polite\" aria-atomic=\"true\">\n <p\n v-if=\"message.length > 0\"\n :id=\"messageId\"\n class=\"mt-2 flex animate-fade-rise items-start gap-2 text-small text-destructive-text\"\n >\n <TriangleAlert class=\"mt-0.5 h-4 w-4 shrink-0\" aria-hidden=\"true\" />\n <span>{{ message }}</span>\n </p>\n </div>\n\n <ul v-if=\"files.length > 0\" class=\"mt-3 flex flex-col gap-1.5\">\n <li\n v-for=\"(file, index) in files\"\n :key=\"rowId(file)\"\n class=\"flex animate-fade-rise items-center gap-2 rounded-md border border-border bg-card px-3 py-2\"\n :style=\"{ animationDelay: listStaggerDelay(index) }\"\n >\n <FileIcon class=\"h-4 w-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span class=\"min-w-0 flex-1 truncate text-sm text-foreground\">{{ file.name }}</span>\n <span class=\"shrink-0 tabular text-small text-muted-foreground\">\n {{ text.size({ bytes: file.size }) }}\n </span>\n <!-- Named with the file it removes. Seven buttons all called \"Remove\"\n are seven identical announcements and one guess about which row a\n reader is on. -->\n <button\n type=\"button\"\n :aria-label=\"text.remove({ file })\"\n :disabled=\"field.disabled\"\n class=\"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors duration-fast ease-out hover:bg-subtle hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:shadow-halo disabled:cursor-not-allowed disabled:opacity-50\"\n @click=\"remove(index)\"\n >\n <X class=\"h-4 w-4\" />\n </button>\n </li>\n </ul>\n </div>\n</template>\n"],"mappings":""}
1
+ {"version":3,"file":"FileUpload.js","names":[],"sources":["../../../../../packages/primitives/file-upload/src/FileUpload.vue"],"sourcesContent":["<script lang=\"ts\">\nimport type { FileUploadLabels, FileUploadRejection } from \"@ecoma-io/loom-labels\";\n\nconst BYTE_UNITS = [\"B\", \"KB\", \"MB\", \"GB\"] as const;\n\n/**\n * A byte count as a person reads it.\n *\n * Binary steps with the short decimal names, which is what every operating\n * system file dialog beside this control shows — a reader comparing \"1.4 MB\"\n * here against \"1.4 MB\" in their own file browser should see the same number,\n * and the pedantically-correct \"1.5 MB\" (or \"1.4 MiB\") only reads as a bug.\n * One decimal below ten and none above it, so the size column stays the same\n * width down the list.\n *\n * Deliberately not exported: it is a formatter this control needs, not a\n * public utility, and the moment a host imports it from here the wording of a\n * file size becomes part of this component's API.\n */\nfunction formatBytes(bytes: number): string {\n let value = Math.max(bytes, 0);\n let step = 0;\n while (value >= 1024 && step < BYTE_UNITS.length - 1) {\n value /= 1024;\n step += 1;\n }\n const rounded = step > 0 && value < 10 ? Math.round(value * 10) / 10 : Math.round(value);\n return `${String(rounded)} ${BYTE_UNITS[step] ?? \"B\"}`;\n}\n\n/**\n * Every refusal names the file, because the reader is looking at a list of\n * several and \"that file is too large\" identifies none of them.\n */\nfunction describeRejection(rejection: FileUploadRejection, limit: string): string {\n const name = rejection.file.name;\n switch (rejection.reason) {\n case \"too-large\":\n return `${name} is larger than ${limit}.`;\n case \"type\":\n return `${name} is not an accepted file type.`;\n case \"duplicate\":\n return `${name} has already been chosen.`;\n case \"too-many\":\n return `${name} was not added — only one file can be chosen.`;\n }\n}\n\n/**\n * Loom's English, co-located with the component so it tree-shakes with it, and\n * exported so a host can build a partial vocabulary against the real thing.\n */\nexport const FILE_UPLOAD_LABELS: FileUploadLabels = {\n zone: ({ multiple }) =>\n multiple ? \"Choose files or drag them here\" : \"Choose a file or drag it here\",\n hint: ({ maxSize }) => `Up to ${formatBytes(maxSize)}`,\n size: ({ bytes }) => formatBytes(bytes),\n remove: ({ file }) => `Remove ${file.name}`,\n rejected: ({ rejections, maxSize }) => {\n const limit = maxSize === undefined ? \"the size limit\" : formatBytes(maxSize);\n return rejections.map((rejection) => describeRejection(rejection, limit)).join(\" \");\n },\n};\n</script>\n\n<script setup lang=\"ts\">\nimport { computed, ref, useId, useTemplateRef } from \"vue\";\nimport { CloudUpload, File as FileIcon, TriangleAlert, X } from \"@lucide/vue\";\nimport { cn } from \"@ecoma-io/loom-core\";\nimport { listStaggerDelay } from \"@ecoma-io/loom-core\";\nimport { useSplitAttrs } from \"@ecoma-io/loom-core\";\nimport { useAncestorDisabled } from \"@ecoma-io/loom-labels\";\nimport { useFieldControl } from \"@ecoma-io/loom-labels\";\nimport { useLabels, type LabelOverrides } from \"@ecoma-io/loom-labels\";\n\n/**\n * FileUpload — choosing files, and nothing else. A drop zone that is also a\n * real file dialog, plus the list of what has been chosen so far, with each\n * row removable.\n *\n * **It chooses; it does not upload.** There is no progress bar here and no\n * request: the host reads `modelValue`, sends it wherever it goes, and renders\n * `Progress` (or `Spinner`, when there is no percentage) for that half. Two\n * controls rather than one, because the upload's states — retrying, partial,\n * failed on the server — belong to whatever is doing the uploading, and a\n * control that owned both would have to guess at all of them.\n *\n * **Drag and drop is a shortcut, never the path.** The zone is a `<label>`\n * wrapped around a real `<input type=\"file\">`, visually hidden by the clip\n * technique rather than by `display: none` or `hidden` — both of which take\n * the input out of the tab order and leave a control only a pointer can reach.\n * Tab lands on the input, Space or Enter opens the system file dialog, and the\n * zone draws the focus ring on its behalf because a 1px input cannot show one.\n * A `div` with a click handler in place of that input is the standard defect\n * in this control, and it is invisible to everyone testing with a mouse.\n *\n * Files arriving while `multiple` is set are appended to the list rather than\n * replacing it: a reader who drops two files and then a third means three, and\n * the native input's replace-everything behaviour is the surprise. Without\n * `multiple` the newest choice replaces the old one, exactly as the input does.\n *\n * Inside a [Field](../Field/Field.vue) it wires itself through\n * `useFieldControl()`: the row's id lands on the input (and on the `<label>`'s\n * `for`, so the zone keeps activating it), and the row's `name`, `required`,\n * `invalid` and the id of its hint or error line arrive with it. The row's\n * description is **merged** with this control's own refusal message rather than\n * replacing it — a file turned away for being too large has to keep saying so.\n *\n * There is no `readonly`, and a row's is ignored rather than approximated.\n * `readonly` is inert on `<input type=\"file\">` in every browser, for the reason\n * it is inert on a checkbox: there is no text to protect, only a dialog to\n * open. A zone that may be read but not added to is a disabled zone above a\n * list — and the list, which is where the value actually is, stays readable\n * either way.\n */\nconst props = withDefaults(\n defineProps<{\n /** The chosen files. Leave it unbound to let the control keep its own list. */\n modelValue?: File[] | undefined;\n /** What the file dialog offers and what a drop is checked against — the native `accept` syntax: `.csv`, `image/png`, `image/*`, comma-separated. */\n accept?: string;\n /** Allows more than one file, and appends each new choice to the list instead of replacing it. */\n multiple?: boolean;\n /** Largest file accepted, in bytes. A file over it is refused and named in the message. */\n maxSize?: number;\n /** Unavailable: dims the zone, refuses the dialog, the drop and every remove button. Unset defers to a wrapping Field. */\n disabled?: boolean | undefined;\n /** Error state: paints the destructive border and ring, and sets `aria-invalid` on the input. Unset defers to a wrapping Field's `error`. */\n invalid?: boolean | undefined;\n /** The zone's own line of copy, which is also the input's accessible name. Defaults to `labels.zone`, whose wording matches `multiple`. */\n label?: string;\n /** The smaller line under it. Defaults to `labels.hint` against the `maxSize` limit, when there is one. */\n hint?: string;\n /**\n * Everything this control says that is not a file name, as any subset of\n * `FileUploadLabels` — the rest stay as the host's `provideLoomLabels`\n * vocabulary left them, and then as Loom's English.\n *\n * This is the per-instance correction, not the place to localise an\n * application. `label` and `hint` are the shorter way to reach the two\n * keys of the same name for one zone; this is what reaches the refusal\n * message, the remove buttons and the size column.\n */\n labels?: LabelOverrides<FileUploadLabels>;\n }>(),\n {\n // Not `[]`. An empty array is a concrete value, so it would say \"the host\n // owns this list\" — and a host that binds nothing would then watch every\n // file it chose vanish on the next render. `undefined` is what leaves the\n // control managing its own, the same distinction `optional()` exists to\n // preserve for the Reka-backed primitives.\n modelValue: undefined,\n multiple: false,\n // Not `false`, for either. `false` is a caller saying \"this zone is fine /\n // enabled even though its row is not\"; absent is a caller saying nothing,\n // and only `undefined` survives to mean that past Vue's\n // absent-Boolean-casts-to-false rule — see TextField.vue, which carries the\n // full reasoning.\n disabled: undefined,\n invalid: undefined,\n },\n);\n\nconst emit = defineEmits<{\n /** The full list after the change — files added, or one removed. Never a partial list. */\n \"update:modelValue\": [files: File[]];\n /** Every file refused by this one interaction, each with the rule that refused it. Fires once per interaction, not once per file. */\n reject: [rejections: FileUploadRejection[]];\n}>();\n\n// `text`, not `labels`: the prop of that name is one of the three sources this\n// resolves, and a template reading the raw prop would be reading the overrides\n// rather than the answer.\nconst text = useLabels(\"fileUpload\", FILE_UPLOAD_LABELS, () => props.labels);\n\n// `class` sizes the whole control, so it lands on the root; everything else a\n// caller passes — `name`, `id`, `data-testid`, `aria-describedby` — describes\n// the real `<input type=\"file\">` and lands there. Splitting them is the only\n// way to route both correctly: the root is not a form control and the input is\n// not the box a caller lays out.\ndefineOptions({ inheritAttrs: false });\nconst { attrs, rest: inputAttrs } = useSplitAttrs();\n\n// The label's `for` needs the input's id, and a caller passing their own id —\n// or a Field row publishing one — must not end up with a label pointing at\n// nothing. `inputId` is resolved from `field` at the foot of this block.\nconst generatedId = useId();\nconst messageId = useId();\n\nconst ownFiles = ref<File[]>([]);\nconst files = computed(() => props.modelValue ?? ownFiles.value);\n\nconst rejections = ref<FileUploadRejection[]>([]);\n\n/**\n * A stable key per file, held against the File object itself.\n *\n * Keying the rows by index instead re-keys every row after the one removed, so\n * removing the first of five replays the entrance animation on the other four\n * — the list appears to reload because one row left it. A WeakMap keys by\n * identity, holds nothing alive, and survives the list being reordered.\n */\nconst rowIds = new WeakMap<File, number>();\nlet nextRowId = 0;\nfunction rowId(file: File): number {\n let id = rowIds.get(file);\n if (id === undefined) {\n id = nextRowId++;\n rowIds.set(file, id);\n }\n return id;\n}\n\n/** Name, size and mtime — what the file system itself calls the same file. */\nfunction signature(file: File): string {\n return `${file.name}:${String(file.size)}:${String(file.lastModified)}`;\n}\n\n/**\n * The same test the native input applies to `accept`, applied again to a drop\n * — the browser enforces it in the file dialog and not at all on a drag, so a\n * zone that only sets the attribute accepts anything dropped on it.\n *\n * `file.type` is the browser's guess from the extension and it is empty for\n * anything it does not recognise, which is why an `accept` written as MIME\n * types alone turns away files it meant to take. Naming both — `.csv,text/csv`\n * — is the form that works, and it is what the documentation shows.\n */\nfunction accepts(file: File): boolean {\n const accept = props.accept?.trim();\n if (accept === undefined || accept.length === 0) return true;\n const name = file.name.toLowerCase();\n const type = file.type.toLowerCase();\n return accept.split(\",\").some((raw) => {\n const pattern = raw.trim().toLowerCase();\n if (pattern.length === 0) return false;\n if (pattern.startsWith(\".\")) return name.endsWith(pattern);\n if (pattern.endsWith(\"/*\")) return type.startsWith(pattern.slice(0, -1));\n return type === pattern;\n });\n}\n\nfunction commit(next: File[]): void {\n if (props.modelValue === undefined) ownFiles.value = next;\n emit(\"update:modelValue\", next);\n}\n\n/** Runs the offered files past every rule, keeping what passes and reporting the rest. */\nfunction add(incoming: File[]): void {\n if (field.disabled) return;\n\n const current = files.value;\n const kept = props.multiple ? [...current] : [];\n const seen = new Set(kept.map(signature));\n const refused: FileUploadRejection[] = [];\n const limit = props.maxSize;\n\n for (const file of incoming) {\n if (!props.multiple && kept.length > 0) {\n refused.push({ file, reason: \"too-many\" });\n } else if (limit !== undefined && file.size > limit) {\n refused.push({ file, reason: \"too-large\" });\n } else if (!accepts(file)) {\n refused.push({ file, reason: \"type\" });\n } else if (seen.has(signature(file))) {\n refused.push({ file, reason: \"duplicate\" });\n } else {\n seen.add(signature(file));\n kept.push(file);\n }\n }\n\n rejections.value = refused;\n if (refused.length > 0) emit(\"reject\", refused);\n\n const unchanged = kept.length === current.length && kept.every((file, i) => file === current[i]);\n if (!unchanged) commit(kept);\n}\n\nfunction remove(index: number): void {\n if (field.disabled) return;\n // The message named files from the last selection. Once the reader edits the\n // list themselves it no longer describes anything on screen, and an error\n // sitting under a list somebody is actively fixing reads as an error about\n // that list.\n rejections.value = [];\n commit(files.value.filter((_, i) => i !== index));\n}\n\nfunction onSelect(event: Event): void {\n const input = event.target as HTMLInputElement;\n add(Array.from(input.files ?? []));\n // Cleared so choosing the same file a second time still fires `change`. The\n // input compares the new selection against its own value and stays silent\n // when they match — which, after a file has been removed from the list, is a\n // control that refuses to take back the file the reader just took out.\n input.value = \"\";\n}\n\n/**\n * Enters minus leaves, rather than a boolean.\n *\n * `dragleave` fires on the zone every time the pointer crosses onto one of its\n * own children, so an enter/leave pair drops the highlight and picks it up\n * again as the reader drags across the icon and the copy — the zone flickers\n * under the cursor at the exact moment it is meant to be saying \"let go here\".\n * A child's `dragenter` always lands before the parent's `dragleave`, so the\n * count never reaches zero until the pointer has genuinely left. `relatedTarget`\n * answers the same question in one line, but it is not reliably populated for a\n * drag that began outside the document — which is every drag this zone sees.\n */\nconst dragDepth = ref(0);\nconst dragging = computed(() => dragDepth.value > 0 && !field.disabled);\n\nfunction onDragEnter(): void {\n dragDepth.value += 1;\n}\n\nfunction onDragLeave(): void {\n dragDepth.value = Math.max(dragDepth.value - 1, 0);\n}\n\nfunction onDragOver(event: DragEvent): void {\n // Without this the browser keeps its default handling, which for a file is\n // \"navigate to it\" — the drop never reaches the zone and the page is gone.\n // Setting dropEffect is what makes the cursor say copy rather than move.\n event.preventDefault();\n if (event.dataTransfer) event.dataTransfer.dropEffect = field.disabled ? \"none\" : \"copy\";\n}\n\nfunction onDrop(event: DragEvent): void {\n event.preventDefault();\n // Reset rather than decrement: the drop consumes the drag, and no matching\n // `dragleave` follows it.\n dragDepth.value = 0;\n add(Array.from(event.dataTransfer?.files ?? []));\n}\n\n// Nothing here listens on `window` or `document`. A page-wide drag handler\n// would be the usual way to stop a file dropped *beside* the zone from\n// navigating the browser away, but a library primitive that installs one\n// silently governs every other drop target on the host's page. That decision\n// is the host's, so the listeners a component cannot tear down are the ones it\n// never registers.\n\n// The two content props sit *above* the resolved label rather than replacing\n// it: `label` is the wording for one zone — \"Drop last month's export here\" —\n// and the label is what every other zone says. Read through `text.` here and\n// not resolved above, so a language switch after mount repaints the copy.\nconst zoneLabel = computed(() => props.label ?? text.value.zone({ multiple: props.multiple }));\n\nconst zoneHint = computed(() => {\n if (props.hint !== undefined) return props.hint;\n // The limit is handed over as a byte count, never as `formatBytes` output: a\n // host that writes `1,5 Mo` or Eastern Arabic digits cannot get there from a\n // string this component already decided the shape of.\n return props.maxSize === undefined ? undefined : text.value.hint({ maxSize: props.maxSize });\n});\n\n// One call over the whole list rather than one per refusal joined with a\n// space. The joiner was the fragment: which character separates two sentences\n// is a property of the script, and a language that would rather count the\n// refused files than list them has no way to say so from a per-file message.\nconst message = computed(() =>\n rejections.value.length === 0\n ? \"\"\n : text.value.rejected({ rejections: rejections.value, maxSize: props.maxSize }),\n);\n\n/**\n * The drop is the reason this control cannot be left to the platform.\n *\n * `<fieldset disabled>` makes the nested `<input type=\"file\">` inert, which\n * closes the click and the keyboard, and it makes each file's remove button\n * inert too. It does nothing at all to the `drop` handler, because that sits on\n * a `<label>` — not a form control, and never disabled by a fieldset. `add()`\n * bails on `field.disabled`, which is exactly the value a disabled fieldset\n * does not reach, so a zone inside a disabled group accepted a dragged file\n * while refusing every other way in.\n *\n * Read off the DOM rather than taken from the Field context, which publishes no\n * `disabled` on purpose — see `../../lib/ancestor-disabled.ts`.\n */\nconst root = useTemplateRef<HTMLElement>(\"root\");\nconst groupDisabled = useAncestorDisabled(() => root.value);\n\n// The Field wiring sits here, below `message`, because it reads it: this is the\n// one control in the family that already had something of its own to say about\n// itself, so its `describedBy` is a *merge* and not a hand-over. Its refusal\n// message reads first — a file turned away for being too large is the most\n// specific thing there is to say — then whatever the caller pointed at, then\n// the row's hint or error, which `useFieldControl` appends. Replacing rather\n// than merging at either end is how a rejection stops being announced.\n//\n// The condition is `message` itself, not `rejections`, so the id can never\n// point at a `<p>` the template has not rendered.\n//\n// `name` and `required` are absent from the object because this component has\n// no prop for either: the row's answer stands unopposed, and its `name` reaches\n// the real `<input type=\"file\">`, which is what a `<form>` posts. `readonly` is\n// absent for the reason the docblock gives.\nconst field = useFieldControl(() => ({\n id: attrs.id as string | undefined,\n describedBy: [message.value.length > 0 ? messageId : undefined, attrs[\"aria-describedby\"]]\n .filter((id): id is string => typeof id === \"string\" && id.length > 0)\n .join(\" \"),\n // The fieldset beats the prop in both directions, exactly as it does for the\n // `<input type=\"file\">` inside this very control.\n disabled: groupDisabled.value ? true : props.disabled,\n invalid: props.invalid,\n}));\n\n// `field.id` is the caller's own id, then the row's; the generated one is the\n// floor beneath both, because the `<label for>` and the input have to agree on\n// an id whether or not anybody supplied one.\nconst inputId = computed(() => field.id ?? generatedId);\n</script>\n\n<template>\n <div ref=\"root\" :class=\"cn('flex w-full flex-col', attrs.class as string)\">\n <!-- The drag handlers sit on a <label>, which the rule reads as a static\n element — and it is usually right to: a box with a drop handler and\n nothing else is a control no keyboard can reach. Here the keyboard\n path is the real <input type=\"file\"> nested inside, which the label\n both names and activates, so the drop adds a pointer shortcut to a\n control that already works without one. -->\n <!-- eslint-disable-next-line vuejs-accessibility/no-static-element-interactions -->\n <label\n :for=\"inputId\"\n :data-dragging=\"dragging || undefined\"\n :data-invalid=\"field.invalid || undefined\"\n :data-disabled=\"field.disabled || undefined\"\n :class=\"\n cn(\n // `group` so the two lines of copy below can name their own disabled\n // colour off this element's state. A colour set here would only be\n // *inherited* by them, and both declare a colour of their own — an\n // inherited value loses to any declaration, however weak.\n 'group flex flex-col items-center justify-center gap-2 rounded-md border border-dashed border-input bg-background px-6 py-8 text-center',\n // Border and fill are the drag feedback, so they carry the transition\n // — fast, because a zone that lights up after the pointer has already\n // crossed it is answering a question the reader stopped asking.\n 'transition-[color,background-color,border-color,box-shadow] duration-fast ease-out',\n // Focus lives on the clipped input, which is 1px and can show a reader\n // nothing. The zone wears the ring on its behalf, the same\n // focus-within arrangement TextField uses for its own inner input.\n 'focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-ring',\n !field.invalid && 'focus-within:shadow-halo',\n // The dash going solid is what keeps the drag state off colour alone.\n 'data-[dragging]:border-solid data-[dragging]:border-primary data-[dragging]:bg-primary-muted',\n // Drained, not dimmed. The zone is a large blank region whose whole\n // purpose is the two lines of text in the middle of it, and\n // `opacity-50` took the headline to 3.06:1 and the hint below it to\n // 2.05:1 — the reader least able to guess what a drop zone wants is\n // the one it stopped telling. The fill falls to the neutral well\n // instead, which is where every other unavailable surface in the\n // library goes, and both lines land at 4.67:1 over it. The dash stays,\n // because it is what marks this out as a drop target even while\n // nothing may be dropped on it; only its colour slackens, from\n // `input` to the lighter `border`, so the state is told in a weight\n // as well as in two fills rather than in hue alone.\n field.disabled\n ? 'cursor-not-allowed border-border bg-muted'\n : 'cursor-pointer hover:bg-subtle',\n // Last of the rules naming a border colour, and the order is\n // load-bearing: `cn()` resolves one by whichever class it saw last,\n // so a zone both refusing a file and unavailable would lose its\n // destructive rim to the disabled one if this sat above it.\n field.invalid && 'border-destructive focus-within:outline-destructive',\n )\n \"\n @dragenter=\"onDragEnter\"\n @dragleave=\"onDragLeave\"\n @dragover=\"onDragOver\"\n @drop=\"onDrop\"\n >\n <!-- `field.attrs` spreads **after** the fallthrough attrs, and that\n position is the contract (see TextField.vue): the bag has already\n resolved the caller's own id and merged their description with this\n zone's refusal message and the row's hint, and it drops every key\n that resolved to nothing, so it can only add. The `:aria-invalid` and\n `:aria-describedby` bindings that used to sit here went with it — an\n individual attribute written after a `v-bind` wins, so either one\n kept would overwrite the resolved value with the raw prop.\n\n `:id` is the deliberate exception, and not that failure: `inputId` is\n the bag's own id with the generated one beneath it, so it can only\n ever restate what the bag holds or supply the id the `<label for>`\n needs when the bag holds none. -->\n <input\n v-bind=\"{ ...inputAttrs, ...field.attrs }\"\n :id=\"inputId\"\n type=\"file\"\n :accept=\"accept\"\n :multiple=\"multiple\"\n :disabled=\"field.disabled\"\n class=\"sr-only\"\n @change=\"onSelect\"\n />\n <!-- The medallion, as EmptyState draws it: a 20px glyph alone in a large\n blank region has nothing holding it and reads as a stray mark. -->\n <span\n aria-hidden=\"true\"\n class=\"grid h-10 w-10 place-items-center rounded-full border border-border bg-subtle text-muted-foreground\"\n >\n <CloudUpload class=\"h-5 w-5\" />\n </span>\n <!-- The disabled colour is declared here rather than inherited from the\n zone, for the reason the `group` on it records: this span sets\n `text-foreground` itself, and an inherited value never beats a\n declared one. The hint below needs no such rule — it is already on\n the muted colour, which holds 4.67:1 over the drained fill. -->\n <span class=\"text-sm text-foreground group-data-[disabled]:text-muted-foreground\">{{\n zoneLabel\n }}</span>\n <span v-if=\"zoneHint\" class=\"text-small text-muted-foreground\">{{ zoneHint }}</span>\n </label>\n\n <!-- The region is in the DOM from the first render, empty. A live region\n mounted at the same moment as its text is a region assistive tech was\n not yet watching, so the first refusal — the one that matters most —\n is the one it announces least reliably. -->\n <div aria-live=\"polite\" aria-atomic=\"true\">\n <p\n v-if=\"message.length > 0\"\n :id=\"messageId\"\n class=\"mt-2 flex animate-fade-rise items-start gap-2 text-small text-destructive-text\"\n >\n <TriangleAlert class=\"mt-0.5 h-4 w-4 shrink-0\" aria-hidden=\"true\" />\n <span>{{ message }}</span>\n </p>\n </div>\n\n <ul v-if=\"files.length > 0\" class=\"mt-3 flex flex-col gap-1.5\">\n <li\n v-for=\"(file, index) in files\"\n :key=\"rowId(file)\"\n class=\"flex animate-fade-rise items-center gap-2 rounded-md border border-border bg-card px-3 py-2\"\n :style=\"{ animationDelay: listStaggerDelay(index) }\"\n >\n <FileIcon class=\"h-4 w-4 shrink-0 text-muted-foreground\" aria-hidden=\"true\" />\n <span class=\"min-w-0 flex-1 truncate text-sm text-foreground\">{{ file.name }}</span>\n <span class=\"shrink-0 tabular text-small text-muted-foreground\">\n {{ text.size({ bytes: file.size }) }}\n </span>\n <!-- Named with the file it removes. Seven buttons all called \"Remove\"\n are seven identical announcements and one guess about which row a\n reader is on. -->\n <!-- Drained, not dimmed: `opacity-50` faded the glyph inside its own\n row card, compositing it below contrast — the failure Button\n records for its dim. The glyph-only control takes the neutral\n well, and the native attribute carries unavailability to AT. -->\n <button\n type=\"button\"\n :aria-label=\"text.remove({ file })\"\n :disabled=\"field.disabled\"\n class=\"inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors duration-fast ease-out hover:bg-subtle hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:shadow-halo disabled:cursor-not-allowed disabled:bg-muted disabled:shadow-none\"\n @click=\"remove(index)\"\n >\n <X class=\"h-4 w-4\" />\n </button>\n </li>\n </ul>\n </div>\n</template>\n"],"mappings":""}
@@ -1,5 +1,5 @@
1
- import { type LabelOverrides } from "@ecoma-io/loom-labels";
2
- import type { FileUploadLabels, FileUploadRejection } from "@ecoma-io/loom-labels";
1
+ import { type LabelOverrides } from "../../../labels/src/index";
2
+ import type { FileUploadLabels, FileUploadRejection } from "../../../labels/src/index";
3
3
  /**
4
4
  * Loom's English, co-located with the component so it tree-shakes with it, and
5
5
  * exported so a host can build a partial vocabulary against the real thing.
@@ -346,7 +346,7 @@ var FileUpload_vue_vue_type_script_setup_true_lang_default = /*@__PURE__*/ defin
346
346
  type: "button",
347
347
  "aria-label": unref(text).remove({ file }),
348
348
  disabled: unref(field).disabled,
349
- class: "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors duration-fast ease-out hover:bg-subtle hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:shadow-halo disabled:cursor-not-allowed disabled:opacity-50",
349
+ class: "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors duration-fast ease-out hover:bg-subtle hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring focus-visible:shadow-halo disabled:cursor-not-allowed disabled:bg-muted disabled:shadow-none",
350
350
  onClick: ($event) => remove(index)
351
351
  }, [createVNode(unref(X), { class: "h-4 w-4" })], 8, _hoisted_11)
352
352
  ], 4);