activeadmin-autocompletes 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 25930eaa8bc6450243eae296499fac843526243dbbc0dba8e3e9c548c13a5104
4
+ data.tar.gz: 4ea216c7a7e4b2527075c4479d7e4293c67bd60de20fe26b52c0280a8640fa03
5
+ SHA512:
6
+ metadata.gz: b16837f4267a1f7110c9d7160a8a16a1c7f12e4cca3727906c2ab915ef22cbb150e376900b374a5b099380a81d0615fdcc03ecf7490ebf8b3f891acaa317ae5b
7
+ data.tar.gz: 047d792fe35bd69259fe22e82d7fb91b2ca7e6609223dc3f48cb696802d00a45ec95bff2139a7675dbb66222602f0f2689413d8fd1df1455036559e9ace0cfaa
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # CHANGELOG
2
+
3
+ ## 0.3.0 (2026-11-12)
4
+
5
+ - Add `params:` and `param_fields:` on autocomplete inputs so options requests can carry static keys and live sibling-field values
6
+ - Clear the current selection when a watched `param_fields` source changes
7
+ - Add optional `page_size:` on inputs (clamped to 1..50)
8
+ - Add `fi` and `sv` placeholders for the search field
9
+ - Prove importmap pin and field partial resolution in engine specs
10
+ - Extract ActiveAdmin lazy autocomplete (filters, form inputs, Stimulus typeahead, multi-select chips) into a standalone gem
11
+ - Add multi-select chips mode (`multiple: true`) for form and filter autocomplete inputs
12
+ - Clamp option `limit` to 1..50 and query string to 200 bytes (server and Stimulus)
13
+ - Align Stimulus `hasMore` pagination with the clamped page size
14
+ - Document host `authorize! :index` contract and multi-endpoint `action:` / `params` usage
15
+ - Pressure binding and L1 load ceilings for the Ruby payload shaping path
16
+ - Rails dummy + request specs for autocomplete options endpoints and filter index rendering
@@ -0,0 +1,26 @@
1
+ # Code of Conduct
2
+
3
+ ## Pledge
4
+
5
+ Participation is a harassment-free experience. Treat each other with respect. Keep private context private.
6
+
7
+ ## Expected behavior
8
+
9
+ - Engage with genuine curiosity. Admit uncertainty.
10
+ - Own every line you submit, including generated code.
11
+ - Correct errors politely.
12
+ - Use language that welcomes people and respects identity and privacy.
13
+
14
+ ## Unacceptable behavior
15
+
16
+ - Harassment, trolling, or insulting comments, public or private.
17
+ - Using jargon or volume, including automated spam, to silence others.
18
+ - Sharing others' data or personal context without permission.
19
+
20
+ ## Automation
21
+
22
+ You remain responsible for bugs, security holes, and bias in generated artifacts you submit.
23
+
24
+ ## Enforcement
25
+
26
+ Report abusive or harassing behavior to contact@kiskolabs.com. Reports are reviewed and investigated.
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,23 @@
1
+ # Contributing Guidelines
2
+
3
+ ## Automation authorship
4
+
5
+ You may use AI and automation tools. You remain the author of every line you submit: review, debug, and understand the change.
6
+
7
+ Keep pull requests small enough to review. Annotate non-obvious logic.
8
+
9
+ Never send project secrets or private context to public models.
10
+
11
+ ## How to contribute
12
+
13
+ ### Reporting issues
14
+
15
+ Verify the facts before filing. Use issue templates for goals, constraints, and reproduction steps.
16
+
17
+ ### Pull requests
18
+
19
+ Keep one pull request to one goal. Explain why the change is needed. Run smoke tests and regression checks locally before opening the request.
20
+
21
+ ### Review
22
+
23
+ Reviewers will ask about approach and trade-offs. Treat suggested changes as part of the work.
data/GOVERNANCE.md ADDED
@@ -0,0 +1,25 @@
1
+ # Project Governance
2
+
3
+ ## Roles
4
+
5
+ ### Contributors
6
+
7
+ Anyone who submits code, documentation, or participates in discussions. Contributors take ownership of problems they identify and follow the project's quality standards.
8
+
9
+ ### Maintainers
10
+
11
+ Maintainers:
12
+
13
+ 1. Set scope and architectural direction.
14
+ 2. Review contributions (human or automated) against security and logic standards.
15
+ 3. Decide when the community is divided.
16
+
17
+ ## Decision making
18
+
19
+ ### Lazy consensus
20
+
21
+ For routine changes, a proposal that draws no objection within two weeks is approved.
22
+
23
+ ### Maintainer approval
24
+
25
+ Major architectural changes, high-risk automation integrations, or changes that affect business logic need explicit maintainer approval. A change must solve a real problem.
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 amkisko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # activeadmin-autocompletes
2
+
3
+ [![Test Status](https://github.com/amkisko/activeadmin-autocompletes.rb/actions/workflows/test.yml/badge.svg)](https://github.com/amkisko/activeadmin-autocompletes.rb/actions/workflows/test.yml)
4
+
5
+ ActiveAdmin-only support for lazy and extensible, fully configurable autocompletes for filters and inputs. Session REST JSON options, Formtastic filter/form inputs, Stimulus typeahead, and multi-select chips.
6
+
7
+ ## Requirements
8
+
9
+ - Ruby 3.4+
10
+ - Rails 7.2.3.2+
11
+ - ActiveAdmin 4.0.0.beta23+
12
+
13
+ ## Install
14
+
15
+ From RubyGems after a release:
16
+
17
+ ```ruby
18
+ # Gemfile
19
+ gem "activeadmin-autocompletes"
20
+ ```
21
+
22
+ From git:
23
+
24
+ ```ruby
25
+ # Gemfile
26
+ gem "activeadmin-autocompletes", github: "amkisko/activeadmin-autocompletes.rb"
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ```ruby
32
+ ActiveAdmin.register Thing do
33
+ extend ActiveAdmin::Autocompletes::FilterAutocomplete
34
+
35
+ # Default action name: :search_options → search_options_admin_things_path
36
+ filter_autocomplete :search do |query:, limit:, offset:|
37
+ Thing.search_options(query:, limit:, offset:)
38
+ end
39
+
40
+ # Extra endpoints: pass action: and use params from the request
41
+ filter_autocomplete :category_items, action: :category_item_options do |query:, limit:, offset:|
42
+ Item.search_options(query:, limit:, offset:, category_id: params[:category_id])
43
+ end
44
+
45
+ filter :city_eq,
46
+ as: :autocomplete,
47
+ label: "City",
48
+ url: -> { search_options_admin_things_path }
49
+
50
+ # Association id filter: hidden submitted id, visible label
51
+ filter :company_id_eq,
52
+ as: :autocomplete,
53
+ value_field: true,
54
+ label: "Company",
55
+ url: -> { search_options_admin_companies_path },
56
+ initial_label: ->(id) { Company.find_by(id: id)&.name.to_s }
57
+
58
+ # Static query params plus live sibling fields (CSS selectors)
59
+ filter :item_id_eq,
60
+ as: :autocomplete,
61
+ value_field: true,
62
+ label: "Item",
63
+ url: -> { category_item_options_admin_things_path },
64
+ page_size: 10,
65
+ params: {scope: "active"},
66
+ param_fields: {category_id: "#q_category_id_eq"},
67
+ initial_label: ->(id) { Item.find_by(id: id)&.name.to_s }
68
+
69
+ # Multi-select chips on forms (and filters when multiple: true)
70
+ form do |f|
71
+ f.input :tag_ids,
72
+ as: :autocomplete,
73
+ multiple: true,
74
+ value_field: true,
75
+ url: -> { search_options_admin_tags_path },
76
+ initial_labels: ->(id) { Tag.find_by(id: id)&.name.to_s }
77
+ end
78
+ end
79
+ ```
80
+
81
+ Options endpoint must return JSON `[{ "label" => "...", "value" => "..." }, ...]` (optional `id` is accepted as the submitted value).
82
+
83
+ Input options:
84
+
85
+ - `params:` - hash or callable; merged into every options request as query keys
86
+ - `param_fields:` - hash of query key => CSS selector; live values read from those fields on each fetch; changing a watched field clears the current selection
87
+ - `page_size:` - client page size (clamped to 1..50, default 20)
88
+
89
+ ### Authorization and bounds
90
+
91
+ Every options `collection_action` calls `authorize! :index, config.resource_class`. Hosts that need a finer permission must wrap the block or authorize in the host policy for `index?` on that resource.
92
+
93
+ Server clamps `limit` to 1..50 and `q` to 200 bytes. The Stimulus controller applies the same page-size and query bounds and uses the clamped limit for `hasMore` pagination. Host search blocks must stay indexed and bounded themselves; the gem does not rewrite queries or add indexes.
94
+
95
+ ## Assets
96
+
97
+ The engine pins the Stimulus controller on `ActiveAdmin.importmap`. Host should watch:
98
+
99
+ ```ruby
100
+ ActiveAdmin::Autocompletes::Engine.root.join("app/assets")
101
+ ```
102
+
103
+ ## Development
104
+
105
+ ```bash
106
+ bundle install
107
+ bundle exec appraisal install
108
+ make test
109
+ ```
110
+
111
+ Maintainers publish with `make release` (`usr/bin/release.rb`).
data/SECURITY.md ADDED
@@ -0,0 +1,25 @@
1
+ # SECURITY
2
+
3
+ ## Reporting a Vulnerability
4
+
5
+ Email security details to: security@kiskolabs.com
6
+
7
+ Send the report only to that address. Include description, steps to reproduce, potential impact, and a suggested fix when you have one.
8
+
9
+ ### Response Timeline
10
+
11
+ - We will acknowledge receipt of your report.
12
+ - We will provide an initial assessment.
13
+ - We will keep you informed of our progress and resolution timeline.
14
+
15
+ ### Disclosure Policy
16
+
17
+ - We will work with you to understand and resolve the issue.
18
+ - We will credit you for the discovery unless you prefer to remain anonymous.
19
+ - We will publish a security advisory after the vulnerability is patched.
20
+ - We will coordinate public disclosure with you.
21
+
22
+ ## Automation Security
23
+
24
+ - Keep production credentials, API keys, and personal data out of prompts sent to third-party models or automation services.
25
+ - Verify automated dependencies.
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/activeadmin/autocompletes/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "activeadmin-autocompletes"
7
+ spec.version = ActiveAdmin::Autocompletes::VERSION
8
+ spec.authors = ["Andrei Makarov"]
9
+ spec.email = ["contact@kiskolabs.com"]
10
+
11
+ spec.summary = "Lazy REST autocomplete inputs and filters for ActiveAdmin"
12
+ spec.description = "ActiveAdmin-only support for lazy and extensible, fully configurable autocompletes for filters and inputs. Collection-action JSON options, Formtastic filter/form inputs, Stimulus typeahead, and multi-select chips."
13
+ spec.license = "MIT"
14
+ spec.platform = Gem::Platform::RUBY
15
+ spec.required_ruby_version = ">= 3.4.0"
16
+
17
+ repository_url = "https://github.com/amkisko/activeadmin-autocompletes.rb"
18
+
19
+ spec.homepage = repository_url
20
+ spec.metadata = {
21
+ "homepage_uri" => repository_url,
22
+ "source_code_uri" => "#{repository_url}/tree/main",
23
+ "changelog_uri" => "#{repository_url}/blob/main/CHANGELOG.md",
24
+ "bug_tracker_uri" => "#{repository_url}/issues",
25
+ "documentation_uri" => "#{repository_url}#readme",
26
+ "allowed_push_host" => "https://rubygems.org",
27
+ "rubygems_mfa_required" => "true"
28
+ }
29
+
30
+ spec.files = Dir.chdir(__dir__) do
31
+ %w[
32
+ CHANGELOG.md
33
+ CODE_OF_CONDUCT.md
34
+ CONTRIBUTING.md
35
+ GOVERNANCE.md
36
+ LICENSE.md
37
+ README.md
38
+ SECURITY.md
39
+ activeadmin-autocompletes.gemspec
40
+ ].select { |path| File.file?(path) } +
41
+ Dir["{app,config,lib,sig}/**/*"]
42
+ end
43
+
44
+ spec.require_paths = ["lib"]
45
+
46
+ spec.add_dependency "activeadmin", ">= 4.0.0.beta23", "< 5"
47
+ spec.add_dependency "importmap-rails"
48
+ spec.add_dependency "rails", ">= 7.2.3.2"
49
+
50
+ spec.add_development_dependency "appraisal", "~> 2"
51
+ spec.add_development_dependency "bundler", ">= 2"
52
+ spec.add_development_dependency "bundler-audit", "~> 0.9"
53
+ spec.add_development_dependency "polyrun", ">= 3.0.0"
54
+ spec.add_development_dependency "rake", "~> 13"
55
+ spec.add_development_dependency "rbs", "~> 4"
56
+ spec.add_development_dependency "rspec", "~> 3"
57
+ spec.add_development_dependency "rspec-rails", ">= 6"
58
+ spec.add_development_dependency "sqlite3", ">= 2.10.2"
59
+ spec.add_development_dependency "rubocop-rspec", "~> 3.8"
60
+ spec.add_development_dependency "rubocop-thread_safety", "~> 0.7"
61
+ spec.add_development_dependency "standard", "~> 1.52"
62
+ spec.add_development_dependency "standard-custom", "~> 1.0"
63
+ spec.add_development_dependency "standard-performance", "~> 1.8"
64
+ spec.add_development_dependency "standard-rspec", "~> 0.3"
65
+ end
@@ -0,0 +1,324 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ const OPTION_CLASS =
4
+ "cursor-pointer px-3 py-2 hover:bg-gray-100 dark:hover:bg-gray-800";
5
+ const OPTION_ACTIVE_CLASS = "bg-gray-100 dark:bg-gray-800";
6
+ const CHIP_CLASS =
7
+ "inline-flex items-center gap-1 rounded-full border border-gray-300 bg-gray-50 px-2 py-0.5 text-sm dark:border-gray-600 dark:bg-gray-800";
8
+ const CONTROLLER = "activeadmin-autocompletes--filter-autocomplete";
9
+
10
+ export default class extends Controller {
11
+ static targets = ["query", "list", "value", "chips", "selected"];
12
+ static values = {
13
+ url: String,
14
+ pageSize: { type: Number, default: 20 },
15
+ multiple: { type: Boolean, default: false },
16
+ params: { type: Object, default: {} },
17
+ paramFields: { type: Object, default: {} },
18
+ };
19
+
20
+ static MAX_PAGE_SIZE = 50;
21
+ static MAX_QUERY_CHARS = 200;
22
+ static MAX_LIST_ITEMS = 100;
23
+
24
+ connect() {
25
+ this.activeIndex = -1;
26
+ this.offset = 0;
27
+ this.hasMore = false;
28
+ this.loading = false;
29
+ this.lastQuery = null;
30
+ this.selected = this.readSelected();
31
+ this.renderChips();
32
+ this.boundClearOnDependency = () => this.clearForDependency();
33
+ this.paramFieldElements().forEach((element) => {
34
+ element.addEventListener("change", this.boundClearOnDependency);
35
+ element.addEventListener("input", this.boundClearOnDependency);
36
+ });
37
+ }
38
+
39
+ disconnect() {
40
+ clearTimeout(this.timeout);
41
+ this.abortController?.abort();
42
+ this.paramFieldElements().forEach((element) => {
43
+ element.removeEventListener("change", this.boundClearOnDependency);
44
+ element.removeEventListener("input", this.boundClearOnDependency);
45
+ });
46
+ }
47
+
48
+ search() {
49
+ clearTimeout(this.timeout);
50
+ this.timeout = setTimeout(() => this.fetchResults({ reset: true }), 250);
51
+ if (!this.multipleValue && this.hasValueTarget) this.valueTarget.value = "";
52
+ }
53
+
54
+ open() {
55
+ this.fetchResults({ reset: true });
56
+ }
57
+
58
+ loadMore() {
59
+ if (!this.hasMore || this.loading || this.listTarget.classList.contains("hidden")) {
60
+ return;
61
+ }
62
+
63
+ const { scrollTop, scrollHeight, clientHeight } = this.listTarget;
64
+ if (scrollTop + clientHeight < scrollHeight - 24) return;
65
+
66
+ this.fetchResults({ reset: false });
67
+ }
68
+
69
+ navigate(event) {
70
+ if (event.key === "Backspace" && this.multipleValue && !this.queryTarget.value) {
71
+ const last = this.selected[this.selected.length - 1];
72
+ if (last) this.removeValue(last.value);
73
+ return;
74
+ }
75
+
76
+ if (this.listTarget.classList.contains("hidden")) return;
77
+
78
+ const options = this.optionElements();
79
+ if (options.length === 0) return;
80
+
81
+ if (event.key === "ArrowDown") {
82
+ event.preventDefault();
83
+ this.activeIndex = Math.min(this.activeIndex + 1, options.length - 1);
84
+ this.highlight(options);
85
+ } else if (event.key === "ArrowUp") {
86
+ event.preventDefault();
87
+ this.activeIndex = Math.max(this.activeIndex - 1, 0);
88
+ this.highlight(options);
89
+ } else if (event.key === "Enter" && this.activeIndex >= 0) {
90
+ event.preventDefault();
91
+ options[this.activeIndex].dispatchEvent(
92
+ new MouseEvent("mousedown", { bubbles: true })
93
+ );
94
+ } else if (event.key === "Escape") {
95
+ this.close();
96
+ }
97
+ }
98
+
99
+ async fetchResults({ reset }) {
100
+ const query = this.queryTarget.value.trim().slice(0, this.constructor.MAX_QUERY_CHARS);
101
+ if (reset) {
102
+ this.offset = 0;
103
+ this.hasMore = false;
104
+ this.lastQuery = query;
105
+ this.activeIndex = -1;
106
+ } else if (query !== this.lastQuery) {
107
+ return;
108
+ }
109
+
110
+ this.loading = true;
111
+ this.abortController?.abort();
112
+ this.abortController = new AbortController();
113
+
114
+ try {
115
+ const url = new URL(this.urlValue, window.location.origin);
116
+ if (query) url.searchParams.set("q", query);
117
+ url.searchParams.set("offset", String(this.offset));
118
+ const limit = Math.min(
119
+ Math.max(Number(this.pageSizeValue) || 20, 1),
120
+ this.constructor.MAX_PAGE_SIZE
121
+ );
122
+ url.searchParams.set("limit", String(limit));
123
+ this.applyExtraParams(url);
124
+
125
+ const response = await fetch(url.toString(), {
126
+ headers: { Accept: "application/json" },
127
+ credentials: "same-origin",
128
+ signal: this.abortController.signal,
129
+ });
130
+
131
+ if (!response.ok) {
132
+ if (reset) this.close();
133
+ return;
134
+ }
135
+
136
+ const items = await response.json();
137
+ const selectedValues = new Set(this.selected.map((item) => String(item.value)));
138
+ const visible = items.filter((item) => !selectedValues.has(String(item.value ?? item.id ?? item.label)));
139
+ this.renderList(visible, { append: !reset });
140
+ this.hasMore = items.length >= limit;
141
+ this.offset += items.length;
142
+ this.setExpanded(true);
143
+ } catch (error) {
144
+ if (error.name !== "AbortError" && reset) this.close();
145
+ } finally {
146
+ this.loading = false;
147
+ }
148
+ }
149
+
150
+ applyExtraParams(url) {
151
+ Object.entries(this.paramsValue || {}).forEach(([key, value]) => {
152
+ if (value == null || value === "") return;
153
+ url.searchParams.set(key, String(value));
154
+ });
155
+
156
+ Object.entries(this.paramFieldsValue || {}).forEach(([key, selector]) => {
157
+ if (!selector) return;
158
+ const element = document.querySelector(selector);
159
+ const value = element?.value;
160
+ if (value == null || value === "") return;
161
+ url.searchParams.set(key, String(value));
162
+ });
163
+ }
164
+
165
+ paramFieldElements() {
166
+ return Object.values(this.paramFieldsValue || {})
167
+ .filter(Boolean)
168
+ .map((selector) => document.querySelector(selector))
169
+ .filter(Boolean);
170
+ }
171
+
172
+ clearForDependency() {
173
+ if (this.multipleValue) {
174
+ this.selected = [];
175
+ this.syncSelectedInputs();
176
+ this.renderChips();
177
+ } else if (this.hasValueTarget) {
178
+ this.valueTarget.value = "";
179
+ this.queryTarget.value = "";
180
+ } else {
181
+ this.queryTarget.value = "";
182
+ }
183
+ this.close();
184
+ }
185
+
186
+ renderList(items, { append }) {
187
+ if (!append) this.listTarget.replaceChildren();
188
+
189
+ const fragment = document.createDocumentFragment();
190
+ items.forEach((item) => {
191
+ const option = document.createElement("li");
192
+ option.className = OPTION_CLASS;
193
+ option.dataset.action = `mousedown->${CONTROLLER}#select`;
194
+ option.dataset.value = item.value ?? item.id ?? item.label;
195
+ option.dataset.label = item.label ?? item.value ?? item.id;
196
+ option.textContent = item.label ?? item.value ?? item.id;
197
+ option.setAttribute("role", "option");
198
+ fragment.appendChild(option);
199
+ });
200
+ this.listTarget.appendChild(fragment);
201
+ this.trimListWindow();
202
+
203
+ if (this.listTarget.children.length === 0) {
204
+ this.close();
205
+ return;
206
+ }
207
+
208
+ this.listTarget.classList.remove("hidden");
209
+ }
210
+
211
+ trimListWindow() {
212
+ const max = this.constructor.MAX_LIST_ITEMS;
213
+ while (this.listTarget.children.length > max) {
214
+ this.listTarget.firstElementChild.remove();
215
+ if (this.activeIndex >= 0) this.activeIndex -= 1;
216
+ }
217
+ }
218
+
219
+ select(event) {
220
+ event.preventDefault();
221
+ const { value, label } = event.currentTarget.dataset;
222
+ if (this.multipleValue) {
223
+ this.addValue(value, label || value);
224
+ this.queryTarget.value = "";
225
+ this.close();
226
+ this.queryTarget.focus();
227
+ return;
228
+ }
229
+
230
+ if (this.hasValueTarget) {
231
+ this.valueTarget.value = value || "";
232
+ this.queryTarget.value = label || value || "";
233
+ } else {
234
+ this.queryTarget.value = value || "";
235
+ }
236
+ this.close();
237
+ }
238
+
239
+ removeChip(event) {
240
+ event.preventDefault();
241
+ this.removeValue(event.currentTarget.dataset.value);
242
+ }
243
+
244
+ addValue(value, label) {
245
+ const key = String(value);
246
+ if (!value || this.selected.some((item) => item.value === key)) return;
247
+ this.selected.push({ value: key, label: label || key });
248
+ this.syncSelectedInputs();
249
+ this.renderChips();
250
+ }
251
+
252
+ removeValue(value) {
253
+ const key = String(value);
254
+ const index = this.selected.findIndex((item) => item.value === key);
255
+ if (index < 0) return;
256
+ this.selected.splice(index, 1);
257
+ this.syncSelectedInputs();
258
+ this.renderChips();
259
+ }
260
+
261
+ readSelected() {
262
+ if (!this.multipleValue) return [];
263
+ return this.selectedTargets.map((input) => ({
264
+ value: input.value,
265
+ label: input.dataset.label || input.value,
266
+ }));
267
+ }
268
+
269
+ syncSelectedInputs() {
270
+ this.selectedTargets.forEach((input) => input.remove());
271
+ const fieldName = this.element.dataset.fieldName;
272
+ if (!fieldName) return;
273
+
274
+ this.selected.forEach((item) => {
275
+ const input = document.createElement("input");
276
+ input.type = "hidden";
277
+ input.name = fieldName;
278
+ input.value = item.value;
279
+ input.dataset.label = item.label;
280
+ input.setAttribute(`data-${CONTROLLER}-target`, "selected");
281
+ this.element.appendChild(input);
282
+ });
283
+ }
284
+
285
+ renderChips() {
286
+ if (!this.multipleValue || !this.hasChipsTarget) return;
287
+ this.chipsTarget.replaceChildren();
288
+ this.selected.forEach((item) => {
289
+ const chip = document.createElement("span");
290
+ chip.className = CHIP_CLASS;
291
+ const text = document.createElement("span");
292
+ text.textContent = item.label;
293
+ const remove = document.createElement("button");
294
+ remove.type = "button";
295
+ remove.textContent = "×";
296
+ remove.setAttribute("aria-label", `Remove ${item.label}`);
297
+ remove.dataset.value = item.value;
298
+ remove.dataset.action = `click->${CONTROLLER}#removeChip`;
299
+ chip.append(text, remove);
300
+ this.chipsTarget.appendChild(chip);
301
+ });
302
+ }
303
+
304
+ close() {
305
+ this.listTarget.classList.add("hidden");
306
+ this.listTarget.replaceChildren();
307
+ this.activeIndex = -1;
308
+ this.setExpanded(false);
309
+ }
310
+
311
+ optionElements() {
312
+ return Array.from(this.listTarget.querySelectorAll('[role="option"]'));
313
+ }
314
+
315
+ highlight(options) {
316
+ options.forEach((option, index) => {
317
+ option.classList.toggle(OPTION_ACTIVE_CLASS, index === this.activeIndex);
318
+ });
319
+ }
320
+
321
+ setExpanded(expanded) {
322
+ this.queryTarget.setAttribute("aria-expanded", expanded ? "true" : "false");
323
+ }
324
+ }