jav_madmin 2.6.7 → 3.3.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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +89 -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 +271 -11
  7. data/app/assets/stylesheets/madmin/pagination.css +25 -21
  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 +2 -1
  11. data/app/controllers/madmin/base_controller.rb +1 -5
  12. data/app/controllers/madmin/resource_controller.rb +53 -19
  13. data/app/helpers/madmin/application_helper.rb +33 -2
  14. data/app/helpers/madmin/filters_helper.rb +26 -0
  15. data/app/helpers/madmin/sort_helper.rb +1 -1
  16. data/app/javascript/madmin/application.js +4 -0
  17. data/app/javascript/madmin/controllers/application.js +0 -3
  18. data/app/javascript/madmin/controllers/filters_controller.js +35 -0
  19. data/app/javascript/madmin/controllers/nested_form_controller.js +5 -0
  20. data/app/javascript/madmin/controllers/select_controller.js +269 -22
  21. data/app/views/layouts/madmin/application.html.erb +1 -0
  22. data/app/views/madmin/application/_active_filters.html.erb +15 -0
  23. data/app/views/madmin/application/_filter.html.erb +13 -0
  24. data/app/views/madmin/application/_filters.html.erb +30 -0
  25. data/app/views/madmin/application/_javascript.html.erb +0 -2
  26. data/app/views/madmin/application/_pagination.html.erb +33 -0
  27. data/app/views/madmin/application/_per_page.html.erb +10 -0
  28. data/app/views/madmin/application/edit.html.erb +1 -1
  29. data/app/views/madmin/application/index.html.erb +9 -12
  30. data/app/views/madmin/application/new.html.erb +1 -1
  31. data/app/views/madmin/application/show.html.erb +2 -2
  32. data/app/views/madmin/fields/array/_form.html.erb +1 -0
  33. data/app/views/madmin/fields/array/_index.html.erb +1 -0
  34. data/app/views/madmin/fields/array/_show.html.erb +1 -0
  35. data/app/views/madmin/fields/attachment/_form.html.erb +1 -5
  36. data/app/views/madmin/fields/attachment/_show.html.erb +3 -1
  37. data/app/views/madmin/fields/attachments/_form.html.erb +1 -1
  38. data/app/views/madmin/fields/belongs_to/_form.html.erb +1 -1
  39. data/app/views/madmin/fields/belongs_to/_index.html.erb +5 -2
  40. data/app/views/madmin/fields/belongs_to/_show.html.erb +1 -1
  41. data/app/views/madmin/fields/enum/_form.html.erb +1 -1
  42. data/app/views/madmin/fields/file/_form.html.erb +1 -1
  43. data/app/views/madmin/fields/has_many/_form.html.erb +1 -1
  44. data/app/views/madmin/fields/has_many/_show.html.erb +3 -11
  45. data/app/views/madmin/fields/has_one/_index.html.erb +5 -1
  46. data/app/views/madmin/fields/has_one/_show.html.erb +1 -1
  47. data/app/views/madmin/fields/json/_form.html.erb +1 -1
  48. data/app/views/madmin/fields/json/_show.html.erb +1 -1
  49. data/app/views/madmin/fields/nested_has_many/_show.html.erb +3 -11
  50. data/app/views/madmin/fields/nested_has_one/_form.html.erb +18 -0
  51. data/app/views/madmin/fields/polymorphic/_form.html.erb +1 -2
  52. data/app/views/madmin/fields/polymorphic/_index.html.erb +5 -1
  53. data/app/views/madmin/fields/polymorphic/_show.html.erb +1 -1
  54. data/app/views/madmin/fields/select/_form.html.erb +1 -1
  55. data/config/importmap.rb +0 -3
  56. data/config/locales/el.yml +47 -0
  57. data/config/locales/en.yml +47 -0
  58. data/config/locales/fr.yml +47 -0
  59. data/config/locales/ko.yml +48 -0
  60. data/lib/generators/madmin/resource/templates/resource.rb.tt +3 -0
  61. data/lib/madmin/field.rb +26 -0
  62. data/lib/madmin/fields/array.rb +16 -0
  63. data/lib/madmin/fields/attachment.rb +7 -0
  64. data/lib/madmin/fields/belongs_to.rb +2 -1
  65. data/lib/madmin/fields/boolean.rb +1 -0
  66. data/lib/madmin/fields/currency.rb +7 -0
  67. data/lib/madmin/fields/date.rb +1 -0
  68. data/lib/madmin/fields/date_time.rb +1 -0
  69. data/lib/madmin/fields/decimal.rb +1 -0
  70. data/lib/madmin/fields/float.rb +1 -0
  71. data/lib/madmin/fields/has_many.rb +8 -23
  72. data/lib/madmin/fields/integer.rb +1 -0
  73. data/lib/madmin/fields/json.rb +21 -0
  74. data/lib/madmin/fields/nested_has_many.rb +2 -2
  75. data/lib/madmin/fields/nested_has_one.rb +48 -0
  76. data/lib/madmin/fields/polymorphic.rb +5 -0
  77. data/lib/madmin/fields/string.rb +2 -0
  78. data/lib/madmin/fields/text.rb +2 -0
  79. data/lib/madmin/filter.rb +113 -0
  80. data/lib/madmin/menu.rb +2 -1
  81. data/lib/madmin/page.rb +89 -0
  82. data/lib/madmin/pagination.rb +50 -0
  83. data/lib/madmin/resource.rb +86 -19
  84. data/lib/madmin/search.rb +5 -7
  85. data/lib/madmin/version.rb +1 -1
  86. data/lib/madmin.rb +45 -3
  87. metadata +17 -15
@@ -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
  }
@@ -21,7 +21,7 @@ table {
21
21
  text-align: left;
22
22
 
23
23
  a {
24
- color: rgb(17 24 39);
24
+ color: var(--text-color);
25
25
  text-decoration: none;
26
26
  }
27
27
 
@@ -46,6 +46,7 @@ table {
46
46
  align-items: center;
47
47
  display: flex;
48
48
  gap: 0.5rem;
49
+ justify-content: flex-end;
49
50
  }
50
51
  }
51
52
 
@@ -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
 
@@ -9,7 +9,7 @@ module Madmin
9
9
  before_action :set_paper_trail_whodunnit, if: -> { respond_to?(:set_paper_trail_whodunnit, true) }
10
10
 
11
11
  def index
12
- @pagy, @records = paginate_collection(scoped_resources)
12
+ @page, @records = paginate_collection(scoped_resources)
13
13
 
14
14
  respond_to do |format|
15
15
  format.html
@@ -27,8 +27,8 @@ module Madmin
27
27
  end
28
28
 
29
29
  def create
30
- @record = resource.model.new(resource_params)
31
- if @record.save
30
+ @record = resource.model.new
31
+ if save_record(resource_params)
32
32
  redirect_to resource.show_path(@record)
33
33
  else
34
34
  render :new, status: :unprocessable_entity
@@ -39,7 +39,7 @@ module Madmin
39
39
  end
40
40
 
41
41
  def update
42
- if @record.update(resource_params)
42
+ if save_record(resource_params)
43
43
  redirect_to resource.show_path(@record)
44
44
  else
45
45
  render :edit, status: :unprocessable_entity
@@ -47,12 +47,25 @@ module Madmin
47
47
  end
48
48
 
49
49
  def destroy
50
- @record.destroy
51
- redirect_to resource.index_path
50
+ if @record.destroy
51
+ redirect_to resource.index_path, status: :see_other
52
+ else
53
+ destroy_failed destroy_errors
54
+ end
55
+ rescue ActiveRecord::DeleteRestrictionError, ActiveRecord::InvalidForeignKey
56
+ destroy_failed t("madmin.flash.destroy_restricted", name: resource.friendly_name)
52
57
  end
53
58
 
54
59
  private
55
60
 
61
+ def destroy_failed(message)
62
+ redirect_back_or_to resource.index_path, alert: message, status: :see_other
63
+ end
64
+
65
+ def destroy_errors
66
+ @record.errors.full_messages.to_sentence.presence || t("madmin.flash.destroy_failed", name: resource.friendly_name)
67
+ end
68
+
56
69
  def set_record
57
70
  @record = resource.model_find(params[:id])
58
71
  end
@@ -68,15 +81,17 @@ module Madmin
68
81
 
69
82
  def scoped_resources
70
83
  resources = resource.model.send(valid_scope)
71
- resources = Madmin::Search.new(resources, resource, search_term).run
84
+ resources = Madmin::Search.new(resources, resource, search_term, filters).run
72
85
 
73
86
  return resources if sort_column.blank?
74
87
 
75
88
  resources.reorder(sort_column => sort_direction)
76
89
  end
77
90
 
91
+ # Returns [page, records]. Override for non-ActiveRecord collections and
92
+ # return an object responding to the Madmin::Page interface.
78
93
  def paginate_collection(collection)
79
- pagy(collection)
94
+ paginate(collection, page: params[:page], per_page: Madmin::Page.per_page_for(params[:per_page]))
80
95
  end
81
96
 
82
97
  def valid_scope
@@ -85,31 +100,50 @@ module Madmin
85
100
  end
86
101
 
87
102
  def resource_params
88
- params.require(resource.param_key)
89
- .permit(*resource.permitted_params)
90
- .transform_values { |v| change_polymorphic(v) }
103
+ cast_fields params.require(resource.param_key).permit(*resource.permitted_params)
91
104
  end
92
105
 
93
106
  def new_resource_params
94
- params.fetch(resource.param_key, {}).permit!
95
- .permit(*resource.permitted_params)
96
- .transform_values { |v| change_polymorphic(v) }
107
+ cast_fields params.fetch(resource.param_key, {}).permit!.permit(*resource.permitted_params)
108
+ end
109
+
110
+ # Lets each field convert its submitted value, such as parsing JSON
111
+ def cast_fields(attributes)
112
+ attributes.to_h.to_h do |name, value|
113
+ field = field_for(name)
114
+ [name, field ? field.cast(value) : value]
115
+ end
97
116
  end
98
117
 
99
- def change_polymorphic(data)
100
- return data unless data.is_a?(ActionController::Parameters) && data[:type]
118
+ # Assigns the attributes and saves, unless a field doesn't accept its value
119
+ def save_record(attributes)
120
+ @record.assign_attributes(attributes)
101
121
 
102
- if data[:type] == "polymorphic"
103
- GlobalID::Locator.locate(data[:value])
122
+ rejected = attributes.select { |name, value| field_for(name)&.accepts?(value) == false }.keys
123
+
124
+ if rejected.any?
125
+ @record.validate
126
+ rejected.each { |name| @record.errors.add(name, :invalid) }
127
+ false
104
128
  else
105
- raise "Unrecognised param data: #{data.inspect}"
129
+ @record.save
106
130
  end
107
131
  end
108
132
 
133
+ def field_for(name)
134
+ resource.get_attribute(name.to_sym)&.field
135
+ end
136
+
109
137
  def search_term
110
138
  @search_term ||= params[:q].to_s.strip
111
139
  end
112
140
 
141
+ # The index filters from `filters[][column]`, `filters[][operator]` and `filters[][value]`
142
+ def filters
143
+ @filters ||= Madmin::Filter.from_params(resource, params.slice(:filters).permit(filters: [:column, :operator, :value]).fetch(:filters, []))
144
+ end
145
+ helper_method :filters
146
+
113
147
  def enforce_readonly
114
148
  redirect_to resource.index_path, alert: t("madmin.flash.readonly", name: resource.friendly_name) if resource.readonly?
115
149
  end
@@ -1,10 +1,41 @@
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))
21
+ end
22
+
23
+ # The current URL with some params changed, or removed when nil. Works on
24
+ # any page, unlike index_path_with
25
+ def current_path_with(changes)
26
+ "#{request.path}?#{request.query_parameters.merge(changes).compact.to_query}"
27
+ end
28
+
29
+ # Hidden fields carrying the current index params through a GET form, so
30
+ # submitting it keeps what index_path_with keeps. Pass the params the form
31
+ # sets itself in except
32
+ def index_params_fields(except: [])
33
+ query = request.query_parameters.except("page", *except.map(&:to_s)).to_query
34
+ fields = query.split("&").map do |pair|
35
+ name, value = pair.split("=", 2).map { |part| CGI.unescape(part) }
36
+ hidden_field_tag name, value, id: nil
37
+ end
38
+ safe_join(fields, "\n")
8
39
  end
9
40
  end
10
41
  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 " "
@@ -1,3 +1,7 @@
1
1
  import "@hotwired/turbo-rails"
2
2
  import "@rails/actiontext"
3
+ import * as ActiveStorage from "@rails/activestorage"
3
4
  import "controllers"
5
+
6
+ // Uploads files straight to storage for fields with `direct_upload: true`. Other file fields submit with the form
7
+ ActiveStorage.start()
@@ -7,6 +7,3 @@ application.debug = false
7
7
  window.Stimulus = application
8
8
 
9
9
  export { application }
10
-
11
- import { Dropdown } from "tailwindcss-stimulus-components"
12
- application.register("dropdown", Dropdown)
@@ -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) %> -