madmin 2.5.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +121 -4
  3. data/app/assets/stylesheets/madmin/actiontext.css +31 -0
  4. data/app/assets/stylesheets/madmin/base.css +22 -11
  5. data/app/assets/stylesheets/madmin/buttons.css +9 -9
  6. data/app/assets/stylesheets/madmin/forms.css +263 -7
  7. data/app/assets/stylesheets/madmin/pagination.css +10 -23
  8. data/app/assets/stylesheets/madmin/reset.css +1 -1
  9. data/app/assets/stylesheets/madmin/sidebar.css +6 -6
  10. data/app/assets/stylesheets/madmin/tables.css +5 -1
  11. data/app/controllers/madmin/base_controller.rb +1 -5
  12. data/app/controllers/madmin/resource_controller.rb +49 -16
  13. data/app/helpers/madmin/application_helper.rb +15 -2
  14. data/app/helpers/madmin/filters_helper.rb +26 -0
  15. data/app/helpers/madmin/sort_helper.rb +2 -2
  16. data/app/javascript/madmin/controllers/filters_controller.js +35 -0
  17. data/app/javascript/madmin/controllers/nested_form_controller.js +5 -0
  18. data/app/javascript/madmin/controllers/select_controller.js +269 -22
  19. data/app/views/layouts/madmin/application.html.erb +1 -0
  20. data/app/views/madmin/application/_active_filters.html.erb +15 -0
  21. data/app/views/madmin/application/_filter.html.erb +13 -0
  22. data/app/views/madmin/application/_filters.html.erb +32 -0
  23. data/app/views/madmin/application/_javascript.html.erb +0 -2
  24. data/app/views/madmin/application/_pagination.html.erb +32 -0
  25. data/app/views/madmin/application/edit.html.erb +1 -1
  26. data/app/views/madmin/application/index.html.erb +18 -15
  27. data/app/views/madmin/application/new.html.erb +1 -1
  28. data/app/views/madmin/application/show.html.erb +5 -3
  29. data/app/views/madmin/fields/array/_form.html.erb +1 -0
  30. data/app/views/madmin/fields/array/_index.html.erb +1 -0
  31. data/app/views/madmin/fields/array/_show.html.erb +1 -0
  32. data/app/views/madmin/fields/attachment/_form.html.erb +0 -4
  33. data/app/views/madmin/fields/attachment/_show.html.erb +3 -1
  34. data/app/views/madmin/fields/attachments/_index.html.erb +1 -1
  35. data/app/views/madmin/fields/belongs_to/_form.html.erb +1 -1
  36. data/app/views/madmin/fields/belongs_to/_index.html.erb +5 -2
  37. data/app/views/madmin/fields/belongs_to/_show.html.erb +1 -1
  38. data/app/views/madmin/fields/enum/_form.html.erb +1 -1
  39. data/app/views/madmin/fields/has_many/_form.html.erb +1 -1
  40. data/app/views/madmin/fields/has_many/_index.html.erb +1 -1
  41. data/app/views/madmin/fields/has_many/_show.html.erb +3 -11
  42. data/app/views/madmin/fields/has_one/_index.html.erb +5 -1
  43. data/app/views/madmin/fields/has_one/_show.html.erb +1 -1
  44. data/app/views/madmin/fields/json/_form.html.erb +1 -1
  45. data/app/views/madmin/fields/json/_show.html.erb +1 -1
  46. data/app/views/madmin/fields/nested_has_many/_index.html.erb +1 -1
  47. data/app/views/madmin/fields/nested_has_many/_show.html.erb +3 -11
  48. data/app/views/madmin/fields/nested_has_one/_form.html.erb +18 -0
  49. data/app/views/madmin/fields/polymorphic/_form.html.erb +1 -2
  50. data/app/views/madmin/fields/polymorphic/_index.html.erb +5 -1
  51. data/app/views/madmin/fields/polymorphic/_show.html.erb +1 -1
  52. data/app/views/madmin/fields/select/_form.html.erb +1 -1
  53. data/config/importmap.rb +0 -1
  54. data/config/locales/el.yml +86 -0
  55. data/config/locales/en.yml +46 -0
  56. data/config/locales/fr.yml +46 -0
  57. data/config/locales/ko.yml +84 -0
  58. data/lib/madmin/field.rb +27 -1
  59. data/lib/madmin/fields/array.rb +16 -0
  60. data/lib/madmin/fields/attachment.rb +7 -0
  61. data/lib/madmin/fields/belongs_to.rb +2 -1
  62. data/lib/madmin/fields/boolean.rb +1 -0
  63. data/lib/madmin/fields/currency.rb +7 -0
  64. data/lib/madmin/fields/date.rb +1 -0
  65. data/lib/madmin/fields/date_time.rb +1 -0
  66. data/lib/madmin/fields/decimal.rb +1 -0
  67. data/lib/madmin/fields/float.rb +1 -0
  68. data/lib/madmin/fields/has_many.rb +6 -23
  69. data/lib/madmin/fields/integer.rb +1 -0
  70. data/lib/madmin/fields/json.rb +21 -0
  71. data/lib/madmin/fields/nested_has_many.rb +2 -2
  72. data/lib/madmin/fields/nested_has_one.rb +48 -0
  73. data/lib/madmin/fields/polymorphic.rb +5 -0
  74. data/lib/madmin/fields/string.rb +3 -1
  75. data/lib/madmin/fields/text.rb +3 -1
  76. data/lib/madmin/filter.rb +96 -0
  77. data/lib/madmin/menu.rb +2 -1
  78. data/lib/madmin/page.rb +61 -0
  79. data/lib/madmin/pagination.rb +50 -0
  80. data/lib/madmin/resource.rb +95 -7
  81. data/lib/madmin/resource_builder.rb +8 -0
  82. data/lib/madmin/search.rb +5 -7
  83. data/lib/madmin/version.rb +1 -1
  84. data/lib/madmin.rb +47 -2
  85. metadata +19 -16
@@ -28,11 +28,11 @@
28
28
  margin-bottom: 0.1rem;
29
29
 
30
30
  &:hover {
31
- background-color: rgb(243 244 246);
31
+ background-color: var(--hover-color);
32
32
  }
33
33
 
34
34
  &.active {
35
- background-color: rgb(243 244 246);
35
+ background-color: var(--hover-color);
36
36
  font-weight: 600;
37
37
  }
38
38
  }
@@ -90,11 +90,11 @@ main {
90
90
  margin-bottom: 0.1rem;
91
91
 
92
92
  &:hover {
93
- background-color: rgb(243 244 246);
93
+ background-color: var(--hover-color);
94
94
  }
95
95
 
96
96
  &.active {
97
- background-color: rgb(243 244 246);
97
+ background-color: var(--hover-color);
98
98
  font-weight: 600;
99
99
  }
100
100
  }
@@ -106,7 +106,7 @@ main {
106
106
  margin-top: 1rem;
107
107
 
108
108
  a {
109
- color: rgb(75 85 99);
109
+ color: var(--light-text-color);
110
110
  display: flex;
111
111
  align-items: center;
112
112
  gap: 0.25rem;
@@ -119,7 +119,7 @@ main {
119
119
  }
120
120
 
121
121
  &:hover {
122
- color: var(--primary-color);
122
+ color: var(--link-color);
123
123
  }
124
124
  }
125
125
  }
@@ -5,6 +5,10 @@
5
5
  position: relative;
6
6
  min-width: 0;
7
7
  width: 100%;
8
+
9
+ &:not(:has(tbody tr)) th {
10
+ border-bottom: none;
11
+ }
8
12
  }
9
13
 
10
14
  table {
@@ -17,7 +21,7 @@ table {
17
21
  text-align: left;
18
22
 
19
23
  a {
20
- color: rgb(17 24 39);
24
+ color: var(--text-color);
21
25
  text-decoration: none;
22
26
  }
23
27
 
@@ -2,11 +2,7 @@ module Madmin
2
2
  class BaseController < ActionController::Base
3
3
  include ::ActiveStorage::SetCurrent if defined?(::ActiveStorage)
4
4
 
5
- if Gem::Version.new(Pagy::VERSION) >= Gem::Version.new("43.0.0.rc")
6
- include Pagy::Method
7
- else
8
- include Pagy::Backend
9
- end
5
+ include Madmin::Pagination
10
6
 
11
7
  protect_from_forgery with: :exception
12
8
 
@@ -3,12 +3,13 @@ module Madmin
3
3
  include SortHelper
4
4
 
5
5
  before_action :set_record, except: [:index, :new, :create]
6
+ before_action :enforce_readonly, only: [:new, :create, :edit, :update, :destroy]
6
7
 
7
8
  # Assign current_user for paper_trail gem
8
9
  before_action :set_paper_trail_whodunnit, if: -> { respond_to?(:set_paper_trail_whodunnit, true) }
9
10
 
10
11
  def index
11
- @pagy, @records = pagy(scoped_resources)
12
+ @page, @records = paginate_collection(scoped_resources)
12
13
 
13
14
  respond_to do |format|
14
15
  format.html
@@ -26,8 +27,8 @@ module Madmin
26
27
  end
27
28
 
28
29
  def create
29
- @record = resource.model.new(resource_params)
30
- if @record.save
30
+ @record = resource.model.new
31
+ if save_record(resource_params)
31
32
  redirect_to resource.show_path(@record)
32
33
  else
33
34
  render :new, status: :unprocessable_entity
@@ -38,7 +39,7 @@ module Madmin
38
39
  end
39
40
 
40
41
  def update
41
- if @record.update(resource_params)
42
+ if save_record(resource_params)
42
43
  redirect_to resource.show_path(@record)
43
44
  else
44
45
  render :edit, status: :unprocessable_entity
@@ -67,41 +68,73 @@ module Madmin
67
68
 
68
69
  def scoped_resources
69
70
  resources = resource.model.send(valid_scope)
70
- resources = Madmin::Search.new(resources, resource, search_term).run
71
+ resources = Madmin::Search.new(resources, resource, search_term, filters).run
72
+
73
+ return resources if sort_column.blank?
74
+
71
75
  resources.reorder(sort_column => sort_direction)
72
76
  end
73
77
 
78
+ # Returns [page, records]. Override for non-ActiveRecord collections and
79
+ # return an object responding to the Madmin::Page interface.
80
+ def paginate_collection(collection)
81
+ paginate(collection, page: params[:page])
82
+ end
83
+
74
84
  def valid_scope
75
85
  scope = params.fetch(:scope, "all")
76
86
  resource.scopes.include?(scope.to_sym) ? scope : :all
77
87
  end
78
88
 
79
89
  def resource_params
80
- params.require(resource.param_key)
81
- .permit(*resource.permitted_params)
82
- .transform_values { |v| change_polymorphic(v) }
90
+ cast_fields params.require(resource.param_key).permit(*resource.permitted_params)
83
91
  end
84
92
 
85
93
  def new_resource_params
86
- params.fetch(resource.param_key, {}).permit!
87
- .permit(*resource.permitted_params)
88
- .transform_values { |v| change_polymorphic(v) }
94
+ cast_fields params.fetch(resource.param_key, {}).permit!.permit(*resource.permitted_params)
95
+ end
96
+
97
+ # Lets each field convert its submitted value, such as parsing JSON
98
+ def cast_fields(attributes)
99
+ attributes.to_h.to_h do |name, value|
100
+ field = field_for(name)
101
+ [name, field ? field.cast(value) : value]
102
+ end
89
103
  end
90
104
 
91
- def change_polymorphic(data)
92
- return data unless data.is_a?(ActionController::Parameters) && data[:type]
105
+ # Assigns the attributes and saves, unless a field doesn't accept its value
106
+ def save_record(attributes)
107
+ @record.assign_attributes(attributes)
93
108
 
94
- if data[:type] == "polymorphic"
95
- GlobalID::Locator.locate(data[:value])
109
+ rejected = attributes.select { |name, value| field_for(name)&.accepts?(value) == false }.keys
110
+
111
+ if rejected.any?
112
+ @record.validate
113
+ rejected.each { |name| @record.errors.add(name, :invalid) }
114
+ false
96
115
  else
97
- raise "Unrecognised param data: #{data.inspect}"
116
+ @record.save
98
117
  end
99
118
  end
100
119
 
120
+ def field_for(name)
121
+ resource.get_attribute(name.to_sym)&.field
122
+ end
123
+
101
124
  def search_term
102
125
  @search_term ||= params[:q].to_s.strip
103
126
  end
104
127
 
128
+ # The index filters from `filters[][column]`, `filters[][operator]` and `filters[][value]`
129
+ def filters
130
+ @filters ||= Madmin::Filter.from_params(resource, params.slice(:filters).permit(filters: [:column, :operator, :value]).fetch(:filters, []))
131
+ end
132
+ helper_method :filters
133
+
134
+ def enforce_readonly
135
+ redirect_to resource.index_path, alert: t("madmin.flash.readonly", name: resource.friendly_name) if resource.readonly?
136
+ end
137
+
105
138
  ActiveSupport.run_load_hooks(:madmin_resource_controller, self)
106
139
  end
107
140
  end
@@ -1,10 +1,23 @@
1
1
  module Madmin
2
2
  module ApplicationHelper
3
- include Pagy::Frontend if defined? Pagy::Frontend
4
3
  include Rails.application.routes.url_helpers
5
4
 
5
+ # Links to the record's page in its resource. Renders the name without a
6
+ # link when the resource's show route isn't drawn.
7
+ def link_to_record(resource, record, **options)
8
+ link_to resource.display_name(record), resource.show_path(record), **options
9
+ rescue Madmin::MissingRoute
10
+ resource.display_name(record)
11
+ end
12
+
6
13
  def clear_search_params
7
- resource.index_path(sort: params[:sort], direction: params[:direction])
14
+ index_path_with(q: nil)
15
+ end
16
+
17
+ # The current index URL with some params changed, keeping the search,
18
+ # scope, sort and filters. Any change goes back to the first page
19
+ def index_path_with(**changes)
20
+ resource.index_path(request.query_parameters.symbolize_keys.except(:page).merge(changes))
8
21
  end
9
22
  end
10
23
  end
@@ -0,0 +1,26 @@
1
+ module Madmin
2
+ module FiltersHelper
3
+ FILTER_INPUT_TYPES = {string: "text", number: "number", date: "date", datetime: "datetime-local", boolean: "text", array: "text"}.freeze
4
+
5
+ # Operators that don't take a value are marked so the filters controller can hide the value input
6
+ def filter_operator_options(type)
7
+ Filter::OPERATORS.fetch(type).map do |operator|
8
+ option = [t("madmin.filters.operators.#{type}.#{operator}"), operator]
9
+ Filter::WITHOUT_VALUE.include?(operator) ? option << {data: {without_value: true}} : option
10
+ end
11
+ end
12
+
13
+ def filter_column_options(resource)
14
+ resource.filterable_attributes.map do |attribute|
15
+ type = attribute.field.filter_type
16
+ [attribute.field.label, attribute.name, {data: {input_type: filter_input_type(type), filter_type: type}}]
17
+ end
18
+ end
19
+
20
+ def filter_input_type(type) = FILTER_INPUT_TYPES.fetch(type)
21
+
22
+ def filter_value_label(filter)
23
+ filter.type.in?([:date, :datetime]) ? l(filter.typed_value, format: :long) : filter.value
24
+ end
25
+ end
26
+ end
@@ -4,7 +4,7 @@ module Madmin
4
4
  matching_column = (column.to_s == sort_column)
5
5
  direction = (sort_direction == "asc") ? "desc" : "asc"
6
6
 
7
- link_to resource.index_path(sort: column, direction: direction, scope: params[:scope], q: params[:q]), options do
7
+ link_to index_path_with(sort: column, direction: direction), options do
8
8
  concat title
9
9
  if matching_column
10
10
  concat " "
@@ -22,7 +22,7 @@ module Madmin
22
22
  end
23
23
 
24
24
  def default_sort_column
25
- resource.try(:default_sort_column) || (["created_at", "id", "uuid"] & resource.model.column_names).first
25
+ resource.try(:default_sort_column) || (["created_at", "id", "uuid"] & resource.sortable_columns).first || resource.sortable_columns.first
26
26
  end
27
27
 
28
28
  def default_sort_direction
@@ -0,0 +1,35 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Adds and removes index filter rows. When the column changes, the row gets
4
+ // that column's operators and input type from the server-rendered markup
5
+ export default class extends Controller {
6
+ static targets = ["rows", "row", "operators"]
7
+
8
+ add() {
9
+ this.rowsTarget.append(this.rowTarget.content.cloneNode(true))
10
+ this.rowsTarget.lastElementChild.querySelector("select").focus()
11
+ }
12
+
13
+ remove(event) {
14
+ event.target.closest("[data-filter-row]").remove()
15
+ }
16
+
17
+ changeColumn(event) {
18
+ const row = event.target.closest("[data-filter-row]")
19
+ const column = event.target.selectedOptions[0]
20
+ const operator = row.querySelector("[name='filters[][operator]']")
21
+ const value = row.querySelector("[name='filters[][value]']")
22
+ const operators = this.operatorsTargets.find(template => template.dataset.filterType == column.dataset.filterType)
23
+
24
+ operator.replaceChildren(operators.content.cloneNode(true))
25
+ value.type = column.dataset.inputType
26
+ value.step = value.type == "number" ? "any" : ""
27
+ value.value = ""
28
+ this.changeOperator({ target: operator })
29
+ }
30
+
31
+ changeOperator(event) {
32
+ const row = event.target.closest("[data-filter-row]")
33
+ row.querySelector("[name='filters[][value]']").hidden = "withoutValue" in event.target.selectedOptions[0].dataset
34
+ }
35
+ }
@@ -7,6 +7,8 @@ export default class extends Controller {
7
7
 
8
8
  connect() {
9
9
  this.wrapperClass = this.data.get("wrapperClass") || "nested-fields"
10
+ // A has_one only allows one record, so the add link hides while it has one
11
+ this.single = this.data.get("single") == "true"
10
12
  }
11
13
 
12
14
  add_association(event) {
@@ -14,6 +16,7 @@ export default class extends Controller {
14
16
 
15
17
  var content = this.templateTarget.innerHTML.replace(/NEW_RECORD/g, new Date().getTime())
16
18
  this.linksTarget.insertAdjacentHTML('beforebegin', content)
19
+ if (this.single) this.linksTarget.hidden = true
17
20
  }
18
21
 
19
22
  remove_association(event) {
@@ -30,5 +33,7 @@ export default class extends Controller {
30
33
  wrapper.querySelector("input[name*='_destroy']").value = 1
31
34
  wrapper.style.display = 'none'
32
35
  }
36
+
37
+ if (this.single) this.linksTarget.hidden = false
33
38
  }
34
39
  }
@@ -1,37 +1,284 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
- import TomSelect from "tom-select"
3
2
 
3
+ let count = 0
4
+
5
+ // Turns a <select> into a searchable combobox. The <select> stays in the form,
6
+ // hidden, and is what gets submitted.
7
+ //
8
+ // Without a url, the <select>'s own options are filtered as you type.
9
+ // With a url, options are loaded from the server when it opens and as you type:
10
+ //
11
+ // <select data-controller="select" data-select-url-value="/madmin/users.json">
12
+ //
13
+ // GET /madmin/users.json => [{"id": 1, "name": "Chris Oliver"}, ...]
14
+ // GET /madmin/users.json?q=chris => [{"id": 1, "name": "Chris Oliver"}]
4
15
  export default class extends Controller {
5
- static values = {
6
- options: Object,
7
- url: String
8
- }
16
+ static values = { url: String }
9
17
 
10
18
  connect() {
11
- let options = {
12
- plugins: ['remove_button'],
13
- valueField: 'id',
14
- labelField: 'name',
15
- searchField: 'name',
19
+ this.multiple = this.element.multiple
20
+ this.results = []
21
+ this.activeIndex = -1
22
+
23
+ this.build()
24
+ this.element.hidden = true
25
+ this.element.after(this.wrapper)
26
+ this.labelFor(this.element.id)?.setAttribute("for", this.input.id)
27
+ this.render()
28
+ }
29
+
30
+ disconnect() {
31
+ clearTimeout(this.timeout)
32
+ this.abortController?.abort()
33
+ this.labelFor(this.input.id)?.setAttribute("for", this.element.id)
34
+ this.wrapper.remove()
35
+ this.element.hidden = false
36
+ }
37
+
38
+ // Events
39
+
40
+ focus() {
41
+ if (!this.multiple) this.input.select()
42
+ this.search()
43
+ }
44
+
45
+ typed() {
46
+ clearTimeout(this.timeout)
47
+ if (this.hasUrlValue) {
48
+ this.timeout = setTimeout(() => this.search(), 300)
49
+ } else {
50
+ this.search()
16
51
  }
52
+ }
53
+
54
+ keydown(event) {
55
+ switch (event.key) {
56
+ case "ArrowDown":
57
+ event.preventDefault()
58
+ this.isOpen ? this.moveActive(1) : this.search()
59
+ break
60
+ case "ArrowUp":
61
+ event.preventDefault()
62
+ this.moveActive(-1)
63
+ break
64
+ case "Enter":
65
+ if (this.isOpen && this.results[this.activeIndex]) {
66
+ event.preventDefault()
67
+ this.choose(this.results[this.activeIndex])
68
+ }
69
+ break
70
+ case "Escape":
71
+ if (this.isOpen) {
72
+ event.preventDefault()
73
+ this.close()
74
+ }
75
+ break
76
+ case "Backspace":
77
+ if (this.multiple && this.input.value === "") {
78
+ this.remove(this.selectedOptions.at(-1)?.value)
79
+ }
80
+ break
81
+ }
82
+ }
83
+
84
+ blur() {
85
+ // Clearing the text of a single select clears its value
86
+ if (!this.multiple && this.input.value === "" && this.element.value !== "") {
87
+ this.choose({ id: "", name: "" })
88
+ }
89
+ this.close()
90
+ }
91
+
92
+ // Searching
93
+
94
+ async search() {
95
+ const query = this.query
17
96
 
18
97
  if (this.hasUrlValue) {
19
- options["load"] = (search, callback) => {
20
- let url = search ? `${this.urlValue}?q=${search}` : this.urlValue;
21
- fetch(url)
22
- .then(response => response.json())
23
- .then(json => {
24
- callback(json);
25
- }).catch(() => {
26
- callback();
27
- });
98
+ this.abortController?.abort()
99
+ this.abortController = new AbortController()
100
+
101
+ const url = new URL(this.urlValue, window.location.origin)
102
+ if (query) url.searchParams.set("q", query)
103
+
104
+ try {
105
+ const response = await fetch(url, { headers: { Accept: "application/json" }, signal: this.abortController.signal })
106
+ this.open(await response.json())
107
+ } catch (error) {
108
+ if (error.name !== "AbortError") this.open([])
28
109
  }
110
+ } else {
111
+ this.open(this.localResults(query))
29
112
  }
113
+ }
30
114
 
31
- this.select = new TomSelect(this.element, options)
115
+ localResults(query) {
116
+ return Array.from(this.element.options)
117
+ .filter(option => option.value !== "")
118
+ .map(option => ({ id: option.value, name: option.text }))
119
+ .filter(result => result.name.toLowerCase().includes(query.toLowerCase()))
32
120
  }
33
121
 
34
- disconnect() {
35
- this.select.destroy()
122
+ // Choosing
123
+
124
+ choose({ id, name }) {
125
+ const value = String(id)
126
+ let option = Array.from(this.element.options).find(option => option.value === value)
127
+ if (!option) {
128
+ option = new Option(name, value)
129
+ this.element.add(option)
130
+ }
131
+ option.selected = true
132
+ this.changed()
133
+
134
+ if (this.multiple) this.input.value = ""
135
+ this.close()
136
+ }
137
+
138
+ remove(value) {
139
+ const option = this.selectedOptions.find(option => option.value === value)
140
+ if (option) {
141
+ option.selected = false
142
+ this.changed()
143
+ }
144
+ }
145
+
146
+ changed() {
147
+ this.element.dispatchEvent(new Event("change", { bubbles: true }))
148
+ this.render()
149
+ }
150
+
151
+ // Rendering
152
+
153
+ build() {
154
+ this.wrapper = document.createElement("div")
155
+ this.wrapper.className = "combobox"
156
+
157
+ this.control = document.createElement("div")
158
+ this.control.className = "combobox-control"
159
+ this.control.addEventListener("click", () => this.input.focus())
160
+
161
+ this.input = document.createElement("input")
162
+ this.input.type = "text"
163
+ this.input.id = `${this.element.id || `select_${++count}`}_combobox`
164
+ this.input.className = "combobox-input"
165
+ this.input.autocomplete = "off"
166
+ this.input.setAttribute("role", "combobox")
167
+ this.input.setAttribute("aria-autocomplete", "list")
168
+ this.input.setAttribute("aria-expanded", "false")
169
+ this.input.addEventListener("focus", () => this.focus())
170
+ this.input.addEventListener("input", () => this.typed())
171
+ this.input.addEventListener("keydown", event => this.keydown(event))
172
+ this.input.addEventListener("blur", () => this.blur())
173
+
174
+ this.listbox = document.createElement("ul")
175
+ this.listbox.id = `${this.input.id}_listbox`
176
+ this.listbox.className = "combobox-listbox"
177
+ this.listbox.hidden = true
178
+ this.listbox.setAttribute("role", "listbox")
179
+ // Keep focus in the input so choosing an option doesn't blur it first
180
+ this.listbox.addEventListener("mousedown", event => event.preventDefault())
181
+ this.input.setAttribute("aria-controls", this.listbox.id)
182
+
183
+ this.control.append(this.input)
184
+ this.wrapper.append(this.control, this.listbox)
185
+ }
186
+
187
+ render() {
188
+ if (this.multiple) {
189
+ this.control.querySelectorAll(".combobox-chip").forEach(chip => chip.remove())
190
+ this.selectedOptions.forEach(option => this.input.before(this.chip(option)))
191
+ } else {
192
+ const option = this.selectedOptions[0]
193
+ this.input.value = option && option.value !== "" ? option.text : ""
194
+ }
195
+ }
196
+
197
+ chip(option) {
198
+ const chip = document.createElement("span")
199
+ chip.className = "combobox-chip"
200
+ chip.textContent = option.text
201
+
202
+ const button = document.createElement("button")
203
+ button.type = "button"
204
+ button.textContent = "×"
205
+ button.setAttribute("aria-label", `Remove ${option.text}`)
206
+ button.addEventListener("click", event => {
207
+ event.stopPropagation()
208
+ this.remove(option.value)
209
+ })
210
+
211
+ chip.append(button)
212
+ return chip
213
+ }
214
+
215
+ open(results) {
216
+ const selected = this.selectedOptions.map(option => option.value)
217
+ this.results = this.multiple ? results.filter(result => !selected.includes(String(result.id))) : results
218
+ this.activeIndex = this.results.length ? 0 : -1
219
+
220
+ this.listbox.replaceChildren(...this.results.map((result, index) => {
221
+ const item = document.createElement("li")
222
+ item.id = `${this.listbox.id}_${index}`
223
+ item.textContent = result.name
224
+ item.setAttribute("role", "option")
225
+ item.addEventListener("click", () => this.choose(result))
226
+ return item
227
+ }))
228
+
229
+ if (this.results.length === 0) {
230
+ const empty = document.createElement("li")
231
+ empty.className = "combobox-empty"
232
+ empty.textContent = "No results"
233
+ this.listbox.append(empty)
234
+ }
235
+
236
+ this.listbox.hidden = false
237
+ this.input.setAttribute("aria-expanded", "true")
238
+ this.highlight()
239
+ }
240
+
241
+ close() {
242
+ clearTimeout(this.timeout)
243
+ this.abortController?.abort()
244
+ this.listbox.hidden = true
245
+ this.input.setAttribute("aria-expanded", "false")
246
+ this.input.removeAttribute("aria-activedescendant")
247
+ this.render()
248
+ }
249
+
250
+ moveActive(step) {
251
+ if (this.results.length === 0) return
252
+ this.activeIndex = (this.activeIndex + step + this.results.length) % this.results.length
253
+ this.highlight()
254
+ }
255
+
256
+ highlight() {
257
+ this.listbox.querySelectorAll("[role=option]").forEach((item, index) => {
258
+ const active = index === this.activeIndex
259
+ item.setAttribute("aria-selected", active)
260
+ if (active) {
261
+ this.input.setAttribute("aria-activedescendant", item.id)
262
+ item.scrollIntoView({ block: "nearest" })
263
+ }
264
+ })
265
+ }
266
+
267
+ // Helpers
268
+
269
+ get query() {
270
+ return this.multiple || this.input.value !== this.selectedOptions[0]?.text ? this.input.value.trim() : ""
271
+ }
272
+
273
+ get isOpen() {
274
+ return !this.listbox.hidden
275
+ }
276
+
277
+ get selectedOptions() {
278
+ return Array.from(this.element.selectedOptions).filter(option => option.value !== "")
279
+ }
280
+
281
+ labelFor(id) {
282
+ return id ? document.querySelector(`label[for="${CSS.escape(id)}"]`) : null
36
283
  }
37
284
  }
@@ -4,6 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="ROBOTS" content="NOODP">
6
6
  <meta name="viewport" content="initial-scale=1">
7
+ <meta name="color-scheme" content="light dark">
7
8
  <title>
8
9
  <% if content_for? :title %>
9
10
  <%= yield(:title) %> -
@@ -0,0 +1,15 @@
1
+ <% if filters.any? %>
2
+ <div class="active-filters">
3
+ <% filters.each do |filter| %>
4
+ <span class="filter-chip">
5
+ <span>
6
+ <strong><%= filter.field.label %></strong>
7
+ <%= t("madmin.filters.operators.#{filter.type}.#{filter.operator}") %>
8
+ <%= tag.strong filter_value_label(filter) if filter.value? %>
9
+ </span>
10
+ <%= link_to "×", index_path_with(filters: filters.excluding(filter).map(&:to_h).presence), aria: {label: t("madmin.filters.remove")} %>
11
+ </span>
12
+ <% end %>
13
+ <%= link_to t("madmin.filters.clear_all"), index_path_with(filters: nil) %>
14
+ </div>
15
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <div class="filter-row" data-filter-row>
2
+ <%= select_tag "filters[][column]", options_for_select(filter_column_options(resource), filter.column),
3
+ id: nil, aria: {label: t("madmin.filters.column")}, data: {action: "filters#changeColumn"} %>
4
+
5
+ <%= select_tag "filters[][operator]", options_for_select(filter_operator_options(filter.type), filter.operator),
6
+ id: nil, aria: {label: t("madmin.filters.operator")}, data: {action: "filters#changeOperator"} %>
7
+
8
+ <%= tag.input name: "filters[][value]", value: filter.value, id: nil,
9
+ type: filter_input_type(filter.type), step: ("any" if filter.type == :number),
10
+ hidden: !filter.value?, aria: {label: t("madmin.filters.value")} %>
11
+
12
+ <button type="button" class="filter-remove" aria-label="<%= t("madmin.filters.remove") %>" data-action="filters#remove">&times;</button>
13
+ </div>