panda-core 0.12.5 → 1.1.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/LICENSE +1 -1
- data/README.md +53 -0
- data/app/assets/builds/panda_core_admin.css +2 -0
- data/app/assets/tailwind/application.css +236 -36
- data/app/builders/panda/core/form_builder.rb +120 -18
- data/app/components/panda/core/UI/badge.html.erb +10 -0
- data/app/components/panda/core/UI/badge.rb +14 -30
- data/app/components/panda/core/UI/button.html.erb +5 -0
- data/app/components/panda/core/UI/button.rb +11 -15
- data/app/components/panda/core/UI/card.html.erb +3 -0
- data/app/components/panda/core/UI/card.rb +7 -6
- data/app/components/panda/core/admin/attribute_diff_component.html.erb +21 -0
- data/app/components/panda/core/admin/attribute_diff_component.rb +29 -0
- data/app/components/panda/core/admin/breadcrumb_component.html.erb +36 -0
- data/app/components/panda/core/admin/breadcrumb_component.rb +12 -64
- data/app/components/panda/core/admin/breadcrumbs_component.html.erb +16 -0
- data/app/components/panda/core/admin/breadcrumbs_component.rb +26 -0
- data/app/components/panda/core/admin/button_component.html.erb +9 -0
- data/app/components/panda/core/admin/button_component.rb +36 -31
- data/app/components/panda/core/admin/callout_component.html.erb +17 -0
- data/app/components/panda/core/admin/callout_component.rb +57 -0
- data/app/components/panda/core/admin/code_block_component.html.erb +9 -0
- data/app/components/panda/core/admin/code_block_component.rb +23 -0
- data/app/components/panda/core/admin/container_component.html.erb +27 -0
- data/app/components/panda/core/admin/container_component.rb +11 -77
- data/app/components/panda/core/admin/contributors_summary_component.html.erb +15 -0
- data/app/components/panda/core/admin/contributors_summary_component.rb +23 -0
- data/app/components/panda/core/admin/dashboard_default_content_component.html.erb +70 -0
- data/app/components/panda/core/admin/dashboard_default_content_component.rb +32 -0
- data/app/components/panda/core/admin/delete_button_component.html.erb +4 -0
- data/app/components/panda/core/admin/delete_button_component.rb +41 -0
- data/app/components/panda/core/admin/dropdown_button_component.html.erb +28 -0
- data/app/components/panda/core/admin/dropdown_button_component.rb +100 -0
- data/app/components/panda/core/admin/dropdown_component.html.erb +32 -0
- data/app/components/panda/core/admin/dropdown_component.rb +65 -0
- data/app/components/panda/core/admin/empty_state_component.html.erb +16 -0
- data/app/components/panda/core/admin/empty_state_component.rb +25 -0
- data/app/components/panda/core/admin/file_gallery_component.html.erb +58 -0
- data/app/components/panda/core/admin/file_gallery_component.rb +26 -98
- data/app/components/panda/core/admin/filter_pills_component.html.erb +6 -0
- data/app/components/panda/core/admin/filter_pills_component.rb +29 -0
- data/app/components/panda/core/admin/flash_message_component.html.erb +11 -0
- data/app/components/panda/core/admin/flash_message_component.rb +33 -60
- data/app/components/panda/core/admin/form_error_component.html.erb +7 -0
- data/app/components/panda/core/admin/form_error_component.rb +8 -16
- data/app/components/panda/core/admin/form_footer_component.html.erb +16 -0
- data/app/components/panda/core/admin/form_footer_component.rb +65 -0
- data/app/components/panda/core/admin/form_input_component.html.erb +1 -0
- data/app/components/panda/core/admin/form_input_component.rb +21 -13
- data/app/components/panda/core/admin/form_section_component.html.erb +16 -0
- data/app/components/panda/core/admin/form_section_component.rb +64 -0
- data/app/components/panda/core/admin/form_select_component.html.erb +5 -0
- data/app/components/panda/core/admin/form_select_component.rb +25 -26
- data/app/components/panda/core/admin/heading_component.html.erb +26 -0
- data/app/components/panda/core/admin/heading_component.rb +23 -60
- data/app/components/panda/core/admin/login_form_component.html.erb +38 -0
- data/app/components/panda/core/admin/login_form_component.rb +28 -0
- data/app/components/panda/core/admin/main_layout_component.html.erb +88 -0
- data/app/components/panda/core/admin/main_layout_component.rb +19 -0
- data/app/components/panda/core/admin/my_profile/connected_account_component.html.erb +41 -0
- data/app/components/panda/core/admin/my_profile/connected_account_component.rb +27 -0
- data/app/components/panda/core/admin/navigation/item_component.html.erb +34 -0
- data/app/components/panda/core/admin/navigation/item_component.rb +69 -0
- data/app/components/panda/core/admin/navigation/sub_item_component.html.erb +9 -0
- data/app/components/panda/core/admin/navigation/sub_item_component.rb +44 -0
- data/app/components/panda/core/admin/navigation/user_menu_component.html.erb +30 -0
- data/app/components/panda/core/admin/navigation/user_menu_component.rb +36 -0
- data/app/components/panda/core/admin/page_header_component.html.erb +28 -0
- data/app/components/panda/core/admin/page_header_component.rb +19 -35
- data/app/components/panda/core/admin/pagination_component.html.erb +33 -0
- data/app/components/panda/core/admin/pagination_component.rb +56 -0
- data/app/components/panda/core/admin/panel_component.html.erb +19 -0
- data/app/components/panda/core/admin/panel_component.rb +4 -29
- data/app/components/panda/core/admin/prose_panel_component.html.erb +6 -0
- data/app/components/panda/core/admin/prose_panel_component.rb +43 -0
- data/app/components/panda/core/admin/search_bar_component.html.erb +19 -0
- data/app/components/panda/core/admin/search_bar_component.rb +31 -0
- data/app/components/panda/core/admin/search_filter_bar_component.html.erb +22 -0
- data/app/components/panda/core/admin/search_filter_bar_component.rb +27 -0
- data/app/components/panda/core/admin/secret_field_component.html.erb +24 -0
- data/app/components/panda/core/admin/secret_field_component.rb +31 -0
- data/app/components/panda/core/admin/sidebar_component.html.erb +160 -0
- data/app/components/panda/core/admin/sidebar_component.rb +55 -0
- data/app/components/panda/core/admin/slideover_component.html.erb +67 -0
- data/app/components/panda/core/admin/slideover_component.rb +31 -70
- data/app/components/panda/core/admin/statistics_component.html.erb +4 -0
- data/app/components/panda/core/admin/statistics_component.rb +6 -8
- data/app/components/panda/core/admin/tab_bar_component.html.erb +48 -0
- data/app/components/panda/core/admin/tab_bar_component.rb +32 -67
- data/app/components/panda/core/admin/table_component.html.erb +82 -0
- data/app/components/panda/core/admin/table_component.rb +54 -87
- data/app/components/panda/core/admin/tag_badge_component.html.erb +7 -0
- data/app/components/panda/core/admin/tag_badge_component.rb +21 -0
- data/app/components/panda/core/admin/tag_component.html.erb +1 -0
- data/app/components/panda/core/admin/tag_component.rb +51 -20
- data/app/components/panda/core/admin/tag_input_component.html.erb +37 -0
- data/app/components/panda/core/admin/tag_input_component.rb +22 -0
- data/app/components/panda/core/admin/tags_panel_component.html.erb +8 -0
- data/app/components/panda/core/admin/tags_panel_component.rb +33 -0
- data/app/components/panda/core/admin/user_activity_component.html.erb +15 -0
- data/app/components/panda/core/admin/user_activity_component.rb +9 -22
- data/app/components/panda/core/admin/user_display_component.html.erb +8 -0
- data/app/components/panda/core/admin/user_display_component.rb +19 -22
- data/app/components/panda/core/base.rb +35 -42
- data/app/components/panda/core/shared/footer_component.html.erb +2 -0
- data/app/components/panda/core/shared/footer_component.rb +11 -0
- data/app/components/panda/core/shared/header_component.html.erb +32 -0
- data/app/components/panda/core/shared/header_component.rb +19 -0
- data/app/constraints/panda/core/admin_constraint.rb +2 -2
- data/app/controllers/concerns/panda/core/admin_authorization.rb +58 -0
- data/app/controllers/concerns/panda/core/authorizable.rb +107 -0
- data/app/controllers/panda/core/admin/base_controller.rb +62 -3
- data/app/controllers/panda/core/admin/editor/search_controller.rb +17 -0
- data/app/controllers/panda/core/admin/feature_flags_controller.rb +35 -0
- data/app/controllers/panda/core/admin/file_categories_controller.rb +84 -0
- data/app/controllers/panda/core/admin/files_controller.rb +230 -0
- data/app/controllers/panda/core/admin/import_sessions_controller.rb +120 -0
- data/app/controllers/panda/core/admin/my_profile/logins_controller.rb +24 -0
- data/app/controllers/panda/core/admin/my_profile_controller.rb +31 -0
- data/app/controllers/panda/core/admin/search_controller.rb +15 -0
- data/app/controllers/panda/core/admin/sessions_controller.rb +84 -10
- data/app/controllers/panda/core/admin/tags_controller.rb +77 -0
- data/app/controllers/panda/core/admin/test_sessions_controller.rb +5 -6
- data/app/controllers/panda/core/admin/users_controller.rb +184 -0
- data/app/controllers/panda/core/application_controller.rb +13 -1
- data/app/helpers/panda/core/active_storage_helper.rb +24 -0
- data/app/helpers/panda/core/feature_flag_helper.rb +11 -0
- data/app/helpers/panda/core/form_helper.rb +15 -0
- data/app/javascript/panda/core/controllers/avatar_upload_controller.js +73 -0
- data/app/javascript/panda/core/controllers/clipboard_controller.js +119 -0
- data/app/javascript/panda/core/controllers/collapsible_item_controller.js +45 -0
- data/app/javascript/panda/core/controllers/custom_select_controller.js +256 -0
- data/app/javascript/panda/core/controllers/datepicker_controller.js +121 -0
- data/app/javascript/panda/core/controllers/daterange_controller.js +193 -0
- data/app/javascript/panda/core/controllers/file_gallery_controller.js +134 -0
- data/app/javascript/panda/core/controllers/file_upload_controller.js +165 -0
- data/app/javascript/panda/core/controllers/global_search_controller.js +184 -0
- data/app/javascript/panda/core/controllers/index.js +36 -0
- data/app/javascript/panda/core/controllers/mobile_sidebar_controller.js +58 -0
- data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +49 -24
- data/app/javascript/panda/core/controllers/row_link_controller.js +27 -0
- data/app/javascript/panda/core/controllers/tag_input_controller.js +183 -0
- data/app/javascript/panda/core/tailwindcss-stimulus-components.js +2 -3
- data/app/javascript/panda/core/vendor/@fortawesome--fontawesome-free@7.2.0.js +8129 -0
- data/app/javascript/panda/core/vendor/@hotwired--turbo.js +7197 -100
- data/app/javascript/panda/core/vendor/@rails--actioncable--src.js +511 -2
- data/app/javascript/panda/core/vendor/@tailwindplus--elements@1.0.22.js +65 -0
- data/app/javascript/panda/core/vendor/cropperjs@2.1.0.js +3 -0
- data/app/javascript/panda/core/vendor/vanilla-calendar-pro.js +2 -0
- data/app/models/concerns/panda/core/has_metadata.rb +164 -0
- data/app/models/concerns/panda/core/importable.rb +79 -0
- data/app/models/concerns/panda/core/searchable.rb +66 -0
- data/app/models/concerns/panda/core/taggable.rb +37 -0
- data/app/models/panda/core/feature_flag.rb +56 -0
- data/app/models/panda/core/file_categorization.rb +14 -0
- data/app/models/panda/core/file_category.rb +45 -0
- data/app/models/panda/core/import_session.rb +79 -0
- data/app/models/panda/core/presence.rb +24 -0
- data/app/models/panda/core/tag.rb +33 -0
- data/app/models/panda/core/tagging.rb +14 -0
- data/app/models/panda/core/user.rb +94 -16
- data/app/models/panda/core/user_activity.rb +42 -0
- data/app/models/panda/core/user_session.rb +35 -0
- data/app/services/panda/core/attach_avatar_service.rb +15 -3
- data/app/services/panda/core/csv_import_service.rb +65 -0
- data/app/services/panda/core/file_categorizer.rb +52 -0
- data/app/services/panda/core/file_parser.rb +76 -0
- data/app/services/panda/core/invite_user_service.rb +44 -0
- data/app/services/panda/core/presence_service.rb +49 -0
- data/app/views/layouts/panda/core/admin.html.erb +6 -76
- data/app/views/layouts/panda/core/admin_simple.html.erb +2 -2
- data/app/views/panda/core/admin/dashboard/_default_content.html.erb +1 -73
- data/app/views/panda/core/admin/dashboard/show.html.erb +12 -15
- data/app/views/panda/core/admin/feature_flags/index.html.erb +56 -0
- data/app/views/panda/core/admin/file_categories/_form.html.erb +20 -0
- data/app/views/panda/core/admin/file_categories/edit.html.erb +5 -0
- data/app/views/panda/core/admin/file_categories/index.html.erb +55 -0
- data/app/views/panda/core/admin/file_categories/new.html.erb +5 -0
- data/app/views/panda/core/admin/files/_file_details.html.erb +156 -0
- data/app/views/panda/core/admin/files/index.html.erb +61 -0
- data/app/views/panda/core/admin/import_sessions/column_map.html.erb +43 -0
- data/app/views/panda/core/admin/import_sessions/index.html.erb +48 -0
- data/app/views/panda/core/admin/import_sessions/new.html.erb +37 -0
- data/app/views/panda/core/admin/import_sessions/preview.html.erb +51 -0
- data/app/views/panda/core/admin/import_sessions/show.html.erb +87 -0
- data/app/views/panda/core/admin/my_profile/edit.html.erb +51 -39
- data/app/views/panda/core/admin/my_profile/logins/show.html.erb +72 -0
- data/app/views/panda/core/admin/sessions/new.html.erb +1 -37
- data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +1 -18
- data/app/views/panda/core/admin/shared/_sidebar.html.erb +1 -99
- data/app/views/panda/core/admin/shared/_slideover.html.erb +3 -3
- data/app/views/panda/core/admin/tags/_form.html.erb +21 -0
- data/app/views/panda/core/admin/tags/edit.html.erb +4 -0
- data/app/views/panda/core/admin/tags/index.html.erb +41 -0
- data/app/views/panda/core/admin/tags/new.html.erb +4 -0
- data/app/views/panda/core/admin/users/activity.html.erb +90 -0
- data/app/views/panda/core/admin/users/edit.html.erb +48 -0
- data/app/views/panda/core/admin/users/index.html.erb +161 -0
- data/app/views/panda/core/admin/users/sessions.html.erb +103 -0
- data/app/views/panda/core/admin/users/show.html.erb +224 -0
- data/app/views/panda/core/shared/_footer.html.erb +1 -2
- data/app/views/panda/core/shared/_header.html.erb +4 -22
- data/config/brakeman.ignore +68 -31
- data/config/importmap.rb +13 -11
- data/config/routes.rb +49 -2
- data/db/migrate/20260202171614_create_panda_core_file_categories.rb +30 -0
- data/db/migrate/20260203000001_add_user_management_fields_to_panda_core_users.rb +21 -0
- data/db/migrate/20260203000002_create_panda_core_user_activities.rb +24 -0
- data/db/migrate/20260203000003_create_panda_core_user_sessions.rb +25 -0
- data/db/migrate/20260207100000_create_panda_core_presences.rb +21 -0
- data/db/migrate/20260213000001_fix_active_storage_attachments_record_id_type.rb +44 -0
- data/db/migrate/20260217000001_create_panda_core_feature_flags.rb +14 -0
- data/db/migrate/20260303000001_add_metadata_to_panda_core_users.rb +11 -0
- data/db/migrate/20260310000001_create_panda_core_tags.rb +27 -0
- data/db/migrate/20260310000002_create_panda_core_import_sessions.rb +29 -0
- data/lib/generators/panda/core/install_generator.rb +32 -0
- data/lib/generators/panda/core/templates/panda.rb +63 -0
- data/lib/panda/core/asset_loader.rb +5 -2
- data/lib/panda/core/authentication.rb +3 -3
- data/lib/panda/core/configuration.rb +195 -1
- data/lib/panda/core/engine/admin_controller_config.rb +3 -6
- data/lib/panda/core/engine/autoload_config.rb +21 -9
- data/lib/panda/core/engine/omniauth_config.rb +108 -38
- data/lib/panda/core/engine/pending_migrations_check.rb +46 -0
- data/lib/panda/core/engine/sqlite_schema_compatibility.rb +36 -0
- data/lib/panda/core/engine/{phlex_config.rb → view_component_config.rb} +5 -7
- data/lib/panda/core/engine.rb +90 -25
- data/lib/panda/core/middleware.rb +35 -0
- data/lib/panda/core/navigation_registry.rb +455 -0
- data/lib/panda/core/oauth_providers.rb +27 -0
- data/lib/panda/core/permission_registry.rb +89 -0
- data/lib/panda/core/search_registry.rb +69 -0
- data/lib/panda/core/seeds/file_categories.rb +30 -0
- data/lib/panda/core/shared/inflections_config.rb +1 -5
- data/lib/panda/core/testing/rails_helper.rb +87 -0
- data/lib/panda/core/testing/support/authentication_test_helpers.rb +22 -18
- data/lib/panda/core/testing/support/system/cuprite_helpers.rb +4 -4
- data/lib/panda/core/testing/view_component_test_controller.rb +19 -0
- data/lib/panda/core/version.rb +1 -1
- data/lib/panda/core/widget_registry.rb +50 -0
- data/lib/panda/core.rb +17 -8
- data/lib/tasks/panda/core/file_categories.rake +41 -0
- data/public/panda-core-assets/panda-core-0.12.5.css +2 -0
- data/public/panda-core-assets/panda-core-0.13.0.css +2 -0
- data/public/panda-core-assets/panda-core-0.14.0.css +2 -0
- data/public/panda-core-assets/panda-core-0.14.4.css +2 -0
- data/public/panda-core-assets/panda-core.css +2 -2
- data/public/panda-core-assets/vanilla-calendar.css +47 -0
- metadata +203 -26
- data/lib/panda/core/engine/inflections_config.rb +0 -23
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["select"]
|
|
5
|
+
static values = { open: { type: Boolean, default: false }, index: { type: Number, default: -1 } }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.element.classList.add("relative")
|
|
9
|
+
this.selectTarget.classList.add("sr-only")
|
|
10
|
+
this.options = Array.from(this.selectTarget.options)
|
|
11
|
+
this.buildUI()
|
|
12
|
+
this.handleOutsideClick = this.handleOutsideClick.bind(this)
|
|
13
|
+
document.addEventListener("click", this.handleOutsideClick)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
disconnect() {
|
|
17
|
+
document.removeEventListener("click", this.handleOutsideClick)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
buildUI() {
|
|
21
|
+
// Find the selected option
|
|
22
|
+
const selectedOption = this.options.find(o => o.selected) || this.options[0]
|
|
23
|
+
const isPlaceholder = selectedOption && (selectedOption.value === "" || selectedOption.disabled)
|
|
24
|
+
|
|
25
|
+
// Build trigger button
|
|
26
|
+
this.trigger = document.createElement("button")
|
|
27
|
+
this.trigger.type = "button"
|
|
28
|
+
this.trigger.setAttribute("role", "combobox")
|
|
29
|
+
this.trigger.setAttribute("aria-expanded", "false")
|
|
30
|
+
this.trigger.setAttribute("aria-haspopup", "listbox")
|
|
31
|
+
// Border/radius are themeable via --panda-select-trigger-border /
|
|
32
|
+
// --panda-input-radius (see app/assets/tailwind/application.css); the
|
|
33
|
+
// var() fallbacks resolve to today's rounded-xl/border-gray-200 exactly,
|
|
34
|
+
// so this is a no-op unless a host app sets data-theme.
|
|
35
|
+
this.trigger.className = "flex items-center justify-between w-full h-11 rounded-[var(--panda-input-radius,var(--radius-xl))] border border-[var(--panda-select-trigger-border,var(--color-gray-200))] bg-white px-3 py-2 text-sm text-left focus:border-transparent focus:ring-2 focus:ring-primary-500 cursor-pointer"
|
|
36
|
+
this.trigger.innerHTML = `
|
|
37
|
+
<span class="truncate ${isPlaceholder ? "text-gray-400" : "text-gray-900"}">${this.escapeHtml(selectedOption ? selectedOption.text : "")}</span>
|
|
38
|
+
<svg class="w-4 h-4 text-gray-400 shrink-0 ml-2 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
39
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
40
|
+
</svg>
|
|
41
|
+
`
|
|
42
|
+
this.trigger.addEventListener("click", (e) => {
|
|
43
|
+
e.preventDefault()
|
|
44
|
+
this.toggleDropdown()
|
|
45
|
+
})
|
|
46
|
+
this.trigger.addEventListener("keydown", (e) => this.handleTriggerKeydown(e))
|
|
47
|
+
|
|
48
|
+
// Build listbox
|
|
49
|
+
this.listbox = document.createElement("ul")
|
|
50
|
+
this.listbox.setAttribute("role", "listbox")
|
|
51
|
+
this.listbox.setAttribute("tabindex", "-1")
|
|
52
|
+
this.listbox.className = "absolute z-50 mt-1 w-full max-h-60 overflow-auto rounded-xl border border-gray-200 bg-white shadow-lg py-1 text-sm focus:outline-none hidden"
|
|
53
|
+
this.listbox.id = `custom-select-listbox-${this.selectTarget.id || Math.random().toString(36).slice(2)}`
|
|
54
|
+
this.trigger.setAttribute("aria-controls", this.listbox.id)
|
|
55
|
+
|
|
56
|
+
this.options.forEach((option, i) => {
|
|
57
|
+
const li = document.createElement("li")
|
|
58
|
+
li.setAttribute("role", "option")
|
|
59
|
+
li.setAttribute("aria-selected", option.selected ? "true" : "false")
|
|
60
|
+
li.setAttribute("data-index", i)
|
|
61
|
+
li.setAttribute("data-value", option.value)
|
|
62
|
+
li.id = `${this.listbox.id}-option-${i}`
|
|
63
|
+
|
|
64
|
+
const isBlank = option.value === "" || option.disabled
|
|
65
|
+
li.className = `relative cursor-pointer select-none px-3 py-2 flex items-center justify-between ${
|
|
66
|
+
option.selected && !isBlank ? "bg-primary-50 text-primary-800" : "text-gray-900"
|
|
67
|
+
} ${isBlank ? "text-gray-400" : ""}`
|
|
68
|
+
|
|
69
|
+
li.innerHTML = `
|
|
70
|
+
<span class="block truncate">${this.escapeHtml(option.text)}</span>
|
|
71
|
+
${option.selected && !isBlank ? '<i class="fa-solid fa-check text-primary-600 text-xs"></i>' : ""}
|
|
72
|
+
`
|
|
73
|
+
|
|
74
|
+
li.addEventListener("click", () => this.selectOption(i))
|
|
75
|
+
li.addEventListener("mouseenter", () => this.highlightOption(i))
|
|
76
|
+
this.listbox.appendChild(li)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
this.listbox.addEventListener("keydown", (e) => this.handleListboxKeydown(e))
|
|
80
|
+
|
|
81
|
+
// Insert after the native select
|
|
82
|
+
this.selectTarget.parentNode.insertBefore(this.trigger, this.selectTarget.nextSibling)
|
|
83
|
+
this.selectTarget.parentNode.appendChild(this.listbox)
|
|
84
|
+
|
|
85
|
+
// Remove any existing SVG chevron (from the FormBuilder select_svg helper)
|
|
86
|
+
const existingSvg = this.element.querySelector("svg.pointer-events-none")
|
|
87
|
+
if (existingSvg) existingSvg.remove()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
toggleDropdown() {
|
|
91
|
+
this.openValue = !this.openValue
|
|
92
|
+
this.renderDropdown()
|
|
93
|
+
|
|
94
|
+
if (this.openValue) {
|
|
95
|
+
// Highlight the currently selected option
|
|
96
|
+
const selectedIdx = this.options.findIndex(o => o.selected)
|
|
97
|
+
if (selectedIdx >= 0) {
|
|
98
|
+
this.indexValue = selectedIdx
|
|
99
|
+
this.highlightOption(selectedIdx)
|
|
100
|
+
}
|
|
101
|
+
this.listbox.focus()
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
renderDropdown() {
|
|
106
|
+
if (this.openValue) {
|
|
107
|
+
this.listbox.classList.remove("hidden")
|
|
108
|
+
this.trigger.setAttribute("aria-expanded", "true")
|
|
109
|
+
this.trigger.querySelector("svg").style.transform = "rotate(180deg)"
|
|
110
|
+
} else {
|
|
111
|
+
this.listbox.classList.add("hidden")
|
|
112
|
+
this.trigger.setAttribute("aria-expanded", "false")
|
|
113
|
+
this.trigger.querySelector("svg").style.transform = ""
|
|
114
|
+
this.indexValue = -1
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
selectOption(index) {
|
|
119
|
+
const option = this.options[index]
|
|
120
|
+
if (!option) return
|
|
121
|
+
|
|
122
|
+
// Update native select
|
|
123
|
+
this.selectTarget.value = option.value
|
|
124
|
+
this.selectTarget.dispatchEvent(new Event("change", { bubbles: true }))
|
|
125
|
+
|
|
126
|
+
// Update trigger text
|
|
127
|
+
const isPlaceholder = option.value === "" || option.disabled
|
|
128
|
+
const triggerText = this.trigger.querySelector("span")
|
|
129
|
+
triggerText.textContent = option.text
|
|
130
|
+
triggerText.className = `truncate ${isPlaceholder ? "text-gray-400" : "text-gray-900"}`
|
|
131
|
+
|
|
132
|
+
// Update option styles and aria
|
|
133
|
+
const items = this.listbox.querySelectorAll("[role='option']")
|
|
134
|
+
items.forEach((li, i) => {
|
|
135
|
+
const opt = this.options[i]
|
|
136
|
+
const isBlank = opt.value === "" || opt.disabled
|
|
137
|
+
const isSelected = i === index
|
|
138
|
+
li.setAttribute("aria-selected", isSelected ? "true" : "false")
|
|
139
|
+
li.className = `relative cursor-pointer select-none px-3 py-2 flex items-center justify-between ${
|
|
140
|
+
isSelected && !isBlank ? "bg-primary-50 text-primary-800" : "text-gray-900"
|
|
141
|
+
} ${isBlank ? "text-gray-400" : ""}`
|
|
142
|
+
li.innerHTML = `
|
|
143
|
+
<span class="block truncate">${this.escapeHtml(opt.text)}</span>
|
|
144
|
+
${isSelected && !isBlank ? '<i class="fa-solid fa-check text-primary-600 text-xs"></i>' : ""}
|
|
145
|
+
`
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Close
|
|
149
|
+
this.openValue = false
|
|
150
|
+
this.renderDropdown()
|
|
151
|
+
this.trigger.focus()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
highlightOption(index) {
|
|
155
|
+
this.indexValue = index
|
|
156
|
+
const items = this.listbox.querySelectorAll("[role='option']")
|
|
157
|
+
items.forEach((li, i) => {
|
|
158
|
+
if (i === index) {
|
|
159
|
+
li.classList.add("bg-primary-100")
|
|
160
|
+
li.scrollIntoView({ block: "nearest" })
|
|
161
|
+
this.trigger.setAttribute("aria-activedescendant", li.id)
|
|
162
|
+
} else {
|
|
163
|
+
li.classList.remove("bg-primary-100")
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
handleTriggerKeydown(event) {
|
|
169
|
+
switch (event.key) {
|
|
170
|
+
case "ArrowDown":
|
|
171
|
+
case "ArrowUp":
|
|
172
|
+
case "Enter":
|
|
173
|
+
case " ":
|
|
174
|
+
event.preventDefault()
|
|
175
|
+
if (!this.openValue) {
|
|
176
|
+
this.openValue = true
|
|
177
|
+
this.renderDropdown()
|
|
178
|
+
const selectedIdx = this.options.findIndex(o => o.selected)
|
|
179
|
+
this.highlightOption(selectedIdx >= 0 ? selectedIdx : 0)
|
|
180
|
+
this.listbox.focus()
|
|
181
|
+
}
|
|
182
|
+
break
|
|
183
|
+
case "Escape":
|
|
184
|
+
if (this.openValue) {
|
|
185
|
+
event.preventDefault()
|
|
186
|
+
this.openValue = false
|
|
187
|
+
this.renderDropdown()
|
|
188
|
+
}
|
|
189
|
+
break
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
handleListboxKeydown(event) {
|
|
194
|
+
const maxIndex = this.options.length - 1
|
|
195
|
+
|
|
196
|
+
switch (event.key) {
|
|
197
|
+
case "ArrowDown":
|
|
198
|
+
event.preventDefault()
|
|
199
|
+
this.highlightOption(Math.min(this.indexValue + 1, maxIndex))
|
|
200
|
+
break
|
|
201
|
+
case "ArrowUp":
|
|
202
|
+
event.preventDefault()
|
|
203
|
+
this.highlightOption(Math.max(this.indexValue - 1, 0))
|
|
204
|
+
break
|
|
205
|
+
case "Enter":
|
|
206
|
+
case " ":
|
|
207
|
+
event.preventDefault()
|
|
208
|
+
if (this.indexValue >= 0) {
|
|
209
|
+
this.selectOption(this.indexValue)
|
|
210
|
+
}
|
|
211
|
+
break
|
|
212
|
+
case "Escape":
|
|
213
|
+
event.preventDefault()
|
|
214
|
+
this.openValue = false
|
|
215
|
+
this.renderDropdown()
|
|
216
|
+
this.trigger.focus()
|
|
217
|
+
break
|
|
218
|
+
case "Tab":
|
|
219
|
+
this.openValue = false
|
|
220
|
+
this.renderDropdown()
|
|
221
|
+
break
|
|
222
|
+
default:
|
|
223
|
+
// Type-ahead: jump to first option starting with typed character
|
|
224
|
+
if (event.key.length === 1) {
|
|
225
|
+
const char = event.key.toLowerCase()
|
|
226
|
+
const startIdx = this.indexValue + 1
|
|
227
|
+
const match = this.options.findIndex((o, i) =>
|
|
228
|
+
i >= startIdx && o.text.toLowerCase().startsWith(char)
|
|
229
|
+
)
|
|
230
|
+
if (match >= 0) {
|
|
231
|
+
this.highlightOption(match)
|
|
232
|
+
} else {
|
|
233
|
+
// Wrap around
|
|
234
|
+
const wrapMatch = this.options.findIndex(o =>
|
|
235
|
+
o.text.toLowerCase().startsWith(char)
|
|
236
|
+
)
|
|
237
|
+
if (wrapMatch >= 0) this.highlightOption(wrapMatch)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
handleOutsideClick(event) {
|
|
245
|
+
if (this.openValue && !this.element.contains(event.target)) {
|
|
246
|
+
this.openValue = false
|
|
247
|
+
this.renderDropdown()
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
escapeHtml(text) {
|
|
252
|
+
const div = document.createElement("div")
|
|
253
|
+
div.textContent = text
|
|
254
|
+
return div.innerHTML
|
|
255
|
+
}
|
|
256
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { Calendar } from "vanilla-calendar-pro"
|
|
3
|
+
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["display", "hidden", "calendar"]
|
|
6
|
+
static values = {
|
|
7
|
+
dateMin: String,
|
|
8
|
+
dateMax: String
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this.isOpen = false
|
|
13
|
+
this.calendar = null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
disconnect() {
|
|
17
|
+
document.removeEventListener("click", this.outsideClick)
|
|
18
|
+
document.removeEventListener("keydown", this.handleKeydown)
|
|
19
|
+
if (this.calendar) this.calendar.destroy()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
toggle(event) {
|
|
23
|
+
event.stopPropagation()
|
|
24
|
+
if (this.isOpen) {
|
|
25
|
+
this.close()
|
|
26
|
+
} else {
|
|
27
|
+
this.open()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
open() {
|
|
32
|
+
if (this.isOpen) return
|
|
33
|
+
|
|
34
|
+
const options = {
|
|
35
|
+
type: "default",
|
|
36
|
+
settings: {
|
|
37
|
+
visibility: {
|
|
38
|
+
theme: "light"
|
|
39
|
+
},
|
|
40
|
+
iso8601: true,
|
|
41
|
+
selection: {
|
|
42
|
+
day: "single"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
actions: {
|
|
46
|
+
clickDay: (event, self) => {
|
|
47
|
+
this.handleDateSelect(self)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (this.hasDateMinValue) {
|
|
53
|
+
options.settings.range = options.settings.range || {}
|
|
54
|
+
options.settings.range.min = this.dateMinValue
|
|
55
|
+
}
|
|
56
|
+
if (this.hasDateMaxValue) {
|
|
57
|
+
options.settings.range = options.settings.range || {}
|
|
58
|
+
options.settings.range.max = this.dateMaxValue
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Set initial selected date from hidden field value
|
|
62
|
+
const currentValue = this.hiddenTarget.value
|
|
63
|
+
if (currentValue && /^\d{4}-\d{2}-\d{2}$/.test(currentValue)) {
|
|
64
|
+
options.selectedDates = [currentValue]
|
|
65
|
+
options.selectedMonth = parseInt(currentValue.split("-")[1], 10) - 1
|
|
66
|
+
options.selectedYear = parseInt(currentValue.split("-")[0], 10)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
this.calendarTarget.innerHTML = ""
|
|
70
|
+
this.calendarTarget.classList.remove("hidden")
|
|
71
|
+
|
|
72
|
+
this.calendar = new Calendar(this.calendarTarget, options)
|
|
73
|
+
this.calendar.init()
|
|
74
|
+
this.isOpen = true
|
|
75
|
+
|
|
76
|
+
document.addEventListener("click", this.outsideClick)
|
|
77
|
+
document.addEventListener("keydown", this.handleKeydown)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
close() {
|
|
81
|
+
if (!this.isOpen) return
|
|
82
|
+
this.calendarTarget.classList.add("hidden")
|
|
83
|
+
if (this.calendar) {
|
|
84
|
+
this.calendar.destroy()
|
|
85
|
+
this.calendar = null
|
|
86
|
+
}
|
|
87
|
+
this.isOpen = false
|
|
88
|
+
document.removeEventListener("click", this.outsideClick)
|
|
89
|
+
document.removeEventListener("keydown", this.handleKeydown)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
handleDateSelect(calendarInstance) {
|
|
93
|
+
const selectedDates = calendarInstance.context.selectedDates
|
|
94
|
+
if (selectedDates && selectedDates.length > 0) {
|
|
95
|
+
const isoDate = selectedDates[selectedDates.length - 1]
|
|
96
|
+
this.hiddenTarget.value = isoDate
|
|
97
|
+
this.displayTarget.value = this.formatDate(isoDate)
|
|
98
|
+
this.hiddenTarget.dispatchEvent(new Event("change", { bubbles: true }))
|
|
99
|
+
this.close()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
outsideClick = (event) => {
|
|
104
|
+
if (this.isOpen && !this.element.contains(event.target)) {
|
|
105
|
+
this.close()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
handleKeydown = (event) => {
|
|
110
|
+
if (event.key === "Escape" && this.isOpen) {
|
|
111
|
+
this.close()
|
|
112
|
+
this.displayTarget.focus()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
formatDate(isoDate) {
|
|
117
|
+
const [year, month, day] = isoDate.split("-")
|
|
118
|
+
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day))
|
|
119
|
+
return date.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { Calendar } from "vanilla-calendar-pro"
|
|
3
|
+
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["startInput", "endInput", "calendar"]
|
|
6
|
+
static values = {
|
|
7
|
+
dateMin: String,
|
|
8
|
+
dateMax: String
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
connect() {
|
|
12
|
+
this.isOpen = false
|
|
13
|
+
this.calendar = null
|
|
14
|
+
this.activeInput = null
|
|
15
|
+
|
|
16
|
+
this.startInputTarget.readOnly = true
|
|
17
|
+
this.endInputTarget.readOnly = true
|
|
18
|
+
this.startInputTarget.style.cursor = "pointer"
|
|
19
|
+
this.endInputTarget.style.cursor = "pointer"
|
|
20
|
+
|
|
21
|
+
this.startInputTarget.addEventListener("click", this.openFromStart)
|
|
22
|
+
this.startInputTarget.addEventListener("focus", this.openFromStart)
|
|
23
|
+
this.endInputTarget.addEventListener("click", this.openFromEnd)
|
|
24
|
+
this.endInputTarget.addEventListener("focus", this.openFromEnd)
|
|
25
|
+
document.addEventListener("click", this.outsideClick)
|
|
26
|
+
document.addEventListener("keydown", this.handleKeydown)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
disconnect() {
|
|
30
|
+
this.startInputTarget.removeEventListener("click", this.openFromStart)
|
|
31
|
+
this.startInputTarget.removeEventListener("focus", this.openFromStart)
|
|
32
|
+
this.endInputTarget.removeEventListener("click", this.openFromEnd)
|
|
33
|
+
this.endInputTarget.removeEventListener("focus", this.openFromEnd)
|
|
34
|
+
document.removeEventListener("click", this.outsideClick)
|
|
35
|
+
document.removeEventListener("keydown", this.handleKeydown)
|
|
36
|
+
if (this.calendar) this.calendar.destroy()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
openFromStart = (event) => {
|
|
40
|
+
event.stopPropagation()
|
|
41
|
+
this.activeInput = "start"
|
|
42
|
+
if (this.isOpen) {
|
|
43
|
+
this.close()
|
|
44
|
+
} else {
|
|
45
|
+
this.open()
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
openFromEnd = (event) => {
|
|
50
|
+
event.stopPropagation()
|
|
51
|
+
this.activeInput = "end"
|
|
52
|
+
if (this.isOpen) {
|
|
53
|
+
this.close()
|
|
54
|
+
} else {
|
|
55
|
+
this.open()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
open() {
|
|
60
|
+
if (this.isOpen) return
|
|
61
|
+
|
|
62
|
+
const options = {
|
|
63
|
+
type: "default",
|
|
64
|
+
settings: {
|
|
65
|
+
visibility: {
|
|
66
|
+
theme: "light",
|
|
67
|
+
positionToInput: "auto"
|
|
68
|
+
},
|
|
69
|
+
iso8601: true,
|
|
70
|
+
selection: {
|
|
71
|
+
day: "multiple-ranged"
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
actions: {
|
|
75
|
+
clickDay: (event, self) => {
|
|
76
|
+
this.handleRangeSelect(self)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (this.hasDateMinValue) {
|
|
82
|
+
options.settings.range = options.settings.range || {}
|
|
83
|
+
options.settings.range.min = this.dateMinValue
|
|
84
|
+
}
|
|
85
|
+
if (this.hasDateMaxValue) {
|
|
86
|
+
options.settings.range = options.settings.range || {}
|
|
87
|
+
options.settings.range.max = this.dateMaxValue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Set initial selected range from input values
|
|
91
|
+
const startDate = this.parseInputDate(this.startInputTarget.value)
|
|
92
|
+
const endDate = this.parseInputDate(this.endInputTarget.value)
|
|
93
|
+
if (startDate && endDate) {
|
|
94
|
+
options.selectedDates = this.getDateRange(startDate, endDate)
|
|
95
|
+
options.selectedMonth = parseInt(startDate.split("-")[1], 10) - 1
|
|
96
|
+
options.selectedYear = parseInt(startDate.split("-")[0], 10)
|
|
97
|
+
} else if (startDate) {
|
|
98
|
+
options.selectedDates = [startDate]
|
|
99
|
+
options.selectedMonth = parseInt(startDate.split("-")[1], 10) - 1
|
|
100
|
+
options.selectedYear = parseInt(startDate.split("-")[0], 10)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
this.calendarTarget.innerHTML = ""
|
|
104
|
+
this.calendarTarget.classList.remove("hidden")
|
|
105
|
+
|
|
106
|
+
this.calendar = new Calendar(this.calendarTarget, options)
|
|
107
|
+
this.calendar.init()
|
|
108
|
+
this.isOpen = true
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
close() {
|
|
112
|
+
if (!this.isOpen) return
|
|
113
|
+
this.calendarTarget.classList.add("hidden")
|
|
114
|
+
if (this.calendar) {
|
|
115
|
+
this.calendar.destroy()
|
|
116
|
+
this.calendar = null
|
|
117
|
+
}
|
|
118
|
+
this.isOpen = false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
handleRangeSelect(calendarInstance) {
|
|
122
|
+
const selectedDates = calendarInstance.context.selectedDates
|
|
123
|
+
if (selectedDates && selectedDates.length > 0) {
|
|
124
|
+
const sorted = [...selectedDates].sort()
|
|
125
|
+
const startDate = sorted[0]
|
|
126
|
+
const endDate = sorted[sorted.length - 1]
|
|
127
|
+
|
|
128
|
+
this.startInputTarget.value = this.formatDate(startDate)
|
|
129
|
+
this.endInputTarget.value = this.formatDate(endDate)
|
|
130
|
+
|
|
131
|
+
this.startInputTarget.dispatchEvent(new Event("change", { bubbles: true }))
|
|
132
|
+
this.endInputTarget.dispatchEvent(new Event("change", { bubbles: true }))
|
|
133
|
+
|
|
134
|
+
// Close after selecting end of range (2+ dates selected)
|
|
135
|
+
if (selectedDates.length >= 2) {
|
|
136
|
+
this.close()
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
outsideClick = (event) => {
|
|
142
|
+
if (this.isOpen && !this.element.contains(event.target)) {
|
|
143
|
+
this.close()
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
handleKeydown = (event) => {
|
|
148
|
+
if (event.key === "Escape" && this.isOpen) {
|
|
149
|
+
this.close()
|
|
150
|
+
if (this.activeInput === "end") {
|
|
151
|
+
this.endInputTarget.focus()
|
|
152
|
+
} else {
|
|
153
|
+
this.startInputTarget.focus()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
getDateRange(start, end) {
|
|
159
|
+
const dates = []
|
|
160
|
+
const current = new Date(start)
|
|
161
|
+
const endDate = new Date(end)
|
|
162
|
+
while (current <= endDate) {
|
|
163
|
+
dates.push(current.toISOString().split("T")[0])
|
|
164
|
+
current.setDate(current.getDate() + 1)
|
|
165
|
+
}
|
|
166
|
+
return dates
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
parseInputDate(value) {
|
|
170
|
+
if (!value) return null
|
|
171
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value
|
|
172
|
+
|
|
173
|
+
const ukMatch = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
|
|
174
|
+
if (ukMatch) {
|
|
175
|
+
return `${ukMatch[3]}-${ukMatch[2].padStart(2, "0")}-${ukMatch[1].padStart(2, "0")}`
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const months = { Jan: "01", Feb: "02", Mar: "03", Apr: "04", May: "05", Jun: "06",
|
|
179
|
+
Jul: "07", Aug: "08", Sep: "09", Oct: "10", Nov: "11", Dec: "12" }
|
|
180
|
+
const namedMatch = value.match(/^(\d{1,2})\s+(\w{3})\s+(\d{4})$/)
|
|
181
|
+
if (namedMatch && months[namedMatch[2]]) {
|
|
182
|
+
return `${namedMatch[3]}-${months[namedMatch[2]]}-${namedMatch[1].padStart(2, "0")}`
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return null
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
formatDate(isoDate) {
|
|
189
|
+
const [year, month, day] = isoDate.split("-")
|
|
190
|
+
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day))
|
|
191
|
+
return date.toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" })
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Controller } from '@hotwired/stimulus'
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ['uploadBackdrop', 'uploadPanel']
|
|
5
|
+
static values = { filesPath: String }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this._handleFilenameInput = this.handleFilenameInput.bind(this)
|
|
9
|
+
this._handleFilenameBlur = this.handleFilenameBlur.bind(this)
|
|
10
|
+
document.addEventListener('input', this._handleFilenameInput)
|
|
11
|
+
document.addEventListener('focusout', this._handleFilenameBlur)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
disconnect() {
|
|
15
|
+
document.removeEventListener('input', this._handleFilenameInput)
|
|
16
|
+
document.removeEventListener('focusout', this._handleFilenameBlur)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
handleFilenameInput(event) {
|
|
20
|
+
if (event.target.id !== 'blob_filename') return
|
|
21
|
+
const { selectionStart } = event.target
|
|
22
|
+
event.target.value = event.target.value.replace(/\s/g, '_').replace(/[^a-z0-9_-]/gi, '')
|
|
23
|
+
event.target.setSelectionRange(selectionStart, selectionStart)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
handleFilenameBlur(event) {
|
|
27
|
+
if (event.target.id !== 'blob_filename') return
|
|
28
|
+
event.target.value = event.target.value.toLowerCase()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
selectFile(event) {
|
|
32
|
+
const button = event.currentTarget
|
|
33
|
+
const fileId = button.dataset.fileId
|
|
34
|
+
|
|
35
|
+
this.selectedFileId = fileId
|
|
36
|
+
this.updateSelectedState(button)
|
|
37
|
+
this.loadFileDetails(fileId)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async loadFileDetails(fileId) {
|
|
41
|
+
const slideoverContent = document.getElementById('file-gallery-slideover-content')
|
|
42
|
+
if (!slideoverContent) return
|
|
43
|
+
|
|
44
|
+
// Show loading state
|
|
45
|
+
slideoverContent.innerHTML =
|
|
46
|
+
'<div class="flex items-center justify-center py-12"><i class="fa-solid fa-spinner fa-spin text-2xl text-gray-400"></i></div>'
|
|
47
|
+
this.showSlideover()
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const response = await fetch(`${this.filesPathValue}/${fileId}`, {
|
|
51
|
+
headers: {
|
|
52
|
+
Accept: 'text/html',
|
|
53
|
+
'X-Requested-With': 'XMLHttpRequest',
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
58
|
+
|
|
59
|
+
slideoverContent.innerHTML = await response.text()
|
|
60
|
+
} catch {
|
|
61
|
+
slideoverContent.innerHTML =
|
|
62
|
+
'<div class="text-center py-12"><i class="fa-solid fa-exclamation-triangle text-2xl text-red-400 mb-2"></i><p class="text-sm text-gray-500">Failed to load file details</p></div>'
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
showSlideover() {
|
|
67
|
+
// Toggle controller is now a descendant (inside ContainerComponent)
|
|
68
|
+
const toggleElement = this.element.querySelector('[data-controller*="toggle"]')
|
|
69
|
+
if (!toggleElement) return
|
|
70
|
+
|
|
71
|
+
const toggleController = this.application.getControllerForElementAndIdentifier(
|
|
72
|
+
toggleElement,
|
|
73
|
+
'toggle'
|
|
74
|
+
)
|
|
75
|
+
if (toggleController) {
|
|
76
|
+
toggleController.show()
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
reopenSlideover() {
|
|
81
|
+
if (this.selectedFileId) {
|
|
82
|
+
this.loadFileDetails(this.selectedFileId)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
openUpload() {
|
|
87
|
+
this.uploadBackdropTarget.classList.remove('hidden')
|
|
88
|
+
this.uploadPanelTarget.classList.remove('hidden')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
closeUpload() {
|
|
92
|
+
this.uploadBackdropTarget.classList.add('hidden')
|
|
93
|
+
this.uploadPanelTarget.classList.add('hidden')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
updateSelectedState(selectedButton) {
|
|
97
|
+
// Remove selected state from all file items
|
|
98
|
+
const allFileItems = this.element.querySelectorAll('[data-action*="file-gallery#selectFile"]')
|
|
99
|
+
allFileItems.forEach((button) => {
|
|
100
|
+
const container = button.closest('.relative').querySelector('.group')
|
|
101
|
+
if (container) {
|
|
102
|
+
container.classList.remove(
|
|
103
|
+
'outline',
|
|
104
|
+
'outline-2',
|
|
105
|
+
'outline-offset-2',
|
|
106
|
+
'outline-panda-dark',
|
|
107
|
+
'dark:outline-panda-light'
|
|
108
|
+
)
|
|
109
|
+
container.classList.add(
|
|
110
|
+
'focus-within:outline-2',
|
|
111
|
+
'focus-within:outline-offset-2',
|
|
112
|
+
'focus-within:outline-primary-600'
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Add selected state to clicked item
|
|
118
|
+
const container = selectedButton.closest('.relative').querySelector('.group')
|
|
119
|
+
if (container) {
|
|
120
|
+
container.classList.add(
|
|
121
|
+
'outline',
|
|
122
|
+
'outline-2',
|
|
123
|
+
'outline-offset-2',
|
|
124
|
+
'outline-panda-dark',
|
|
125
|
+
'dark:outline-panda-light'
|
|
126
|
+
)
|
|
127
|
+
container.classList.remove(
|
|
128
|
+
'focus-within:outline-2',
|
|
129
|
+
'focus-within:outline-offset-2',
|
|
130
|
+
'focus-within:outline-primary-600'
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|