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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -1
- data/README.md +506 -1
- data/app/assets/icons/lucide/LICENSE +43 -0
- data/app/assets/icons/lucide/arrow-down.svg +1 -0
- data/app/assets/icons/lucide/arrow-left.svg +1 -0
- data/app/assets/icons/lucide/arrow-up.svg +1 -0
- data/app/assets/icons/lucide/ban.svg +1 -0
- data/app/assets/icons/lucide/brain.svg +1 -0
- data/app/assets/icons/lucide/check.svg +1 -0
- data/app/assets/icons/lucide/chevron-down.svg +1 -0
- data/app/assets/icons/lucide/chevron-left.svg +1 -0
- data/app/assets/icons/lucide/chevron-right.svg +1 -0
- data/app/assets/icons/lucide/circle-check.svg +1 -0
- data/app/assets/icons/lucide/circle-dashed.svg +1 -0
- data/app/assets/icons/lucide/circle-question-mark.svg +1 -0
- data/app/assets/icons/lucide/circle-stop.svg +1 -0
- data/app/assets/icons/lucide/circle-x.svg +1 -0
- data/app/assets/icons/lucide/clock.svg +1 -0
- data/app/assets/icons/lucide/copy.svg +1 -0
- data/app/assets/icons/lucide/ellipsis-vertical.svg +1 -0
- data/app/assets/icons/lucide/file.svg +1 -0
- data/app/assets/icons/lucide/folder.svg +1 -0
- data/app/assets/icons/lucide/grip-vertical.svg +1 -0
- data/app/assets/icons/lucide/info.svg +1 -0
- data/app/assets/icons/lucide/lightbulb.svg +1 -0
- data/app/assets/icons/lucide/list-checks.svg +1 -0
- data/app/assets/icons/lucide/loader-circle.svg +1 -0
- data/app/assets/icons/lucide/messages-square.svg +1 -0
- data/app/assets/icons/lucide/paperclip.svg +1 -0
- data/app/assets/icons/lucide/pencil.svg +1 -0
- data/app/assets/icons/lucide/plus.svg +1 -0
- data/app/assets/icons/lucide/rotate-cw.svg +1 -0
- data/app/assets/icons/lucide/shield-alert.svg +1 -0
- data/app/assets/icons/lucide/timer.svg +1 -0
- data/app/assets/icons/lucide/triangle-alert.svg +1 -0
- data/app/assets/icons/lucide/wrench.svg +1 -0
- data/app/assets/icons/lucide/x.svg +1 -0
- data/app/assets/javascripts/unmagic/components/ai_chat.js +142 -0
- data/app/assets/javascripts/unmagic/components/autoscroll.js +103 -0
- data/app/assets/javascripts/unmagic/components/board.js +65 -0
- data/app/assets/javascripts/unmagic/components/dropzone.js +206 -0
- data/app/assets/javascripts/unmagic/components/elapsed.js +64 -0
- data/app/assets/javascripts/unmagic/components/optimistic.js +71 -0
- data/app/assets/javascripts/unmagic/components/placement.js +30 -0
- data/app/assets/javascripts/unmagic/components/slash_menu.js +186 -0
- data/app/assets/javascripts/unmagic/components/sortable.js +6 -0
- data/app/assets/javascripts/unmagic/components/sortable_item.js +122 -0
- data/app/assets/javascripts/unmagic/components/sortable_list.js +135 -0
- data/app/assets/javascripts/unmagic/components/sortable_param.js +14 -0
- data/app/assets/javascripts/unmagic/components/sortable_session.js +502 -0
- data/app/assets/javascripts/unmagic/components/streaming_markdown.js +269 -0
- data/app/assets/javascripts/unmagic/components/toasts.js +14 -4
- data/app/assets/javascripts/unmagic/components/toolbar.js +76 -0
- data/app/assets/javascripts/unmagic/components/upsert.js +4 -20
- data/app/assets/javascripts/unmagic/components.js +10 -0
- data/app/assets/tailwind/unmagic_components/engine.css +1515 -0
- data/lib/unmagic/components/action_view_helpers.rb +558 -3
- data/lib/unmagic/components/ai_chat/action_bar.rb +70 -0
- data/lib/unmagic/components/ai_chat/attachments.rb +119 -0
- data/lib/unmagic/components/ai_chat/branch_picker.rb +65 -0
- data/lib/unmagic/components/ai_chat/citation.rb +89 -0
- data/lib/unmagic/components/ai_chat/composer.rb +160 -0
- data/lib/unmagic/components/ai_chat/failure.rb +80 -0
- data/lib/unmagic/components/ai_chat/message.rb +142 -0
- data/lib/unmagic/components/ai_chat/payload.rb +93 -0
- data/lib/unmagic/components/ai_chat/permission.rb +143 -0
- data/lib/unmagic/components/ai_chat/plan.rb +91 -0
- data/lib/unmagic/components/ai_chat/proposal.rb +102 -0
- data/lib/unmagic/components/ai_chat/reasoning.rb +75 -0
- data/lib/unmagic/components/ai_chat/request.rb +206 -0
- data/lib/unmagic/components/ai_chat/section.rb +53 -0
- data/lib/unmagic/components/ai_chat/slash_menu.rb +68 -0
- data/lib/unmagic/components/ai_chat/tool_call.rb +181 -0
- data/lib/unmagic/components/ai_chat/transcript.rb +68 -0
- data/lib/unmagic/components/ai_chat/welcome.rb +73 -0
- data/lib/unmagic/components/ai_chat/workspace.rb +74 -0
- data/lib/unmagic/components/ai_chat.rb +31 -0
- data/lib/unmagic/components/avatar.rb +134 -0
- data/lib/unmagic/components/board.rb +182 -0
- data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/application_controller.rb +80 -0
- data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/demos_controller.rb +137 -0
- data/lib/unmagic/components/browser/app/controllers/unmagic/components/browser/pages_controller.rb +40 -0
- data/lib/unmagic/components/browser/app/helpers/unmagic/components/browser/application_helper.rb +52 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/board_store.rb +88 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/catalog.rb +65 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/pager.rb +11 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/profile.rb +25 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/reply.rb +72 -0
- data/lib/unmagic/components/browser/app/models/unmagic/components/browser/thing.rb +31 -0
- data/lib/unmagic/components/browser/app/views/layouts/unmagic/components/browser/application.html.erb +65 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_code.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_crumb.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_example.html.erb +37 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/application/_nav.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/demos/edit_profile.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_conversation.html.erb +101 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_live.html.erb +53 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat/_welcome.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_always.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_hover.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_action_bar/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_dropzone.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_sent.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_attachments/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_positions.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_branch_picker/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_compact.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_quote.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_citation/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_parts.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_states.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_composer/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_full.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_plain.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_failure/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_parts.html.erb +23 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_states.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_message/_turns.html.erb +16 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_json.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_text.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_payload/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_answered.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_permission/_waiting.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_empty.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_states.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_plan/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_decided.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_inline.html.erb +18 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_proposal/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_settled.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thinking.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_reasoning/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_answered.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_choices.html.erb +13 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_form.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_request/_thumbnail.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_composer.html.erb +15 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_slash_menu/_thumbnail.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_made.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_run.html.erb +25 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_states.html.erb +22 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_tool_call/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_suggestions.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_welcome/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_files.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/ai_chat_workspace/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_group.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_sizes.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/avatar/_tints.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_in_context.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/badge/_tones.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_handles.html.erb +13 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_live.html.erb +34 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/board/_thumbnail.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_sizes.html.erb +12 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/button/_variants.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_neutral.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/callout/_tones.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_actions_and_footer.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_flush_table.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_link.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/card/_thumbnail.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_danger.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_plain.html.erb +2 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/confirm/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_from_element.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/copy_button/_with_label.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_inline.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_stacked.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/detail_list/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_dialog_tag.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_modal.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/dialog/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_countdown.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_running.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/elapsed/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_default.html.erb +1 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/empty_state/_thumbnail.html.erb +3 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_autogrow.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_control_classes.html.erb +54 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_fields.html.erb +28 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_states.html.erb +11 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_thumbnail.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/forms/_uuid_field.html.erb +9 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_absolute.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_relative.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/local_time/_thumbnail.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_actions.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_labelled.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/menu/_thumbnail.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_default.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/page_header/_with_leading.html.erb +14 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_blocking_out.html.erb +25 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_card.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_detail_list.html.erb +15 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_page_header.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_text_and_button.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/skeleton/_thumbnail.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_connected.html.erb +17 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_grid.html.erb +20 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_handles.html.erb +22 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/sortable_list/_thumbnail.html.erb +7 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_reveal.html.erb +19 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_stopped.html.erb +31 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/streaming_markdown/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_deferred.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_empty.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_sortable.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_table_tag.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/table/_thumbnail.html.erb +4 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_links.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_panels.html.erb +18 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tabs/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_above_dialog.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_flash.html.erb +13 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_stream.html.erb +6 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/toast/_thumbnail.html.erb +10 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_placement.html.erb +8 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_term.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/examples/tooltip/_thumbnail.html.erb +5 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/component.html.erb +58 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/installation.html.erb +47 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/overview.html.erb +49 -0
- data/lib/unmagic/components/browser/app/views/unmagic/components/browser/pages/theming.html.erb +52 -0
- data/lib/unmagic/components/browser/assets/browser.css +2 -0
- data/lib/unmagic/components/browser/catalog/ai_chat.rb +21 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_action_bar.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_attachments.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_branch_picker.rb +12 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_citation.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_composer.rb +16 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_failure.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_message.rb +17 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_payload.rb +16 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_permission.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_plan.rb +14 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_proposal.rb +15 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_reasoning.rb +14 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_request.rb +17 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_slash_menu.rb +14 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_tool_call.rb +17 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_welcome.rb +13 -0
- data/lib/unmagic/components/browser/catalog/ai_chat_workspace.rb +12 -0
- data/lib/unmagic/components/browser/catalog/avatar.rb +15 -0
- data/lib/unmagic/components/browser/catalog/badge.rb +11 -0
- data/lib/unmagic/components/browser/catalog/board.rb +18 -0
- data/lib/unmagic/components/browser/catalog/button.rb +14 -0
- data/lib/unmagic/components/browser/catalog/callout.rb +13 -0
- data/lib/unmagic/components/browser/catalog/card.rb +16 -0
- data/lib/unmagic/components/browser/catalog/confirm.rb +14 -0
- data/lib/unmagic/components/browser/catalog/copy_button.rb +15 -0
- data/lib/unmagic/components/browser/catalog/detail_list.rb +14 -0
- data/lib/unmagic/components/browser/catalog/dialog.rb +15 -0
- data/lib/unmagic/components/browser/catalog/elapsed.rb +14 -0
- data/lib/unmagic/components/browser/catalog/empty_state.rb +11 -0
- data/lib/unmagic/components/browser/catalog/forms.rb +21 -0
- data/lib/unmagic/components/browser/catalog/local_time.rb +13 -0
- data/lib/unmagic/components/browser/catalog/menu.rb +15 -0
- data/lib/unmagic/components/browser/catalog/page_header.rb +13 -0
- data/lib/unmagic/components/browser/catalog/skeleton.rb +18 -0
- data/lib/unmagic/components/browser/catalog/sortable_list.rb +17 -0
- data/lib/unmagic/components/browser/catalog/streaming_markdown.rb +17 -0
- data/lib/unmagic/components/browser/catalog/table.rb +17 -0
- data/lib/unmagic/components/browser/catalog/tabs.rb +15 -0
- data/lib/unmagic/components/browser/catalog/toast.rb +17 -0
- data/lib/unmagic/components/browser/catalog/tooltip.rb +14 -0
- data/lib/unmagic/components/browser/config/routes.rb +41 -0
- data/lib/unmagic/components/browser/tailwind/browser.css +33 -0
- data/lib/unmagic/components/browser.rb +39 -0
- data/lib/unmagic/components/configuration.rb +31 -1
- data/lib/unmagic/components/duration.rb +35 -0
- data/lib/unmagic/components/elapsed.rb +46 -0
- data/lib/unmagic/components/engine.rb +9 -0
- data/lib/unmagic/components/icons.rb +34 -26
- data/lib/unmagic/components/modal.rb +1 -1
- data/lib/unmagic/components/page_header.rb +8 -0
- data/lib/unmagic/components/sortable.rb +104 -0
- data/lib/unmagic/components/streaming_markdown.rb +34 -0
- data/lib/unmagic/components/turbo_stream_actions.rb +16 -0
- data/lib/unmagic/components/version.rb +1 -1
- data/lib/unmagic/components.rb +30 -1
- 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,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 }
|