poetry-ui 0.0.2
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 +7 -0
- data/.gitattributes +6 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +87 -0
- data/THIRD_PARTY_NOTICES.md +201 -0
- data/app/components/poetry/ui/accordion/component.html.erb +3 -0
- data/app/components/poetry/ui/accordion/component.rb +197 -0
- data/app/components/poetry/ui/accordion/preview.rb +39 -0
- data/app/components/poetry/ui/accordion/style.rb +37 -0
- data/app/components/poetry/ui/alert/component.html.erb +6 -0
- data/app/components/poetry/ui/alert/component.rb +61 -0
- data/app/components/poetry/ui/alert/preview.rb +35 -0
- data/app/components/poetry/ui/alert/style.rb +24 -0
- data/app/components/poetry/ui/alert_dialog/component.html.erb +17 -0
- data/app/components/poetry/ui/alert_dialog/component.rb +208 -0
- data/app/components/poetry/ui/alert_dialog/preview.rb +48 -0
- data/app/components/poetry/ui/alert_dialog/style.rb +46 -0
- data/app/components/poetry/ui/aspect_ratio/component.rb +63 -0
- data/app/components/poetry/ui/aspect_ratio/preview.rb +29 -0
- data/app/components/poetry/ui/aspect_ratio/style.rb +13 -0
- data/app/components/poetry/ui/attachment/component.html.erb +13 -0
- data/app/components/poetry/ui/attachment/component.rb +139 -0
- data/app/components/poetry/ui/attachment/preview.rb +83 -0
- data/app/components/poetry/ui/attachment/style.rb +59 -0
- data/app/components/poetry/ui/autocomplete/component.html.erb +16 -0
- data/app/components/poetry/ui/autocomplete/component.rb +193 -0
- data/app/components/poetry/ui/autocomplete/preview.rb +39 -0
- data/app/components/poetry/ui/autocomplete/style.rb +18 -0
- data/app/components/poetry/ui/avatar/component.rb +103 -0
- data/app/components/poetry/ui/avatar/group_preview.html.erb +11 -0
- data/app/components/poetry/ui/avatar/preview.rb +50 -0
- data/app/components/poetry/ui/avatar/style.rb +34 -0
- data/app/components/poetry/ui/badge/component.rb +73 -0
- data/app/components/poetry/ui/badge/preview.rb +54 -0
- data/app/components/poetry/ui/badge/style.rb +35 -0
- data/app/components/poetry/ui/breadcrumb/component.html.erb +32 -0
- data/app/components/poetry/ui/breadcrumb/component.rb +102 -0
- data/app/components/poetry/ui/breadcrumb/dropdown_crumb_preview.html.erb +21 -0
- data/app/components/poetry/ui/breadcrumb/preview.rb +45 -0
- data/app/components/poetry/ui/breadcrumb/style.rb +25 -0
- data/app/components/poetry/ui/bubble/component.html.erb +4 -0
- data/app/components/poetry/ui/bubble/component.rb +106 -0
- data/app/components/poetry/ui/bubble/preview.rb +62 -0
- data/app/components/poetry/ui/bubble/style.rb +47 -0
- data/app/components/poetry/ui/button/component.html.erb +14 -0
- data/app/components/poetry/ui/button/component.rb +168 -0
- data/app/components/poetry/ui/button/preview.rb +94 -0
- data/app/components/poetry/ui/button/style.rb +41 -0
- data/app/components/poetry/ui/button_group/component.rb +70 -0
- data/app/components/poetry/ui/button_group/popup_triggers_preview.html.erb +21 -0
- data/app/components/poetry/ui/button_group/preview.rb +46 -0
- data/app/components/poetry/ui/button_group/style.rb +27 -0
- data/app/components/poetry/ui/button_group/text_prefix_preview.html.erb +7 -0
- data/app/components/poetry/ui/calendar/component.html.erb +72 -0
- data/app/components/poetry/ui/calendar/component.rb +366 -0
- data/app/components/poetry/ui/calendar/preview.rb +55 -0
- data/app/components/poetry/ui/calendar/style.rb +55 -0
- data/app/components/poetry/ui/card/component.html.erb +11 -0
- data/app/components/poetry/ui/card/component.rb +90 -0
- data/app/components/poetry/ui/card/preview.rb +29 -0
- data/app/components/poetry/ui/card/style.rb +23 -0
- data/app/components/poetry/ui/carousel/component.html.erb +17 -0
- data/app/components/poetry/ui/carousel/component.rb +179 -0
- data/app/components/poetry/ui/carousel/preview.rb +67 -0
- data/app/components/poetry/ui/carousel/style.rb +39 -0
- data/app/components/poetry/ui/checkbox/component.html.erb +22 -0
- data/app/components/poetry/ui/checkbox/component.rb +179 -0
- data/app/components/poetry/ui/checkbox/preview.rb +68 -0
- data/app/components/poetry/ui/checkbox/style.rb +28 -0
- data/app/components/poetry/ui/clipboard_text/component.html.erb +15 -0
- data/app/components/poetry/ui/clipboard_text/component.rb +154 -0
- data/app/components/poetry/ui/clipboard_text/preview.rb +24 -0
- data/app/components/poetry/ui/clipboard_text/style.rb +23 -0
- data/app/components/poetry/ui/code_block/component.html.erb +13 -0
- data/app/components/poetry/ui/code_block/component.rb +141 -0
- data/app/components/poetry/ui/code_block/preview.rb +40 -0
- data/app/components/poetry/ui/code_block/style.rb +38 -0
- data/app/components/poetry/ui/collapsible/component.html.erb +4 -0
- data/app/components/poetry/ui/collapsible/component.rb +134 -0
- data/app/components/poetry/ui/collapsible/preview.rb +24 -0
- data/app/components/poetry/ui/combobox/component.html.erb +49 -0
- data/app/components/poetry/ui/combobox/component.rb +1086 -0
- data/app/components/poetry/ui/combobox/preview.rb +164 -0
- data/app/components/poetry/ui/combobox/style.rb +148 -0
- data/app/components/poetry/ui/command/component.html.erb +20 -0
- data/app/components/poetry/ui/command/component.rb +526 -0
- data/app/components/poetry/ui/command/dialog_component.html.erb +14 -0
- data/app/components/poetry/ui/command/dialog_component.rb +206 -0
- data/app/components/poetry/ui/command/dialog_preview.rb +39 -0
- data/app/components/poetry/ui/command/preview.rb +121 -0
- data/app/components/poetry/ui/command/style.rb +78 -0
- data/app/components/poetry/ui/composable_trigger.rb +63 -0
- data/app/components/poetry/ui/context_menu/component.html.erb +6 -0
- data/app/components/poetry/ui/context_menu/component.rb +358 -0
- data/app/components/poetry/ui/context_menu/preview.rb +111 -0
- data/app/components/poetry/ui/context_menu/style.rb +59 -0
- data/app/components/poetry/ui/data_table/component.html.erb +78 -0
- data/app/components/poetry/ui/data_table/component.rb +277 -0
- data/app/components/poetry/ui/data_table/preview.rb +68 -0
- data/app/components/poetry/ui/data_table/state.rb +115 -0
- data/app/components/poetry/ui/data_table/style.rb +42 -0
- data/app/components/poetry/ui/date_field/component.html.erb +7 -0
- data/app/components/poetry/ui/date_field/component.rb +209 -0
- data/app/components/poetry/ui/date_field/preview.rb +42 -0
- data/app/components/poetry/ui/date_field/style.rb +35 -0
- data/app/components/poetry/ui/date_picker/component.html.erb +23 -0
- data/app/components/poetry/ui/date_picker/component.rb +242 -0
- data/app/components/poetry/ui/date_picker/preview.rb +31 -0
- data/app/components/poetry/ui/date_picker/style.rb +25 -0
- data/app/components/poetry/ui/deferred/component.html.erb +16 -0
- data/app/components/poetry/ui/deferred/component.rb +90 -0
- data/app/components/poetry/ui/deferred/preview.rb +24 -0
- data/app/components/poetry/ui/dialog/component.html.erb +21 -0
- data/app/components/poetry/ui/dialog/component.rb +194 -0
- data/app/components/poetry/ui/dialog/preview.rb +33 -0
- data/app/components/poetry/ui/dialog/style.rb +30 -0
- data/app/components/poetry/ui/drawer/component.html.erb +16 -0
- data/app/components/poetry/ui/drawer/component.rb +207 -0
- data/app/components/poetry/ui/drawer/preview.rb +69 -0
- data/app/components/poetry/ui/drawer/style.rb +105 -0
- data/app/components/poetry/ui/dropdown_menu/component.html.erb +6 -0
- data/app/components/poetry/ui/dropdown_menu/component.rb +350 -0
- data/app/components/poetry/ui/dropdown_menu/preview.rb +101 -0
- data/app/components/poetry/ui/dropdown_menu/style.rb +60 -0
- data/app/components/poetry/ui/empty/component.html.erb +10 -0
- data/app/components/poetry/ui/empty/component.rb +79 -0
- data/app/components/poetry/ui/empty/preview.rb +27 -0
- data/app/components/poetry/ui/empty/style.rb +27 -0
- data/app/components/poetry/ui/family_identity.rb +52 -0
- data/app/components/poetry/ui/field/component.html.erb +19 -0
- data/app/components/poetry/ui/field/component.rb +183 -0
- data/app/components/poetry/ui/field/preview.html.erb +30 -0
- data/app/components/poetry/ui/field/preview.rb +72 -0
- data/app/components/poetry/ui/field/style.rb +68 -0
- data/app/components/poetry/ui/field_group/component.rb +61 -0
- data/app/components/poetry/ui/field_group/preview.html.erb +30 -0
- data/app/components/poetry/ui/field_group/preview.rb +20 -0
- data/app/components/poetry/ui/field_group/style.rb +25 -0
- data/app/components/poetry/ui/field_separator/component.rb +70 -0
- data/app/components/poetry/ui/field_separator/preview.rb +18 -0
- data/app/components/poetry/ui/field_separator/style.rb +20 -0
- data/app/components/poetry/ui/fieldset/component.rb +90 -0
- data/app/components/poetry/ui/fieldset/preview.html.erb +22 -0
- data/app/components/poetry/ui/fieldset/preview.rb +25 -0
- data/app/components/poetry/ui/fieldset/style.rb +18 -0
- data/app/components/poetry/ui/file_input/component.html.erb +14 -0
- data/app/components/poetry/ui/file_input/component.rb +188 -0
- data/app/components/poetry/ui/file_input/preview.rb +30 -0
- data/app/components/poetry/ui/file_input/style.rb +35 -0
- data/app/components/poetry/ui/hover_card/component.html.erb +6 -0
- data/app/components/poetry/ui/hover_card/component.rb +201 -0
- data/app/components/poetry/ui/hover_card/preview.rb +60 -0
- data/app/components/poetry/ui/hover_card/style.rb +17 -0
- data/app/components/poetry/ui/icon/component.rb +127 -0
- data/app/components/poetry/ui/icon/preview.rb +21 -0
- data/app/components/poetry/ui/input/component.rb +80 -0
- data/app/components/poetry/ui/input/preview.rb +35 -0
- data/app/components/poetry/ui/input/style.rb +18 -0
- data/app/components/poetry/ui/input_group/button_preview.html.erb +12 -0
- data/app/components/poetry/ui/input_group/component.rb +69 -0
- data/app/components/poetry/ui/input_group/preview.rb +23 -0
- data/app/components/poetry/ui/input_group/search_preview.html.erb +13 -0
- data/app/components/poetry/ui/input_group/style.rb +40 -0
- data/app/components/poetry/ui/input_group/textarea_preview.html.erb +10 -0
- data/app/components/poetry/ui/input_group_field.rb +61 -0
- data/app/components/poetry/ui/input_otp/component.html.erb +25 -0
- data/app/components/poetry/ui/input_otp/component.rb +250 -0
- data/app/components/poetry/ui/input_otp/preview.rb +91 -0
- data/app/components/poetry/ui/input_otp/style.rb +36 -0
- data/app/components/poetry/ui/item/component.html.erb +13 -0
- data/app/components/poetry/ui/item/component.rb +99 -0
- data/app/components/poetry/ui/item/group_preview.html.erb +15 -0
- data/app/components/poetry/ui/item/preview.rb +52 -0
- data/app/components/poetry/ui/item/style.rb +45 -0
- data/app/components/poetry/ui/kbd/component.rb +47 -0
- data/app/components/poetry/ui/kbd/group_preview.html.erb +4 -0
- data/app/components/poetry/ui/kbd/preview.rb +23 -0
- data/app/components/poetry/ui/kbd/style.rb +13 -0
- data/app/components/poetry/ui/label/component.rb +46 -0
- data/app/components/poetry/ui/label/preview.html.erb +5 -0
- data/app/components/poetry/ui/label/preview.rb +16 -0
- data/app/components/poetry/ui/label/style.rb +13 -0
- data/app/components/poetry/ui/link/component.rb +62 -0
- data/app/components/poetry/ui/link/preview.rb +31 -0
- data/app/components/poetry/ui/link/style.rb +20 -0
- data/app/components/poetry/ui/marker/component.html.erb +4 -0
- data/app/components/poetry/ui/marker/component.rb +81 -0
- data/app/components/poetry/ui/marker/preview.rb +43 -0
- data/app/components/poetry/ui/marker/style.rb +24 -0
- data/app/components/poetry/ui/menubar/component.html.erb +3 -0
- data/app/components/poetry/ui/menubar/component.rb +493 -0
- data/app/components/poetry/ui/menubar/preview.rb +164 -0
- data/app/components/poetry/ui/menubar/style.rb +71 -0
- data/app/components/poetry/ui/menus.rb +386 -0
- data/app/components/poetry/ui/message/component.html.erb +8 -0
- data/app/components/poetry/ui/message/component.rb +64 -0
- data/app/components/poetry/ui/message/preview.rb +33 -0
- data/app/components/poetry/ui/message/style.rb +29 -0
- data/app/components/poetry/ui/message_scroller/component.html.erb +15 -0
- data/app/components/poetry/ui/message_scroller/component.rb +163 -0
- data/app/components/poetry/ui/message_scroller/preview.rb +33 -0
- data/app/components/poetry/ui/message_scroller/style.rb +39 -0
- data/app/components/poetry/ui/metadata_list/component.rb +88 -0
- data/app/components/poetry/ui/metadata_list/preview.rb +55 -0
- data/app/components/poetry/ui/metadata_list/style.rb +35 -0
- data/app/components/poetry/ui/meter/component.rb +117 -0
- data/app/components/poetry/ui/meter/preview.rb +28 -0
- data/app/components/poetry/ui/meter/style.rb +16 -0
- data/app/components/poetry/ui/native_select/component.rb +124 -0
- data/app/components/poetry/ui/native_select/labelled_preview.html.erb +7 -0
- data/app/components/poetry/ui/native_select/preview.rb +20 -0
- data/app/components/poetry/ui/native_select/style.rb +22 -0
- data/app/components/poetry/ui/navigation_menu/bar_preview.html.erb +14 -0
- data/app/components/poetry/ui/navigation_menu/component.html.erb +25 -0
- data/app/components/poetry/ui/navigation_menu/component.rb +254 -0
- data/app/components/poetry/ui/navigation_menu/preview.rb +20 -0
- data/app/components/poetry/ui/navigation_menu/style.rb +73 -0
- data/app/components/poetry/ui/navigation_menu/viewport_preview.html.erb +23 -0
- data/app/components/poetry/ui/number_field/component.html.erb +19 -0
- data/app/components/poetry/ui/number_field/component.rb +263 -0
- data/app/components/poetry/ui/number_field/preview.rb +66 -0
- data/app/components/poetry/ui/number_field/style.rb +14 -0
- data/app/components/poetry/ui/pagination/component.html.erb +40 -0
- data/app/components/poetry/ui/pagination/component.rb +182 -0
- data/app/components/poetry/ui/pagination/preview.rb +47 -0
- data/app/components/poetry/ui/pagination/style.rb +27 -0
- data/app/components/poetry/ui/popover/component.html.erb +17 -0
- data/app/components/poetry/ui/popover/component.rb +203 -0
- data/app/components/poetry/ui/popover/preview.html.erb +29 -0
- data/app/components/poetry/ui/popover/preview.rb +66 -0
- data/app/components/poetry/ui/popover/style.rb +26 -0
- data/app/components/poetry/ui/popper_consumer.rb +69 -0
- data/app/components/poetry/ui/progress/component.rb +95 -0
- data/app/components/poetry/ui/progress/preview.rb +22 -0
- data/app/components/poetry/ui/progress/style.rb +19 -0
- data/app/components/poetry/ui/questionnaire/component.html.erb +80 -0
- data/app/components/poetry/ui/questionnaire/component.rb +396 -0
- data/app/components/poetry/ui/questionnaire/preview.rb +99 -0
- data/app/components/poetry/ui/questionnaire/style.rb +53 -0
- data/app/components/poetry/ui/radio_group/component.html.erb +4 -0
- data/app/components/poetry/ui/radio_group/component.rb +323 -0
- data/app/components/poetry/ui/radio_group/preview.rb +109 -0
- data/app/components/poetry/ui/radio_group/style.rb +53 -0
- data/app/components/poetry/ui/resizable/component.html.erb +10 -0
- data/app/components/poetry/ui/resizable/component.rb +178 -0
- data/app/components/poetry/ui/resizable/preview.rb +42 -0
- data/app/components/poetry/ui/resizable/style.rb +30 -0
- data/app/components/poetry/ui/scroll_area/component.rb +76 -0
- data/app/components/poetry/ui/scroll_area/preview.rb +35 -0
- data/app/components/poetry/ui/scroll_area/style.rb +19 -0
- data/app/components/poetry/ui/search_field/component.html.erb +15 -0
- data/app/components/poetry/ui/search_field/component.rb +137 -0
- data/app/components/poetry/ui/search_field/preview.rb +23 -0
- data/app/components/poetry/ui/search_field/style.rb +18 -0
- data/app/components/poetry/ui/select/component.html.erb +15 -0
- data/app/components/poetry/ui/select/component.rb +639 -0
- data/app/components/poetry/ui/select/preview.rb +113 -0
- data/app/components/poetry/ui/select/style.rb +58 -0
- data/app/components/poetry/ui/sensitive_input/component.html.erb +29 -0
- data/app/components/poetry/ui/sensitive_input/component.rb +274 -0
- data/app/components/poetry/ui/sensitive_input/preview.rb +40 -0
- data/app/components/poetry/ui/sensitive_input/style.rb +49 -0
- data/app/components/poetry/ui/separator/component.rb +57 -0
- data/app/components/poetry/ui/separator/preview.rb +19 -0
- data/app/components/poetry/ui/separator/style.rb +15 -0
- data/app/components/poetry/ui/sheet/component.html.erb +21 -0
- data/app/components/poetry/ui/sheet/component.rb +97 -0
- data/app/components/poetry/ui/sheet/preview.rb +57 -0
- data/app/components/poetry/ui/sheet/style.rb +47 -0
- data/app/components/poetry/ui/sidebar/component.html.erb +20 -0
- data/app/components/poetry/ui/sidebar/component.rb +249 -0
- data/app/components/poetry/ui/sidebar/preview.rb +21 -0
- data/app/components/poetry/ui/sidebar/shell_preview.html.erb +47 -0
- data/app/components/poetry/ui/sidebar/style.rb +139 -0
- data/app/components/poetry/ui/skeleton/component.rb +39 -0
- data/app/components/poetry/ui/skeleton/preview.rb +22 -0
- data/app/components/poetry/ui/skeleton/style.rb +13 -0
- data/app/components/poetry/ui/slider/component.html.erb +13 -0
- data/app/components/poetry/ui/slider/component.rb +360 -0
- data/app/components/poetry/ui/slider/preview.rb +73 -0
- data/app/components/poetry/ui/slider/style.rb +55 -0
- data/app/components/poetry/ui/spinner/component.rb +58 -0
- data/app/components/poetry/ui/spinner/preview.rb +22 -0
- data/app/components/poetry/ui/spinner/style.rb +12 -0
- data/app/components/poetry/ui/stat/component.html.erb +13 -0
- data/app/components/poetry/ui/stat/component.rb +102 -0
- data/app/components/poetry/ui/stat/preview.rb +47 -0
- data/app/components/poetry/ui/stat/style.rb +29 -0
- data/app/components/poetry/ui/switch/component.html.erb +15 -0
- data/app/components/poetry/ui/switch/component.rb +136 -0
- data/app/components/poetry/ui/switch/preview.rb +66 -0
- data/app/components/poetry/ui/switch/style.rb +22 -0
- data/app/components/poetry/ui/table/component.html.erb +3 -0
- data/app/components/poetry/ui/table/component.rb +93 -0
- data/app/components/poetry/ui/table/preview.rb +96 -0
- data/app/components/poetry/ui/table/style.rb +26 -0
- data/app/components/poetry/ui/tabs/component.html.erb +10 -0
- data/app/components/poetry/ui/tabs/component.rb +253 -0
- data/app/components/poetry/ui/tabs/preview.rb +43 -0
- data/app/components/poetry/ui/tabs/style.rb +49 -0
- data/app/components/poetry/ui/tag_group/component.html.erb +8 -0
- data/app/components/poetry/ui/tag_group/component.rb +203 -0
- data/app/components/poetry/ui/tag_group/preview.rb +43 -0
- data/app/components/poetry/ui/tag_group/style.rb +29 -0
- data/app/components/poetry/ui/textarea/component.rb +64 -0
- data/app/components/poetry/ui/textarea/preview.rb +75 -0
- data/app/components/poetry/ui/textarea/style.rb +18 -0
- data/app/components/poetry/ui/time_field/component.html.erb +6 -0
- data/app/components/poetry/ui/time_field/component.rb +85 -0
- data/app/components/poetry/ui/time_field/preview.rb +40 -0
- data/app/components/poetry/ui/time_field/style.rb +30 -0
- data/app/components/poetry/ui/timeline/component.rb +134 -0
- data/app/components/poetry/ui/timeline/preview.rb +61 -0
- data/app/components/poetry/ui/timeline/style.rb +54 -0
- data/app/components/poetry/ui/toast/component.html.erb +24 -0
- data/app/components/poetry/ui/toast/component.rb +178 -0
- data/app/components/poetry/ui/toast/preview.rb +69 -0
- data/app/components/poetry/ui/toast/style.rb +33 -0
- data/app/components/poetry/ui/toast_trigger/addressed_preview.html.erb +12 -0
- data/app/components/poetry/ui/toast_trigger/component.html.erb +3 -0
- data/app/components/poetry/ui/toast_trigger/component.rb +82 -0
- data/app/components/poetry/ui/toast_trigger/default_preview.html.erb +11 -0
- data/app/components/poetry/ui/toast_trigger/preview.rb +19 -0
- data/app/components/poetry/ui/toaster/component.html.erb +1 -0
- data/app/components/poetry/ui/toaster/component.rb +99 -0
- data/app/components/poetry/ui/toaster/positions_preview.html.erb +24 -0
- data/app/components/poetry/ui/toaster/preview.rb +46 -0
- data/app/components/poetry/ui/toaster/stamp_preview.html.erb +15 -0
- data/app/components/poetry/ui/toaster/style.rb +26 -0
- data/app/components/poetry/ui/toggle/component.rb +120 -0
- data/app/components/poetry/ui/toggle/preview.rb +79 -0
- data/app/components/poetry/ui/toggle/style.rb +33 -0
- data/app/components/poetry/ui/toggle_group/component.html.erb +4 -0
- data/app/components/poetry/ui/toggle_group/component.rb +252 -0
- data/app/components/poetry/ui/toggle_group/preview.rb +143 -0
- data/app/components/poetry/ui/toggle_group/style.rb +33 -0
- data/app/components/poetry/ui/toolbar/component.rb +125 -0
- data/app/components/poetry/ui/toolbar/preview.rb +41 -0
- data/app/components/poetry/ui/toolbar/style.rb +20 -0
- data/app/components/poetry/ui/tooltip/component.html.erb +20 -0
- data/app/components/poetry/ui/tooltip/component.rb +185 -0
- data/app/components/poetry/ui/tooltip/preview.rb +74 -0
- data/app/components/poetry/ui/tooltip/previews/provider_row.html.erb +25 -0
- data/app/components/poetry/ui/tooltip/style.rb +24 -0
- data/app/components/poetry/ui/tree/component.html.erb +32 -0
- data/app/components/poetry/ui/tree/component.rb +233 -0
- data/app/components/poetry/ui/tree/preview.rb +55 -0
- data/app/components/poetry/ui/tree/style.rb +29 -0
- data/app/components/poetry/ui/typeset/component.rb +73 -0
- data/app/components/poetry/ui/typeset/preview.rb +45 -0
- data/app/components/poetry/ui/typeset/style.rb +15 -0
- data/app/controllers/poetry/ui/llms_controller.rb +28 -0
- data/app/helpers/poetry/ui/components_helper.rb +1794 -0
- data/app/helpers/poetry/ui/form_builder/type_inference.rb +117 -0
- data/app/helpers/poetry/ui/form_builder.rb +916 -0
- data/app/helpers/poetry/ui/optimistic_form_builder.rb +55 -0
- data/app/helpers/poetry/ui/toast_stream_actions.rb +27 -0
- data/config/component_descriptions.yml +96 -0
- data/config/component_registry.yml +12257 -0
- data/config/locales/en.yml +93 -0
- data/config/routes.rb +8 -0
- data/config/template_classes.txt +113 -0
- data/lib/generators/poetry/add/add_generator.rb +218 -0
- data/lib/generators/poetry/agent_rules/agent_rules_generator.rb +91 -0
- data/lib/generators/poetry/agents/agents_generator.rb +24 -0
- data/lib/generators/poetry/agents_section.rb +148 -0
- data/lib/generators/poetry/block/block_generator.rb +67 -0
- data/lib/generators/poetry/block/templates/action_bar.html.erb +45 -0
- data/lib/generators/poetry/block/templates/app_shell.html.erb +139 -0
- data/lib/generators/poetry/block/templates/data_index.html.erb +85 -0
- data/lib/generators/poetry/block/templates/destructive_panel.html.erb +26 -0
- data/lib/generators/poetry/block/templates/page_header.html.erb +25 -0
- data/lib/generators/poetry/block/templates/section_card.html.erb +42 -0
- data/lib/generators/poetry/block/templates/stepper.html.erb +79 -0
- data/lib/generators/poetry/block/templates/top_nav.html.erb +44 -0
- data/lib/generators/poetry/diff/diff_generator.rb +135 -0
- data/lib/generators/poetry/editor/editor_generator.rb +344 -0
- data/lib/generators/poetry/editor/templates/poetry-check-hook +81 -0
- data/lib/generators/poetry/install/install_generator.rb +364 -0
- data/lib/generators/poetry/pagination/pagination_generator.rb +76 -0
- data/lib/generators/poetry/pagination/templates/kaminari_paginator.html.erb +29 -0
- data/lib/generators/poetry/pagination/templates/poetry_link_renderer.rb +34 -0
- data/lib/generators/poetry/pagination/templates/poetry_pagy_helper.rb +28 -0
- data/lib/generators/poetry/recipes/agent_embed/app/javascript/controllers/agent_demo_controller.js +131 -0
- data/lib/generators/poetry/recipes/screen_data_index/app/controllers/orders_controller.rb +15 -0
- data/lib/generators/poetry/recipes/screen_data_index/app/views/orders/index.html.erb +7 -0
- data/lib/generators/poetry/recipes/screen_data_index/test/system/orders_index_test.rb +16 -0
- data/lib/generators/poetry/recipes/screen_settings/app/controllers/settings_controller.rb +14 -0
- data/lib/generators/poetry/recipes/screen_settings/app/views/settings/show.html.erb +9 -0
- data/lib/generators/poetry/recipes/screen_settings/test/system/settings_show_test.rb +16 -0
- data/lib/generators/poetry/scaffold_templates/scaffold_templates_generator.rb +60 -0
- data/lib/generators/poetry/scaffold_templates/templates/_form.html.erb.tt +77 -0
- data/lib/generators/poetry/scaffold_templates/templates/controller.rb.tt +78 -0
- data/lib/generators/poetry/scaffold_templates/templates/edit.html.erb.tt +14 -0
- data/lib/generators/poetry/scaffold_templates/templates/index.html.erb.tt +24 -0
- data/lib/generators/poetry/scaffold_templates/templates/new.html.erb.tt +11 -0
- data/lib/generators/poetry/scaffold_templates/templates/partial.html.erb.tt +13 -0
- data/lib/generators/poetry/scaffold_templates/templates/show.html.erb.tt +18 -0
- data/lib/generators/poetry/skill/skill_generator.rb +34 -0
- data/lib/generators/poetry/skill/templates/poetry-component/SKILL.md +58 -0
- data/lib/generators/poetry/skill/templates/poetry-component/references/anatomy.md +152 -0
- data/lib/generators/poetry/skill/templates/poetry-component/references/checklist.md +67 -0
- data/lib/generators/poetry/skill/templates/poetry-component/references/documentation.md +137 -0
- data/lib/generators/poetry/skill/templates/poetry-design/SKILL.md +56 -0
- data/lib/generators/poetry/skill/templates/poetry-design/references/audit.md +123 -0
- data/lib/generators/poetry/skill/templates/poetry-design/references/compose.md +73 -0
- data/lib/generators/poetry/skill/templates/poetry-design/references/figma.md +68 -0
- data/lib/generators/poetry/skill/templates/poetry-design/references/paper.md +56 -0
- data/lib/generators/poetry/skill/templates/poetry-design/references/study.md +47 -0
- data/lib/generators/poetry/skill/templates/poetry-design/references/theme.md +70 -0
- data/lib/generators/poetry/skills_section.rb +58 -0
- data/lib/poetry/ui/chat.rb +283 -0
- data/lib/poetry/ui/code_block_highlighter.rb +60 -0
- data/lib/poetry/ui/engine.rb +55 -0
- data/lib/poetry/ui/recipes.rb +157 -0
- data/lib/poetry/ui/testing/combobox.rb +167 -0
- data/lib/poetry/ui/testing/dialog.rb +73 -0
- data/lib/poetry/ui/testing/menu.rb +127 -0
- data/lib/poetry/ui/testing/registration.rb +58 -0
- data/lib/poetry/ui/testing/select.rb +147 -0
- data/lib/poetry/ui/testing/tester.rb +130 -0
- data/lib/poetry/ui/testing.rb +130 -0
- data/lib/poetry/ui/themes.rb +109 -0
- data/lib/poetry/ui/version.rb +8 -0
- data/lib/poetry/ui/webmcp.rb +91 -0
- data/lib/poetry/ui.rb +285 -0
- data/lib/poetry-ui.rb +5 -0
- data/lib/tasks/poetry/check.rake +73 -0
- data/lib/tasks/poetry/design.rake +293 -0
- data/lib/tasks/poetry/verify.rake +36 -0
- data/themes/default.css +1793 -0
- data/themes/luma.css +1764 -0
- data/themes/lyra.css +1768 -0
- data/themes/maia.css +1768 -0
- data/themes/mira.css +1760 -0
- data/themes/nova.css +1755 -0
- data/themes/rhea.css +1771 -0
- data/themes/sera.css +1757 -0
- data/themes/vega.css +1775 -0
- data/typeset/typeset.css +499 -0
- metadata +502 -0
|
@@ -0,0 +1,1086 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Poetry
|
|
4
|
+
module Ui
|
|
5
|
+
# Combobox family: the searchable select - a trigger, a filterable
|
|
6
|
+
# listbox popup, and a native-select form story.
|
|
7
|
+
module Combobox
|
|
8
|
+
# The placement vocabularies, declared ONCE at module level - shared
|
|
9
|
+
# by the validations and the part-state declarations.
|
|
10
|
+
SIDES = %i[top right bottom left].freeze
|
|
11
|
+
# The closed vocabulary for the align axis.
|
|
12
|
+
ALIGNS = %i[start center end].freeze
|
|
13
|
+
# The closed vocabulary for the reading-direction axis.
|
|
14
|
+
DIRS = %i[ltr rtl].freeze
|
|
15
|
+
|
|
16
|
+
# The server-side option registry: collects every option in render
|
|
17
|
+
# order with unique non-blank values, keeps labels for the native
|
|
18
|
+
# <select> and the value display, assigns the server-stable option
|
|
19
|
+
# ids ("#{id}-item-<n>") that aria-activedescendant depends on, and
|
|
20
|
+
# seats the initial highlight (the selected option, else the first
|
|
21
|
+
# enabled item). Duplicate or blank values raise at render.
|
|
22
|
+
#
|
|
23
|
+
# @api private
|
|
24
|
+
class OptionSet
|
|
25
|
+
# One registered option: value, label, disabled.
|
|
26
|
+
# @api private
|
|
27
|
+
Entry = Struct.new(:value, :label, :disabled)
|
|
28
|
+
|
|
29
|
+
attr_reader :entries, :highlighted_id
|
|
30
|
+
|
|
31
|
+
def initialize(base_id:, highlight_value: nil)
|
|
32
|
+
@base_id = base_id
|
|
33
|
+
@highlight_value = highlight_value.presence
|
|
34
|
+
@entries = []
|
|
35
|
+
@seen = Set.new
|
|
36
|
+
@highlighted_id = nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Registers one option; returns [item_id, highlighted].
|
|
40
|
+
def register(value:, label:, disabled: false)
|
|
41
|
+
key = value.to_s
|
|
42
|
+
raise ArgumentError, "Combobox item requires a non-blank value:" if key.blank?
|
|
43
|
+
unless @seen.add?(key)
|
|
44
|
+
raise ArgumentError, "duplicate Combobox option value #{key.inspect} - option values must be " \
|
|
45
|
+
"unique within their combobox"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
item_id = "#{@base_id}-item-#{@entries.size}"
|
|
49
|
+
@entries << Entry.new(key, label, disabled)
|
|
50
|
+
highlighted = highlight?(key, disabled)
|
|
51
|
+
@highlighted_id = item_id if highlighted
|
|
52
|
+
[item_id, highlighted]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def label_for(value)
|
|
56
|
+
entries.find { |entry| entry.value == value }&.label
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# A selected value seats the highlight on ITS option (unless
|
|
62
|
+
# disabled; the controller's open-seed falls back client-side);
|
|
63
|
+
# no value -> first enabled (Command's rule).
|
|
64
|
+
def highlight?(value, disabled)
|
|
65
|
+
return false if @highlighted_id || disabled
|
|
66
|
+
|
|
67
|
+
@highlight_value ? value == @highlight_value : true
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Shared part builders for the option union - mixed into the root
|
|
72
|
+
# Component and the nested Group so both levels render the same
|
|
73
|
+
# anatomy through the same Builder. Hosts must expose #option_set
|
|
74
|
+
# (the shared OptionSet) and #selected_value.
|
|
75
|
+
#
|
|
76
|
+
# @api private
|
|
77
|
+
module Helpers
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def item_component(**)
|
|
81
|
+
Item.new(option_set: option_set, selected_value: selected_value, **)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The engine's separator part (Command::Style verbatim) - hidden by
|
|
85
|
+
# the controller whenever the query is non-empty.
|
|
86
|
+
def separator_part(**options)
|
|
87
|
+
attrs = {
|
|
88
|
+
# aria-hidden, never role=separator: inside role=listbox a
|
|
89
|
+
# separator role is flagged (the select/command axe rule).
|
|
90
|
+
"data-slot" => "combobox-separator", "aria-hidden" => "true",
|
|
91
|
+
"class" => Command::Style.css(:separator, class: options.delete(:class))
|
|
92
|
+
}
|
|
93
|
+
content_tag(:div, nil, attrs.merge(options))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# COMBOBOX-OWNED addition onto each combobox-item: the
|
|
97
|
+
# committed-value check - TRAILING (ms-auto), not Select's
|
|
98
|
+
# absolute gutter. Server-rendered always; the item's
|
|
99
|
+
# data-selected absence hides it in CSS while unselected.
|
|
100
|
+
def item_indicator
|
|
101
|
+
content_tag(:span, "data-slot" => "combobox-item-indicator",
|
|
102
|
+
"class" => Style.css(:item_indicator, class: Style.css(:item_indicator_state))) do
|
|
103
|
+
render(Icon::Component.new(name: :check, class: Style.css(:indicator_check)))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# One role=option div carrying BOTH meanings: it stays a valid
|
|
109
|
+
# COMMAND item
|
|
110
|
+
# (data-value + data-poetry-collection-item + the engine's
|
|
111
|
+
# activate/pointerHighlight actions + keywords/filter_value/
|
|
112
|
+
# always_render, NO tabindex - activedescendant, never DOM focus)
|
|
113
|
+
# AND wears Select's committed-value surface (aria-selected +
|
|
114
|
+
# data-selected twin-written together, never separately, plus the
|
|
115
|
+
# trailing indicator). A part component ON PURPOSE: rendering
|
|
116
|
+
# happens in DOM order, so the shared OptionSet assigns server-
|
|
117
|
+
# stable ids and registers native <option>s in exactly the order
|
|
118
|
+
# the listbox renders - top level or grouped.
|
|
119
|
+
#
|
|
120
|
+
# @api private
|
|
121
|
+
class Item < Poetry::Core::Component
|
|
122
|
+
internal_component!
|
|
123
|
+
include Helpers
|
|
124
|
+
|
|
125
|
+
attr_reader :option_set, :selected_value
|
|
126
|
+
|
|
127
|
+
def initialize(option_set:, selected_value:, value:, **options)
|
|
128
|
+
super(options)
|
|
129
|
+
@option_set = option_set
|
|
130
|
+
@selected_value = selected_value
|
|
131
|
+
@value = value.to_s
|
|
132
|
+
@disabled = options.delete(:disabled) || false
|
|
133
|
+
@text_value = options.delete(:text_value)
|
|
134
|
+
@keywords = Array(options.delete(:keywords)).map(&:to_s)
|
|
135
|
+
@filter_value = options.delete(:filter_value)
|
|
136
|
+
@always_render = options.delete(:always_render) || false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def call
|
|
140
|
+
label_html = content || "".html_safe
|
|
141
|
+
plain_label = (@text_value.presence || ActionView::Base.full_sanitizer.sanitize(label_html.to_s)).squish
|
|
142
|
+
item_id, highlighted = option_set.register(value: @value, label: plain_label, disabled: @disabled)
|
|
143
|
+
|
|
144
|
+
# multiple passes the committed LIST - selection is ARRAY
|
|
145
|
+
# INCLUSION; single stays the scalar equality.
|
|
146
|
+
selected = if selected_value.is_a?(Array)
|
|
147
|
+
selected_value.include?(@value)
|
|
148
|
+
else
|
|
149
|
+
selected_value.present? && @value == selected_value
|
|
150
|
+
end
|
|
151
|
+
attrs = {
|
|
152
|
+
"id" => item_id, "data-slot" => "combobox-item", "role" => "option",
|
|
153
|
+
"data-poetry-collection-item" => "", "data-value" => @value,
|
|
154
|
+
"aria-selected" => selected.to_s,
|
|
155
|
+
"class" => Command::Style.css(:item, class: [Style.css(:item_fill),
|
|
156
|
+
html_attributes.delete(:class)].compact.join(" "))
|
|
157
|
+
}.merge(stimulus_attributes(:command) do |command|
|
|
158
|
+
command.with_action(:activate, on: :click)
|
|
159
|
+
command.with_action(:pointer_highlight, on: :pointermove)
|
|
160
|
+
end)
|
|
161
|
+
# The selected state: bare data-selected on the committed
|
|
162
|
+
# option, NOTHING while unselected (absence IS the state).
|
|
163
|
+
attrs["data-selected"] = "" if selected
|
|
164
|
+
attrs["data-highlighted"] = "" if highlighted
|
|
165
|
+
if @disabled
|
|
166
|
+
attrs["aria-disabled"] = "true"
|
|
167
|
+
attrs["data-disabled"] = ""
|
|
168
|
+
end
|
|
169
|
+
attrs["data-text-value"] = @text_value if @text_value
|
|
170
|
+
attrs["data-keywords"] = @keywords.join(" ") if @keywords.any?
|
|
171
|
+
attrs["data-filter-value"] = @filter_value if @filter_value
|
|
172
|
+
attrs["data-always-render"] = "" if @always_render
|
|
173
|
+
content_tag(:div, Poetry::Core::HTML::Attributes.merged(attrs, html_attributes)) do
|
|
174
|
+
safe_join([content_tag(:span, label_html, "data-slot" => "combobox-item-text",
|
|
175
|
+
"class" => Style.css(:item_text)),
|
|
176
|
+
item_indicator])
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# role=group labelled by its heading part (Command's group shape,
|
|
182
|
+
# the source's combobox geometry: unpadded, the label themed) -
|
|
183
|
+
# the same item union one level down, registering its options into
|
|
184
|
+
# the PARENT's option set so ids, the native <select>, and the
|
|
185
|
+
# value display see every option in DOM order. Kept internal ON
|
|
186
|
+
# PURPOSE: nested parts are anatomy, not registered components.
|
|
187
|
+
#
|
|
188
|
+
# @api private
|
|
189
|
+
class Group < Poetry::Core::Component
|
|
190
|
+
internal_component!
|
|
191
|
+
include Helpers
|
|
192
|
+
|
|
193
|
+
attr_reader :option_set, :selected_value
|
|
194
|
+
|
|
195
|
+
renders_many :items,
|
|
196
|
+
doc: "The group's members: with_item options and with_separator dividers, interleaved in " \
|
|
197
|
+
"declaration order.",
|
|
198
|
+
types: {
|
|
199
|
+
item: { renders: ->(**options) { item_component(**options) }, as: :item },
|
|
200
|
+
separator: { renders: ->(**options) { separator_part(**options) }, as: :separator }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
def initialize(option_set:, selected_value:, heading:, always_render: false, **extra_attributes)
|
|
204
|
+
raise ArgumentError, "Combobox group requires heading: (the group's accessible name)" if heading.blank?
|
|
205
|
+
|
|
206
|
+
super(extra_attributes)
|
|
207
|
+
@option_set = option_set
|
|
208
|
+
@selected_value = selected_value
|
|
209
|
+
@heading_text = heading
|
|
210
|
+
@always_render = always_render
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def before_render
|
|
214
|
+
raise ArgumentError, "Combobox group requires at least one item" unless items?
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def call
|
|
218
|
+
attrs = { "data-slot" => "combobox-group", "role" => "group", "aria-labelledby" => heading_id }
|
|
219
|
+
# The group is unpadded and hookless (the list carries the
|
|
220
|
+
# inset) - a class attribute only when the caller passes one.
|
|
221
|
+
classes = Style.css(:group, class: html_attributes.delete(:class))
|
|
222
|
+
attrs["class"] = classes if classes.present?
|
|
223
|
+
attrs["data-always-render"] = "" if @always_render
|
|
224
|
+
content_tag(:div, Poetry::Core::HTML::Attributes.merged(attrs, html_attributes)) do
|
|
225
|
+
safe_join([heading_part, *items])
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
private
|
|
230
|
+
|
|
231
|
+
def group_id
|
|
232
|
+
@group_id ||= poetry_instance_id("poetry-combobox-group")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def heading_id
|
|
236
|
+
"#{group_id}-heading"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def heading_part
|
|
240
|
+
content_tag(:div, @heading_text, "data-slot" => "combobox-label", "id" => heading_id,
|
|
241
|
+
"class" => Style.css(:label))
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# A searchable select: a button that opens a popup holding a filter
|
|
246
|
+
# input and a listbox of options, committing the chosen value to a
|
|
247
|
+
# visually hidden native <select> that is the form's source of
|
|
248
|
+
# truth. Options are server-rendered and filtered client-side as
|
|
249
|
+
# you type; the selected option's label shows in the trigger.
|
|
250
|
+
#
|
|
251
|
+
# Opening focuses the filter input (the selected option is
|
|
252
|
+
# highlighted and scrolled into view, not focused); Tab while open
|
|
253
|
+
# closes WITHOUT committing; a printable key on the closed trigger
|
|
254
|
+
# opens the popup and seeds the filter.
|
|
255
|
+
#
|
|
256
|
+
# With multiple: true, value: takes a LIST, a chips field (chips in
|
|
257
|
+
# value order plus the filter input inline) replaces the trigger,
|
|
258
|
+
# the native <select multiple> posts name[], the listbox turns
|
|
259
|
+
# aria-multiselectable, and selection TOGGLES with the popup
|
|
260
|
+
# staying open.
|
|
261
|
+
#
|
|
262
|
+
# @example
|
|
263
|
+
# render Poetry::Ui::Combobox::Component.new(name: "framework", "aria-label" => "Framework") do |combobox|
|
|
264
|
+
# combobox.with_item(value: "rails") { "Ruby on Rails" }
|
|
265
|
+
# combobox.with_item(value: "hanami") { "Hanami" }
|
|
266
|
+
# end
|
|
267
|
+
class Component < Poetry::Core::Component
|
|
268
|
+
include Helpers
|
|
269
|
+
|
|
270
|
+
# The trigger-bound ARIA surface: Field's control_attributes (and
|
|
271
|
+
# bare aria-label usage) land on the TRIGGER - the combobox is the
|
|
272
|
+
# interactive control the label must reach - never on the root div.
|
|
273
|
+
TRIGGER_ARIA_KEYS = %w[label labelledby describedby invalid required].freeze
|
|
274
|
+
|
|
275
|
+
# Projected into the registry, llms.txt, and the agent surface.
|
|
276
|
+
AGENT_RULES = [
|
|
277
|
+
"Use poetry_combobox (f.poetry_combobox in forms) - never hand-wire Popover+Command+hidden-input; " \
|
|
278
|
+
"this component IS that wiring, with the form story done right.",
|
|
279
|
+
"Combobox picks VALUES. Filter-then-ACT is bare Command; short known lists are Select; free text " \
|
|
280
|
+
"is Input.",
|
|
281
|
+
"Every Combobox MUST be named (Field label via id: or aria-label) - a nameless bare combobox " \
|
|
282
|
+
"fails at render.",
|
|
283
|
+
"NEVER write aria-selected from highlight logic (position is data-highlighted + " \
|
|
284
|
+
"aria-activedescendant); NEVER write the display without the native select first - the commit " \
|
|
285
|
+
"pipeline does all of it; agents patching DOM must too.",
|
|
286
|
+
"Async options: filter: false + the Turbo-frame ?q= recipe - AND the frame must render the twin " \
|
|
287
|
+
"native <option> for every committable item (the recipe's one hard rule).",
|
|
288
|
+
"multiple: true is the multi-select/chips mode: value: takes an ARRAY, the native <select " \
|
|
289
|
+
"multiple> posts name[] (the [] is appended for you), selection TOGGLES with the popup " \
|
|
290
|
+
"staying open, and chips replace the trigger - never fake multi with hidden inputs.",
|
|
291
|
+
"Do not put interactive elements inside options (an option IS the interactive unit).",
|
|
292
|
+
"Deselection in single mode is include_blank (a visible blank option) or show_clear: (the " \
|
|
293
|
+
"trigger-side X - single mode only), never a re-click toggle - " \
|
|
294
|
+
"committing the already-selected value closes without change. In multiple, re-committing " \
|
|
295
|
+
"IS the deselect gesture (chip-remove is its pointer twin)."
|
|
296
|
+
].freeze
|
|
297
|
+
|
|
298
|
+
# The required slots, stated statically so static checks can flag
|
|
299
|
+
# a missing item without rendering.
|
|
300
|
+
REQUIRED_SLOTS = { item: "at least one item" }.freeze
|
|
301
|
+
|
|
302
|
+
renders_one :trigger, doc: "Optional custom trigger content rendered BEFORE the value span (rare); the " \
|
|
303
|
+
"component owns role=combobox + the aria wiring + the chevrons regardless, so " \
|
|
304
|
+
"composition cannot drop the contract."
|
|
305
|
+
|
|
306
|
+
renders_one :empty, doc: "Custom zero-results content (defaults to t('poetry.combobox.empty'))."
|
|
307
|
+
renders_one :loading, doc: "Custom pending content (a spinner); the HOST toggles visibility (Turbo frame " \
|
|
308
|
+
"events) - the part renders hidden (Command parity)."
|
|
309
|
+
|
|
310
|
+
renders_many :items,
|
|
311
|
+
doc: "The option UNION forwarded to the embedded command list: item | group (heading + items) " \
|
|
312
|
+
"| separator - one ordered collection (interleaving preserved; items and groups are part " \
|
|
313
|
+
"COMPONENTS so option registration follows render/DOM order).",
|
|
314
|
+
types: {
|
|
315
|
+
item: { renders: ->(**options) { item_component(**options) }, as: :item },
|
|
316
|
+
group: {
|
|
317
|
+
renders: lambda { |**options|
|
|
318
|
+
Group.new(option_set: option_set, selected_value: selected_value, **options)
|
|
319
|
+
},
|
|
320
|
+
as: :group
|
|
321
|
+
},
|
|
322
|
+
separator: { renders: ->(**options) { separator_part(**options) }, as: :separator }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
use_stimulus do
|
|
326
|
+
on :root do
|
|
327
|
+
controller :combobox do
|
|
328
|
+
register
|
|
329
|
+
value :open
|
|
330
|
+
value :value, from: :value_payload
|
|
331
|
+
value :modal
|
|
332
|
+
value :multiple, if: :multiple
|
|
333
|
+
end
|
|
334
|
+
# multiple mounts the command engine on the ROOT (the inline
|
|
335
|
+
# input sits outside the popup); single keeps it on the
|
|
336
|
+
# popup's command part.
|
|
337
|
+
controller :command, if: :multiple do
|
|
338
|
+
register
|
|
339
|
+
value :filter
|
|
340
|
+
value :loop
|
|
341
|
+
end
|
|
342
|
+
controller :popper do
|
|
343
|
+
register
|
|
344
|
+
value :side
|
|
345
|
+
value :align
|
|
346
|
+
value :side_offset
|
|
347
|
+
value :align_offset
|
|
348
|
+
value :avoid_collisions
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
on :trigger do
|
|
352
|
+
controller :combobox do
|
|
353
|
+
action :toggle, on: :click
|
|
354
|
+
action :trigger_keydown, on: :keydown
|
|
355
|
+
end
|
|
356
|
+
controller(:popper) { target :anchor }
|
|
357
|
+
end
|
|
358
|
+
# multiple: the chips frame replaces the trigger as the anchor.
|
|
359
|
+
on :chips do
|
|
360
|
+
controller(:combobox) { action :chips_pointerdown, on: :mousedown }
|
|
361
|
+
controller(:popper) { target :anchor }
|
|
362
|
+
end
|
|
363
|
+
# multiple: both engines' keyboard maps ride the one input.
|
|
364
|
+
on :inline_input do
|
|
365
|
+
controller :command do
|
|
366
|
+
action :filter_input, on: :input
|
|
367
|
+
action :keydown, on: :keydown
|
|
368
|
+
end
|
|
369
|
+
controller(:combobox) { action :input_keydown, on: :keydown }
|
|
370
|
+
end
|
|
371
|
+
on :content do
|
|
372
|
+
controller(:popper) { target :content }
|
|
373
|
+
end
|
|
374
|
+
# single-mode: the embedded engine root inside the popup.
|
|
375
|
+
on :command_part do
|
|
376
|
+
controller :command do
|
|
377
|
+
register
|
|
378
|
+
value :filter
|
|
379
|
+
value :loop
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
on :input do
|
|
383
|
+
controller :command do
|
|
384
|
+
action :filter_input, on: :input
|
|
385
|
+
action :keydown, on: :keydown
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
on :item do
|
|
389
|
+
controller :command do
|
|
390
|
+
action :activate, on: :click
|
|
391
|
+
action :pointer_highlight, on: :pointermove
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
on :native do
|
|
395
|
+
controller(:combobox) { action :native_changed, on: :change }
|
|
396
|
+
end
|
|
397
|
+
on :clear do
|
|
398
|
+
controller(:combobox) { action :clear, on: :click }
|
|
399
|
+
end
|
|
400
|
+
on :chip do
|
|
401
|
+
controller(:combobox) { action :chip_keydown, on: :keydown }
|
|
402
|
+
end
|
|
403
|
+
on :chip_remove do
|
|
404
|
+
controller(:combobox) { action :remove_chip, on: :click }
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# The operate surface: what an in-page agent may do to a rendered
|
|
409
|
+
# combobox (registered per instance, opt-in - see poetry-agent).
|
|
410
|
+
tool :set_value,
|
|
411
|
+
description: "Select the option whose value matches; the schema lists the rendered options, " \
|
|
412
|
+
"and clear selects nothing.",
|
|
413
|
+
params: { value: { type: "string", required: true, description: "The option value to select." } },
|
|
414
|
+
executes: %i[combobox set_value],
|
|
415
|
+
mutating: true
|
|
416
|
+
tool :clear,
|
|
417
|
+
description: "Clear the current selection.",
|
|
418
|
+
executes: %i[combobox clear],
|
|
419
|
+
mutating: true
|
|
420
|
+
|
|
421
|
+
# The rendered instance knows its options (the option union renders
|
|
422
|
+
# into a capture before the root's attributes): the payload's schema
|
|
423
|
+
# lists the enabled values as the enum, each with its label as the
|
|
424
|
+
# title - an agent reads "Japan" and passes "jp" - so an unknown
|
|
425
|
+
# value is refused before dispatch instead of committing nothing.
|
|
426
|
+
# multiple: keeps the bare schema (its value is a list).
|
|
427
|
+
def webmcp_tool_definition(definition)
|
|
428
|
+
return definition unless definition["name"] == "set_value" && !multiple
|
|
429
|
+
|
|
430
|
+
entries = option_set.entries.reject(&:disabled)
|
|
431
|
+
return definition if entries.empty?
|
|
432
|
+
|
|
433
|
+
schema = definition.fetch("inputSchema")
|
|
434
|
+
value = schema.fetch("properties").fetch("value").merge(
|
|
435
|
+
"enum" => entries.map(&:value),
|
|
436
|
+
"anyOf" => entries.map { |entry| { "type" => "string", "const" => entry.value, "title" => entry.label } }
|
|
437
|
+
)
|
|
438
|
+
definition.merge("inputSchema" => schema.merge("properties" => schema["properties"].merge("value" => value)))
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
option :value, :string, doc: "The committed value; with multiple:, an array of values."
|
|
442
|
+
option :name, :string, doc: "The form field name on the native <select>; multiple: appends [] for you."
|
|
443
|
+
option :placeholder, :string,
|
|
444
|
+
doc: "Shown in the value display (multiple: in the inline input) while nothing is committed."
|
|
445
|
+
option :search_placeholder, :string, doc: "Placeholder for the popup's filter input (single mode)."
|
|
446
|
+
option :id, :string, doc: "The trigger's DOM id - the Field label target; every other part id derives from it."
|
|
447
|
+
option :open, :boolean, default: false, doc: "Server-renders the popup open."
|
|
448
|
+
option :required, :boolean, default: false, doc: "Forwards to the native <select> for constraint validation."
|
|
449
|
+
option :disabled, :boolean, default: false,
|
|
450
|
+
doc: "Disables the trigger, the filter input, and the native select."
|
|
451
|
+
option :multiple, :boolean, default: false,
|
|
452
|
+
doc: "Multi-select mode: value: becomes LIST-capable (single stays the scalar), " \
|
|
453
|
+
"the trigger is replaced by the chips field, the native <select multiple> " \
|
|
454
|
+
"posts name[], selection toggles without closing."
|
|
455
|
+
option :modal, :boolean, default: false,
|
|
456
|
+
doc: "DEFAULT FALSE - popover semantics (Tab-out closes, no scrim). true restores " \
|
|
457
|
+
"the focus-scope trap for dialog-critical pickers."
|
|
458
|
+
option :show_clear, :boolean, default: false,
|
|
459
|
+
doc: "Single mode only: the trigger-side deselection X - swaps in over the " \
|
|
460
|
+
"chevrons while a value is committed and commits the blank value, so the " \
|
|
461
|
+
"cleared state serializes as \"\"."
|
|
462
|
+
option :filter, :boolean, default: true,
|
|
463
|
+
doc: "Forwarded to the embedded engine: false = server-driven options (the async " \
|
|
464
|
+
"Turbo-frame recipe)."
|
|
465
|
+
option :loop, :boolean, default: false, doc: "Wraps arrow-key highlight movement past either end of the list."
|
|
466
|
+
option :side, :symbol, default: :bottom,
|
|
467
|
+
doc: "The popup's preferred side of the trigger; collisions may flip it."
|
|
468
|
+
option :align, :symbol, default: :start, doc: "The popup's alignment along the trigger's edge."
|
|
469
|
+
option :side_offset, :integer, default: 4, doc: "Gap in px between the trigger and the popup."
|
|
470
|
+
option :align_offset, :integer, default: 0, doc: "Skid in px along the aligned edge."
|
|
471
|
+
option :avoid_collisions, :boolean, default: true, doc: "Flips/shifts the popup to stay inside the viewport."
|
|
472
|
+
option :dir, :symbol, doc: "Writing-direction override (ltr/rtl) stamped on the root."
|
|
473
|
+
option :width, :string,
|
|
474
|
+
doc: "The trigger width utility class; the popup ALWAYS tracks the trigger's measured width, so one " \
|
|
475
|
+
"knob sizes both surfaces. nil resolves to the dictionary's default (w-50)."
|
|
476
|
+
|
|
477
|
+
validates :side, inclusion: { in: SIDES }
|
|
478
|
+
validates :align, inclusion: { in: ALIGNS }
|
|
479
|
+
validates :dir, inclusion: { in: DIRS }, allow_nil: true
|
|
480
|
+
|
|
481
|
+
part "combobox", "Root wrapper carrying both controllers (combobox + popper) and the " \
|
|
482
|
+
"optional dir attribute"
|
|
483
|
+
part "combobox-native", "The visually-hidden native <select> - the serialization truth " \
|
|
484
|
+
"(Select's decision verbatim); plumbing, never styled or targeted"
|
|
485
|
+
part "combobox-trigger", "The role=combobox button (the demo's outline Button) the field " \
|
|
486
|
+
"label reaches - value display and chevrons ride inside",
|
|
487
|
+
states: {
|
|
488
|
+
"data-placeholder" => "no option is committed (bare; the controller toggles it " \
|
|
489
|
+
"on every commit)",
|
|
490
|
+
"data-popup-open" => "the popup is open (bare while open, absent while closed - " \
|
|
491
|
+
"the controller flips it with the open state)"
|
|
492
|
+
}
|
|
493
|
+
part "combobox-value", "The value display span - the selected option's label, or the placeholder",
|
|
494
|
+
states: {
|
|
495
|
+
"data-placeholder" => "placeholder: is given - carries the placeholder text so " \
|
|
496
|
+
"the controller can restore it"
|
|
497
|
+
}
|
|
498
|
+
part "combobox-chip-input", "The inline filter input beside the chips (multiple: only) - the one " \
|
|
499
|
+
"typing surface; the source's chip input"
|
|
500
|
+
part "combobox-content", "The popper-positioned popup housing the embedded Command " \
|
|
501
|
+
"anatomy - open/closed and the resolved placement ride here",
|
|
502
|
+
states: {
|
|
503
|
+
"data-open" => "popup is open (the controller flips the pair at runtime)",
|
|
504
|
+
"data-closed" => "popup is closed or animating out (the server-rendered state)",
|
|
505
|
+
"data-chips" => "multiple: - chips mode; the popup's minimum width follows the chips field",
|
|
506
|
+
"data-side" => { condition: "the placement side - server-rendered from side:, " \
|
|
507
|
+
"rewritten to the resolved side by popper on open",
|
|
508
|
+
values: SIDES.map(&:to_s) },
|
|
509
|
+
"data-align" => { condition: "the placement alignment - server-rendered from " \
|
|
510
|
+
"align:, rewritten by popper on open",
|
|
511
|
+
values: ALIGNS.map(&:to_s) }
|
|
512
|
+
},
|
|
513
|
+
vars: {
|
|
514
|
+
"--transform-origin" => "popper - the animation origin matching the resolved placement",
|
|
515
|
+
"--available-width" => "popper - viewport space available to the popup post-flip",
|
|
516
|
+
"--available-height" => "popper - viewport space available to the popup post-flip",
|
|
517
|
+
"--anchor-width" => "popper - the trigger's measured width (the popup width " \
|
|
518
|
+
"tracks it - one knob, two surfaces)",
|
|
519
|
+
"--anchor-height" => "popper - the trigger's measured height"
|
|
520
|
+
}
|
|
521
|
+
part "combobox-clear", "The show_clear: deselection X - a trigger sibling seated over " \
|
|
522
|
+
"the chevron slot; pressing it commits the blank value and " \
|
|
523
|
+
"returns focus to the trigger. Wears the html hidden attribute " \
|
|
524
|
+
"while no value is committed (the controller flips it on every " \
|
|
525
|
+
"commit; the chevron swap derives from that one flip in CSS)"
|
|
526
|
+
part "combobox-command", "The embedded engine root - Command's anatomy rendered here against its " \
|
|
527
|
+
"own controller (composition at the markup contract)"
|
|
528
|
+
part "combobox-input-wrapper", "The input row - search icon + filter input above the list"
|
|
529
|
+
part "combobox-search-icon", "Decorative search glyph beside the input"
|
|
530
|
+
part "combobox-input", "The filter input (role=combobox) - the typing session and " \
|
|
531
|
+
"aria-activedescendant live here. Single: in the popup with its own " \
|
|
532
|
+
"accessible name; multiple: INLINE in the chips frame (the " \
|
|
533
|
+
"input-inside layout), where the field label reaches it",
|
|
534
|
+
states: {
|
|
535
|
+
"data-popup-open" => "multiple: the popup is open (bare while open, absent while " \
|
|
536
|
+
"closed - the input carries the flip; single's trigger owns it)"
|
|
537
|
+
}
|
|
538
|
+
part "combobox-list", "THE role=listbox - the aria-controls target of both combobox roles"
|
|
539
|
+
part "combobox-empty", "Zero-matches message - rendered hidden; the engine unhides it " \
|
|
540
|
+
"when the filter pass leaves no visible items"
|
|
541
|
+
part "combobox-group", "role=group labelled by its heading - hidden by the engine when " \
|
|
542
|
+
"every member item is filtered out"
|
|
543
|
+
part "combobox-label", "The group heading - styled, no ARIA role (the group " \
|
|
544
|
+
"points at it via aria-labelledby)"
|
|
545
|
+
part "combobox-item", "One role=option div wearing BOTH meanings - a Command item " \
|
|
546
|
+
"(filtering + highlight) AND Select's committed-value surface",
|
|
547
|
+
states: {
|
|
548
|
+
"data-value" => "always - the option's committable value (the native <option> twin)",
|
|
549
|
+
"data-selected" => "the option is committed (bare; absent while unselected - the " \
|
|
550
|
+
"controller twin-writes it with aria-selected)",
|
|
551
|
+
"data-highlighted" => "the item holds the activedescendant highlight (the server " \
|
|
552
|
+
"seeds it; the engine moves it with the input's " \
|
|
553
|
+
"aria-activedescendant)",
|
|
554
|
+
"data-disabled" => "disabled: is set (aria-disabled rides along)",
|
|
555
|
+
"data-hidden" => "the filter scored the item zero (the engine pairs it with " \
|
|
556
|
+
"hidden; never rendered server-side)"
|
|
557
|
+
}
|
|
558
|
+
part "combobox-item-text", "The option's label span - the filter/typematch text source"
|
|
559
|
+
part "combobox-item-indicator", "The trailing committed-value check (ms-auto per the " \
|
|
560
|
+
"demo) - the parent item's data-selected absence hides it"
|
|
561
|
+
part "combobox-separator", "Decorative divider (aria-hidden) - hidden by the engine " \
|
|
562
|
+
"whenever the query is non-empty"
|
|
563
|
+
part "combobox-loading", "Pending affordance (role=status) - rendered hidden; the HOST " \
|
|
564
|
+
"unhides it around async refills"
|
|
565
|
+
part "combobox-status", "The engine's sr-only polite result-count live region",
|
|
566
|
+
states: {
|
|
567
|
+
"data-zero" => "always - the localized zero-results template",
|
|
568
|
+
"data-one" => "always - the localized one-result template",
|
|
569
|
+
"data-other" => "always - the localized many-results template (a literal count " \
|
|
570
|
+
"placeholder the controller interpolates)"
|
|
571
|
+
}
|
|
572
|
+
part "combobox-chips", "The chips FIELD frame (multiple: only) - the popper anchor " \
|
|
573
|
+
"replacing the trigger; chips + the inline input flex-wrap inside, " \
|
|
574
|
+
"and role=toolbar rides it only while it holds >=1 chip",
|
|
575
|
+
states: {
|
|
576
|
+
"data-placeholder" => "the selection is empty (bare; the controller flips it on " \
|
|
577
|
+
"every commit - the toolbar role departs with it)",
|
|
578
|
+
"data-disabled" => "disabled: is set - every chip mutation is gated",
|
|
579
|
+
"data-remove-label" => "always - the localized chip-remove template (a literal " \
|
|
580
|
+
"label placeholder the controller interpolates for " \
|
|
581
|
+
"client-built chips)"
|
|
582
|
+
}
|
|
583
|
+
part "combobox-chip", "One committed value (multiple: only) - a div taking REAL focus " \
|
|
584
|
+
"(tabindex=-1, styled by :focus-visible; chips NEVER wear " \
|
|
585
|
+
"data-highlighted), named by its value text, holding the remove button",
|
|
586
|
+
states: {
|
|
587
|
+
"data-value" => "always - the chip's committed value (the native <option> twin)",
|
|
588
|
+
"data-disabled" => "disabled: is set (chip focus is blocked entirely)"
|
|
589
|
+
}
|
|
590
|
+
part "combobox-chip-remove", "The chip's native remove button (tabindex=-1, labelled " \
|
|
591
|
+
"'Remove <label>') - a press removes the value and is never " \
|
|
592
|
+
"a chips-area press"
|
|
593
|
+
|
|
594
|
+
# Normalizes the multiple-mode list value before the typed
|
|
595
|
+
# attribute write.
|
|
596
|
+
# @api private
|
|
597
|
+
def initialize(attributes = {})
|
|
598
|
+
# multiple: value: is LIST-capable (single keeps the scalar
|
|
599
|
+
# :string cast) - the array is normalized ahead of the typed
|
|
600
|
+
# attribute write, List-cast semantics (scalars adopt as
|
|
601
|
+
# one-element lists).
|
|
602
|
+
if attributes.values_at(:multiple, "multiple").any?
|
|
603
|
+
raw = attributes.delete(:value) { attributes.delete("value") }
|
|
604
|
+
@list_value = Array(raw).map(&:to_s).select(&:present?).uniq
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
super
|
|
608
|
+
@trigger_aria = extract_trigger_aria!
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Enforces the item, naming, and mode-compatibility contracts.
|
|
612
|
+
# @api private
|
|
613
|
+
def before_render
|
|
614
|
+
raise ArgumentError, "Combobox requires at least one item (with_item / with_group)" unless items?
|
|
615
|
+
|
|
616
|
+
if show_clear && multiple
|
|
617
|
+
raise ArgumentError, "show_clear: is single-mode only - multiple already has per-chip " \
|
|
618
|
+
"removal and the closed-popup Escape wipe"
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
return if named?
|
|
622
|
+
|
|
623
|
+
raise ArgumentError, "Combobox requires an accessible name - compose with a Field label " \
|
|
624
|
+
"(id: + label[for: id]) or pass 'aria-label'"
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# The Field-targetable id lands on the TRIGGER (label[for=id]
|
|
628
|
+
# click-focuses the combobox); every other part derives from it -
|
|
629
|
+
# server-generated, portal-safe, stream-safe. Stable ids:
|
|
630
|
+
# '#{id}' trigger / '#{id}-content' / '#{id}-list' (the
|
|
631
|
+
# aria-controls target of BOTH combobox roles) / '#{id}-native' /
|
|
632
|
+
# '#{id}-input' / '#{id}-item-<n>'.
|
|
633
|
+
# @api private
|
|
634
|
+
def trigger_id
|
|
635
|
+
@trigger_id ||= id.presence || poetry_instance_id("poetry-combobox")
|
|
636
|
+
end
|
|
637
|
+
|
|
638
|
+
# The popup panel's id.
|
|
639
|
+
# @api private
|
|
640
|
+
def content_id
|
|
641
|
+
"#{trigger_id}-content"
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
# The listbox's id - both combobox roles point aria-controls here.
|
|
645
|
+
# @api private
|
|
646
|
+
def list_id
|
|
647
|
+
"#{trigger_id}-list"
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
# The hidden native select's id.
|
|
651
|
+
# @api private
|
|
652
|
+
def native_id
|
|
653
|
+
"#{trigger_id}-native"
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# The popup filter input's id (single mode).
|
|
657
|
+
# @api private
|
|
658
|
+
def input_id
|
|
659
|
+
"#{trigger_id}-input"
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
# The shared option registry for this render.
|
|
663
|
+
# @api private
|
|
664
|
+
def option_set
|
|
665
|
+
# multiple seeds the highlight on the FIRST committed value (the
|
|
666
|
+
# scalar rule, list-shaped).
|
|
667
|
+
@option_set ||= OptionSet.new(base_id: trigger_id,
|
|
668
|
+
highlight_value: multiple ? selected_values.first : selected_value)
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# single: the committed scalar; multiple: the committed LIST (array
|
|
672
|
+
# inclusion is the selection test everywhere downstream - items,
|
|
673
|
+
# native options, chips).
|
|
674
|
+
# @api private
|
|
675
|
+
def selected_value
|
|
676
|
+
@selected_value ||= multiple ? selected_values : value.presence.to_s
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# The committed list (multiple mode; empty otherwise).
|
|
680
|
+
# @api private
|
|
681
|
+
def selected_values
|
|
682
|
+
@list_value || []
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# The committed option's label for the value display (single mode).
|
|
686
|
+
# @api private
|
|
687
|
+
def selected_label
|
|
688
|
+
option_set.label_for(selected_value) if !multiple && selected_value.present?
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Attributes for the root wrapper.
|
|
692
|
+
# @api private
|
|
693
|
+
def root_attributes
|
|
694
|
+
root = { "data-slot" => "combobox" }
|
|
695
|
+
root["dir"] = dir.to_s if dir
|
|
696
|
+
html_attributes.merge_if_not_set(
|
|
697
|
+
root.merge(root_stimulus_attributes).merge(component_data_attributes)
|
|
698
|
+
)
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
# The serialization truth: a real <select> carrying
|
|
702
|
+
# name/required/disabled and ALL options with selected - visually
|
|
703
|
+
# hidden (sr-only, painted) and out of both trees (aria-hidden +
|
|
704
|
+
# tabindex=-1). The change action is the autofill-adoption path.
|
|
705
|
+
# multiple flips the multiple attribute and posts the Rails array
|
|
706
|
+
# convention (name[], appended unless the given name already ends
|
|
707
|
+
# with it).
|
|
708
|
+
# @api private
|
|
709
|
+
def native_select
|
|
710
|
+
attrs = {
|
|
711
|
+
"id" => native_id, "data-slot" => "combobox-native",
|
|
712
|
+
"aria-hidden" => "true", "tabindex" => "-1", "class" => Style.css(:native)
|
|
713
|
+
}
|
|
714
|
+
attrs["name"] = native_name if name.present?
|
|
715
|
+
attrs["multiple"] = true if multiple
|
|
716
|
+
attrs["required"] = true if required
|
|
717
|
+
attrs["disabled"] = true if disabled
|
|
718
|
+
attrs.merge!(stimulus_attributes_for(:native))
|
|
719
|
+
content_tag(:select, native_options, attrs)
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# The width utility the trigger/chips carry - the option, or the
|
|
723
|
+
# dictionary default (w-50).
|
|
724
|
+
# @api private
|
|
725
|
+
def width_classes = width || css(:default_width)
|
|
726
|
+
|
|
727
|
+
# The combobox-role trigger button: aria-controls points at the
|
|
728
|
+
# LISTBOX (the a11y-true relationship the controller resolves the
|
|
729
|
+
# popup through), aria-haspopup=listbox names the popup kind, and
|
|
730
|
+
# there is NO aria-autocomplete here - the typing session belongs
|
|
731
|
+
# to the popup input.
|
|
732
|
+
# @api private
|
|
733
|
+
def trigger_button
|
|
734
|
+
attrs = {
|
|
735
|
+
"id" => trigger_id, "data-slot" => "combobox-trigger", "type" => "button",
|
|
736
|
+
"role" => "combobox", "aria-expanded" => open.to_s, "aria-controls" => list_id,
|
|
737
|
+
"aria-haspopup" => "listbox",
|
|
738
|
+
"class" => classnames(css(:trigger), width_classes)
|
|
739
|
+
}
|
|
740
|
+
# The trigger state: bare data-popup-open while open, NO
|
|
741
|
+
# attribute while closed (absence IS the state).
|
|
742
|
+
attrs["data-popup-open"] = "" if open
|
|
743
|
+
attrs["data-placeholder"] = "" unless selected_label
|
|
744
|
+
attrs["disabled"] = true if disabled
|
|
745
|
+
attrs.merge!(trigger_stimulus_attributes)
|
|
746
|
+
attrs.merge!(trigger_aria_attributes)
|
|
747
|
+
content_tag(:button, attrs) do
|
|
748
|
+
safe_join([leading_content, chevrons])
|
|
749
|
+
end
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# A custom trigger slot (leading icon) GROUPS with the value display
|
|
753
|
+
# - three bare children under justify-between would strand the text
|
|
754
|
+
# mid-row (icon left, text center, chevrons right). Slot-less
|
|
755
|
+
# triggers keep the flat two-child markup.
|
|
756
|
+
# @api private
|
|
757
|
+
def leading_content
|
|
758
|
+
return value_display unless trigger?
|
|
759
|
+
|
|
760
|
+
content_tag(:span, safe_join([trigger, value_display]), "class" => Style.css(:trigger_leading))
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
# The chips FIELD (multiple: - replaces the trigger with an
|
|
764
|
+
# input-inside layout): the popper anchor frame holding one chip
|
|
765
|
+
# per committed value IN VALUE ORDER, the inline filter input
|
|
766
|
+
# (data-slot=command-input - the engine's markup contract), and
|
|
767
|
+
# the <template> chip skeleton the controller clones for
|
|
768
|
+
# client-side commits. role=toolbar rides the frame only while it
|
|
769
|
+
# holds chips; an empty selection wears data-placeholder instead.
|
|
770
|
+
# @api private
|
|
771
|
+
def chips_frame
|
|
772
|
+
attrs = {
|
|
773
|
+
"data-slot" => "combobox-chips",
|
|
774
|
+
"data-remove-label" => t("poetry.combobox.remove", label: "%{label}"), # rubocop:disable Style/FormatStringToken
|
|
775
|
+
"class" => classnames(css(:chips), width_classes)
|
|
776
|
+
}
|
|
777
|
+
attrs["role"] = "toolbar" if selected_values.any?
|
|
778
|
+
attrs["data-placeholder"] = "" if selected_values.empty?
|
|
779
|
+
attrs["data-disabled"] = "" if disabled
|
|
780
|
+
attrs.merge!(chips_stimulus_attributes)
|
|
781
|
+
content_tag(:div, attrs) do
|
|
782
|
+
chips = selected_values.map { |chip_value| chip_part(chip_value) }
|
|
783
|
+
safe_join(chips + [inline_input, chip_template])
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
# The popup (popper CONTENT; focus-scope + dismissable tokens are
|
|
788
|
+
# appended by the controller on open - NEVER roving-focus, the
|
|
789
|
+
# embedded Command is activedescendant). No role: the listbox
|
|
790
|
+
# lives inside.
|
|
791
|
+
# @api private
|
|
792
|
+
def content_attributes
|
|
793
|
+
attrs = {
|
|
794
|
+
"id" => content_id, "data-slot" => "combobox-content", "tabindex" => "-1",
|
|
795
|
+
(open ? "data-open" : "data-closed") => "",
|
|
796
|
+
# Initial placement, re-resolved live by popper on open.
|
|
797
|
+
"data-side" => side, "data-align" => align,
|
|
798
|
+
"class" => css(:content)
|
|
799
|
+
}.merge(stimulus_attributes_for(:content))
|
|
800
|
+
attrs["hidden"] = true unless open
|
|
801
|
+
attrs
|
|
802
|
+
end
|
|
803
|
+
|
|
804
|
+
# The committed value(s) as the controller's value payload.
|
|
805
|
+
# @api private
|
|
806
|
+
def value_payload = multiple ? selected_values : value.to_s
|
|
807
|
+
|
|
808
|
+
# The embedded engine root with filter/loop forwarded - the
|
|
809
|
+
# composition boundary: this component renders Command's anatomy
|
|
810
|
+
# against the engine's own controller, reused unchanged. Nesting
|
|
811
|
+
# Command::Component instead would collide ids (its root id would
|
|
812
|
+
# duplicate the trigger's), could not seat the indicator inside
|
|
813
|
+
# each item, and could not retune the input's height.
|
|
814
|
+
# @api private
|
|
815
|
+
def command_attributes
|
|
816
|
+
{ "data-slot" => "combobox-command", "class" => Command::Style.css }
|
|
817
|
+
.merge(stimulus_attributes_for(:command_part))
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
# The popup's filter input (Command's contract, retuned to h-9):
|
|
821
|
+
# its OWN accessible name - t('.filter_label'), distinct
|
|
822
|
+
# from the field label naming the trigger. aria-expanded is
|
|
823
|
+
# statically true (the listbox is always rendered inside the
|
|
824
|
+
# popup; the TRIGGER carries the dynamic flip).
|
|
825
|
+
# @api private
|
|
826
|
+
def input_attributes
|
|
827
|
+
attrs = {
|
|
828
|
+
"type" => "text", "id" => input_id, "data-slot" => "combobox-input",
|
|
829
|
+
"role" => "combobox", "aria-expanded" => "true", "aria-controls" => list_id,
|
|
830
|
+
"aria-autocomplete" => "list", "autocomplete" => "off", "autocorrect" => "off",
|
|
831
|
+
"spellcheck" => "false", "aria-label" => t("poetry.combobox.filter_label"),
|
|
832
|
+
"class" => Command::Style.css(:input, class: css(:input_fill))
|
|
833
|
+
}
|
|
834
|
+
# The source stamps chips mode on the popup (its min-width follows the field).
|
|
835
|
+
attrs["data-chips"] = "true" if multiple
|
|
836
|
+
attrs["placeholder"] = search_placeholder if search_placeholder.present?
|
|
837
|
+
attrs["disabled"] = true if disabled
|
|
838
|
+
attrs["aria-activedescendant"] = option_set.highlighted_id if option_set.highlighted_id
|
|
839
|
+
attrs.merge!(stimulus_attributes_for(:input))
|
|
840
|
+
attrs
|
|
841
|
+
end
|
|
842
|
+
|
|
843
|
+
# THE listbox - the aria-controls target of both combobox roles
|
|
844
|
+
# (the trigger resolves the popup through it). multiple declares
|
|
845
|
+
# aria-multiselectable. Wears the combobox's own list rule (the
|
|
846
|
+
# inset rides the list; the parts wear the source's combobox
|
|
847
|
+
# vocabulary - the engine resolves either family's slot names).
|
|
848
|
+
# @api private
|
|
849
|
+
def list_attributes
|
|
850
|
+
attrs = {
|
|
851
|
+
"id" => list_id, "data-slot" => "combobox-list", "role" => "listbox",
|
|
852
|
+
"tabindex" => "-1", "aria-label" => t("poetry.command.list_label"),
|
|
853
|
+
"class" => css(:list)
|
|
854
|
+
}
|
|
855
|
+
attrs["aria-multiselectable"] = "true" if multiple
|
|
856
|
+
attrs
|
|
857
|
+
end
|
|
858
|
+
|
|
859
|
+
# Zero-matches message - rendered hidden; the engine unhides it
|
|
860
|
+
# when the filter pass leaves no visible items.
|
|
861
|
+
# @api private
|
|
862
|
+
def empty_part
|
|
863
|
+
content_tag(:div, empty? ? empty : t("poetry.combobox.empty"),
|
|
864
|
+
"data-slot" => "combobox-empty", "hidden" => true, "class" => Command::Style.css(:empty))
|
|
865
|
+
end
|
|
866
|
+
|
|
867
|
+
# Pending affordance (role=status) - rendered hidden; the HOST
|
|
868
|
+
# unhides it around async refills (Command's part, shared keys).
|
|
869
|
+
# @api private
|
|
870
|
+
def loading_part
|
|
871
|
+
content_tag(:div, "data-slot" => "combobox-loading", "role" => "status",
|
|
872
|
+
"hidden" => true, "class" => Command::Style.css(:loading)) do
|
|
873
|
+
safe_join([content_tag(:span, t("poetry.command.loading"), class: Command::Style.css(:sr_only)),
|
|
874
|
+
loading].compact)
|
|
875
|
+
end
|
|
876
|
+
end
|
|
877
|
+
|
|
878
|
+
# The engine's sr-only polite result-count region (localized
|
|
879
|
+
# templates carried as data attributes - Command's keys, shared).
|
|
880
|
+
# @api private
|
|
881
|
+
def status_part
|
|
882
|
+
content_tag(:span, nil,
|
|
883
|
+
"data-slot" => "combobox-status", "role" => "status", "aria-live" => "polite",
|
|
884
|
+
"class" => Command::Style.css(:status),
|
|
885
|
+
"data-zero" => t("poetry.command.results", count: 0),
|
|
886
|
+
"data-one" => t("poetry.command.results", count: 1),
|
|
887
|
+
"data-other" => t("poetry.command.results.other", count: "%{count}")) # rubocop:disable Style/FormatStringToken
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
private
|
|
891
|
+
|
|
892
|
+
def named?
|
|
893
|
+
id.present? || @trigger_aria["label"].present? || @trigger_aria["labelledby"].present?
|
|
894
|
+
end
|
|
895
|
+
|
|
896
|
+
# Pull the trigger-bound aria-* out of the root's html attributes
|
|
897
|
+
# (both flat "aria-label" and nested aria: {label:} spellings) so
|
|
898
|
+
# Field's control_attributes wire the combobox, not the wrapper.
|
|
899
|
+
def extract_trigger_aria!
|
|
900
|
+
aria = {}.with_indifferent_access
|
|
901
|
+
nested = @html_attributes.delete("aria")
|
|
902
|
+
nested.each { |nested_key, nested_value| aria[nested_key] = nested_value } if nested.is_a?(Hash)
|
|
903
|
+
@html_attributes.keys.grep(/\Aaria-/).each do |flat|
|
|
904
|
+
aria[flat.delete_prefix("aria-")] = @html_attributes.delete(flat)
|
|
905
|
+
end
|
|
906
|
+
aria
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def trigger_aria_attributes
|
|
910
|
+
TRIGGER_ARIA_KEYS.each_with_object({}) do |key, attrs|
|
|
911
|
+
attrs["aria-#{key}"] = @trigger_aria[key] unless @trigger_aria[key].nil?
|
|
912
|
+
end
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
# The value DISPLAY: server-rendered selected-option label (no
|
|
916
|
+
# FOUC), or the placeholder text; data-placeholder on the span
|
|
917
|
+
# carries the placeholder so the controller can restore it.
|
|
918
|
+
def value_display
|
|
919
|
+
attrs = { "data-slot" => "combobox-value", "class" => css(:value) }
|
|
920
|
+
attrs["data-placeholder"] = placeholder if placeholder.present?
|
|
921
|
+
content_tag(:span, selected_label || placeholder, attrs)
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
# The double chevron is the combobox tell (Select wears
|
|
925
|
+
# chevron-down). show_clear adds the swap class: the chevron
|
|
926
|
+
# goes invisible (keeping its box) while the sibling X is showable.
|
|
927
|
+
def chevrons
|
|
928
|
+
classes = classnames(Style.css(:trigger_icon), (Style.css(:trigger_icon_swap) if show_clear))
|
|
929
|
+
render(Icon::Component.new(name: :"chevrons-up-down", class: classes))
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
# The show_clear: X - a SIBLING of the trigger (button-in-button is
|
|
933
|
+
# invalid HTML), absolutely seated over the chevron slot. Server
|
|
934
|
+
# renders the truthful initial state (hidden with no value); the
|
|
935
|
+
# controller flips it on every commit.
|
|
936
|
+
def clear_button
|
|
937
|
+
attrs = {
|
|
938
|
+
"data-slot" => "combobox-clear", "type" => "button",
|
|
939
|
+
"aria-label" => t("poetry.combobox.clear"), "class" => css(:clear)
|
|
940
|
+
}
|
|
941
|
+
attrs["hidden"] = true unless selected_label
|
|
942
|
+
attrs["disabled"] = true if disabled
|
|
943
|
+
attrs.merge!(stimulus_attributes_for(:clear))
|
|
944
|
+
content_tag(:button, attrs) do
|
|
945
|
+
render(Icon::Component.new(name: :x, class: Style.css(:clear_icon)))
|
|
946
|
+
end
|
|
947
|
+
end
|
|
948
|
+
|
|
949
|
+
# One chip: a div taking REAL focus (tabindex=-1, styled by
|
|
950
|
+
# :focus-visible - NO data-highlighted here), named by its value
|
|
951
|
+
# text, holding the native remove button. nil value = the blank
|
|
952
|
+
# skeleton the <template> ships for the controller.
|
|
953
|
+
def chip_part(chip_value = nil)
|
|
954
|
+
label = chip_value && (option_set.label_for(chip_value) || chip_value)
|
|
955
|
+
attrs = {
|
|
956
|
+
"data-slot" => "combobox-chip", "tabindex" => "-1", "class" => css(:chip)
|
|
957
|
+
}
|
|
958
|
+
attrs["data-value"] = chip_value if chip_value
|
|
959
|
+
attrs["aria-label"] = label if label
|
|
960
|
+
# aria-disabled is the truth AND the axe contrast exemption - a
|
|
961
|
+
# div can't carry native disabled, so without it the dimmed chip
|
|
962
|
+
# reads as failing text.
|
|
963
|
+
attrs["aria-disabled"] = "true" if disabled
|
|
964
|
+
attrs["data-disabled"] = "" if disabled
|
|
965
|
+
attrs.merge!(stimulus_attributes_for(:chip))
|
|
966
|
+
content_tag(:div, attrs) do
|
|
967
|
+
safe_join([label, chip_remove_part(label)].compact)
|
|
968
|
+
end
|
|
969
|
+
end
|
|
970
|
+
|
|
971
|
+
# ChipRemove: a NATIVE button (tabindex=-1) labelled "Remove
|
|
972
|
+
# <label>" via I18n, so every chip's remove affordance has an
|
|
973
|
+
# accessible name.
|
|
974
|
+
def chip_remove_part(label)
|
|
975
|
+
attrs = {
|
|
976
|
+
"data-slot" => "combobox-chip-remove", "type" => "button", "tabindex" => "-1",
|
|
977
|
+
"class" => css(:chip_remove)
|
|
978
|
+
}
|
|
979
|
+
attrs["aria-label"] = t("poetry.combobox.remove", label: label) if label
|
|
980
|
+
attrs["disabled"] = true if disabled
|
|
981
|
+
attrs.merge!(stimulus_attributes_for(:chip_remove))
|
|
982
|
+
content_tag(:button, attrs) do
|
|
983
|
+
render(Icon::Component.new(name: :x, class: Style.css(:chip_remove_icon)))
|
|
984
|
+
end
|
|
985
|
+
end
|
|
986
|
+
|
|
987
|
+
# The blank chip skeleton the controller clones per client-side
|
|
988
|
+
# commit - SERVER markup stays the single source of chip anatomy.
|
|
989
|
+
def chip_template
|
|
990
|
+
content_tag(:template, chip_part)
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
def inline_input
|
|
994
|
+
tag.input(**Poetry::Core::HTML::Attributes.new(inline_input_attributes).to_attributes)
|
|
995
|
+
end
|
|
996
|
+
|
|
997
|
+
# The inline filter input (multiple): the ONE typing surface - the
|
|
998
|
+
# Field-targetable id lands HERE (no trigger exists), aria-expanded
|
|
999
|
+
# is DYNAMIC (the input sits outside the popup, unlike single's
|
|
1000
|
+
# always-rendered listbox), and the input NEVER mirrors selection
|
|
1001
|
+
# text (the placeholder rides it; chips are the value display).
|
|
1002
|
+
def inline_input_attributes
|
|
1003
|
+
attrs = {
|
|
1004
|
+
"type" => "text", "id" => trigger_id, "data-slot" => "combobox-chip-input",
|
|
1005
|
+
"role" => "combobox", "aria-expanded" => open.to_s, "aria-controls" => list_id,
|
|
1006
|
+
"aria-haspopup" => "listbox", "aria-autocomplete" => "list", "autocomplete" => "off",
|
|
1007
|
+
"autocorrect" => "off", "spellcheck" => "false", "class" => css(:chip_input)
|
|
1008
|
+
}
|
|
1009
|
+
# The input state: bare data-popup-open while open, NO
|
|
1010
|
+
# attribute while closed (absence IS the state).
|
|
1011
|
+
attrs["data-popup-open"] = "" if open
|
|
1012
|
+
attrs["placeholder"] = placeholder if placeholder.present?
|
|
1013
|
+
attrs["disabled"] = true if disabled
|
|
1014
|
+
attrs["aria-activedescendant"] = option_set.highlighted_id if open && option_set.highlighted_id
|
|
1015
|
+
attrs.merge!(inline_input_stimulus_attributes)
|
|
1016
|
+
attrs.merge!(trigger_aria_attributes)
|
|
1017
|
+
attrs
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
# The frame carries the popper anchor AND the combobox press action
|
|
1021
|
+
# - one Attributes instance (the Accordion lesson, via Select).
|
|
1022
|
+
def chips_stimulus_attributes
|
|
1023
|
+
stimulus_attributes_for(:chips)
|
|
1024
|
+
end
|
|
1025
|
+
|
|
1026
|
+
# BOTH controllers' keyboard maps ride the one input: the engine's
|
|
1027
|
+
# filter/arrows/Enter, then the shell's chips map (Backspace /
|
|
1028
|
+
# ArrowLeft / reopen / the closed-popup Escape wipe).
|
|
1029
|
+
def inline_input_stimulus_attributes
|
|
1030
|
+
stimulus_attributes_for(:inline_input)
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
# The blank option (Rails include_blank semantics, value="") rides
|
|
1034
|
+
# the placeholder - and is always present when no option matches,
|
|
1035
|
+
# so the native select never silently rests on the first option
|
|
1036
|
+
# while the trigger shows the placeholder (Select-exact). multiple
|
|
1037
|
+
# has no blank option (an empty multi-select just posts nothing);
|
|
1038
|
+
# selected flags flip to array inclusion.
|
|
1039
|
+
def native_options
|
|
1040
|
+
rendered = []
|
|
1041
|
+
# show_clear forces the blank option even while a value is
|
|
1042
|
+
# committed - the cleared state must serialize as "" (a native
|
|
1043
|
+
# select with no blank option cannot rest on nothing).
|
|
1044
|
+
if !multiple && (placeholder.present? || selected_label.nil? || show_clear)
|
|
1045
|
+
rendered << tag.option(placeholder.to_s, value: "", selected: selected_label.nil? || nil)
|
|
1046
|
+
end
|
|
1047
|
+
option_set.entries.each do |entry|
|
|
1048
|
+
rendered << tag.option(entry.label, value: entry.value,
|
|
1049
|
+
selected: native_selected?(entry.value) || nil,
|
|
1050
|
+
disabled: entry.disabled || nil)
|
|
1051
|
+
end
|
|
1052
|
+
safe_join(rendered)
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
def native_name
|
|
1056
|
+
return name unless multiple
|
|
1057
|
+
|
|
1058
|
+
name.end_with?("[]") ? name : "#{name}[]"
|
|
1059
|
+
end
|
|
1060
|
+
|
|
1061
|
+
def native_selected?(entry_value)
|
|
1062
|
+
multiple ? selected_values.include?(entry_value) : entry_value == selected_value
|
|
1063
|
+
end
|
|
1064
|
+
|
|
1065
|
+
# BOTH controllers build into ONE Attributes instance - a plain
|
|
1066
|
+
# Hash#merge of two would overwrite data-controller instead of
|
|
1067
|
+
# token-concatenating it (the Accordion lesson, via Select).
|
|
1068
|
+
# multiple adds a THIRD: the command engine mounts on the ROOT (the
|
|
1069
|
+
# inline input sits outside the popup, so the engine must scope
|
|
1070
|
+
# over both) - single keeps it on the popup's command part.
|
|
1071
|
+
def root_stimulus_attributes
|
|
1072
|
+
stimulus_attributes_for(:root)
|
|
1073
|
+
end
|
|
1074
|
+
|
|
1075
|
+
def trigger_stimulus_attributes
|
|
1076
|
+
stimulus_attributes_for(:trigger)
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
private :trigger_id, :content_id, :list_id, :native_id, :input_id, :option_set
|
|
1080
|
+
private :selected_label, :root_attributes, :native_select, :width_classes, :trigger_button
|
|
1081
|
+
private :leading_content, :chips_frame, :content_attributes, :value_payload, :command_attributes
|
|
1082
|
+
private :input_attributes, :list_attributes, :empty_part, :loading_part, :status_part
|
|
1083
|
+
end
|
|
1084
|
+
end
|
|
1085
|
+
end
|
|
1086
|
+
end
|