kiso 0.1.0.pre → 0.2.0.pre
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +36 -2
- data/README.md +67 -27
- data/Rakefile +8 -0
- data/app/assets/tailwind/kiso/checkbox.css +18 -0
- data/app/assets/tailwind/kiso/color-mode.css +9 -0
- data/app/assets/tailwind/kiso/dashboard.css +194 -0
- data/app/assets/tailwind/kiso/engine.css +117 -0
- data/app/assets/tailwind/kiso/input-otp.css +10 -0
- data/app/assets/tailwind/kiso/radio-group.css +17 -0
- data/app/helpers/kiso/component_helper.rb +46 -27
- data/app/helpers/kiso/icon_helper.rb +53 -9
- data/app/helpers/kiso/theme_helper.rb +38 -0
- data/app/javascript/controllers/kiso/combobox_controller.js +616 -0
- data/app/javascript/controllers/kiso/command_controller.js +184 -0
- data/app/javascript/controllers/kiso/command_dialog_controller.js +104 -0
- data/app/javascript/controllers/kiso/dropdown_menu_controller.js +684 -0
- data/app/javascript/controllers/kiso/index.d.ts +12 -0
- data/app/javascript/controllers/kiso/index.js +42 -0
- data/app/javascript/controllers/kiso/input_otp_controller.js +195 -0
- data/app/javascript/controllers/kiso/popover_controller.js +254 -0
- data/app/javascript/controllers/kiso/select_controller.js +307 -0
- data/app/javascript/controllers/kiso/sidebar_controller.js +84 -0
- data/app/javascript/controllers/kiso/theme_controller.js +89 -0
- data/app/javascript/controllers/kiso/toggle_controller.js +24 -0
- data/app/javascript/controllers/kiso/toggle_group_controller.js +128 -0
- data/app/javascript/kiso/utils/focusable.js +8 -0
- data/app/javascript/kiso/utils/highlight.js +43 -0
- data/app/javascript/kiso/utils/positioning.js +86 -0
- data/app/javascript/kiso/vendor/floating-ui-core.js +1 -0
- data/app/javascript/kiso/vendor/floating-ui-dom.js +1 -0
- data/app/views/kiso/components/_alert.html.erb +1 -1
- data/app/views/kiso/components/_avatar.html.erb +23 -0
- data/app/views/kiso/components/_badge.html.erb +1 -1
- data/app/views/kiso/components/_breadcrumb.html.erb +8 -0
- data/app/views/kiso/components/_button.html.erb +1 -1
- data/app/views/kiso/components/_card.html.erb +1 -1
- data/app/views/kiso/components/_checkbox.html.erb +7 -0
- data/app/views/kiso/components/_color_mode_button.html.erb +14 -0
- data/app/views/kiso/components/_color_mode_select.html.erb +24 -0
- data/app/views/kiso/components/_combobox.html.erb +12 -0
- data/app/views/kiso/components/_command.html.erb +7 -0
- data/app/views/kiso/components/_dashboard_group.html.erb +14 -0
- data/app/views/kiso/components/_dashboard_navbar.html.erb +7 -0
- data/app/views/kiso/components/_dashboard_panel.html.erb +7 -0
- data/app/views/kiso/components/_dashboard_sidebar.html.erb +11 -0
- data/app/views/kiso/components/_dashboard_toolbar.html.erb +7 -0
- data/app/views/kiso/components/_dropdown_menu.html.erb +7 -0
- data/app/views/kiso/components/{_empty_state.html.erb → _empty.html.erb} +2 -2
- data/app/views/kiso/components/_field.html.erb +12 -0
- data/app/views/kiso/components/_field_group.html.erb +7 -0
- data/app/views/kiso/components/_field_set.html.erb +7 -0
- data/app/views/kiso/components/_input.html.erb +8 -0
- data/app/views/kiso/components/_input_group.html.erb +8 -0
- data/app/views/kiso/components/_input_otp.html.erb +22 -0
- data/app/views/kiso/components/_kbd.html.erb +7 -0
- data/app/views/kiso/components/_label.html.erb +5 -0
- data/app/views/kiso/components/_nav.html.erb +7 -0
- data/app/views/kiso/components/_pagination.html.erb +9 -0
- data/app/views/kiso/components/_popover.html.erb +8 -0
- data/app/views/kiso/components/_radio_group.html.erb +8 -0
- data/app/views/kiso/components/_select.html.erb +8 -0
- data/app/views/kiso/components/_select_native.html.erb +16 -0
- data/app/views/kiso/components/_separator.html.erb +1 -1
- data/app/views/kiso/components/_stats_card.html.erb +1 -1
- data/app/views/kiso/components/_stats_grid.html.erb +1 -1
- data/app/views/kiso/components/_switch.html.erb +10 -0
- data/app/views/kiso/components/_table.html.erb +2 -1
- data/app/views/kiso/components/_textarea.html.erb +9 -0
- data/app/views/kiso/components/_toggle.html.erb +12 -0
- data/app/views/kiso/components/_toggle_group.html.erb +12 -0
- data/app/views/kiso/components/alert/_description.html.erb +1 -1
- data/app/views/kiso/components/alert/_title.html.erb +1 -1
- data/app/views/kiso/components/avatar/_badge.html.erb +7 -0
- data/app/views/kiso/components/avatar/_fallback.html.erb +7 -0
- data/app/views/kiso/components/avatar/_group.html.erb +7 -0
- data/app/views/kiso/components/avatar/_group_count.html.erb +7 -0
- data/app/views/kiso/components/avatar/_image.html.erb +6 -0
- data/app/views/kiso/components/breadcrumb/_ellipsis.html.erb +10 -0
- data/app/views/kiso/components/breadcrumb/_item.html.erb +7 -0
- data/app/views/kiso/components/breadcrumb/_link.html.erb +7 -0
- data/app/views/kiso/components/breadcrumb/_list.html.erb +7 -0
- data/app/views/kiso/components/breadcrumb/_page.html.erb +9 -0
- data/app/views/kiso/components/breadcrumb/_separator.html.erb +9 -0
- data/app/views/kiso/components/card/_action.html.erb +7 -0
- data/app/views/kiso/components/card/_content.html.erb +1 -1
- data/app/views/kiso/components/card/_description.html.erb +1 -1
- data/app/views/kiso/components/card/_footer.html.erb +1 -1
- data/app/views/kiso/components/card/_header.html.erb +1 -1
- data/app/views/kiso/components/card/_title.html.erb +1 -1
- data/app/views/kiso/components/combobox/_chip.html.erb +19 -0
- data/app/views/kiso/components/combobox/_chips.html.erb +20 -0
- data/app/views/kiso/components/combobox/_chips_input.html.erb +10 -0
- data/app/views/kiso/components/combobox/_content.html.erb +9 -0
- data/app/views/kiso/components/combobox/_empty.html.erb +9 -0
- data/app/views/kiso/components/combobox/_group.html.erb +8 -0
- data/app/views/kiso/components/combobox/_input.html.erb +23 -0
- data/app/views/kiso/components/combobox/_item.html.erb +19 -0
- data/app/views/kiso/components/combobox/_label.html.erb +7 -0
- data/app/views/kiso/components/combobox/_list.html.erb +10 -0
- data/app/views/kiso/components/combobox/_separator.html.erb +6 -0
- data/app/views/kiso/components/command/_dialog.html.erb +11 -0
- data/app/views/kiso/components/command/_empty.html.erb +9 -0
- data/app/views/kiso/components/command/_group.html.erb +14 -0
- data/app/views/kiso/components/command/_input.html.erb +16 -0
- data/app/views/kiso/components/command/_item.html.erb +13 -0
- data/app/views/kiso/components/command/_list.html.erb +10 -0
- data/app/views/kiso/components/command/_separator.html.erb +7 -0
- data/app/views/kiso/components/command/_shortcut.html.erb +7 -0
- data/app/views/kiso/components/dashboard_navbar/_toggle.html.erb +11 -0
- data/app/views/kiso/components/dashboard_sidebar/_collapse.html.erb +12 -0
- data/app/views/kiso/components/dashboard_sidebar/_footer.html.erb +7 -0
- data/app/views/kiso/components/dashboard_sidebar/_header.html.erb +7 -0
- data/app/views/kiso/components/dashboard_sidebar/_toggle.html.erb +11 -0
- data/app/views/kiso/components/dashboard_toolbar/_left.html.erb +7 -0
- data/app/views/kiso/components/dashboard_toolbar/_right.html.erb +7 -0
- data/app/views/kiso/components/dropdown_menu/_checkbox_item.html.erb +18 -0
- data/app/views/kiso/components/dropdown_menu/_content.html.erb +10 -0
- data/app/views/kiso/components/dropdown_menu/_group.html.erb +8 -0
- data/app/views/kiso/components/dropdown_menu/_item.html.erb +15 -0
- data/app/views/kiso/components/dropdown_menu/_label.html.erb +8 -0
- data/app/views/kiso/components/dropdown_menu/_radio_group.html.erb +10 -0
- data/app/views/kiso/components/dropdown_menu/_radio_item.html.erb +19 -0
- data/app/views/kiso/components/dropdown_menu/_separator.html.erb +6 -0
- data/app/views/kiso/components/dropdown_menu/_shortcut.html.erb +7 -0
- data/app/views/kiso/components/dropdown_menu/_sub.html.erb +8 -0
- data/app/views/kiso/components/dropdown_menu/_sub_content.html.erb +10 -0
- data/app/views/kiso/components/dropdown_menu/_sub_trigger.html.erb +12 -0
- data/app/views/kiso/components/dropdown_menu/_trigger.html.erb +9 -0
- data/app/views/kiso/components/empty/_content.html.erb +7 -0
- data/app/views/kiso/components/empty/_description.html.erb +7 -0
- data/app/views/kiso/components/empty/_header.html.erb +7 -0
- data/app/views/kiso/components/empty/_media.html.erb +7 -0
- data/app/views/kiso/components/empty/_title.html.erb +7 -0
- data/app/views/kiso/components/field/_content.html.erb +7 -0
- data/app/views/kiso/components/field/_description.html.erb +7 -0
- data/app/views/kiso/components/field/_error.html.erb +22 -0
- data/app/views/kiso/components/field/_label.html.erb +5 -0
- data/app/views/kiso/components/field/_separator.html.erb +15 -0
- data/app/views/kiso/components/field/_title.html.erb +7 -0
- data/app/views/kiso/components/field_set/_legend.html.erb +9 -0
- data/app/views/kiso/components/input_group/_addon.html.erb +7 -0
- data/app/views/kiso/components/input_otp/_group.html.erb +7 -0
- data/app/views/kiso/components/input_otp/_separator.html.erb +8 -0
- data/app/views/kiso/components/input_otp/_slot.html.erb +11 -0
- data/app/views/kiso/components/kbd/_group.html.erb +7 -0
- data/app/views/kiso/components/nav/_item.html.erb +15 -0
- data/app/views/kiso/components/nav/_section.html.erb +37 -0
- data/app/views/kiso/components/nav/_section_title.html.erb +7 -0
- data/app/views/kiso/components/pagination/_content.html.erb +7 -0
- data/app/views/kiso/components/pagination/_ellipsis.html.erb +9 -0
- data/app/views/kiso/components/pagination/_item.html.erb +7 -0
- data/app/views/kiso/components/pagination/_link.html.erb +9 -0
- data/app/views/kiso/components/pagination/_next.html.erb +12 -0
- data/app/views/kiso/components/pagination/_previous.html.erb +12 -0
- data/app/views/kiso/components/popover/_anchor.html.erb +8 -0
- data/app/views/kiso/components/popover/_content.html.erb +11 -0
- data/app/views/kiso/components/popover/_description.html.erb +7 -0
- data/app/views/kiso/components/popover/_header.html.erb +7 -0
- data/app/views/kiso/components/popover/_title.html.erb +7 -0
- data/app/views/kiso/components/popover/_trigger.html.erb +9 -0
- data/app/views/kiso/components/radio_group/_item.html.erb +6 -0
- data/app/views/kiso/components/select/_content.html.erb +10 -0
- data/app/views/kiso/components/select/_group.html.erb +8 -0
- data/app/views/kiso/components/select/_item.html.erb +19 -0
- data/app/views/kiso/components/select/_label.html.erb +7 -0
- data/app/views/kiso/components/select/_separator.html.erb +6 -0
- data/app/views/kiso/components/select/_trigger.html.erb +13 -0
- data/app/views/kiso/components/select/_value.html.erb +11 -0
- data/app/views/kiso/components/stats_card/_description.html.erb +1 -1
- data/app/views/kiso/components/stats_card/_header.html.erb +1 -1
- data/app/views/kiso/components/stats_card/_label.html.erb +1 -1
- data/app/views/kiso/components/stats_card/_value.html.erb +1 -1
- data/app/views/kiso/components/table/_body.html.erb +1 -1
- data/app/views/kiso/components/table/_caption.html.erb +1 -1
- data/app/views/kiso/components/table/_cell.html.erb +1 -1
- data/app/views/kiso/components/table/_footer.html.erb +1 -1
- data/app/views/kiso/components/table/_head.html.erb +1 -1
- data/app/views/kiso/components/table/_header.html.erb +1 -1
- data/app/views/kiso/components/table/_row.html.erb +1 -1
- data/app/views/kiso/components/toggle_group/_item.html.erb +13 -0
- data/config/deploy.docs.yml +31 -0
- data/config/deploy.yml +34 -0
- data/config/importmap.rb +10 -0
- data/lib/kiso/cli/base.rb +15 -0
- data/lib/kiso/cli/icons.rb +2 -1
- data/lib/kiso/cli/main.rb +6 -0
- data/lib/kiso/cli/make.rb +22 -12
- data/lib/kiso/configuration.rb +54 -0
- data/lib/kiso/engine.rb +36 -1
- data/lib/kiso/theme_overrides.rb +130 -0
- data/lib/kiso/themes/alert.rb +16 -1
- data/lib/kiso/themes/avatar.rb +53 -0
- data/lib/kiso/themes/badge.rb +15 -5
- data/lib/kiso/themes/breadcrumb.rb +44 -0
- data/lib/kiso/themes/button.rb +15 -2
- data/lib/kiso/themes/card.rb +18 -2
- data/lib/kiso/themes/checkbox.rb +33 -0
- data/lib/kiso/themes/color_mode_button.rb +15 -0
- data/lib/kiso/themes/color_mode_select.rb +7 -0
- data/lib/kiso/themes/combobox.rb +97 -0
- data/lib/kiso/themes/command.rb +79 -0
- data/lib/kiso/themes/dashboard.rb +51 -0
- data/lib/kiso/themes/dropdown_menu.rb +108 -0
- data/lib/kiso/themes/empty.rb +54 -0
- data/lib/kiso/themes/field.rb +76 -0
- data/lib/kiso/themes/field_group.rb +15 -0
- data/lib/kiso/themes/field_set.rb +32 -0
- data/lib/kiso/themes/input.rb +33 -0
- data/lib/kiso/themes/input_group.rb +39 -0
- data/lib/kiso/themes/input_otp.rb +46 -0
- data/lib/kiso/themes/kbd.rb +31 -0
- data/lib/kiso/themes/label.rb +16 -0
- data/lib/kiso/themes/nav.rb +27 -0
- data/lib/kiso/themes/pagination.rb +73 -0
- data/lib/kiso/themes/popover.rb +32 -0
- data/lib/kiso/themes/radio_group.rb +43 -0
- data/lib/kiso/themes/select.rb +78 -0
- data/lib/kiso/themes/select_native.rb +49 -0
- data/lib/kiso/themes/separator.rb +8 -2
- data/lib/kiso/themes/shared.rb +51 -0
- data/lib/kiso/themes/stats_card.rb +26 -14
- data/lib/kiso/themes/switch.rb +56 -0
- data/lib/kiso/themes/table.rb +18 -15
- data/lib/kiso/themes/textarea.rb +33 -0
- data/lib/kiso/themes/toggle.rb +71 -0
- data/lib/kiso/themes/toggle_group.rb +13 -0
- data/lib/kiso/version.rb +4 -1
- data/lib/kiso.rb +70 -2
- metadata +183 -22
- data/app/views/kiso/components/empty_state/_content.html.erb +0 -7
- data/app/views/kiso/components/empty_state/_description.html.erb +0 -7
- data/app/views/kiso/components/empty_state/_header.html.erb +0 -7
- data/app/views/kiso/components/empty_state/_media.html.erb +0 -7
- data/app/views/kiso/components/empty_state/_title.html.erb +0 -7
- data/lib/kiso/themes/empty_state.rb +0 -42
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
import { highlightItem, wrapIndex } from "kiso-ui/utils/highlight"
|
|
3
|
+
import { startPositioning } from "kiso-ui/utils/positioning"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Combobox autocomplete with keyboard navigation, filtering, and form integration.
|
|
7
|
+
* Supports single-select and multi-select (with removable chips).
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* <div data-controller="kiso--combobox" data-kiso--combobox-multiple-value="false">
|
|
11
|
+
* <div data-slot="combobox-input">
|
|
12
|
+
* <input data-kiso--combobox-target="input"
|
|
13
|
+
* data-action="input->kiso--combobox#filter focus->kiso--combobox#onInputFocus
|
|
14
|
+
* keydown->kiso--combobox#inputKeydown">
|
|
15
|
+
* <button data-kiso--combobox-target="trigger"
|
|
16
|
+
* data-action="click->kiso--combobox#toggle">
|
|
17
|
+
* </button>
|
|
18
|
+
* </div>
|
|
19
|
+
* <div data-kiso--combobox-target="content" hidden>
|
|
20
|
+
* <div data-kiso--combobox-target="list" role="listbox">
|
|
21
|
+
* <div data-kiso--combobox-target="item" data-value="rails"
|
|
22
|
+
* data-action="click->kiso--combobox#selectItem" role="option">
|
|
23
|
+
* <span data-slot="combobox-item-text">Rails</span>
|
|
24
|
+
* <span data-kiso--combobox-target="indicator" hidden>✓</span>
|
|
25
|
+
* </div>
|
|
26
|
+
* </div>
|
|
27
|
+
* <div data-kiso--combobox-target="empty" hidden>No results.</div>
|
|
28
|
+
* </div>
|
|
29
|
+
* <input type="hidden" data-kiso--combobox-target="hiddenInput" name="framework">
|
|
30
|
+
* </div>
|
|
31
|
+
*
|
|
32
|
+
* @property {HTMLInputElement} inputTarget - Text input for searching/filtering
|
|
33
|
+
* @property {HTMLElement} contentTarget - The dropdown panel
|
|
34
|
+
* @property {HTMLElement} listTarget - Scrollable list inside the dropdown
|
|
35
|
+
* @property {HTMLElement[]} itemTargets - Selectable option elements
|
|
36
|
+
* @property {HTMLElement[]} indicatorTargets - Checkmark indicators inside items
|
|
37
|
+
* @property {HTMLElement} emptyTarget - "No results" message element
|
|
38
|
+
* @property {HTMLInputElement} hiddenInputTarget - Hidden input for form submission
|
|
39
|
+
* @property {HTMLElement} triggerTarget - Chevron button to toggle dropdown
|
|
40
|
+
* @property {HTMLElement} chipsTarget - Multi-select chip container
|
|
41
|
+
* @property {HTMLElement[]} chipTargets - Individual chip elements
|
|
42
|
+
* @property {HTMLTemplateElement} chipTemplateTarget - Template for cloning new chips
|
|
43
|
+
* @property {boolean} multipleValue - Enables multi-select mode when true
|
|
44
|
+
*
|
|
45
|
+
* @fires kiso--combobox:change - When selection changes.
|
|
46
|
+
* Detail: `{ value: string }` (single) or `{ value: string[] }` (multiple).
|
|
47
|
+
*/
|
|
48
|
+
export default class extends Controller {
|
|
49
|
+
static targets = [
|
|
50
|
+
"input",
|
|
51
|
+
"content",
|
|
52
|
+
"list",
|
|
53
|
+
"item",
|
|
54
|
+
"indicator",
|
|
55
|
+
"empty",
|
|
56
|
+
"hiddenInput",
|
|
57
|
+
"trigger",
|
|
58
|
+
"chips",
|
|
59
|
+
"chip",
|
|
60
|
+
"chipTemplate",
|
|
61
|
+
]
|
|
62
|
+
static values = { multiple: { type: Boolean, default: false } }
|
|
63
|
+
|
|
64
|
+
connect() {
|
|
65
|
+
this._open = false
|
|
66
|
+
this._highlightedIndex = -1
|
|
67
|
+
this._selectedValues = new Set()
|
|
68
|
+
this._handleOutsideClick = this._handleOutsideClick.bind(this)
|
|
69
|
+
|
|
70
|
+
// Initialize selected state from pre-rendered chips (multi-select)
|
|
71
|
+
if (this.multipleValue && this.hasChipTarget) {
|
|
72
|
+
this.chipTargets.forEach((chip) => {
|
|
73
|
+
const value = chip.dataset.value
|
|
74
|
+
if (value) this._selectedValues.add(value)
|
|
75
|
+
})
|
|
76
|
+
this._syncIndicators()
|
|
77
|
+
this._syncHiddenInput()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
disconnect() {
|
|
82
|
+
this._cleanupPosition?.()
|
|
83
|
+
this._removeGlobalListeners()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// --- Actions ---
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Toggles the dropdown open or closed.
|
|
90
|
+
*
|
|
91
|
+
* @param {Event} event
|
|
92
|
+
*/
|
|
93
|
+
toggle(event) {
|
|
94
|
+
event.preventDefault()
|
|
95
|
+
if (this._open) {
|
|
96
|
+
this.close()
|
|
97
|
+
} else {
|
|
98
|
+
this.open()
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Opens the dropdown when the input receives focus.
|
|
104
|
+
* Skipped when focus is returned programmatically after selection.
|
|
105
|
+
*/
|
|
106
|
+
onInputFocus() {
|
|
107
|
+
if (!this._open && !this._suppressFocusOpen) {
|
|
108
|
+
this.open()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Filters the item list based on the current input value.
|
|
114
|
+
* Hides non-matching items, updates the empty state, and auto-highlights
|
|
115
|
+
* the first visible item.
|
|
116
|
+
*/
|
|
117
|
+
filter() {
|
|
118
|
+
const query = this.hasInputTarget ? this.inputTarget.value.toLowerCase().trim() : ""
|
|
119
|
+
let visibleCount = 0
|
|
120
|
+
|
|
121
|
+
this.itemTargets.forEach((item) => {
|
|
122
|
+
const text = this._itemText(item).toLowerCase()
|
|
123
|
+
const matches = text.includes(query)
|
|
124
|
+
item.hidden = !matches
|
|
125
|
+
if (matches) visibleCount++
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// Show/hide empty state
|
|
129
|
+
if (this.hasEmptyTarget) {
|
|
130
|
+
this.emptyTarget.hidden = visibleCount > 0
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Show/hide group labels and separators based on visible items
|
|
134
|
+
this._updateGroupVisibility()
|
|
135
|
+
|
|
136
|
+
// Reset highlighting
|
|
137
|
+
this._highlightIndex(-1)
|
|
138
|
+
|
|
139
|
+
// Auto-highlight first visible item
|
|
140
|
+
const firstVisible = this._visibleEnabledItems
|
|
141
|
+
if (firstVisible.length > 0) {
|
|
142
|
+
this._highlightIndex(0)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Handles keyboard events on the input field.
|
|
148
|
+
* Supports ArrowDown/Up, Enter, Escape, Backspace (remove last chip),
|
|
149
|
+
* Tab, Home, and End.
|
|
150
|
+
*
|
|
151
|
+
* @param {KeyboardEvent} event
|
|
152
|
+
*/
|
|
153
|
+
inputKeydown(event) {
|
|
154
|
+
switch (event.key) {
|
|
155
|
+
case "ArrowDown":
|
|
156
|
+
event.preventDefault()
|
|
157
|
+
if (!this._open) {
|
|
158
|
+
this.open()
|
|
159
|
+
} else {
|
|
160
|
+
this._moveHighlight(1)
|
|
161
|
+
}
|
|
162
|
+
break
|
|
163
|
+
case "ArrowUp":
|
|
164
|
+
event.preventDefault()
|
|
165
|
+
if (!this._open) {
|
|
166
|
+
this.open()
|
|
167
|
+
} else {
|
|
168
|
+
this._moveHighlight(-1)
|
|
169
|
+
}
|
|
170
|
+
break
|
|
171
|
+
case "Enter":
|
|
172
|
+
event.preventDefault()
|
|
173
|
+
if (this._open && this._highlightedIndex >= 0) {
|
|
174
|
+
const items = this._visibleEnabledItems
|
|
175
|
+
if (this._highlightedIndex < items.length) {
|
|
176
|
+
this._doSelect(items[this._highlightedIndex])
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
break
|
|
180
|
+
case "Escape":
|
|
181
|
+
event.preventDefault()
|
|
182
|
+
if (this._open) {
|
|
183
|
+
this.close()
|
|
184
|
+
}
|
|
185
|
+
break
|
|
186
|
+
case "Backspace":
|
|
187
|
+
// In multi-select, remove last chip if input is empty
|
|
188
|
+
if (this.multipleValue && this.hasInputTarget && this.inputTarget.value === "") {
|
|
189
|
+
this._removeLastChip()
|
|
190
|
+
}
|
|
191
|
+
break
|
|
192
|
+
case "Tab":
|
|
193
|
+
if (this._open) {
|
|
194
|
+
this.close()
|
|
195
|
+
}
|
|
196
|
+
break
|
|
197
|
+
case "Home":
|
|
198
|
+
if (this._open) {
|
|
199
|
+
event.preventDefault()
|
|
200
|
+
const items = this._visibleEnabledItems
|
|
201
|
+
if (items.length > 0) this._highlightIndex(0)
|
|
202
|
+
}
|
|
203
|
+
break
|
|
204
|
+
case "End":
|
|
205
|
+
if (this._open) {
|
|
206
|
+
event.preventDefault()
|
|
207
|
+
const items = this._visibleEnabledItems
|
|
208
|
+
if (items.length > 0) this._highlightIndex(items.length - 1)
|
|
209
|
+
}
|
|
210
|
+
break
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Selects an item when clicked.
|
|
216
|
+
*
|
|
217
|
+
* @param {Event} event - Click event from an item element
|
|
218
|
+
*/
|
|
219
|
+
selectItem(event) {
|
|
220
|
+
const item = event.currentTarget
|
|
221
|
+
if (item.dataset.disabled === "true") return
|
|
222
|
+
this._doSelect(item)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Removes a chip in multi-select mode.
|
|
227
|
+
* Deselects the value and removes the chip element from the DOM.
|
|
228
|
+
*
|
|
229
|
+
* @param {Event} event - Click event from a chip's remove button
|
|
230
|
+
*/
|
|
231
|
+
removeChip(event) {
|
|
232
|
+
const value = event.currentTarget.dataset.value
|
|
233
|
+
if (!value) return
|
|
234
|
+
|
|
235
|
+
this._selectedValues.delete(value)
|
|
236
|
+
|
|
237
|
+
// Remove the chip element
|
|
238
|
+
const chip = this.chipTargets.find((c) => c.dataset.value === value)
|
|
239
|
+
if (chip) chip.remove()
|
|
240
|
+
|
|
241
|
+
this._syncIndicators()
|
|
242
|
+
this._syncHiddenInput()
|
|
243
|
+
this.dispatch("change", { detail: { value: Array.from(this._selectedValues) } })
|
|
244
|
+
|
|
245
|
+
// Refocus the input
|
|
246
|
+
if (this.hasInputTarget) {
|
|
247
|
+
this.inputTarget.focus()
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// --- Open / Close ---
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Opens the dropdown, resets filtering, and highlights the selected
|
|
255
|
+
* item (single mode) or the first item (multi mode).
|
|
256
|
+
*/
|
|
257
|
+
open() {
|
|
258
|
+
if (this._open) return
|
|
259
|
+
|
|
260
|
+
this._open = true
|
|
261
|
+
if (this.hasContentTarget) {
|
|
262
|
+
this.contentTarget.hidden = false
|
|
263
|
+
this._positionContent()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
this._addGlobalListeners()
|
|
267
|
+
|
|
268
|
+
// Reset filter to show all items
|
|
269
|
+
this.itemTargets.forEach((item) => {
|
|
270
|
+
item.hidden = false
|
|
271
|
+
})
|
|
272
|
+
if (this.hasEmptyTarget) {
|
|
273
|
+
this.emptyTarget.hidden = true
|
|
274
|
+
}
|
|
275
|
+
this._updateGroupVisibility()
|
|
276
|
+
|
|
277
|
+
// Highlight the selected item (single mode) or first item
|
|
278
|
+
if (!this.multipleValue) {
|
|
279
|
+
const selectedIndex = this._visibleEnabledItems.findIndex(
|
|
280
|
+
(item) => item.getAttribute("aria-selected") === "true",
|
|
281
|
+
)
|
|
282
|
+
this._highlightIndex(selectedIndex >= 0 ? selectedIndex : 0)
|
|
283
|
+
} else {
|
|
284
|
+
this._highlightIndex(0)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Update trigger aria
|
|
288
|
+
if (this.hasTriggerTarget) {
|
|
289
|
+
this.triggerTarget.setAttribute("aria-expanded", "true")
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Closes the dropdown and clears the filter text (multi-select mode only).
|
|
295
|
+
*/
|
|
296
|
+
close() {
|
|
297
|
+
if (!this._open) return
|
|
298
|
+
|
|
299
|
+
this._cleanupPosition?.()
|
|
300
|
+
this._cleanupPosition = null
|
|
301
|
+
this._open = false
|
|
302
|
+
if (this.hasContentTarget) {
|
|
303
|
+
this.contentTarget.hidden = true
|
|
304
|
+
}
|
|
305
|
+
this._highlightIndex(-1)
|
|
306
|
+
this._removeGlobalListeners()
|
|
307
|
+
|
|
308
|
+
// Update trigger aria
|
|
309
|
+
if (this.hasTriggerTarget) {
|
|
310
|
+
this.triggerTarget.setAttribute("aria-expanded", "false")
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Clear filter text unless it shows selected value (single mode)
|
|
314
|
+
if (this.hasInputTarget && this.multipleValue) {
|
|
315
|
+
this.inputTarget.value = ""
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// --- Private ---
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Routes selection to single or multi-select handler.
|
|
323
|
+
*
|
|
324
|
+
* @param {HTMLElement} item - The item element to select
|
|
325
|
+
* @private
|
|
326
|
+
*/
|
|
327
|
+
_doSelect(item) {
|
|
328
|
+
const value = item.dataset.value
|
|
329
|
+
if (!value) return
|
|
330
|
+
|
|
331
|
+
if (this.multipleValue) {
|
|
332
|
+
this._toggleMultiSelect(value, item)
|
|
333
|
+
} else {
|
|
334
|
+
this._singleSelect(value, item)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Handles single-select: updates aria, indicators, hidden input,
|
|
340
|
+
* and closes the dropdown.
|
|
341
|
+
*
|
|
342
|
+
* @param {string} value - The selected value
|
|
343
|
+
* @param {HTMLElement} item - The selected item element
|
|
344
|
+
* @private
|
|
345
|
+
*/
|
|
346
|
+
_singleSelect(value, item) {
|
|
347
|
+
const text = this._itemText(item)
|
|
348
|
+
|
|
349
|
+
// Update selected state on all items
|
|
350
|
+
this.itemTargets.forEach((i) => {
|
|
351
|
+
const isSelected = i.dataset.value === value
|
|
352
|
+
i.setAttribute("aria-selected", isSelected)
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
// Update indicators
|
|
356
|
+
this._syncIndicators()
|
|
357
|
+
|
|
358
|
+
// Set hidden input value
|
|
359
|
+
if (this.hasHiddenInputTarget) {
|
|
360
|
+
this.hiddenInputTarget.value = value
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Set input text to selected item
|
|
364
|
+
if (this.hasInputTarget) {
|
|
365
|
+
this.inputTarget.value = text
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
this.close()
|
|
369
|
+
this.dispatch("change", { detail: { value } })
|
|
370
|
+
|
|
371
|
+
// Focus the input after close, suppressing the auto-open behavior
|
|
372
|
+
if (this.hasInputTarget) {
|
|
373
|
+
this._suppressFocusOpen = true
|
|
374
|
+
this.inputTarget.focus()
|
|
375
|
+
this._suppressFocusOpen = false
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Handles multi-select toggle: adds/removes from selection,
|
|
381
|
+
* creates/removes chips, and re-filters the list.
|
|
382
|
+
*
|
|
383
|
+
* @param {string} value - The toggled value
|
|
384
|
+
* @param {HTMLElement} item - The toggled item element
|
|
385
|
+
* @private
|
|
386
|
+
*/
|
|
387
|
+
_toggleMultiSelect(value, item) {
|
|
388
|
+
if (this._selectedValues.has(value)) {
|
|
389
|
+
// Deselect
|
|
390
|
+
this._selectedValues.delete(value)
|
|
391
|
+
item.setAttribute("aria-selected", "false")
|
|
392
|
+
|
|
393
|
+
// Remove the chip
|
|
394
|
+
const chip = this.chipTargets.find((c) => c.dataset.value === value)
|
|
395
|
+
if (chip) chip.remove()
|
|
396
|
+
} else {
|
|
397
|
+
// Select
|
|
398
|
+
this._selectedValues.add(value)
|
|
399
|
+
item.setAttribute("aria-selected", "true")
|
|
400
|
+
|
|
401
|
+
// Create a chip
|
|
402
|
+
this._createChip(value, this._itemText(item))
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
this._syncIndicators()
|
|
406
|
+
this._syncHiddenInput()
|
|
407
|
+
this.dispatch("change", { detail: { value: Array.from(this._selectedValues) } })
|
|
408
|
+
|
|
409
|
+
// Clear input and refocus
|
|
410
|
+
if (this.hasInputTarget) {
|
|
411
|
+
this.inputTarget.value = ""
|
|
412
|
+
this.inputTarget.focus()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Re-filter to show all items again
|
|
416
|
+
this.filter()
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Creates a chip element for a selected value in multi-select mode
|
|
421
|
+
* by cloning the server-rendered `<template>` and inserting it before the input.
|
|
422
|
+
*
|
|
423
|
+
* @param {string} value - The selected value
|
|
424
|
+
* @param {string} text - The display text
|
|
425
|
+
* @private
|
|
426
|
+
*/
|
|
427
|
+
_createChip(value, text) {
|
|
428
|
+
if (!this.hasChipsTarget || !this.hasChipTemplateTarget) return
|
|
429
|
+
|
|
430
|
+
const chip = this.chipTemplateTarget.content.firstElementChild.cloneNode(true)
|
|
431
|
+
chip.dataset.value = value
|
|
432
|
+
|
|
433
|
+
const textEl = chip.querySelector("[data-slot='combobox-chip-text']")
|
|
434
|
+
if (textEl) textEl.textContent = text
|
|
435
|
+
|
|
436
|
+
const removeBtn = chip.querySelector("[data-slot='combobox-chip-remove']")
|
|
437
|
+
if (removeBtn) removeBtn.dataset.value = value
|
|
438
|
+
|
|
439
|
+
// Insert chip before the input element
|
|
440
|
+
const input = this.chipsTarget.querySelector("[data-slot='combobox-chip-input']")
|
|
441
|
+
if (input) {
|
|
442
|
+
this.chipsTarget.insertBefore(chip, input)
|
|
443
|
+
} else {
|
|
444
|
+
this.chipsTarget.appendChild(chip)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Removes the last chip in multi-select mode (triggered by Backspace
|
|
450
|
+
* when the input is empty).
|
|
451
|
+
*
|
|
452
|
+
* @private
|
|
453
|
+
*/
|
|
454
|
+
_removeLastChip() {
|
|
455
|
+
const chips = this.chipTargets
|
|
456
|
+
if (chips.length === 0) return
|
|
457
|
+
|
|
458
|
+
const lastChip = chips[chips.length - 1]
|
|
459
|
+
const value = lastChip.dataset.value
|
|
460
|
+
if (value) {
|
|
461
|
+
this._selectedValues.delete(value)
|
|
462
|
+
lastChip.remove()
|
|
463
|
+
this._syncIndicators()
|
|
464
|
+
this._syncHiddenInput()
|
|
465
|
+
this.dispatch("change", { detail: { value: Array.from(this._selectedValues) } })
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Syncs checkmark indicator visibility with current selection state.
|
|
471
|
+
*
|
|
472
|
+
* @private
|
|
473
|
+
*/
|
|
474
|
+
_syncIndicators() {
|
|
475
|
+
this.itemTargets.forEach((item) => {
|
|
476
|
+
const value = item.dataset.value
|
|
477
|
+
const isSelected = this.multipleValue
|
|
478
|
+
? this._selectedValues.has(value)
|
|
479
|
+
: item.getAttribute("aria-selected") === "true"
|
|
480
|
+
|
|
481
|
+
const indicator = item.querySelector("[data-slot='combobox-item-indicator']")
|
|
482
|
+
if (indicator) {
|
|
483
|
+
indicator.hidden = !isSelected
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Syncs the hidden input value with the current selection.
|
|
490
|
+
* Multi-select joins values with commas.
|
|
491
|
+
*
|
|
492
|
+
* @private
|
|
493
|
+
*/
|
|
494
|
+
_syncHiddenInput() {
|
|
495
|
+
if (!this.hasHiddenInputTarget) return
|
|
496
|
+
|
|
497
|
+
if (this.multipleValue) {
|
|
498
|
+
this.hiddenInputTarget.value = Array.from(this._selectedValues).join(",")
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Extracts the display text from an item element.
|
|
504
|
+
*
|
|
505
|
+
* @param {HTMLElement} item
|
|
506
|
+
* @returns {string}
|
|
507
|
+
* @private
|
|
508
|
+
*/
|
|
509
|
+
_itemText(item) {
|
|
510
|
+
const textEl = item.querySelector("[data-slot='combobox-item-text']")
|
|
511
|
+
return textEl ? textEl.textContent.trim() : item.dataset.value || ""
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Returns visible, non-disabled items.
|
|
516
|
+
*
|
|
517
|
+
* @returns {HTMLElement[]}
|
|
518
|
+
* @private
|
|
519
|
+
*/
|
|
520
|
+
get _visibleEnabledItems() {
|
|
521
|
+
return this.itemTargets.filter((item) => !item.hidden && item.dataset.disabled !== "true")
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Highlights an item at the given index and scrolls it into view.
|
|
526
|
+
* Pass -1 to clear all highlights.
|
|
527
|
+
*
|
|
528
|
+
* @param {number} index - Index within visible enabled items, or -1 to clear
|
|
529
|
+
* @private
|
|
530
|
+
*/
|
|
531
|
+
_highlightIndex(index) {
|
|
532
|
+
this._highlightedIndex = index
|
|
533
|
+
highlightItem(this.itemTargets, this._visibleEnabledItems, index)
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Moves the highlight by a given direction (+1 or -1), wrapping at boundaries.
|
|
538
|
+
*
|
|
539
|
+
* @param {number} direction - +1 for next, -1 for previous
|
|
540
|
+
* @private
|
|
541
|
+
*/
|
|
542
|
+
_moveHighlight(direction) {
|
|
543
|
+
const items = this._visibleEnabledItems
|
|
544
|
+
if (items.length === 0) return
|
|
545
|
+
this._highlightIndex(wrapIndex(this._highlightedIndex, direction, items.length))
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Shows/hides group labels and separators based on whether they
|
|
550
|
+
* contain any visible items.
|
|
551
|
+
*
|
|
552
|
+
* @private
|
|
553
|
+
*/
|
|
554
|
+
_updateGroupVisibility() {
|
|
555
|
+
// Show/hide group labels based on whether they have visible items
|
|
556
|
+
if (!this.hasListTarget) return
|
|
557
|
+
|
|
558
|
+
const groups = this.listTarget.querySelectorAll("[data-slot='combobox-group']")
|
|
559
|
+
groups.forEach((group) => {
|
|
560
|
+
const items = group.querySelectorAll("[data-slot='combobox-item']")
|
|
561
|
+
const hasVisible = Array.from(items).some((item) => !item.hidden)
|
|
562
|
+
group.hidden = !hasVisible
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
// Show/hide separators based on adjacent visible groups
|
|
566
|
+
const separators = this.listTarget.querySelectorAll("[data-slot='combobox-separator']")
|
|
567
|
+
separators.forEach((sep) => {
|
|
568
|
+
const prevGroup = sep.previousElementSibling
|
|
569
|
+
const nextGroup = sep.nextElementSibling
|
|
570
|
+
const prevVisible = prevGroup && !prevGroup.hidden
|
|
571
|
+
const nextVisible = nextGroup && !nextGroup.hidden
|
|
572
|
+
sep.hidden = !(prevVisible && nextVisible)
|
|
573
|
+
})
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Positions the dropdown relative to the input/chips container with matching width.
|
|
578
|
+
* Starts auto-updating on scroll/resize.
|
|
579
|
+
*
|
|
580
|
+
* @private
|
|
581
|
+
*/
|
|
582
|
+
_positionContent() {
|
|
583
|
+
if (!this.hasContentTarget) return
|
|
584
|
+
|
|
585
|
+
const anchor =
|
|
586
|
+
this.element.querySelector("[data-slot='combobox-input']") ||
|
|
587
|
+
this.element.querySelector("[data-slot='combobox-chips']") ||
|
|
588
|
+
this.element
|
|
589
|
+
|
|
590
|
+
this._cleanupPosition = startPositioning(anchor, this.contentTarget, {
|
|
591
|
+
matchWidth: true,
|
|
592
|
+
})
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Closes the dropdown when clicking outside the component.
|
|
597
|
+
*
|
|
598
|
+
* @param {MouseEvent} event
|
|
599
|
+
* @private
|
|
600
|
+
*/
|
|
601
|
+
_handleOutsideClick(event) {
|
|
602
|
+
if (!this.element.contains(event.target)) {
|
|
603
|
+
this.close()
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** @private */
|
|
608
|
+
_addGlobalListeners() {
|
|
609
|
+
document.addEventListener("click", this._handleOutsideClick, true)
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** @private */
|
|
613
|
+
_removeGlobalListeners() {
|
|
614
|
+
document.removeEventListener("click", this._handleOutsideClick, true)
|
|
615
|
+
}
|
|
616
|
+
}
|