diff --git a/app/javascript/application.js b/app/javascript/application.js
index 91f60f003..56c8fbf2a 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -10,6 +10,7 @@ import ToggleCookies from './elements/toggle_cookies'
import DisableHidden from './elements/disable_hidden'
import TurboModal from './elements/turbo_modal'
import NativeAction from './elements/native_action'
+import NativeMenu from './elements/native_menu'
import NativeSearch from './elements/native_search'
import NativeModal from './elements/native_modal'
import NativeEvent from './elements/native_event'
@@ -181,6 +182,7 @@ safeRegisterElement('toggle-visible', ToggleVisible)
safeRegisterElement('disable-hidden', DisableHidden)
safeRegisterElement('turbo-modal', TurboModal)
safeRegisterElement('native-action', NativeAction)
+safeRegisterElement('native-menu', NativeMenu)
safeRegisterElement('native-search', NativeSearch)
safeRegisterElement('native-modal', NativeModal)
safeRegisterElement('native-event', NativeEvent)
diff --git a/app/javascript/elements/native_menu.js b/app/javascript/elements/native_menu.js
new file mode 100644
index 000000000..95842bdfc
--- /dev/null
+++ b/app/javascript/elements/native_menu.js
@@ -0,0 +1,48 @@
+let counter = 0
+
+export default class extends HTMLElement {
+ connectedCallback () {
+ if (window.webkit?.messageHandlers?.native) {
+ this.addEventListener('click', this.onClick)
+ } else {
+ document.addEventListener('pointerdown', this.onPointerDown)
+ }
+ }
+
+ disconnectedCallback () {
+ this.removeEventListener('click', this.onClick)
+ document.removeEventListener('pointerdown', this.onPointerDown)
+ }
+
+ onPointerDown = (event) => {
+ if (this.contains(document.activeElement) && !this.contains(event.target)) {
+ document.activeElement.blur()
+ }
+ }
+
+ onClick = (event) => {
+ if (event.target.closest('native-menu-action')) return
+
+ const rect = this.firstElementChild.getBoundingClientRect()
+
+ const items = [...this.querySelectorAll('native-menu-action')].map((action) => {
+ const target = action.querySelector('a, button, [role="button"]') || action.firstElementChild
+
+ target.dataset.nativeId ||= `native-menu-${++counter}`
+
+ return {
+ id: target.dataset.nativeId,
+ title: action.dataset.label || target.textContent.trim(),
+ icon: action.dataset.icon,
+ destructive: action.dataset.destructive === 'true',
+ selected: action.dataset.selected === 'true'
+ }
+ })
+
+ window.webkit.messageHandlers.native.postMessage({
+ type: 'menu',
+ rect: { x: rect.left + window.scrollX, y: rect.top + window.scrollY, width: rect.width, height: rect.height },
+ items
+ })
+ }
+}
diff --git a/app/views/icons/_dots.html.erb b/app/views/icons/_dots.html.erb
new file mode 100644
index 000000000..8ffd09f7e
--- /dev/null
+++ b/app/views/icons/_dots.html.erb
@@ -0,0 +1,6 @@
+
diff --git a/app/views/submissions_filters/_applied_filters.html.erb b/app/views/submissions_filters/_applied_filters.html.erb
index c83770b0a..63fcf4b92 100644
--- a/app/views/submissions_filters/_applied_filters.html.erb
+++ b/app/views/submissions_filters/_applied_filters.html.erb
@@ -11,7 +11,7 @@
<% status_chip_html = capture do %>
<% if status_icon || default_status_icon %>
- <%= link_to submissions_filter_path('status', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal', native_modal: 'medium' }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
+ <%= link_to submissions_filter_path('status', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal', native_modal: 'medium', native_title: t('status') }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
<%= svg_icon(status_icon || default_status_icon, class: 'w-5 h-5 shrink-0') %>
<%= t(params[:status]) %>
<% end %>
@@ -46,7 +46,7 @@
<% end %>
<% if params[:folder].present? %>
- <%= link_to submissions_filter_path('folder', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal', native_modal: 'medium' }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
+ <%= link_to submissions_filter_path('folder', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal', native_modal: 'medium', native_title: t('folder') }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
<%= svg_icon('folder', class: 'w-5 h-5 shrink-0') %>
<%= params[:folder] %>
<% end %>
@@ -57,7 +57,7 @@
<% end %>
<% if params[:author].present? %>
- <%= link_to submissions_filter_path('author', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal', native_modal: 'medium' }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
+ <%= link_to submissions_filter_path('author', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal', native_modal: 'medium', native_title: t('author') }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
<%= svg_icon('user', class: 'w-5 h-5 shrink-0') %>
<%= current_account.users.accessible_by(current_ability).where(account: current_account).find_by(email: params[:author])&.full_name || 'NA' %>
<% end %>
@@ -68,7 +68,7 @@
<% end %>
<% if query_params[:completed_at_from].present? || query_params[:completed_at_to].present? %>
- <%= link_to submissions_filter_path('completed_at', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal', native_modal: 'medium' }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
+ <%= link_to submissions_filter_path('completed_at', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal', native_modal: 'medium', native_title: t('completed_at') }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
<%= svg_icon('calendar_check', class: 'w-5 h-5 shrink-0') %>
<% if query_params[:completed_at_from] == query_params[:completed_at_to] %>
@@ -86,7 +86,7 @@
<% end %>
<% if query_params[:created_at_from].present? || query_params[:created_at_to].present? %>
- <%= link_to submissions_filter_path('created_at', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal', native_modal: 'medium' }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
+ <%= link_to submissions_filter_path('created_at', query_params.merge(path: url_for, with_remove: true)), data: { turbo_frame: 'modal', native_modal: 'medium', native_title: t('created_at') }, class: 'flex items-center space-x-1 flex-1 min-w-0 pr-1' do %>
<%= svg_icon('calendar', class: 'w-5 h-5 shrink-0') %>
<% if query_params[:created_at_from] == query_params[:created_at_to] %>
@@ -105,53 +105,59 @@
<% end %>
<% with_status_dropdown = with_status_button && chips_html.blank? %>
<% if with_status_dropdown %>
-
+
-
+
-
- <%= link_to url_for(params: request.query_parameters.except('status')), class: ('bg-base-200' if current_status == 'all') do %>
- <%= svg_icon('list', class: 'w-5 h-5 shrink-0') %>
- <%= t('all') %>
- <% if local_assigns[:all_count] %>
-
- <%= local_assigns[:all_count] %>
-
+
+ <%= link_to url_for(params: request.query_parameters.except('status')), class: 'flex items-center gap-2', data: { native_title: t('all') } do %>
+ <%= svg_icon('list', class: 'w-5 h-5 shrink-0') %>
+ <%= t('all') %>
+ <% if local_assigns[:all_count] %>
+
+ <%= local_assigns[:all_count] %>
+
+ <% end %>
<% end %>
- <% end %>
+
-
- <%= link_to url_for(params: request.query_parameters.merge('status' => 'pending')), class: ('bg-base-200' if current_status == 'pending') do %>
- <%= svg_icon('clock', class: 'w-5 h-5 shrink-0') %>
- <%= t('pending') %>
- <% if local_assigns[:pending_count] %>
-
- <%= local_assigns[:pending_count] %>
-
+
+ <%= link_to url_for(params: request.query_parameters.merge('status' => 'pending')), class: 'flex items-center gap-2', data: { native_title: t('pending') } do %>
+ <%= svg_icon('clock', class: 'w-5 h-5 shrink-0') %>
+ <%= t('pending') %>
+ <% if local_assigns[:pending_count] %>
+
+ <%= local_assigns[:pending_count] %>
+
+ <% end %>
<% end %>
- <% end %>
+
-
- <%= link_to url_for(params: request.query_parameters.merge('status' => 'completed')), class: ('bg-base-200' if current_status == 'completed') do %>
- <%= svg_icon('circle_check', class: 'w-5 h-5 shrink-0') %>
- <%= t('completed') %>
- <% if local_assigns[:completed_count] %>
-
- <%= local_assigns[:completed_count] %>
-
+
+ <%= link_to url_for(params: request.query_parameters.merge('status' => 'completed')), class: 'flex items-center gap-2', data: { native_title: t('completed') } do %>
+ <%= svg_icon('circle_check', class: 'w-5 h-5 shrink-0') %>
+ <%= t('completed') %>
+ <% if local_assigns[:completed_count] %>
+
+ <%= local_assigns[:completed_count] %>
+
+ <% end %>
<% end %>
- <% end %>
+
-
+
<% end %>
<% if chips_html.present? || status_chip_html.present? %>
<% if with_status_button && !with_status_dropdown %>
-
+
<%= svg_icon(status_icons[current_status], class: 'w-5 h-5 shrink-0') %>
<%= t(current_status) %>
<%= svg_icon('chevron_down', class: 'w-4 h-4 shrink-0') %>
diff --git a/app/views/submissions_filters/_filter_button.html.erb b/app/views/submissions_filters/_filter_button.html.erb
index c485080cb..07a07c7f9 100644
--- a/app/views/submissions_filters/_filter_button.html.erb
+++ b/app/views/submissions_filters/_filter_button.html.erb
@@ -1,42 +1,52 @@
<% query_params = params.permit(:q, :scope, :archived).merge(filter_params) %>
<% advanced_filters = filter_params.then { |f| f[:status].in?(%w[pending completed]) ? f.except(:status) : f } %>
-
+
-
+
-
- <%= link_to submissions_filter_path('completed_at', query_params.merge(path: url_for)), data: { turbo_frame: 'modal', native_modal: 'medium' } do %>
- <%= svg_icon('calendar_check', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
- <%= t('completed_at') %>
- <% end %>
+
+ <%= link_to submissions_filter_path('completed_at', query_params.merge(path: url_for)), class: 'flex items-center gap-2', data: { turbo_frame: 'modal', native_modal: 'medium' } do %>
+ <%= svg_icon('calendar_check', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
+ <%= t('completed_at') %>
+ <% end %>
+
-
- <%= link_to submissions_filter_path('created_at', query_params.merge(path: url_for)), data: { turbo_frame: 'modal', native_modal: 'medium' } do %>
- <%= svg_icon('calendar', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
- <%= t('created_at') %>
- <% end %>
+
+ <%= link_to submissions_filter_path('created_at', query_params.merge(path: url_for)), class: 'flex items-center gap-2', data: { turbo_frame: 'modal', native_modal: 'medium' } do %>
+ <%= svg_icon('calendar', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
+ <%= t('created_at') %>
+ <% end %>
+
-
- <%= link_to submissions_filter_path('status', query_params.merge(path: url_for)), data: { turbo_frame: 'modal', native_modal: 'medium' } do %>
- <%= svg_icon('info_circle', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
- <%= t('status') %>
- <% end %>
+
+ <%= link_to submissions_filter_path('status', query_params.merge(path: url_for)), class: 'flex items-center gap-2', data: { turbo_frame: 'modal', native_modal: 'medium' } do %>
+ <%= svg_icon('info_circle', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
+ <%= t('status') %>
+ <% end %>
+
-
- <%= link_to submissions_filter_path('folder', query_params.merge(path: url_for)), data: { turbo_frame: 'modal', native_modal: 'medium' } do %>
- <%= svg_icon('folder', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
- <%= t('folder') %>
- <% end %>
+
+ <%= link_to submissions_filter_path('folder', query_params.merge(path: url_for)), class: 'flex items-center gap-2', data: { turbo_frame: 'modal', native_modal: 'medium' } do %>
+ <%= svg_icon('folder', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
+ <%= t('folder') %>
+ <% end %>
+
-
- <%= link_to submissions_filter_path('author', query_params.merge(path: url_for)), data: { turbo_frame: 'modal', native_modal: 'medium' } do %>
- <%= svg_icon('user', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
- <%= t('author') %>
- <% end %>
+
+ <%= link_to submissions_filter_path('author', query_params.merge(path: url_for)), class: 'flex items-center gap-2', data: { turbo_frame: 'modal', native_modal: 'medium' } do %>
+ <%= svg_icon('user', class: 'w-5 h-5 flex-shrink-0 stroke-2') %>
+ <%= t('author') %>
+ <% end %>
+
-
+
diff --git a/app/views/templates/_template.html.erb b/app/views/templates/_template.html.erb
index 936de7e85..c05b1208f 100644
--- a/app/views/templates/_template.html.erb
+++ b/app/views/templates/_template.html.erb
@@ -68,4 +68,53 @@
<% end %>
+ <% if !template.archived_at? && (can?(:update, template) || can?(:create, template) || can?(:destroy, template)) %>
+
+
+
+ <% if can?(:update, template) %>
+ -
+
+ <%= link_to edit_template_path(template), class: 'flex items-center gap-2', data: { native_title: template.name } do %>
+ <%= svg_icon('pencil', class: 'w-5 h-5 flex-shrink-0') %>
+ <%= t('edit') %>
+ <% end %>
+
+
+ <% end %>
+ <% if can?(:update, template) && template.account_id == current_account.id %>
+ -
+
+ <%= link_to edit_template_folder_path(template.id), class: 'flex items-center gap-2', data: { turbo_frame: :modal, native_modal: :medium } do %>
+ <%= svg_icon('folder_share', class: 'w-5 h-5 flex-shrink-0') %>
+ <%= t('move') %>
+ <% end %>
+
+
+ <% end %>
+ <% if can?(:create, template) %>
+ -
+
+ <%= link_to new_template_clone_path(template), class: 'flex items-center gap-2', data: { turbo_frame: :modal, native_modal: :medium } do %>
+ <%= svg_icon('copy', class: 'w-5 h-5 flex-shrink-0') %>
+ <%= t('clone') %>
+ <% end %>
+
+
+ <% end %>
+ <% if can?(:destroy, template) %>
+ -
+
+
+ <%= button_to '', template_path(template), method: :delete, form: { id: form_uuid, class: 'hidden' } %>
+
+
+ <% end %>
+
+
+ <% end %>