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,206 @@
1
+ // <unmagic-dropzone input="#files"> — a region that takes dropped and pasted
2
+ // files, rendered by `ai_chat_dropzone`.
3
+ //
4
+ // Dragging files over it shows its overlay (data-dragging); dropping or pasting
5
+ // them attaches them. Dragging is never the only way in: the paired file input is
6
+ // the keyboard's route, and files picked through it are attached the same way.
7
+ //
8
+ // input="#files" the file input (required)
9
+ // url="/uploads" upload each file on the spot: POST, as `file`, expecting JSON
10
+ // back with a `value`, which is then posted with the message
11
+ // as a hidden field named by `field`. Without a url, the files
12
+ // wait on the input and go with the form.
13
+ // field="message[attachments][]"
14
+ // chips="#composer [data-ai-chat-chips]" where attached files are listed
15
+ //
16
+ // Chips are cloned from the element's own <template data-dropzone-chip>, and a
17
+ // polite live region says what was attached. It fires unmagic-dropzone:attach and
18
+ // unmagic-dropzone:error, clears its chips once the form submits successfully, and
19
+ // drops the overlay before Turbo caches the page. Needs Turbo for that clearing.
20
+
21
+ class UnmagicDropzone extends HTMLElement {
22
+ #depth = 0
23
+ #transfer = new DataTransfer()
24
+ #input = null
25
+
26
+ constructor() {
27
+ super()
28
+ this.addEventListener("dragenter", (event) => {
29
+ if (!hasFiles(event)) return
30
+ this.#depth++
31
+ this.setAttribute("data-dragging", "")
32
+ })
33
+ this.addEventListener("dragleave", (event) => {
34
+ if (!hasFiles(event)) return
35
+ this.#depth = Math.max(0, this.#depth - 1)
36
+ if (this.#depth === 0) this.removeAttribute("data-dragging")
37
+ })
38
+ this.addEventListener("dragover", (event) => {
39
+ if (!hasFiles(event)) return
40
+ event.preventDefault()
41
+ event.dataTransfer.dropEffect = "copy"
42
+ })
43
+ this.addEventListener("drop", (event) => {
44
+ if (!hasFiles(event)) return
45
+ event.preventDefault()
46
+ this.#reset()
47
+ this.attach(event.dataTransfer.files)
48
+ })
49
+ this.addEventListener("paste", (event) => {
50
+ const files = event.clipboardData?.files
51
+ if (files?.length) this.attach(files)
52
+ })
53
+ }
54
+
55
+ connectedCallback() {
56
+ this.#input = document.querySelector(this.getAttribute("input"))
57
+ this.#input?.addEventListener("change", this.#picked)
58
+ document.addEventListener("turbo:before-cache", this.#reset)
59
+ document.addEventListener("turbo:submit-end", this.#submitted)
60
+ }
61
+
62
+ disconnectedCallback() {
63
+ this.#input?.removeEventListener("change", this.#picked)
64
+ this.#input = null
65
+ document.removeEventListener("turbo:before-cache", this.#reset)
66
+ document.removeEventListener("turbo:submit-end", this.#submitted)
67
+ }
68
+
69
+ attach(files) {
70
+ for (const file of Array.from(files)) {
71
+ if (this.hasAttribute("url")) this.#upload(file)
72
+ else this.#hold(file)
73
+ }
74
+ }
75
+
76
+ get #chips() {
77
+ return document.querySelector(this.getAttribute("chips")) ?? this.querySelector("[data-ai-chat-chips]")
78
+ }
79
+
80
+ // The input's own files are replaced by the full set, so a second pick adds to
81
+ // the first rather than replacing it.
82
+ #picked = () => {
83
+ const added = Array.from(this.#input.files).filter((file) => !this.#holds(file))
84
+ if (this.hasAttribute("url")) {
85
+ this.#input.value = ""
86
+ for (const file of added) this.#upload(file)
87
+ } else {
88
+ for (const file of added) this.#hold(file)
89
+ }
90
+ }
91
+
92
+ #holds(file) {
93
+ return Array.from(this.#transfer.files).some((held) => held === file)
94
+ }
95
+
96
+ #hold(file) {
97
+ if (!this.#input) return
98
+ this.#transfer.items.add(file)
99
+ this.#input.files = this.#transfer.files
100
+
101
+ this.#chip(file, () => {
102
+ const kept = new DataTransfer()
103
+ for (const held of this.#transfer.files) if (held !== file) kept.items.add(held)
104
+ this.#transfer = kept
105
+ this.#input.files = kept.files
106
+ })
107
+ this.#attached(file)
108
+ }
109
+
110
+ async #upload(file) {
111
+ const body = new FormData()
112
+ body.append("file", file)
113
+ const token = document.querySelector("meta[name=csrf-token]")?.content
114
+
115
+ try {
116
+ const response = await fetch(this.getAttribute("url"), {
117
+ method: "POST",
118
+ body,
119
+ headers: { Accept: "application/json", ...(token ? { "X-CSRF-Token": token } : {}) },
120
+ credentials: "same-origin",
121
+ })
122
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
123
+
124
+ const { value } = await response.json()
125
+ const chip = this.#chip(file)
126
+ const hidden = document.createElement("input")
127
+ hidden.type = "hidden"
128
+ hidden.name = this.getAttribute("field")
129
+ hidden.value = value
130
+ chip?.append(hidden)
131
+ this.#attached(file)
132
+ } catch (error) {
133
+ this.#say("failed", file)
134
+ this.dispatchEvent(new CustomEvent("unmagic-dropzone:error", { bubbles: true, detail: { file, error } }))
135
+ }
136
+ }
137
+
138
+ #chip(file, removed = () => {}) {
139
+ const template = this.querySelector(":scope > template[data-dropzone-chip]")
140
+ const chips = this.#chips
141
+ if (!template || !chips) return null
142
+
143
+ const chip = template.content.firstElementChild.cloneNode(true)
144
+ chip.querySelector("[data-dropzone-name]").textContent = file.name
145
+ chip.querySelector("[data-dropzone-size]").textContent = humanSize(file.size)
146
+
147
+ const remove = chip.querySelector("[data-dropzone-remove]")
148
+ const label = this.#words("remove", file)
149
+ remove.setAttribute("aria-label", label)
150
+ remove.title = label
151
+ remove.addEventListener("click", () => {
152
+ chip.remove()
153
+ removed()
154
+ })
155
+
156
+ chips.append(chip)
157
+ return chip
158
+ }
159
+
160
+ #attached(file) {
161
+ this.#say("attached", file)
162
+ this.dispatchEvent(new CustomEvent("unmagic-dropzone:attach", { bubbles: true, detail: { file } }))
163
+ }
164
+
165
+ #say(key, file) {
166
+ const status = this.querySelector(":scope > [data-dropzone-status]")
167
+ if (status) status.textContent = this.#words(key, file)
168
+ }
169
+
170
+ #words(key, file) {
171
+ return (this.dataset[key] || "").replaceAll("{name}", file.name)
172
+ }
173
+
174
+ // The files went with the message: forget them.
175
+ #submitted = (event) => {
176
+ const form = this.#input?.form ?? this.#chips?.closest("form")
177
+ if (event.target !== form || !event.detail?.success) return
178
+
179
+ this.#chips?.replaceChildren()
180
+ this.#transfer = new DataTransfer()
181
+ if (this.#input) this.#input.value = ""
182
+ }
183
+
184
+ #reset = () => {
185
+ this.#depth = 0
186
+ this.removeAttribute("data-dragging")
187
+ }
188
+ }
189
+
190
+ function hasFiles(event) {
191
+ return Array.from(event.dataTransfer?.types ?? []).includes("Files")
192
+ }
193
+
194
+ function humanSize(bytes) {
195
+ if (bytes < 1024) return `${bytes} Bytes`
196
+ const units = ["KB", "MB", "GB"]
197
+ let size = bytes / 1024
198
+ let unit = 0
199
+ while (size >= 1024 && unit < units.length - 1) {
200
+ size /= 1024
201
+ unit++
202
+ }
203
+ return `${size < 10 ? size.toFixed(1).replace(/\.0$/, "") : Math.round(size)} ${units[unit]}`
204
+ }
205
+
206
+ customElements.get("unmagic-dropzone") || customElements.define("unmagic-dropzone", UnmagicDropzone)
@@ -0,0 +1,64 @@
1
+ // <unmagic-elapsed since="…"> — a clock counting up from a moment the server
2
+ // named, rendered by `elapsed_tag`.
3
+ //
4
+ // Work that takes a minute and work that has hung look the same behind a spinner;
5
+ // a number that keeps moving is the difference. The server draws the first reading,
6
+ // so this only keeps it moving, a second at a time.
7
+ //
8
+ // since="2026-09-16T12:00:00.000Z" count up from this instant
9
+ // until="2026-09-16T12:05:00.000Z" or count down to it, stopping at zero and
10
+ // firing unmagic-elapsed:end
11
+ //
12
+ // The words match Unmagic::Components::Duration in Ruby — 2s, 3m 5s, 1h 4m 2s —
13
+ // in whole seconds, floored going up and ceiled going down. Change both together.
14
+
15
+ class UnmagicElapsed extends HTMLElement {
16
+ static observedAttributes = ["since", "until"]
17
+
18
+ #timer = null
19
+ #ended = false
20
+
21
+ connectedCallback() {
22
+ this.#ended = false
23
+ this.#draw()
24
+ this.#timer = setInterval(() => this.#draw(), 1000)
25
+ }
26
+
27
+ disconnectedCallback() {
28
+ clearInterval(this.#timer)
29
+ this.#timer = null
30
+ }
31
+
32
+ attributeChangedCallback() {
33
+ if (!this.isConnected) return
34
+ this.#ended = false
35
+ this.#draw()
36
+ }
37
+
38
+ #draw() {
39
+ const until = this.getAttribute("until")
40
+ const now = Date.now()
41
+
42
+ if (until) {
43
+ const left = Math.max(0, Math.ceil((Date.parse(until) - now) / 1000))
44
+ this.textContent = reading(left)
45
+ if (left === 0 && !this.#ended) {
46
+ this.#ended = true
47
+ clearInterval(this.#timer)
48
+ this.dispatchEvent(new CustomEvent("unmagic-elapsed:end", { bubbles: true }))
49
+ }
50
+ } else {
51
+ const since = Date.parse(this.getAttribute("since"))
52
+ if (Number.isNaN(since)) return
53
+ this.textContent = reading(Math.max(0, Math.floor((now - since) / 1000)))
54
+ }
55
+ }
56
+ }
57
+
58
+ function reading(seconds) {
59
+ if (seconds < 60) return `${seconds}s`
60
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`
61
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m ${seconds % 60}s`
62
+ }
63
+
64
+ customElements.get("unmagic-elapsed") || customElements.define("unmagic-elapsed", UnmagicElapsed)
@@ -0,0 +1,71 @@
1
+ // <unmagic-optimistic container="#entries"> — draws a form's submission before
2
+ // the server has heard of it, rendered by `ai_chat_composer`.
3
+ //
4
+ // On the enclosing form's submit, the element clones its <template> and fills it
5
+ // from the form's fields:
6
+ //
7
+ // data-optimistic-id="message[client_id]" the element's id, from that field
8
+ // data-optimistic-text="message[content]" the element's text, from that field
9
+ //
10
+ // then places it by id, exactly as the `upsert` stream action does (placement.js):
11
+ // an element with that id already on the page is replaced, otherwise the clone
12
+ // goes into `container`. The server then broadcasts the real element under the
13
+ // same id and the two become one. Pair it with <unmagic-uuid-input> so both sides
14
+ // know the id up front. A required field keeps an empty form from submitting, so
15
+ // there's never a placeholder with no record coming for it.
16
+ //
17
+ // container="#entries" where a new element goes (required)
18
+ // scroll="false" don't scroll the new element into view
19
+ //
20
+ // It fires unmagic-optimistic:insert with the element. Needs Turbo, for the
21
+ // broadcast that reconciles what it drew.
22
+ import { place } from "unmagic/components/placement"
23
+
24
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)")
25
+
26
+ class UnmagicOptimistic extends HTMLElement {
27
+ #form = null
28
+
29
+ connectedCallback() {
30
+ this.#form = this.closest("form")
31
+ this.#form?.addEventListener("submit", this.#insert)
32
+ }
33
+
34
+ disconnectedCallback() {
35
+ this.#form?.removeEventListener("submit", this.#insert)
36
+ this.#form = null
37
+ }
38
+
39
+ // Not skipped when the event is already handled: Turbo prevents the native
40
+ // submit to send it itself, which is exactly the submission to draw.
41
+ #insert = () => {
42
+ const template = this.querySelector(":scope > template")
43
+ if (!template) return
44
+
45
+ const clone = template.content.cloneNode(true)
46
+ for (const node of clone.querySelectorAll("[data-optimistic-id]")) {
47
+ node.id = this.#field(node.dataset.optimisticId)
48
+ }
49
+ for (const node of clone.querySelectorAll("[data-optimistic-text]")) {
50
+ node.textContent = this.#field(node.dataset.optimisticText)
51
+ }
52
+
53
+ const incoming = clone.firstElementChild
54
+ if (!incoming) return
55
+
56
+ const placed = place(incoming, document.querySelector(this.getAttribute("container")))
57
+ if (!placed) return
58
+
59
+ if (this.getAttribute("scroll") !== "false") {
60
+ placed.scrollIntoView({ block: "end", behavior: reducedMotion.matches ? "instant" : "smooth" })
61
+ }
62
+ this.dispatchEvent(new CustomEvent("unmagic-optimistic:insert", { bubbles: true, detail: { element: placed } }))
63
+ }
64
+
65
+ #field(name) {
66
+ const field = this.#form?.elements.namedItem(name)
67
+ return field && "value" in field ? field.value : ""
68
+ }
69
+ }
70
+
71
+ customElements.get("unmagic-optimistic") || customElements.define("unmagic-optimistic", UnmagicOptimistic)
@@ -0,0 +1,30 @@
1
+ // Places an element by its id: the reconcile-by-id contract the `upsert` stream
2
+ // action and <unmagic-optimistic> both keep, so the two can never disagree about
3
+ // where something lands.
4
+ //
5
+ // If an element with the incoming one's id is already in the document —
6
+ // server-rendered, streamed in earlier, or an optimistic placeholder the server is
7
+ // now confirming — it is replaced where it stands. Otherwise the incoming element
8
+ // is inserted into the container at the position its id sorts to, so
9
+ // out-of-order delivery still lands in id order. That ordering assumes
10
+ // time-ordered ids (UUIDv7, ULID); with random ids the position is stable but
11
+ // arbitrary. An element without an id goes last.
12
+
13
+ export function place(incoming, container, { descending = false } = {}) {
14
+ const existing = incoming.id ? document.getElementById(incoming.id) : null
15
+ if (existing) {
16
+ existing.replaceWith(incoming)
17
+ return incoming
18
+ }
19
+
20
+ if (!container) return null
21
+
22
+ const successor = incoming.id
23
+ ? Array.from(container.children).find(
24
+ (child) => child.id && (descending ? child.id < incoming.id : child.id > incoming.id),
25
+ )
26
+ : null
27
+
28
+ container.insertBefore(incoming, successor ?? null)
29
+ return incoming
30
+ }
@@ -0,0 +1,186 @@
1
+ // <unmagic-slash-menu trigger="/"> — commands offered on a slash in a text field,
2
+ // rendered by `ai_chat_slash_menu`.
3
+ //
4
+ // Type the trigger where a word starts and the menu opens on every command; keep
5
+ // typing to narrow it by prefix. Up and Down move (wrapping), Home and End jump,
6
+ // Enter or Tab takes the selected one, Escape closes and leaves the text. While
7
+ // it's open those keys are the menu's alone, so Enter can't send a half-typed
8
+ // message out from under it. Picking writes "/name " where the token was.
9
+ //
10
+ // The server renders every item, so what can be offered is only ever what exists.
11
+ // Focus stays in the field throughout: it is the WAI-ARIA combobox pattern, with
12
+ // aria-expanded and aria-activedescendant kept true on the field.
13
+ //
14
+ // for="message_content" the field's id; otherwise the composer's field, or the
15
+ // first textarea in the enclosing form
16
+ // trigger="/" the character that opens it
17
+ // insert="%{trigger}%{name} " what a pick writes
18
+ //
19
+ // Items are its [role=option] elements, named by data-name. It fires
20
+ // unmagic-slash-menu:pick with the name, and closes before Turbo caches the page.
21
+
22
+ class UnmagicSlashMenu extends HTMLElement {
23
+ #field = null
24
+
25
+ constructor() {
26
+ super()
27
+ // mousedown fires before the field blurs, so the cursor is still where the
28
+ // name has to go.
29
+ this.addEventListener("mousedown", (event) => {
30
+ const option = event.target instanceof Element && event.target.closest("[role=option]")
31
+ if (!option) return
32
+ event.preventDefault()
33
+ this.#write(option.dataset.name)
34
+ })
35
+ }
36
+
37
+ connectedCallback() {
38
+ this.#field = this.#resolveField()
39
+ const field = this.#field
40
+ if (field) {
41
+ field.setAttribute("role", "combobox")
42
+ field.setAttribute("aria-autocomplete", "list")
43
+ field.setAttribute("aria-expanded", "false")
44
+ if (this.#listbox) field.setAttribute("aria-controls", this.#listbox.id)
45
+ field.addEventListener("input", this.#search)
46
+ field.addEventListener("keydown", this.#navigate)
47
+ field.addEventListener("blur", this.close)
48
+ }
49
+ document.addEventListener("turbo:before-cache", this.close)
50
+ this.close()
51
+ }
52
+
53
+ disconnectedCallback() {
54
+ const field = this.#field
55
+ field?.removeEventListener("input", this.#search)
56
+ field?.removeEventListener("keydown", this.#navigate)
57
+ field?.removeEventListener("blur", this.close)
58
+ document.removeEventListener("turbo:before-cache", this.close)
59
+ this.#field = null
60
+ }
61
+
62
+ get open() {
63
+ return !this.hidden
64
+ }
65
+
66
+ close = () => {
67
+ this.hidden = true
68
+ this.#field?.setAttribute("aria-expanded", "false")
69
+ this.#field?.removeAttribute("aria-activedescendant")
70
+ }
71
+
72
+ get #listbox() {
73
+ return this.querySelector("[role=listbox]")
74
+ }
75
+
76
+ get #options() {
77
+ return Array.from(this.querySelectorAll("[role=option]"))
78
+ }
79
+
80
+ get #offered() {
81
+ return this.#options.filter((option) => !option.hidden)
82
+ }
83
+
84
+ get #selected() {
85
+ const offered = this.#offered
86
+ return offered.find((option) => option.getAttribute("aria-selected") === "true") ?? offered[0]
87
+ }
88
+
89
+ #resolveField() {
90
+ const id = this.getAttribute("for")
91
+ if (id) return document.getElementById(id)
92
+
93
+ const scope = this.closest(".UnmagicAIChatComposer") ?? this.closest("form")
94
+ return scope?.querySelector("[data-ai-chat-composer-field]") ?? scope?.querySelector("textarea")
95
+ }
96
+
97
+ // A trigger starting a word, and what has been typed since, anchored to the
98
+ // cursor: a slash in a path written further back isn't a command.
99
+ #token() {
100
+ const field = this.#field
101
+ const trigger = escape(this.getAttribute("trigger") || "/")
102
+ return field.value.slice(0, field.selectionStart).match(new RegExp(`(?:^|\\s)${trigger}([\\w-]*)$`))
103
+ }
104
+
105
+ #search = () => {
106
+ const token = this.#token()
107
+ if (!token) return this.close()
108
+
109
+ const typed = token[1].toLowerCase()
110
+ for (const option of this.#options) option.hidden = !option.dataset.name.toLowerCase().startsWith(typed)
111
+
112
+ const offered = this.#offered
113
+ if (offered.length === 0) return this.close()
114
+
115
+ this.hidden = false
116
+ this.#field.setAttribute("aria-expanded", "true")
117
+ this.#select(offered[0])
118
+ }
119
+
120
+ #navigate = (event) => {
121
+ if (!this.open || event.isComposing) return
122
+
123
+ const offered = this.#offered
124
+ const index = offered.indexOf(this.#selected)
125
+
126
+ switch (event.key) {
127
+ case "ArrowDown":
128
+ this.#select(offered[(index + 1) % offered.length])
129
+ break
130
+ case "ArrowUp":
131
+ this.#select(offered[(index - 1 + offered.length) % offered.length])
132
+ break
133
+ case "Home":
134
+ this.#select(offered[0])
135
+ break
136
+ case "End":
137
+ this.#select(offered[offered.length - 1])
138
+ break
139
+ case "Enter":
140
+ case "Tab":
141
+ if (event.shiftKey && event.key === "Enter") return
142
+ this.#write(this.#selected?.dataset.name)
143
+ break
144
+ case "Escape":
145
+ this.close()
146
+ break
147
+ default:
148
+ return
149
+ }
150
+
151
+ event.preventDefault()
152
+ event.stopImmediatePropagation()
153
+ }
154
+
155
+ #select(option) {
156
+ for (const other of this.#options) other.setAttribute("aria-selected", other === option ? "true" : "false")
157
+ if (!option) return
158
+ this.#field.setAttribute("aria-activedescendant", option.id)
159
+ option.scrollIntoView({ block: "nearest" })
160
+ }
161
+
162
+ #write(name) {
163
+ const token = name && this.#token()
164
+ if (!token) return this.close()
165
+
166
+ const field = this.#field
167
+ const trigger = this.getAttribute("trigger") || "/"
168
+ const template = this.getAttribute("insert") || "%{trigger}%{name} "
169
+ const written = template.replaceAll("%{trigger}", trigger).replaceAll("%{name}", name)
170
+ const cursor = field.selectionStart
171
+ const start = cursor - token[1].length - trigger.length
172
+
173
+ field.value = field.value.slice(0, start) + written + field.value.slice(cursor)
174
+ field.selectionStart = field.selectionEnd = start + written.length
175
+ this.close()
176
+ field.focus()
177
+ field.dispatchEvent(new Event("input", { bubbles: true }))
178
+ this.dispatchEvent(new CustomEvent("unmagic-slash-menu:pick", { bubbles: true, detail: { name } }))
179
+ }
180
+ }
181
+
182
+ function escape(text) {
183
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
184
+ }
185
+
186
+ customElements.get("unmagic-slash-menu") || customElements.define("unmagic-slash-menu", UnmagicSlashMenu)
@@ -0,0 +1,6 @@
1
+ // Drag-and-drop ordering: `sortable_list` and `board` need this one import.
2
+ //
3
+ // import "unmagic/components/sortable"
4
+ import "unmagic/components/sortable_param"
5
+ import "unmagic/components/sortable_list"
6
+ import "unmagic/components/sortable_item"
@@ -0,0 +1,122 @@
1
+ // <unmagic-sortable-item key="…" data-sortable-rank="…"> — one item in an
2
+ // <unmagic-sortable-list>, rendered by `sortable_list`'s item and `board`.
3
+ //
4
+ // key names the record to the server
5
+ // data-sortable-rank its rank, read as a neighbour's when another item drops
6
+ // data-sortable-label what announcements call it; its text otherwise
7
+ //
8
+ // With a [data-sortable-handle] of its own inside (not one belonging to an item
9
+ // nested in it), only a press on a handle drags it, straight away, and the
10
+ // handle's own buttons and links stay clickable. Without one, the whole item
11
+ // drags once the pointer has moved a few pixels, so a click on a link in it still
12
+ // follows the link; a press in a text field never starts a drag. On a touch
13
+ // screen an item without a handle lifts after a long press, so a swipe still
14
+ // scrolls and a tap still taps. A handle with touch-action: none (the
15
+ // sortable_handle grip) drags at once; any other handle waits for the long press
16
+ // too, so it doesn't stop the page scrolling.
17
+ //
18
+ // The keyboard stop is its first focusable handle, or the item itself (given a
19
+ // tabindex) when it has none. Space or Enter there picks it up; see
20
+ // sortable_session.js for the rest.
21
+ import { beginPointerDrag, beginKeyboardDrag, dragging, keyboardDragOf } from "unmagic/components/sortable_session"
22
+
23
+ const TYPING = "input, textarea, select, [contenteditable]:not([contenteditable=false])"
24
+ const INTERACTIVE = `a[href], button, summary, label, [role=button], ${TYPING}`
25
+
26
+ class UnmagicSortableItem extends HTMLElement {
27
+ constructor() {
28
+ super()
29
+ this.addEventListener("pointerdown", this.#pointerdown)
30
+ this.addEventListener("keydown", this.#keydown)
31
+ }
32
+
33
+ // A drag moves the element, and a morph refresh may too, so this runs again each
34
+ // time; everything it does is safe to repeat. A morph also resets attributes to
35
+ // the server's markup without reconnecting, so it runs after every morph too.
36
+ connectedCallback() {
37
+ this.setUp()
38
+ }
39
+
40
+ setUp() {
41
+ const stop = this.keyboardStop
42
+ if (stop === this && !this.hasAttribute("tabindex")) this.tabIndex = 0
43
+
44
+ const list = this.list
45
+ if (list && !this.hasAttribute(DRAGGING_COPY)) {
46
+ const described = new Set((stop.getAttribute("aria-describedby") || "").split(/\s+/).filter(Boolean))
47
+ described.add(list.instructionsId)
48
+ stop.setAttribute("aria-describedby", [...described].join(" "))
49
+ }
50
+ }
51
+
52
+ get key() {
53
+ return this.getAttribute("key")
54
+ }
55
+
56
+ get rank() {
57
+ return this.getAttribute("data-sortable-rank")
58
+ }
59
+
60
+ get label() {
61
+ return this.getAttribute("data-sortable-label") || this.textContent.replace(/\s+/g, " ").trim().slice(0, 80)
62
+ }
63
+
64
+ get list() {
65
+ return this.closest("unmagic-sortable-list")
66
+ }
67
+
68
+ get handles() {
69
+ return [...this.querySelectorAll("[data-sortable-handle]")].filter(
70
+ (handle) => handle.closest("unmagic-sortable-item") === this,
71
+ )
72
+ }
73
+
74
+ get keyboardStop() {
75
+ return this.handles.find((handle) => handle.matches("button, a[href], [tabindex]")) ?? this
76
+ }
77
+
78
+ #pointerdown = (event) => {
79
+ if (event.button !== 0 || dragging()) return
80
+ if (event.target.closest("unmagic-sortable-item") !== this) return
81
+
82
+ const handles = this.handles
83
+ const handle = event.target.closest("[data-sortable-handle]")
84
+ const own = handle && handles.includes(handle) ? handle : null
85
+ if (handles.length > 0 && !own) return
86
+
87
+ const control = event.target.closest(own ? INTERACTIVE : TYPING)
88
+ if (control && control !== own && this.contains(control)) return
89
+
90
+ // A handle drags at once, except by a finger on one that still lets the page
91
+ // scroll (touch-action other than none), which waits for a long press.
92
+ const scrolls = event.pointerType === "touch" && own && getComputedStyle(own).touchAction !== "none"
93
+ beginPointerDrag(this, event, { immediate: Boolean(own) && !scrolls })
94
+ }
95
+
96
+ #keydown = (event) => {
97
+ const drag = keyboardDragOf(this)
98
+ if (drag) return drag.key(event)
99
+ if (dragging() || (event.key !== " " && event.key !== "Enter")) return
100
+
101
+ const stop = this.keyboardStop
102
+ if (event.target !== stop) return
103
+
104
+ event.preventDefault()
105
+ event.stopPropagation()
106
+ beginKeyboardDrag(this, stop)
107
+ }
108
+ }
109
+
110
+ const DRAGGING_COPY = "data-sortable-dragging"
111
+
112
+ const INSTALLED = Symbol.for("unmagic.components.sortable_item")
113
+ if (!document[INSTALLED]) {
114
+ document[INSTALLED] = true
115
+ document.addEventListener("turbo:morph", () => {
116
+ for (const item of document.querySelectorAll("unmagic-sortable-item")) item.setUp?.()
117
+ })
118
+ }
119
+
120
+ customElements.get("unmagic-sortable-item") || customElements.define("unmagic-sortable-item", UnmagicSortableItem)
121
+
122
+ export { UnmagicSortableItem }