unmagic-components 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 (292) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -1
  3. data/README.md +506 -1
  4. data/app/assets/icons/lucide/LICENSE +43 -0
  5. data/app/assets/icons/lucide/arrow-down.svg +1 -0
  6. data/app/assets/icons/lucide/arrow-left.svg +1 -0
  7. data/app/assets/icons/lucide/arrow-up.svg +1 -0
  8. data/app/assets/icons/lucide/ban.svg +1 -0
  9. data/app/assets/icons/lucide/brain.svg +1 -0
  10. data/app/assets/icons/lucide/check.svg +1 -0
  11. data/app/assets/icons/lucide/chevron-down.svg +1 -0
  12. data/app/assets/icons/lucide/chevron-left.svg +1 -0
  13. data/app/assets/icons/lucide/chevron-right.svg +1 -0
  14. data/app/assets/icons/lucide/circle-check.svg +1 -0
  15. data/app/assets/icons/lucide/circle-dashed.svg +1 -0
  16. data/app/assets/icons/lucide/circle-question-mark.svg +1 -0
  17. data/app/assets/icons/lucide/circle-stop.svg +1 -0
  18. data/app/assets/icons/lucide/circle-x.svg +1 -0
  19. data/app/assets/icons/lucide/clock.svg +1 -0
  20. data/app/assets/icons/lucide/copy.svg +1 -0
  21. data/app/assets/icons/lucide/ellipsis-vertical.svg +1 -0
  22. data/app/assets/icons/lucide/file.svg +1 -0
  23. data/app/assets/icons/lucide/folder.svg +1 -0
  24. data/app/assets/icons/lucide/grip-vertical.svg +1 -0
  25. data/app/assets/icons/lucide/info.svg +1 -0
  26. data/app/assets/icons/lucide/lightbulb.svg +1 -0
  27. data/app/assets/icons/lucide/list-checks.svg +1 -0
  28. data/app/assets/icons/lucide/loader-circle.svg +1 -0
  29. data/app/assets/icons/lucide/messages-square.svg +1 -0
  30. data/app/assets/icons/lucide/paperclip.svg +1 -0
  31. data/app/assets/icons/lucide/pencil.svg +1 -0
  32. data/app/assets/icons/lucide/plus.svg +1 -0
  33. data/app/assets/icons/lucide/rotate-cw.svg +1 -0
  34. data/app/assets/icons/lucide/shield-alert.svg +1 -0
  35. data/app/assets/icons/lucide/timer.svg +1 -0
  36. data/app/assets/icons/lucide/triangle-alert.svg +1 -0
  37. data/app/assets/icons/lucide/wrench.svg +1 -0
  38. data/app/assets/icons/lucide/x.svg +1 -0
  39. data/app/assets/javascripts/unmagic/components/ai_chat.js +142 -0
  40. data/app/assets/javascripts/unmagic/components/autoscroll.js +103 -0
  41. data/app/assets/javascripts/unmagic/components/board.js +65 -0
  42. data/app/assets/javascripts/unmagic/components/dropzone.js +206 -0
  43. data/app/assets/javascripts/unmagic/components/elapsed.js +64 -0
  44. data/app/assets/javascripts/unmagic/components/optimistic.js +71 -0
  45. data/app/assets/javascripts/unmagic/components/placement.js +30 -0
  46. data/app/assets/javascripts/unmagic/components/slash_menu.js +186 -0
  47. data/app/assets/javascripts/unmagic/components/sortable.js +6 -0
  48. data/app/assets/javascripts/unmagic/components/sortable_item.js +122 -0
  49. data/app/assets/javascripts/unmagic/components/sortable_list.js +135 -0
  50. data/app/assets/javascripts/unmagic/components/sortable_param.js +14 -0
  51. data/app/assets/javascripts/unmagic/components/sortable_session.js +502 -0
  52. data/app/assets/javascripts/unmagic/components/streaming_markdown.js +269 -0
  53. data/app/assets/javascripts/unmagic/components/toasts.js +14 -4
  54. data/app/assets/javascripts/unmagic/components/toolbar.js +76 -0
  55. data/app/assets/javascripts/unmagic/components/upsert.js +4 -20
  56. data/app/assets/javascripts/unmagic/components.js +10 -0
  57. data/app/assets/tailwind/unmagic_components/engine.css +1515 -0
  58. data/lib/unmagic/components/action_view_helpers.rb +558 -3
  59. data/lib/unmagic/components/ai_chat/action_bar.rb +70 -0
  60. data/lib/unmagic/components/ai_chat/attachments.rb +119 -0
  61. data/lib/unmagic/components/ai_chat/branch_picker.rb +65 -0
  62. data/lib/unmagic/components/ai_chat/citation.rb +89 -0
  63. data/lib/unmagic/components/ai_chat/composer.rb +160 -0
  64. data/lib/unmagic/components/ai_chat/failure.rb +80 -0
  65. data/lib/unmagic/components/ai_chat/message.rb +142 -0
  66. data/lib/unmagic/components/ai_chat/payload.rb +93 -0
  67. data/lib/unmagic/components/ai_chat/permission.rb +143 -0
  68. data/lib/unmagic/components/ai_chat/plan.rb +91 -0
  69. data/lib/unmagic/components/ai_chat/proposal.rb +102 -0
  70. data/lib/unmagic/components/ai_chat/reasoning.rb +75 -0
  71. data/lib/unmagic/components/ai_chat/request.rb +206 -0
  72. data/lib/unmagic/components/ai_chat/section.rb +53 -0
  73. data/lib/unmagic/components/ai_chat/slash_menu.rb +68 -0
  74. data/lib/unmagic/components/ai_chat/tool_call.rb +181 -0
  75. data/lib/unmagic/components/ai_chat/transcript.rb +68 -0
  76. data/lib/unmagic/components/ai_chat/welcome.rb +73 -0
  77. data/lib/unmagic/components/ai_chat/workspace.rb +74 -0
  78. data/lib/unmagic/components/ai_chat.rb +31 -0
  79. data/lib/unmagic/components/avatar.rb +134 -0
  80. data/lib/unmagic/components/board.rb +182 -0
  81. data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/application_controller.rb +80 -0
  82. data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/demos_controller.rb +137 -0
  83. data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/pages_controller.rb +40 -0
  84. data/lib/unmagic/components/browser/app/helpers/unmagic/components/browser/application_helper.rb +52 -0
  85. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/board_store.rb +88 -0
  86. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/catalog.rb +65 -0
  87. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/pager.rb +11 -0
  88. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/profile.rb +25 -0
  89. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/reply.rb +72 -0
  90. data/lib/unmagic/components/browser/app/models/unmagic/components/browser/thing.rb +31 -0
  91. data/lib/unmagic/components/browser/app/views/layouts/unmagic/components/browser/application.html.erb +65 -0
  92. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_code.html.erb +7 -0
  93. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_crumb.html.erb +7 -0
  94. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_example.html.erb +37 -0
  95. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_nav.html.erb +17 -0
  96. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/demos/edit_profile.html.erb +9 -0
  97. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_conversation.html.erb +101 -0
  98. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_live.html.erb +53 -0
  99. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_thumbnail.html.erb +7 -0
  100. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_welcome.html.erb +12 -0
  101. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_always.html.erb +6 -0
  102. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_hover.html.erb +12 -0
  103. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_thumbnail.html.erb +5 -0
  104. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_dropzone.html.erb +17 -0
  105. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_sent.html.erb +11 -0
  106. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_thumbnail.html.erb +6 -0
  107. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_positions.html.erb +5 -0
  108. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_thumbnail.html.erb +3 -0
  109. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_compact.html.erb +10 -0
  110. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_quote.html.erb +11 -0
  111. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_thumbnail.html.erb +7 -0
  112. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_parts.html.erb +14 -0
  113. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_states.html.erb +8 -0
  114. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_thumbnail.html.erb +5 -0
  115. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_full.html.erb +11 -0
  116. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_plain.html.erb +3 -0
  117. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_thumbnail.html.erb +5 -0
  118. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_parts.html.erb +23 -0
  119. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_states.html.erb +9 -0
  120. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_thumbnail.html.erb +4 -0
  121. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_turns.html.erb +16 -0
  122. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_json.html.erb +4 -0
  123. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_text.html.erb +4 -0
  124. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_thumbnail.html.erb +3 -0
  125. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_answered.html.erb +12 -0
  126. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_thumbnail.html.erb +7 -0
  127. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_waiting.html.erb +12 -0
  128. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_empty.html.erb +3 -0
  129. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_states.html.erb +9 -0
  130. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_thumbnail.html.erb +7 -0
  131. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_decided.html.erb +11 -0
  132. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_inline.html.erb +18 -0
  133. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_thumbnail.html.erb +6 -0
  134. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_settled.html.erb +6 -0
  135. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thinking.html.erb +5 -0
  136. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thumbnail.html.erb +3 -0
  137. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_answered.html.erb +10 -0
  138. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_choices.html.erb +13 -0
  139. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_form.html.erb +9 -0
  140. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_thumbnail.html.erb +8 -0
  141. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_composer.html.erb +15 -0
  142. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_thumbnail.html.erb +9 -0
  143. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_made.html.erb +12 -0
  144. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_run.html.erb +25 -0
  145. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_states.html.erb +22 -0
  146. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_thumbnail.html.erb +7 -0
  147. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_suggestions.html.erb +11 -0
  148. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_thumbnail.html.erb +6 -0
  149. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_files.html.erb +12 -0
  150. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_thumbnail.html.erb +7 -0
  151. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_group.html.erb +5 -0
  152. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_sizes.html.erb +8 -0
  153. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_thumbnail.html.erb +5 -0
  154. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_tints.html.erb +7 -0
  155. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_in_context.html.erb +14 -0
  156. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_thumbnail.html.erb +5 -0
  157. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_tones.html.erb +8 -0
  158. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_handles.html.erb +13 -0
  159. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_live.html.erb +34 -0
  160. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_thumbnail.html.erb +10 -0
  161. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_sizes.html.erb +12 -0
  162. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_thumbnail.html.erb +4 -0
  163. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_variants.html.erb +6 -0
  164. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_neutral.html.erb +3 -0
  165. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_thumbnail.html.erb +5 -0
  166. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_tones.html.erb +14 -0
  167. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_actions_and_footer.html.erb +7 -0
  168. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_flush_table.html.erb +6 -0
  169. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_link.html.erb +6 -0
  170. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_thumbnail.html.erb +6 -0
  171. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_danger.html.erb +3 -0
  172. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_plain.html.erb +2 -0
  173. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_thumbnail.html.erb +7 -0
  174. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_from_element.html.erb +4 -0
  175. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_thumbnail.html.erb +4 -0
  176. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_with_label.html.erb +3 -0
  177. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_inline.html.erb +5 -0
  178. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_stacked.html.erb +7 -0
  179. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_thumbnail.html.erb +5 -0
  180. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_dialog_tag.html.erb +8 -0
  181. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_modal.html.erb +10 -0
  182. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_thumbnail.html.erb +4 -0
  183. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_countdown.html.erb +3 -0
  184. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_running.html.erb +8 -0
  185. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_thumbnail.html.erb +4 -0
  186. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_default.html.erb +1 -0
  187. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_thumbnail.html.erb +3 -0
  188. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_autogrow.html.erb +4 -0
  189. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_control_classes.html.erb +54 -0
  190. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_fields.html.erb +28 -0
  191. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_states.html.erb +11 -0
  192. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_thumbnail.html.erb +8 -0
  193. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_uuid_field.html.erb +9 -0
  194. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_absolute.html.erb +5 -0
  195. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_relative.html.erb +14 -0
  196. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_thumbnail.html.erb +14 -0
  197. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_actions.html.erb +10 -0
  198. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_labelled.html.erb +6 -0
  199. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_thumbnail.html.erb +14 -0
  200. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_default.html.erb +7 -0
  201. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_thumbnail.html.erb +5 -0
  202. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_with_leading.html.erb +14 -0
  203. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_blocking_out.html.erb +25 -0
  204. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_card.html.erb +7 -0
  205. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_detail_list.html.erb +15 -0
  206. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_page_header.html.erb +7 -0
  207. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_text_and_button.html.erb +17 -0
  208. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_thumbnail.html.erb +10 -0
  209. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_connected.html.erb +17 -0
  210. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_grid.html.erb +20 -0
  211. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_handles.html.erb +22 -0
  212. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_thumbnail.html.erb +7 -0
  213. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_reveal.html.erb +19 -0
  214. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_stopped.html.erb +31 -0
  215. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_thumbnail.html.erb +5 -0
  216. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_deferred.html.erb +6 -0
  217. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_empty.html.erb +5 -0
  218. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_sortable.html.erb +8 -0
  219. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_table_tag.html.erb +4 -0
  220. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_thumbnail.html.erb +4 -0
  221. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_links.html.erb +5 -0
  222. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_panels.html.erb +18 -0
  223. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_thumbnail.html.erb +5 -0
  224. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_above_dialog.html.erb +10 -0
  225. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_flash.html.erb +13 -0
  226. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_stream.html.erb +6 -0
  227. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_thumbnail.html.erb +10 -0
  228. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_placement.html.erb +8 -0
  229. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_term.html.erb +5 -0
  230. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_thumbnail.html.erb +5 -0
  231. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/component.html.erb +58 -0
  232. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/installation.html.erb +47 -0
  233. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/overview.html.erb +49 -0
  234. data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/theming.html.erb +52 -0
  235. data/lib/unmagic/components/browser/assets/browser.css +2 -0
  236. data/lib/unmagic/components/browser/catalog/ai_chat.rb +21 -0
  237. data/lib/unmagic/components/browser/catalog/ai_chat_action_bar.rb +15 -0
  238. data/lib/unmagic/components/browser/catalog/ai_chat_attachments.rb +15 -0
  239. data/lib/unmagic/components/browser/catalog/ai_chat_branch_picker.rb +12 -0
  240. data/lib/unmagic/components/browser/catalog/ai_chat_citation.rb +15 -0
  241. data/lib/unmagic/components/browser/catalog/ai_chat_composer.rb +16 -0
  242. data/lib/unmagic/components/browser/catalog/ai_chat_failure.rb +15 -0
  243. data/lib/unmagic/components/browser/catalog/ai_chat_message.rb +17 -0
  244. data/lib/unmagic/components/browser/catalog/ai_chat_payload.rb +16 -0
  245. data/lib/unmagic/components/browser/catalog/ai_chat_permission.rb +15 -0
  246. data/lib/unmagic/components/browser/catalog/ai_chat_plan.rb +14 -0
  247. data/lib/unmagic/components/browser/catalog/ai_chat_proposal.rb +15 -0
  248. data/lib/unmagic/components/browser/catalog/ai_chat_reasoning.rb +14 -0
  249. data/lib/unmagic/components/browser/catalog/ai_chat_request.rb +17 -0
  250. data/lib/unmagic/components/browser/catalog/ai_chat_slash_menu.rb +14 -0
  251. data/lib/unmagic/components/browser/catalog/ai_chat_tool_call.rb +17 -0
  252. data/lib/unmagic/components/browser/catalog/ai_chat_welcome.rb +13 -0
  253. data/lib/unmagic/components/browser/catalog/ai_chat_workspace.rb +12 -0
  254. data/lib/unmagic/components/browser/catalog/avatar.rb +15 -0
  255. data/lib/unmagic/components/browser/catalog/badge.rb +11 -0
  256. data/lib/unmagic/components/browser/catalog/board.rb +18 -0
  257. data/lib/unmagic/components/browser/catalog/button.rb +14 -0
  258. data/lib/unmagic/components/browser/catalog/callout.rb +13 -0
  259. data/lib/unmagic/components/browser/catalog/card.rb +16 -0
  260. data/lib/unmagic/components/browser/catalog/confirm.rb +14 -0
  261. data/lib/unmagic/components/browser/catalog/copy_button.rb +15 -0
  262. data/lib/unmagic/components/browser/catalog/detail_list.rb +14 -0
  263. data/lib/unmagic/components/browser/catalog/dialog.rb +15 -0
  264. data/lib/unmagic/components/browser/catalog/elapsed.rb +14 -0
  265. data/lib/unmagic/components/browser/catalog/empty_state.rb +11 -0
  266. data/lib/unmagic/components/browser/catalog/forms.rb +21 -0
  267. data/lib/unmagic/components/browser/catalog/local_time.rb +13 -0
  268. data/lib/unmagic/components/browser/catalog/menu.rb +15 -0
  269. data/lib/unmagic/components/browser/catalog/page_header.rb +13 -0
  270. data/lib/unmagic/components/browser/catalog/skeleton.rb +18 -0
  271. data/lib/unmagic/components/browser/catalog/sortable_list.rb +17 -0
  272. data/lib/unmagic/components/browser/catalog/streaming_markdown.rb +17 -0
  273. data/lib/unmagic/components/browser/catalog/table.rb +17 -0
  274. data/lib/unmagic/components/browser/catalog/tabs.rb +15 -0
  275. data/lib/unmagic/components/browser/catalog/toast.rb +17 -0
  276. data/lib/unmagic/components/browser/catalog/tooltip.rb +14 -0
  277. data/lib/unmagic/components/browser/config/routes.rb +41 -0
  278. data/lib/unmagic/components/browser/tailwind/browser.css +33 -0
  279. data/lib/unmagic/components/browser.rb +39 -0
  280. data/lib/unmagic/components/configuration.rb +31 -1
  281. data/lib/unmagic/components/duration.rb +35 -0
  282. data/lib/unmagic/components/elapsed.rb +46 -0
  283. data/lib/unmagic/components/engine.rb +9 -0
  284. data/lib/unmagic/components/icons.rb +34 -26
  285. data/lib/unmagic/components/modal.rb +1 -1
  286. data/lib/unmagic/components/page_header.rb +8 -0
  287. data/lib/unmagic/components/sortable.rb +104 -0
  288. data/lib/unmagic/components/streaming_markdown.rb +34 -0
  289. data/lib/unmagic/components/turbo_stream_actions.rb +16 -0
  290. data/lib/unmagic/components/version.rb +1 -1
  291. data/lib/unmagic/components.rb +30 -1
  292. metadata +308 -4
@@ -0,0 +1,135 @@
1
+ // <unmagic-sortable-list namespace="…" url="…"> — a list whose items can be
2
+ // dragged into a new place, rendered by `sortable_list` and `board`.
3
+ //
4
+ // namespace lists sharing one exchange items; a list without one takes only
5
+ // its own
6
+ // url where a drop is posted (PATCH); none, and the drop only fires the
7
+ // event below
8
+ // orientation vertical (default), horizontal or grid: which arrow keys move an
9
+ // item along the list
10
+ // label what the list is called in announcements
11
+ // data-sortable-instructions, -picked, -moved, -dropped, -cancelled
12
+ // the words announced, with {item}, {list}, {position}, {count}
13
+ //
14
+ // A drop into this list fires unmagic-sortable:move on the item, cancelable, with
15
+ // { key, original, prev, next, params }. Unless it's cancelled the list posts:
16
+ //
17
+ // moved the item's key
18
+ // original the rank it had when it was picked up, so the server can refuse a
19
+ // move made against a stale order
20
+ // prev the rank of the item now before it (blank at the start)
21
+ // next the rank of the item now after it (blank at the end)
22
+ // <name> one field per <unmagic-sortable-param> in this list: the column a
23
+ // card was dropped into
24
+ //
25
+ // With Turbo the post goes as a Turbo form submission, so a refresh or stream in
26
+ // the response reconciles the page. unmagic-sortable's endpoint does exactly this.
27
+
28
+ let counter = 0
29
+
30
+ class UnmagicSortableList extends HTMLElement {
31
+ #instructions = null
32
+
33
+ get namespace() {
34
+ return this.getAttribute("namespace") || ""
35
+ }
36
+
37
+ get orientation() {
38
+ return this.getAttribute("orientation") || "vertical"
39
+ }
40
+
41
+ get label() {
42
+ return this.getAttribute("label") || ""
43
+ }
44
+
45
+ // Whether an item that came from `origin` may be dropped here.
46
+ accepts(origin) {
47
+ return origin === this || (this.namespace !== "" && this.namespace === origin?.namespace)
48
+ }
49
+
50
+ // This list's own items, in order, leaving out those of any list inside it.
51
+ items() {
52
+ return [...this.querySelectorAll("unmagic-sortable-item")].filter((item) => item.list === this)
53
+ }
54
+
55
+ itemsExcept(item) {
56
+ return this.items().filter((other) => other !== item)
57
+ }
58
+
59
+ // The first child that stays at the end whatever is dropped (an "add" tile).
60
+ tail() {
61
+ return [...this.children].find((child) => child.hasAttribute("data-sortable-tail")) ?? null
62
+ }
63
+
64
+ // A hidden element holding the keyboard instructions, which every item's
65
+ // keyboard stop is described by. Made on demand, since a morph can remove it.
66
+ get instructionsId() {
67
+ if (!this.#instructions?.isConnected) {
68
+ this.#instructions = document.createElement("span")
69
+ this.#instructions.id = `unmagic-sortable-instructions-${++counter}`
70
+ this.#instructions.hidden = true
71
+ this.#instructions.textContent = this.dataset.sortableInstructions || ""
72
+ this.append(this.#instructions)
73
+ }
74
+ return this.#instructions.id
75
+ }
76
+
77
+ submit(item) {
78
+ const items = this.items()
79
+ const index = items.indexOf(item)
80
+ const params = this.#params()
81
+ const detail = {
82
+ key: item.key,
83
+ original: item.rank ?? "",
84
+ prev: items[index - 1]?.rank ?? "",
85
+ next: items[index + 1]?.rank ?? "",
86
+ params: Object.fromEntries(params.map((param) => [param.name, param.value])),
87
+ }
88
+
89
+ const proceed = item.dispatchEvent(new CustomEvent("unmagic-sortable:move", { bubbles: true, cancelable: true, detail }))
90
+ const url = this.getAttribute("url")
91
+ if (!proceed || !url) return
92
+
93
+ const form = document.createElement("form")
94
+ form.method = "post"
95
+ form.action = url
96
+ form.hidden = true
97
+ const token = document.querySelector("meta[name=csrf-token]")?.content
98
+ const tokenName = document.querySelector("meta[name=csrf-param]")?.content || "authenticity_token"
99
+
100
+ field(form, "_method", "patch")
101
+ if (token) field(form, tokenName, token)
102
+ field(form, "moved", detail.key)
103
+ field(form, "original", detail.original)
104
+ field(form, "prev", detail.prev)
105
+ field(form, "next", detail.next)
106
+ for (const param of params) field(form, param.name, param.value)
107
+
108
+ document.body.append(form)
109
+ form.requestSubmit()
110
+ form.remove()
111
+ }
112
+
113
+ // This list's own params: not an inner list's, and not inside one of this list's
114
+ // items. A board's card list sits inside its column's item, so an item around
115
+ // the list itself doesn't count.
116
+ #params() {
117
+ return [...this.querySelectorAll("unmagic-sortable-param")].filter((param) => {
118
+ if (param.closest("unmagic-sortable-list") !== this) return false
119
+ const item = param.closest("unmagic-sortable-item")
120
+ return !item || !this.contains(item)
121
+ })
122
+ }
123
+ }
124
+
125
+ function field(form, name, value) {
126
+ const input = document.createElement("input")
127
+ input.type = "hidden"
128
+ input.name = name
129
+ input.value = value ?? ""
130
+ form.append(input)
131
+ }
132
+
133
+ customElements.get("unmagic-sortable-list") || customElements.define("unmagic-sortable-list", UnmagicSortableList)
134
+
135
+ export { UnmagicSortableList }
@@ -0,0 +1,14 @@
1
+ // <unmagic-sortable-param name="column_id" value="3"> — one field a list posts
2
+ // with a drop into it, rendered by `sortable_list`'s params:. It shows nothing.
3
+
4
+ class UnmagicSortableParam extends HTMLElement {
5
+ get name() {
6
+ return this.getAttribute("name")
7
+ }
8
+
9
+ get value() {
10
+ return this.getAttribute("value")
11
+ }
12
+ }
13
+
14
+ customElements.get("unmagic-sortable-param") || customElements.define("unmagic-sortable-param", UnmagicSortableParam)
@@ -0,0 +1,502 @@
1
+ // The drag engine behind <unmagic-sortable-list> and <unmagic-sortable-item>, by
2
+ // pointer or by keyboard. One drag at a time.
3
+ //
4
+ // By pointer it floats a copy of the item under the cursor while the item itself
5
+ // moves through the lists as a live preview of where it will land: into whichever
6
+ // list under the pointer accepts it, at the slot the pointer is in (vertical lists
7
+ // and wrapping rows alike). Near the edge of anything scrollable, including the
8
+ // page, it scrolls. Escape puts the item back. On release, the list it landed in
9
+ // posts the move.
10
+ //
11
+ // On a touch screen, a finger is usually scrolling, so unless it's on a handle
12
+ // that stops scrolling (a grip) the item waits for a long press (LONG_PRESS,
13
+ // holding within TOUCH_SLOP) before it lifts. Moving sooner is a scroll and
14
+ // nothing is dragged; lifting sooner is a tap and reaches whatever was tapped.
15
+ // Once lifted, the page stops scrolling under the finger for the rest of the
16
+ // gesture.
17
+ //
18
+ // By keyboard, Space or Enter on an item's handle (or the item, when it has none)
19
+ // picks it up. The arrow keys along the list move it within the list; the arrows
20
+ // across it move it to the neighbouring list that accepts it. Space or Enter drops
21
+ // it, and Escape or leaving it puts it back. Every step is announced.
22
+ //
23
+ // The engine uses the elements only through their public methods and properties
24
+ // (item.list, list.accepts, list.items, list.submit), so nothing imports it back.
25
+ //
26
+ // State is data attributes, which the CSS and the sortable-* Tailwind variants key
27
+ // on: data-sortable-dragging on the floating copy, data-sortable-placeholder on the
28
+ // item while a pointer drags it, data-sortable-lifted while a keyboard holds it,
29
+ // data-sortable-active on every list that can take it, and data-sortable-over on
30
+ // the list under the pointer.
31
+
32
+ const THRESHOLD = 4 // pixels of movement before a press becomes a drag
33
+ const LONG_PRESS = 250 // milliseconds a finger holds still before an item lifts
34
+ const TOUCH_SLOP = 8 // pixels a finger may drift during that hold
35
+ const EDGE = 56 // pixels from a scroller's edge where dragging scrolls it
36
+ const MAX_SPEED = 20 // pixels a frame at the very edge
37
+
38
+ const LIST = "unmagic-sortable-list"
39
+ const DRAGGING = "data-sortable-dragging"
40
+ const PLACEHOLDER = "data-sortable-placeholder"
41
+ const LIFTED = "data-sortable-lifted"
42
+ const ACTIVE = "data-sortable-active"
43
+ const OVER = "data-sortable-over"
44
+
45
+ let active = null
46
+
47
+ export function dragging() {
48
+ return active !== null
49
+ }
50
+
51
+ export function beginPointerDrag(item, event, { immediate = false } = {}) {
52
+ if (!active) active = new PointerDrag(item, event, immediate)
53
+ }
54
+
55
+ export function beginKeyboardDrag(item, stop) {
56
+ if (!active) active = new KeyboardDrag(item, stop)
57
+ }
58
+
59
+ export function keyboardDragOf(item) {
60
+ return active instanceof KeyboardDrag && active.item === item ? active : null
61
+ }
62
+
63
+ // Put a list's item where `before` is, or at the end of its items when null, which
64
+ // keeps it ahead of anything marked data-sortable-tail (an "add" tile).
65
+ export function place(list, item, before) {
66
+ if (before) {
67
+ before.before(item)
68
+ } else {
69
+ const tail = list.tail()
70
+ if (tail) tail.before(item)
71
+ else list.append(item)
72
+ }
73
+ }
74
+
75
+ class Drag {
76
+ constructor(item) {
77
+ this.item = item
78
+ this.origin = item.list
79
+ this.startParent = item.parentElement
80
+ this.startNext = item.nextElementSibling
81
+ this.targets = []
82
+ // A subclass's cancel isn't assigned until this constructor returns, so the
83
+ // listener calls through to whichever cancel the drag ends up with.
84
+ this.cacheListener = () => this.cancel()
85
+ document.addEventListener("turbo:before-cache", this.cacheListener)
86
+ }
87
+
88
+ // Every list that can take the item: its own, and any sharing its namespace.
89
+ lightUp() {
90
+ this.targets = [...document.querySelectorAll(LIST)].filter((list) => list.accepts(this.origin))
91
+ for (const list of this.targets) list.setAttribute(ACTIVE, "")
92
+ }
93
+
94
+ end() {
95
+ for (const list of this.targets) {
96
+ list.removeAttribute(ACTIVE)
97
+ list.removeAttribute(OVER)
98
+ }
99
+ document.removeEventListener("turbo:before-cache", this.cacheListener)
100
+ active = null
101
+ }
102
+
103
+ moved() {
104
+ return this.item.parentElement !== this.startParent || this.item.nextElementSibling !== this.startNext
105
+ }
106
+
107
+ restore() {
108
+ this.startParent.insertBefore(this.item, this.startNext)
109
+ }
110
+
111
+ commit() {
112
+ if (this.moved()) this.item.list?.submit(this.item)
113
+ }
114
+ }
115
+
116
+ class PointerDrag extends Drag {
117
+ #pointerId
118
+ #offsetX
119
+ #offsetY
120
+ #width
121
+ #startX
122
+ #startY
123
+ #x
124
+ #y
125
+ #started = false
126
+ #clone = null
127
+ #frame = null
128
+ #touch = false
129
+ #press = null
130
+
131
+ constructor(item, event, immediate) {
132
+ super(item)
133
+ this.#pointerId = event.pointerId
134
+ this.#touch = event.pointerType === "touch" && !immediate
135
+
136
+ const box = item.getBoundingClientRect()
137
+ this.#offsetX = event.clientX - box.left
138
+ this.#offsetY = event.clientY - box.top
139
+ this.#width = box.width
140
+ this.#startX = this.#x = event.clientX
141
+ this.#startY = this.#y = event.clientY
142
+
143
+ // A touch that may yet be a scroll isn't captured, so the browser can pan.
144
+ if (!this.#touch) item.setPointerCapture?.(event.pointerId)
145
+ window.addEventListener("pointermove", this.#move)
146
+ window.addEventListener("pointerup", this.#up)
147
+ window.addEventListener("pointercancel", this.cancel)
148
+ window.addEventListener("keydown", this.#keydown)
149
+
150
+ if (this.#touch) {
151
+ // Non-passive, so it can stop the page scrolling once the item has lifted. It
152
+ // does nothing before then, so a scroll is still a scroll.
153
+ window.addEventListener("touchmove", this.#holdPage, { passive: false })
154
+ window.addEventListener("contextmenu", this.#noMenu)
155
+ this.#press = setTimeout(this.#lift, LONG_PRESS)
156
+ }
157
+
158
+ if (immediate) {
159
+ event.preventDefault()
160
+ this.#start()
161
+ this.#follow()
162
+ }
163
+ }
164
+
165
+ #move = (event) => {
166
+ if (event.pointerId !== this.#pointerId) return
167
+ this.#x = event.clientX
168
+ this.#y = event.clientY
169
+
170
+ if (!this.#started) {
171
+ const distance = Math.hypot(this.#x - this.#startX, this.#y - this.#startY)
172
+ if (this.#touch) {
173
+ if (distance > TOUCH_SLOP) this.#stop() // moved before the hold: a scroll
174
+ return
175
+ }
176
+ if (distance < THRESHOLD) return
177
+ this.#start()
178
+ }
179
+
180
+ event.preventDefault()
181
+ this.#follow()
182
+ this.#reposition()
183
+ }
184
+
185
+ // The copy is made before anything else is marked, so its own lists aren't
186
+ // mistaken for targets, and it is inert and stripped of ids so it can't be.
187
+ #start() {
188
+ this.#started = true
189
+ this.lightUp()
190
+
191
+ const clone = this.item.cloneNode(true)
192
+ clone.setAttribute(DRAGGING, "")
193
+ clone.setAttribute("aria-hidden", "true")
194
+ clone.inert = true
195
+ clone.removeAttribute("id")
196
+ for (const element of clone.querySelectorAll("[id]")) element.removeAttribute("id")
197
+ clone.style.width = `${this.#width}px`
198
+ document.body.append(clone)
199
+ this.#clone = clone
200
+
201
+ this.item.setAttribute(PLACEHOLDER, "")
202
+ this.#frame = requestAnimationFrame(this.#scroll)
203
+ }
204
+
205
+ // The long press held: lift the item where the finger is, and take the pointer.
206
+ #lift = () => {
207
+ this.#press = null
208
+ if (active !== this || this.#started) return
209
+
210
+ this.item.setPointerCapture?.(this.#pointerId)
211
+ navigator.vibrate?.(10)
212
+ this.#start()
213
+ this.#follow()
214
+ }
215
+
216
+ #holdPage = (event) => {
217
+ if (this.#started && event.cancelable) event.preventDefault()
218
+ }
219
+
220
+ #noMenu = (event) => {
221
+ event.preventDefault()
222
+ }
223
+
224
+ #follow() {
225
+ this.#clone.style.transform = `translate(${this.#x - this.#offsetX}px, ${this.#y - this.#offsetY}px)`
226
+ }
227
+
228
+ #reposition() {
229
+ const list = this.#listAt(this.#x, this.#y)
230
+ for (const target of this.targets) target.toggleAttribute(OVER, target === list)
231
+ if (list) place(list, this.item, slotBefore(list, this.item, this.#x, this.#y))
232
+ }
233
+
234
+ // The innermost list under the pointer that takes this item. A column dragged
235
+ // over a column's cards passes the cards' list by for the board's.
236
+ #listAt(x, y) {
237
+ let list = document.elementFromPoint(x, y)?.closest(LIST)
238
+ while (list) {
239
+ if (list.accepts(this.origin) && !this.item.contains(list)) return list
240
+ list = list.parentElement?.closest(LIST)
241
+ }
242
+ return null
243
+ }
244
+
245
+ #scroll = () => {
246
+ this.#frame = null
247
+ if (!active || active !== this) return
248
+
249
+ let scrolled = false
250
+ for (const scroller of scrollersAt(this.#x, this.#y)) {
251
+ const page = scroller === document.scrollingElement
252
+ const box = page ? { left: 0, top: 0, right: innerWidth, bottom: innerHeight } : scroller.getBoundingClientRect()
253
+ const dx = scroller.scrollWidth > scroller.clientWidth ? speed(this.#x, box.left, box.right) : 0
254
+ const dy = scroller.scrollHeight > scroller.clientHeight ? speed(this.#y, box.top, box.bottom) : 0
255
+ if (!dx && !dy) continue
256
+
257
+ const beforeLeft = scroller.scrollLeft
258
+ const beforeTop = scroller.scrollTop
259
+ scroller.scrollLeft += dx
260
+ scroller.scrollTop += dy
261
+ scrolled ||= scroller.scrollLeft !== beforeLeft || scroller.scrollTop !== beforeTop
262
+ }
263
+
264
+ if (scrolled) this.#reposition()
265
+ this.#frame = requestAnimationFrame(this.#scroll)
266
+ }
267
+
268
+ #keydown = (event) => {
269
+ if (event.key !== "Escape" || !this.#started) return
270
+ event.preventDefault()
271
+ this.cancel()
272
+ }
273
+
274
+ #up = (event) => {
275
+ if (event.pointerId !== this.#pointerId) return
276
+ const started = this.#started
277
+ this.#stop()
278
+ if (!started) return
279
+
280
+ suppressNextClick()
281
+ this.commit()
282
+ }
283
+
284
+ cancel = (event) => {
285
+ if (event?.pointerId !== undefined && event.pointerId !== this.#pointerId) return
286
+ const started = this.#started
287
+ this.#stop()
288
+ if (started) this.restore()
289
+ }
290
+
291
+ #stop() {
292
+ clearTimeout(this.#press)
293
+ this.#press = null
294
+ window.removeEventListener("touchmove", this.#holdPage)
295
+ window.removeEventListener("contextmenu", this.#noMenu)
296
+ window.removeEventListener("pointermove", this.#move)
297
+ window.removeEventListener("pointerup", this.#up)
298
+ window.removeEventListener("pointercancel", this.cancel)
299
+ window.removeEventListener("keydown", this.#keydown)
300
+ if (this.#frame) cancelAnimationFrame(this.#frame)
301
+ this.#frame = null
302
+ this.#clone?.remove()
303
+ this.item.removeAttribute(PLACEHOLDER)
304
+ this.end()
305
+ }
306
+ }
307
+
308
+ class KeyboardDrag extends Drag {
309
+ #stop
310
+ #moving = false
311
+
312
+ constructor(item, stop) {
313
+ super(item)
314
+ this.#stop = stop
315
+ this.lightUp()
316
+ item.setAttribute(LIFTED, "")
317
+ stop.addEventListener("blur", this.#blur)
318
+ announce(item, "picked")
319
+ }
320
+
321
+ // Keys while holding the item. Handled here and nowhere else: an outer item
322
+ // mustn't act on them too.
323
+ key(event) {
324
+ const list = this.item.list
325
+ const orientation = list?.orientation || "vertical"
326
+ const along = ALONG[orientation][event.key]
327
+ const across = ACROSS[orientation][event.key]
328
+
329
+ if (event.key === " " || event.key === "Enter") this.drop()
330
+ else if (event.key === "Escape") this.cancel()
331
+ else if (along) this.#step(along)
332
+ else if (across) this.#jump(across)
333
+ else return
334
+
335
+ event.preventDefault()
336
+ event.stopPropagation()
337
+ }
338
+
339
+ #step(delta) {
340
+ const items = this.item.list.items()
341
+ const neighbour = items[items.indexOf(this.item) + delta]
342
+ if (!neighbour) return
343
+
344
+ this.#moveTo(() => (delta < 0 ? neighbour.before(this.item) : neighbour.after(this.item)))
345
+ }
346
+
347
+ // To the same position in the next list along, or its end.
348
+ #jump(delta) {
349
+ const lists = this.targets.filter((list) => !this.item.contains(list))
350
+ const next = lists[lists.indexOf(this.item.list) + delta]
351
+ if (!next) return
352
+
353
+ const index = this.item.list.items().indexOf(this.item)
354
+ this.#moveTo(() => place(next, this.item, next.itemsExcept(this.item)[index] ?? null))
355
+ }
356
+
357
+ // Moving a focused element can drop its focus, so it's handed straight back.
358
+ #moveTo(move) {
359
+ this.#moving = true
360
+ move()
361
+ this.#stop.focus()
362
+ this.#moving = false
363
+ this.item.scrollIntoView({ block: "nearest", inline: "nearest" })
364
+ announce(this.item, "moved")
365
+ }
366
+
367
+ drop() {
368
+ this.#finish()
369
+ announce(this.item, "dropped")
370
+ this.commit()
371
+ }
372
+
373
+ cancel = () => {
374
+ this.#moving = true
375
+ this.restore()
376
+ this.#stop.focus()
377
+ this.#moving = false
378
+ this.#finish()
379
+ announce(this.item, "cancelled")
380
+ }
381
+
382
+ #blur = () => {
383
+ if (this.#moving) return
384
+ setTimeout(() => {
385
+ if (active === this && document.activeElement !== this.#stop) this.cancel()
386
+ })
387
+ }
388
+
389
+ #finish() {
390
+ this.#stop.removeEventListener("blur", this.#blur)
391
+ this.item.removeAttribute(LIFTED)
392
+ this.end()
393
+ }
394
+ }
395
+
396
+ // Which arrow moves an item along its list, and which moves it across to the next
397
+ // list, for each orientation.
398
+ const ALONG = {
399
+ vertical: { ArrowUp: -1, ArrowDown: 1 },
400
+ horizontal: { ArrowLeft: -1, ArrowRight: 1 },
401
+ grid: { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -1, ArrowDown: 1 },
402
+ }
403
+ const ACROSS = {
404
+ vertical: { ArrowLeft: -1, ArrowRight: 1 },
405
+ horizontal: { ArrowUp: -1, ArrowDown: 1 },
406
+ grid: {},
407
+ }
408
+
409
+ // The item the dragged one should sit before, or null for the end. It finds the
410
+ // item whose centre is nearest the pointer and picks a side: in a wrapping row, a
411
+ // row above means before and the same row goes by the centre line; in a list of
412
+ // full-width items, above the centre means before.
413
+ function slotBefore(list, dragged, x, y) {
414
+ const others = list.itemsExcept(dragged)
415
+ if (others.length === 0) return null
416
+
417
+ let nearest = null
418
+ let distance = Infinity
419
+ for (const item of others) {
420
+ const box = item.getBoundingClientRect()
421
+ const centreX = box.left + box.width / 2
422
+ const centreY = box.top + box.height / 2
423
+ const d = Math.hypot(x - centreX, y - centreY)
424
+ if (d < distance) {
425
+ distance = d
426
+ nearest = { item, box, centreX, centreY }
427
+ }
428
+ }
429
+
430
+ const across = nearest.box.width < list.getBoundingClientRect().width - 4
431
+ let after
432
+ if (across) {
433
+ if (y < nearest.box.top) after = false
434
+ else if (y > nearest.box.bottom) after = true
435
+ else after = x > nearest.centreX
436
+ } else {
437
+ after = y > nearest.centreY
438
+ }
439
+
440
+ if (!after) return nearest.item
441
+ return others[others.indexOf(nearest.item) + 1] ?? null
442
+ }
443
+
444
+ // Everything under a point that scrolls, innermost first, and the page.
445
+ function scrollersAt(x, y) {
446
+ const found = []
447
+ for (let element = document.elementFromPoint(x, y); element; element = element.parentElement) {
448
+ if (element === document.body || element === document.documentElement) break
449
+ const style = getComputedStyle(element)
450
+ const scrolls = /(auto|scroll)/.test(style.overflowX + style.overflowY)
451
+ if (scrolls && (element.scrollWidth > element.clientWidth || element.scrollHeight > element.clientHeight)) {
452
+ found.push(element)
453
+ }
454
+ }
455
+ found.push(document.scrollingElement)
456
+ return found
457
+ }
458
+
459
+ // How far to scroll this frame: faster the nearer the pointer is to an edge, and
460
+ // as fast as it goes past one.
461
+ function speed(position, start, end) {
462
+ if (position < start + EDGE) return -Math.round(MAX_SPEED * Math.min(1, (start + EDGE - position) / EDGE))
463
+ if (position > end - EDGE) return Math.round(MAX_SPEED * Math.min(1, (position - (end - EDGE)) / EDGE))
464
+ return 0
465
+ }
466
+
467
+ // A drag ends with a click on whatever is under the pointer; swallow it so a drag
468
+ // off a linked card doesn't also follow the link.
469
+ function suppressNextClick() {
470
+ const swallow = (event) => {
471
+ event.stopPropagation()
472
+ event.preventDefault()
473
+ }
474
+ window.addEventListener("click", swallow, { capture: true, once: true })
475
+ setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0)
476
+ }
477
+
478
+ function announce(item, key) {
479
+ const list = item.list
480
+ const template = list?.dataset[`sortable${key[0].toUpperCase()}${key.slice(1)}`]
481
+ if (!template) return
482
+
483
+ const items = list.items()
484
+ const text = template
485
+ .replaceAll("{item}", item.label)
486
+ .replaceAll("{list}", list.label)
487
+ .replaceAll("{position}", String(items.indexOf(item) + 1))
488
+ .replaceAll("{count}", String(items.length))
489
+
490
+ let region = document.querySelector("[data-unmagic-sortable-live]")
491
+ if (!region) {
492
+ region = document.createElement("div")
493
+ region.setAttribute("data-unmagic-sortable-live", "")
494
+ region.setAttribute("aria-live", "assertive")
495
+ region.className = "UnmagicVisuallyHidden"
496
+ document.body.append(region)
497
+ }
498
+ region.textContent = ""
499
+ requestAnimationFrame(() => {
500
+ region.textContent = text
501
+ })
502
+ }