phlex_kit 0.5.1 → 0.6.1
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/README.md +21 -8
- data/app/components/phlex_kit/accordion/accordion.css +2 -0
- data/app/components/phlex_kit/accordion/accordion.rb +1 -1
- data/app/components/phlex_kit/accordion/accordion_content.rb +3 -1
- data/app/components/phlex_kit/accordion/accordion_controller.js +26 -2
- data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +20 -4
- data/app/components/phlex_kit/accordion/accordion_trigger.rb +18 -2
- data/app/components/phlex_kit/alert/alert.rb +1 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_cancel.rb +1 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +1 -1
- data/app/components/phlex_kit/attachment/attachment.rb +2 -2
- data/app/components/phlex_kit/attachment/attachment_media.rb +1 -1
- data/app/components/phlex_kit/avatar/avatar.rb +1 -1
- data/app/components/phlex_kit/badge/badge.rb +1 -1
- data/app/components/phlex_kit/button/button.rb +1 -1
- data/app/components/phlex_kit/button_group/button_group.rb +1 -1
- data/app/components/phlex_kit/calendar/calendar_controller.js +12 -8
- data/app/components/phlex_kit/card/card.rb +1 -1
- data/app/components/phlex_kit/carousel/carousel.rb +1 -1
- data/app/components/phlex_kit/clipboard/clipboard_popover.rb +1 -1
- data/app/components/phlex_kit/combobox/combobox.css +21 -12
- data/app/components/phlex_kit/combobox/combobox.rb +19 -3
- data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +7 -2
- data/app/components/phlex_kit/combobox/combobox_checkbox.rb +3 -0
- data/app/components/phlex_kit/combobox/combobox_controller.js +85 -20
- data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +11 -2
- data/app/components/phlex_kit/combobox/combobox_item.rb +4 -1
- data/app/components/phlex_kit/combobox/combobox_list.rb +9 -2
- data/app/components/phlex_kit/combobox/combobox_popover.rb +8 -7
- data/app/components/phlex_kit/combobox/combobox_radio.rb +3 -0
- data/app/components/phlex_kit/combobox/combobox_search_input.rb +8 -3
- data/app/components/phlex_kit/command/command.rb +17 -2
- data/app/components/phlex_kit/command/command_controller.js +93 -3
- data/app/components/phlex_kit/command/command_dialog_content.rb +16 -4
- data/app/components/phlex_kit/command/command_input.rb +7 -2
- data/app/components/phlex_kit/command/command_item.rb +12 -3
- data/app/components/phlex_kit/command/command_list.rb +9 -2
- data/app/components/phlex_kit/context_menu/context_menu.css +25 -13
- data/app/components/phlex_kit/context_menu/context_menu_content.rb +1 -1
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +71 -12
- data/app/components/phlex_kit/context_menu/context_menu_item.rb +7 -3
- data/app/components/phlex_kit/context_menu/context_menu_label.rb +1 -1
- data/app/components/phlex_kit/context_menu/context_menu_trigger.rb +3 -1
- data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +1 -1
- data/app/components/phlex_kit/data_table/data_table_controller.js +12 -1
- data/app/components/phlex_kit/data_table/data_table_pagination.rb +12 -10
- data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +1 -0
- data/app/components/phlex_kit/data_table/data_table_sort_head.rb +5 -1
- data/app/components/phlex_kit/dialog/dialog_content.rb +12 -4
- data/app/components/phlex_kit/dialog/dialog_controller.js +13 -0
- data/app/components/phlex_kit/drawer/drawer_content.rb +2 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +36 -15
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -3
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +8 -11
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +1 -1
- data/app/components/phlex_kit/empty/empty_media.rb +1 -1
- data/app/components/phlex_kit/field/field.rb +1 -1
- data/app/components/phlex_kit/field/field_legend.rb +1 -1
- data/app/components/phlex_kit/hover_card/hover_card.css +27 -8
- data/app/components/phlex_kit/hover_card/hover_card.rb +2 -1
- data/app/components/phlex_kit/hover_card/hover_card_content.rb +2 -2
- data/app/components/phlex_kit/hover_card/hover_card_controller.js +19 -5
- data/app/components/phlex_kit/input_group/input_group_addon.rb +1 -1
- data/app/components/phlex_kit/input_otp/input_otp.rb +4 -1
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +12 -1
- data/app/components/phlex_kit/item/item.rb +1 -1
- data/app/components/phlex_kit/item/item_media.rb +1 -1
- data/app/components/phlex_kit/link/link.rb +1 -1
- data/app/components/phlex_kit/marker/marker.rb +1 -1
- data/app/components/phlex_kit/menubar/menubar.css +36 -14
- data/app/components/phlex_kit/menubar/menubar.rb +1 -1
- data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +5 -2
- data/app/components/phlex_kit/menubar/menubar_content.rb +1 -1
- data/app/components/phlex_kit/menubar/menubar_controller.js +100 -12
- data/app/components/phlex_kit/menubar/menubar_item.rb +8 -5
- data/app/components/phlex_kit/menubar/menubar_radio_item.rb +5 -2
- data/app/components/phlex_kit/menubar/menubar_trigger.rb +1 -0
- data/app/components/phlex_kit/native_select/native_select.rb +1 -1
- data/app/components/phlex_kit/navigation_menu/navigation_menu.css +24 -7
- data/app/components/phlex_kit/navigation_menu/navigation_menu_content.rb +3 -1
- data/app/components/phlex_kit/popover/popover.css +24 -9
- data/app/components/phlex_kit/popover/popover.rb +2 -1
- data/app/components/phlex_kit/popover/popover_content.rb +6 -4
- data/app/components/phlex_kit/popover/popover_controller.js +12 -8
- data/app/components/phlex_kit/popover/popover_trigger.rb +1 -1
- data/app/components/phlex_kit/resizable/resizable_panel.rb +4 -2
- data/app/components/phlex_kit/select/select.css +24 -12
- data/app/components/phlex_kit/select/select_content.rb +6 -4
- data/app/components/phlex_kit/select/select_controller.js +21 -17
- data/app/components/phlex_kit/select/select_trigger.rb +1 -1
- data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +68 -2
- data/app/components/phlex_kit/sheet/sheet_controller.js +28 -3
- data/app/components/phlex_kit/sidebar/sidebar.rb +1 -1
- data/app/components/phlex_kit/sidebar/sidebar_wrapper.rb +1 -1
- data/app/components/phlex_kit/spinner/spinner.rb +1 -1
- data/app/components/phlex_kit/switch/switch.rb +7 -4
- data/app/components/phlex_kit/tabs/tabs.rb +1 -1
- data/app/components/phlex_kit/tabs/tabs_content.rb +4 -1
- data/app/components/phlex_kit/tabs/tabs_controller.js +60 -7
- data/app/components/phlex_kit/tabs/tabs_list.rb +2 -2
- data/app/components/phlex_kit/tabs/tabs_trigger.rb +14 -2
- data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +11 -3
- data/app/components/phlex_kit/toast/toast_controller.js +17 -0
- data/app/components/phlex_kit/toast/toast_icon.rb +1 -1
- data/app/components/phlex_kit/toast/toast_item.rb +1 -1
- data/app/components/phlex_kit/toggle/toggle.rb +2 -2
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +2 -2
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +1 -1
- data/app/components/phlex_kit/tooltip/tooltip.css +40 -22
- data/app/components/phlex_kit/tooltip/tooltip.rb +3 -1
- data/app/components/phlex_kit/tooltip/tooltip_content.rb +1 -1
- data/app/components/phlex_kit/tooltip/tooltip_controller.js +32 -0
- data/app/components/phlex_kit/tooltip/tooltip_trigger.rb +8 -3
- data/app/components/phlex_kit/typography/text.rb +1 -1
- data/app/javascript/phlex_kit/controllers/index.js +2 -0
- data/lib/generators/phlex_kit/component/component_generator.rb +28 -0
- data/lib/generators/phlex_kit/install/templates/phlex_kit.rb +3 -0
- data/lib/phlex_kit/base_component.rb +12 -0
- data/lib/phlex_kit/engine.rb +11 -1
- data/lib/phlex_kit/icons/lucide.rb +1 -1
- data/lib/phlex_kit/icons/phosphor.rb +1 -1
- data/lib/phlex_kit/icons/remix.rb +1 -1
- data/lib/phlex_kit/icons/tabler.rb +1 -1
- data/lib/phlex_kit/version.rb +1 -1
- data/lib/phlex_kit.rb +5 -0
- metadata +2 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { Controller } from "@hotwired/stimulus";
|
|
2
2
|
|
|
3
|
-
// Ported from ruby_ui's combobox controller
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// Ported from ruby_ui's combobox controller, minus the @floating-ui/dom
|
|
4
|
+
// dependency: like upstream, the panel is a native popover (manual — this
|
|
5
|
+
// controller owns open/close and click-outside via a window action), and CSS
|
|
6
|
+
// anchor positioning replaces computePosition (combobox.css: trigger-width
|
|
7
|
+
// floor via anchor-size(), viewport-edge flip via position-try-fallbacks),
|
|
8
|
+
// so only @hotwired/stimulus is needed. PhlexKit also COMPLETES upstream's unfinished
|
|
8
9
|
// input/badge trigger variants: the inputTrigger / badgeContainer / badgeInput /
|
|
9
10
|
// clearButton targets, filtering from whichever field fired, chip rendering
|
|
10
11
|
// with per-chip remove, backspace removal, and clearAll all had no upstream
|
|
@@ -14,7 +15,6 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
14
15
|
export default class extends Controller {
|
|
15
16
|
static values = {
|
|
16
17
|
term: String,
|
|
17
|
-
minPopoverWidth: { type: Number, default: 240 },
|
|
18
18
|
autoHighlight: { type: Boolean, default: false }
|
|
19
19
|
}
|
|
20
20
|
|
|
@@ -30,15 +30,61 @@ export default class extends Controller {
|
|
|
30
30
|
"inputTrigger",
|
|
31
31
|
"badgeContainer",
|
|
32
32
|
"badgeInput",
|
|
33
|
-
"clearButton"
|
|
33
|
+
"clearButton",
|
|
34
|
+
"list",
|
|
35
|
+
"liveRegion"
|
|
34
36
|
]
|
|
35
37
|
|
|
36
38
|
selectedItemIndex = null
|
|
37
39
|
|
|
38
40
|
connect() {
|
|
41
|
+
this.generateItemIds()
|
|
39
42
|
this.updateTriggerContent()
|
|
40
43
|
}
|
|
41
44
|
|
|
45
|
+
// ARIA plumbing: every option gets an id derived from the listbox id so the
|
|
46
|
+
// combobox element can point aria-activedescendant at the keyboard highlight,
|
|
47
|
+
// and aria-controls is wired to the listbox (mirrors select_controller.js).
|
|
48
|
+
generateItemIds() {
|
|
49
|
+
const list = this.hasListTarget ? this.listTarget : (this.hasPopoverTarget ? this.popoverTarget : this.element)
|
|
50
|
+
if (!list.id) list.id = `pk-combobox-list-${Math.random().toString(36).slice(2, 10)}`
|
|
51
|
+
|
|
52
|
+
this.itemTargets.forEach((item, index) => {
|
|
53
|
+
if (!item.id) item.id = `${list.id}-${index}`
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
this.ariaExpandedElements().forEach((el) => el.setAttribute("aria-controls", list.id))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The elements carrying the open/closed combobox state: the trigger button
|
|
60
|
+
// (button layout) and/or whichever filter field the layout has.
|
|
61
|
+
ariaExpandedElements() {
|
|
62
|
+
const els = []
|
|
63
|
+
if (this.hasInputTriggerTarget) els.push(this.inputTriggerTarget)
|
|
64
|
+
if (this.hasBadgeInputTarget) els.push(this.badgeInputTarget)
|
|
65
|
+
if (els.length === 0 && this.hasTriggerTarget) els.push(this.triggerTarget)
|
|
66
|
+
if (this.hasSearchInputTarget) els.push(this.searchInputTarget)
|
|
67
|
+
return els
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
isOpen() {
|
|
71
|
+
return this.hasPopoverTarget && this.popoverTarget.matches(":popover-open")
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setExpanded(expanded) {
|
|
75
|
+
this.ariaExpandedElements().forEach((el) => el.setAttribute("aria-expanded", expanded ? "true" : "false"))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setActiveDescendant(id) {
|
|
79
|
+
const field = this.filterField()
|
|
80
|
+
if (field) field.setAttribute("aria-activedescendant", id)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
clearActiveDescendant() {
|
|
84
|
+
const field = this.filterField()
|
|
85
|
+
if (field) field.removeAttribute("aria-activedescendant")
|
|
86
|
+
}
|
|
87
|
+
|
|
42
88
|
inputChanged(e) {
|
|
43
89
|
this.updateTriggerContent()
|
|
44
90
|
|
|
@@ -81,13 +127,15 @@ export default class extends Controller {
|
|
|
81
127
|
updateTriggerContent() {
|
|
82
128
|
const checked = this.checkedInputs()
|
|
83
129
|
|
|
130
|
+
this.syncAriaSelected()
|
|
131
|
+
|
|
84
132
|
if (this.hasTriggerContentTarget) {
|
|
85
133
|
this.triggerContentTarget.innerText = this.selectionLabel(checked) || this.triggerTarget.dataset.placeholder
|
|
86
134
|
}
|
|
87
135
|
|
|
88
136
|
// Input trigger: the field is also the filter, so only reflect the
|
|
89
137
|
// selection while the popover is closed — never stomp a query mid-typing.
|
|
90
|
-
if (this.hasInputTriggerTarget && this.
|
|
138
|
+
if (this.hasInputTriggerTarget && !this.isOpen()) {
|
|
91
139
|
this.inputTriggerTarget.value = this.selectionLabel(checked)
|
|
92
140
|
}
|
|
93
141
|
|
|
@@ -100,6 +148,14 @@ export default class extends Controller {
|
|
|
100
148
|
}
|
|
101
149
|
}
|
|
102
150
|
|
|
151
|
+
// The chosen option(s) carry aria-selected; the option label is the ARIA
|
|
152
|
+
// option, its inner input holds the real checked state.
|
|
153
|
+
syncAriaSelected() {
|
|
154
|
+
this.inputTargets.forEach((input) => {
|
|
155
|
+
input.parentElement.setAttribute("aria-selected", input.checked ? "true" : "false")
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
103
159
|
renderBadges(checked) {
|
|
104
160
|
this.badgeContainerTarget.replaceChildren(...checked.map((input) => this.buildBadge(input)))
|
|
105
161
|
this.badgeContainerTarget.classList.toggle("pk-hidden", checked.length === 0)
|
|
@@ -159,7 +215,7 @@ export default class extends Controller {
|
|
|
159
215
|
togglePopover(event) {
|
|
160
216
|
event.preventDefault()
|
|
161
217
|
|
|
162
|
-
if (this.
|
|
218
|
+
if (this.isOpen()) {
|
|
163
219
|
this.closePopover()
|
|
164
220
|
} else {
|
|
165
221
|
this.openPopover(event)
|
|
@@ -168,13 +224,13 @@ export default class extends Controller {
|
|
|
168
224
|
|
|
169
225
|
openPopover(event) {
|
|
170
226
|
if (event) event.preventDefault()
|
|
171
|
-
if (this.
|
|
227
|
+
if (this.isOpen()) return
|
|
172
228
|
|
|
173
|
-
this.
|
|
174
|
-
this.triggerTarget.ariaExpanded = "true"
|
|
229
|
+
this.setExpanded(true)
|
|
175
230
|
this.selectedItemIndex = null
|
|
176
231
|
this.itemTargets.forEach(item => item.ariaCurrent = "false")
|
|
177
|
-
this.
|
|
232
|
+
this.clearActiveDescendant()
|
|
233
|
+
this.popoverTarget.showPopover()
|
|
178
234
|
|
|
179
235
|
const field = this.filterField()
|
|
180
236
|
if (field) {
|
|
@@ -186,13 +242,16 @@ export default class extends Controller {
|
|
|
186
242
|
}
|
|
187
243
|
|
|
188
244
|
closePopover() {
|
|
189
|
-
this.
|
|
190
|
-
|
|
245
|
+
this.setExpanded(false)
|
|
246
|
+
// aria-activedescendant holds an element id; on close it must be removed,
|
|
247
|
+
// not left pointing at a hidden option.
|
|
248
|
+
this.clearActiveDescendant()
|
|
249
|
+
if (this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
|
|
191
250
|
this.updateTriggerContent() // reflect the selection into an input trigger
|
|
192
251
|
}
|
|
193
252
|
|
|
194
253
|
onClickOutside(event) {
|
|
195
|
-
if (this.
|
|
254
|
+
if (!this.isOpen()) return
|
|
196
255
|
if (this.element.contains(event.target)) return
|
|
197
256
|
|
|
198
257
|
this.closePopover()
|
|
@@ -227,6 +286,7 @@ export default class extends Controller {
|
|
|
227
286
|
let resultCount = 0
|
|
228
287
|
|
|
229
288
|
this.selectedItemIndex = null
|
|
289
|
+
this.clearActiveDescendant()
|
|
230
290
|
|
|
231
291
|
this.inputTargets.forEach((input) => {
|
|
232
292
|
const text = this.inputContent(input).toLowerCase()
|
|
@@ -243,6 +303,12 @@ export default class extends Controller {
|
|
|
243
303
|
this.emptyStateTarget.classList.toggle("pk-hidden", resultCount !== 0)
|
|
244
304
|
}
|
|
245
305
|
|
|
306
|
+
// Announce the filtered result count to screen readers.
|
|
307
|
+
if (this.hasLiveRegionTarget) {
|
|
308
|
+
this.liveRegionTarget.textContent =
|
|
309
|
+
resultCount === 0 ? "No results" : `${resultCount} result${resultCount === 1 ? "" : "s"}`
|
|
310
|
+
}
|
|
311
|
+
|
|
246
312
|
// autoHighlight: keep the first visible option marked while typing so
|
|
247
313
|
// Enter picks it immediately (their autoHighlight prop).
|
|
248
314
|
if (this.autoHighlightValue && filterTerm && resultCount > 0) {
|
|
@@ -284,6 +350,9 @@ export default class extends Controller {
|
|
|
284
350
|
visibleInputs.forEach((input, index) => {
|
|
285
351
|
if (index == this.selectedItemIndex) {
|
|
286
352
|
input.parentElement.ariaCurrent = "true"
|
|
353
|
+
// Focus stays in the filter field; the highlighted option is exposed
|
|
354
|
+
// via aria-activedescendant (option ids come from generateItemIds).
|
|
355
|
+
this.setActiveDescendant(input.parentElement.id)
|
|
287
356
|
input.parentElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
|
288
357
|
} else {
|
|
289
358
|
input.parentElement.ariaCurrent = "false"
|
|
@@ -304,8 +373,4 @@ export default class extends Controller {
|
|
|
304
373
|
this.selectedItemIndex = ((this.selectedItemIndex % length) + length) % length
|
|
305
374
|
}
|
|
306
375
|
|
|
307
|
-
updatePopoverWidth() {
|
|
308
|
-
const width = Math.max(this.triggerTarget.offsetWidth, this.minPopoverWidthValue)
|
|
309
|
-
this.popoverTarget.style.width = `${width}px`
|
|
310
|
-
}
|
|
311
376
|
}
|
|
@@ -9,15 +9,20 @@ module PhlexKit
|
|
|
9
9
|
# popover's own bindings only fire for the button+search layout.
|
|
10
10
|
# See combobox.rb.
|
|
11
11
|
class ComboboxInputTrigger < BaseComponent
|
|
12
|
-
def initialize(placeholder: "", disabled: false, invalid: false, **attrs)
|
|
12
|
+
def initialize(placeholder: "", disabled: false, invalid: false, list_id: nil, **attrs)
|
|
13
13
|
@placeholder = placeholder
|
|
14
14
|
@disabled = disabled
|
|
15
15
|
@invalid = invalid
|
|
16
|
+
@list_id = list_id
|
|
16
17
|
@attrs = attrs
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def view_template
|
|
20
|
-
|
|
21
|
+
# The ARIA combobox role sits on the field itself (the interactive
|
|
22
|
+
# element); the wrapper keeps aria-invalid only because the invalid
|
|
23
|
+
# styling targets it. Pass `list_id:` matching the ComboboxList id to
|
|
24
|
+
# wire aria-controls statically — otherwise the controller wires it.
|
|
25
|
+
wrapper_aria = {}
|
|
21
26
|
wrapper_aria[:invalid] = "true" if @invalid
|
|
22
27
|
div(**mix({
|
|
23
28
|
class: "pk-combobox-input-trigger",
|
|
@@ -35,8 +40,12 @@ module PhlexKit
|
|
|
35
40
|
].join(" ")
|
|
36
41
|
}
|
|
37
42
|
}, @attrs)) do
|
|
43
|
+
field_aria = { haspopup: "listbox", expanded: "false", autocomplete: "list", controls: @list_id }
|
|
44
|
+
field_aria[:invalid] = "true" if @invalid
|
|
38
45
|
input(
|
|
39
46
|
type: :text,
|
|
47
|
+
role: "combobox",
|
|
48
|
+
aria: field_aria,
|
|
40
49
|
placeholder: @placeholder,
|
|
41
50
|
disabled: @disabled,
|
|
42
51
|
autocomplete: "off",
|
|
@@ -2,7 +2,9 @@ module PhlexKit
|
|
|
2
2
|
# One option row — a <label> wrapping a ComboboxCheckbox/ComboboxRadio, the
|
|
3
3
|
# option text, and optionally a ComboboxItemIndicator. Being a label, clicking
|
|
4
4
|
# anywhere toggles the input; the controller hides it (.pk-hidden) when
|
|
5
|
-
# filtered out
|
|
5
|
+
# filtered out, assigns each option an id (for the combobox element's
|
|
6
|
+
# aria-activedescendant), and keeps aria-selected in sync with the inner
|
|
7
|
+
# input's checked state. See combobox.rb.
|
|
6
8
|
class ComboboxItem < BaseComponent
|
|
7
9
|
def initialize(**attrs)
|
|
8
10
|
@attrs = attrs
|
|
@@ -12,6 +14,7 @@ module PhlexKit
|
|
|
12
14
|
label(**mix({
|
|
13
15
|
class: "pk-combobox-item",
|
|
14
16
|
role: "option",
|
|
17
|
+
aria: { selected: "false" },
|
|
15
18
|
data: { phlex_kit__combobox_target: "item" }
|
|
16
19
|
}, @attrs), &)
|
|
17
20
|
end
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
-
# Scrollable option list in a PhlexKit::ComboboxPopover.
|
|
2
|
+
# Scrollable option list in a PhlexKit::ComboboxPopover. Carries the listbox
|
|
3
|
+
# role and an id the controller points the combobox element's aria-controls
|
|
4
|
+
# at (and derives option ids from). See combobox.rb.
|
|
3
5
|
class ComboboxList < BaseComponent
|
|
4
6
|
def initialize(**attrs)
|
|
5
7
|
@attrs = attrs
|
|
6
8
|
end
|
|
7
9
|
|
|
8
10
|
def view_template(&)
|
|
9
|
-
div(**mix({
|
|
11
|
+
div(**mix({
|
|
12
|
+
id: "pk-combobox-list-#{SecureRandom.hex(4)}",
|
|
13
|
+
class: "pk-combobox-list",
|
|
14
|
+
role: "listbox",
|
|
15
|
+
data: { phlex_kit__combobox_target: "list" }
|
|
16
|
+
}, @attrs), &)
|
|
10
17
|
end
|
|
11
18
|
end
|
|
12
19
|
end
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
-
# The combobox dropdown panel
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# inside
|
|
2
|
+
# The combobox dropdown panel — like upstream, a native [popover] (manual:
|
|
3
|
+
# the controller owns open/close and click-outside), anchor-positioned to
|
|
4
|
+
# the combobox with viewport-edge flipping (combobox.css). The keyboard nav
|
|
5
|
+
# actions ride on it, since focus sits in the search input inside. See
|
|
6
|
+
# combobox.rb.
|
|
6
7
|
class ComboboxPopover < BaseComponent
|
|
7
8
|
def initialize(**attrs)
|
|
8
9
|
@attrs = attrs
|
|
@@ -10,15 +11,15 @@ module PhlexKit
|
|
|
10
11
|
|
|
11
12
|
def view_template(&)
|
|
12
13
|
div(**mix({
|
|
13
|
-
class: "pk-combobox-popover
|
|
14
|
+
class: "pk-combobox-popover",
|
|
15
|
+
popover: "manual",
|
|
14
16
|
data: {
|
|
15
17
|
phlex_kit__combobox_target: "popover",
|
|
16
18
|
action: [
|
|
17
19
|
"keydown.down->phlex-kit--combobox#keyDownPressed",
|
|
18
20
|
"keydown.up->phlex-kit--combobox#keyUpPressed",
|
|
19
21
|
"keydown.enter->phlex-kit--combobox#keyEnterPressed",
|
|
20
|
-
"keydown.esc->phlex-kit--combobox#closePopover:prevent"
|
|
21
|
-
"resize@window->phlex-kit--combobox#updatePopoverWidth"
|
|
22
|
+
"keydown.esc->phlex-kit--combobox#closePopover:prevent"
|
|
22
23
|
].join(" ")
|
|
23
24
|
}
|
|
24
25
|
}, @attrs), &)
|
|
@@ -10,6 +10,9 @@ module PhlexKit
|
|
|
10
10
|
def view_template
|
|
11
11
|
input(**mix({
|
|
12
12
|
type: :radio,
|
|
13
|
+
# The option label is the interactive surface (ARIA option); the real
|
|
14
|
+
# input must not be a second tab stop inside it.
|
|
15
|
+
tabindex: "-1",
|
|
13
16
|
class: "pk-combobox-radio",
|
|
14
17
|
data: {
|
|
15
18
|
phlex_kit__combobox_target: "input",
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# Filter input at the top of a PhlexKit::ComboboxPopover (magnifier icon +
|
|
3
3
|
# borderless search field). Filtering happens client-side against each item's
|
|
4
|
-
# text (or data-text).
|
|
4
|
+
# text (or data-text). The field carries the ARIA combobox role (keyboard nav
|
|
5
|
+
# highlights options via aria-activedescendant while focus stays here); pass
|
|
6
|
+
# `list_id:` matching the ComboboxList id to wire aria-controls statically —
|
|
7
|
+
# otherwise the controller wires it on connect. See combobox.rb.
|
|
5
8
|
class ComboboxSearchInput < BaseComponent
|
|
6
|
-
def initialize(placeholder:, **attrs)
|
|
9
|
+
def initialize(placeholder:, list_id: nil, **attrs)
|
|
7
10
|
@placeholder = placeholder
|
|
11
|
+
@list_id = list_id
|
|
8
12
|
@attrs = attrs
|
|
9
13
|
end
|
|
10
14
|
|
|
@@ -13,7 +17,8 @@ module PhlexKit
|
|
|
13
17
|
icon
|
|
14
18
|
input(**mix({
|
|
15
19
|
type: :search,
|
|
16
|
-
role: "
|
|
20
|
+
role: "combobox",
|
|
21
|
+
aria: { autocomplete: "list", expanded: "false", controls: @list_id },
|
|
17
22
|
autocorrect: "off",
|
|
18
23
|
autocomplete: "off",
|
|
19
24
|
spellcheck: "false",
|
|
@@ -11,8 +11,23 @@ module PhlexKit
|
|
|
11
11
|
@attrs = attrs
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
def view_template
|
|
15
|
-
div(**mix({ class: "pk-command" }, @attrs)
|
|
14
|
+
def view_template
|
|
15
|
+
div(**mix({ class: "pk-command" }, @attrs)) do
|
|
16
|
+
live_region
|
|
17
|
+
yield
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Screen-reader announcement of the filtered result count — the controller
|
|
24
|
+
# writes "N results" / "No results" into it from filter().
|
|
25
|
+
def live_region
|
|
26
|
+
div(
|
|
27
|
+
class: "pk-sr-only",
|
|
28
|
+
aria: { live: "polite" },
|
|
29
|
+
data: { phlex_kit__command_target: "liveRegion" }
|
|
30
|
+
)
|
|
16
31
|
end
|
|
17
32
|
end
|
|
18
33
|
end
|
|
@@ -33,25 +33,81 @@ function fuzzyScore(query, value) {
|
|
|
33
33
|
// nav, dismiss) is unchanged.
|
|
34
34
|
// Connects to data-controller="phlex-kit--command"
|
|
35
35
|
export default class extends Controller {
|
|
36
|
-
static targets = ["input", "group", "item", "empty", "separator"];
|
|
36
|
+
static targets = ["input", "group", "item", "empty", "separator", "list", "liveRegion"];
|
|
37
37
|
|
|
38
38
|
connect() {
|
|
39
39
|
this.selectedIndex = -1;
|
|
40
|
+
// Remember what had focus before the palette grabbed it (for a cloned
|
|
41
|
+
// dialog this is the element focused when the overlay was inserted), so
|
|
42
|
+
// dismiss() can hand focus back instead of dropping it on <body>.
|
|
43
|
+
this.previouslyFocused = document.activeElement;
|
|
40
44
|
|
|
41
45
|
if (!this.hasInputTarget) {
|
|
42
46
|
return;
|
|
43
47
|
}
|
|
44
48
|
|
|
49
|
+
this.generateItemIds();
|
|
45
50
|
this.inputTarget.focus();
|
|
46
51
|
this.searchIndex = this.buildSearchIndex();
|
|
47
52
|
this.toggleVisibility(this.emptyTargets, false);
|
|
48
53
|
}
|
|
49
54
|
|
|
55
|
+
// ARIA plumbing: every result gets an id derived from the listbox id so the
|
|
56
|
+
// input can point aria-activedescendant at the keyboard highlight, and
|
|
57
|
+
// aria-controls is wired to the listbox (mirrors select_controller.js).
|
|
58
|
+
generateItemIds() {
|
|
59
|
+
const list = this.hasListTarget ? this.listTarget : this.element;
|
|
60
|
+
if (!list.id) list.id = `pk-command-list-${Math.random().toString(36).slice(2, 10)}`;
|
|
61
|
+
|
|
62
|
+
this.itemTargets.forEach((item, index) => {
|
|
63
|
+
if (!item.id) item.id = `${list.id}-${index}`;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
this.inputTarget.setAttribute("aria-controls", list.id);
|
|
67
|
+
}
|
|
68
|
+
|
|
50
69
|
dismiss() {
|
|
51
70
|
// allow scroll on body
|
|
52
71
|
document.body.style.removeProperty("overflow");
|
|
72
|
+
const previous = this.previouslyFocused;
|
|
53
73
|
// remove the element
|
|
54
74
|
this.element.remove();
|
|
75
|
+
// restore focus to wherever it was before the palette opened
|
|
76
|
+
if (previous instanceof HTMLElement && document.contains(previous)) {
|
|
77
|
+
previous.focus();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Focus trap for the cloned dialog overlay: Tab / Shift+Tab cycle among the
|
|
82
|
+
// overlay's own focusable elements instead of escaping to the page beneath.
|
|
83
|
+
trapFocus(e) {
|
|
84
|
+
if (e.key !== "Tab") return;
|
|
85
|
+
|
|
86
|
+
const focusables = Array.from(
|
|
87
|
+
this.element.querySelectorAll(
|
|
88
|
+
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
|
89
|
+
),
|
|
90
|
+
).filter((el) => !el.closest(".pk-hidden"));
|
|
91
|
+
|
|
92
|
+
if (focusables.length === 0) {
|
|
93
|
+
e.preventDefault();
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const first = focusables[0];
|
|
98
|
+
const last = focusables[focusables.length - 1];
|
|
99
|
+
const active = document.activeElement;
|
|
100
|
+
|
|
101
|
+
if (e.shiftKey && active === first) {
|
|
102
|
+
e.preventDefault();
|
|
103
|
+
last.focus();
|
|
104
|
+
} else if (!e.shiftKey && active === last) {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
first.focus();
|
|
107
|
+
} else if (!this.element.contains(active)) {
|
|
108
|
+
e.preventDefault();
|
|
109
|
+
first.focus();
|
|
110
|
+
}
|
|
55
111
|
}
|
|
56
112
|
|
|
57
113
|
focusInput() {
|
|
@@ -65,6 +121,7 @@ export default class extends Controller {
|
|
|
65
121
|
const query = e.target.value.toLowerCase();
|
|
66
122
|
if (query.length === 0) {
|
|
67
123
|
this.resetVisibility();
|
|
124
|
+
this.announceResultCount(null);
|
|
68
125
|
return;
|
|
69
126
|
}
|
|
70
127
|
|
|
@@ -75,11 +132,24 @@ export default class extends Controller {
|
|
|
75
132
|
this.toggleVisibility([result.item.element], true),
|
|
76
133
|
);
|
|
77
134
|
|
|
135
|
+
this.announceResultCount(results.length);
|
|
78
136
|
this.toggleVisibility(this.emptyTargets, results.length === 0);
|
|
79
137
|
this.toggleVisibility(this.separatorTargets, false);
|
|
80
138
|
this.updateGroupVisibility();
|
|
81
139
|
}
|
|
82
140
|
|
|
141
|
+
// Announce the filtered result count to screen readers (null = no query).
|
|
142
|
+
announceResultCount(count) {
|
|
143
|
+
if (!this.hasLiveRegionTarget) return;
|
|
144
|
+
|
|
145
|
+
if (count === null) {
|
|
146
|
+
this.liveRegionTarget.textContent = "";
|
|
147
|
+
} else {
|
|
148
|
+
this.liveRegionTarget.textContent =
|
|
149
|
+
count === 0 ? "No results" : `${count} result${count === 1 ? "" : "s"}`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
83
153
|
toggleVisibility(elements, isVisible) {
|
|
84
154
|
elements.forEach((el) => el.classList.toggle("pk-hidden", !isVisible));
|
|
85
155
|
}
|
|
@@ -120,8 +190,11 @@ export default class extends Controller {
|
|
|
120
190
|
}
|
|
121
191
|
|
|
122
192
|
handleKeydown(e) {
|
|
193
|
+
// Disabled items stay visible (dimmed via [data-disabled]) but are
|
|
194
|
+
// skipped by keyboard selection.
|
|
123
195
|
const visibleItems = this.itemTargets.filter(
|
|
124
|
-
(item) =>
|
|
196
|
+
(item) =>
|
|
197
|
+
!item.classList.contains("pk-hidden") && !item.dataset.disabled,
|
|
125
198
|
);
|
|
126
199
|
if (e.key === "ArrowDown") {
|
|
127
200
|
e.preventDefault();
|
|
@@ -136,7 +209,9 @@ export default class extends Controller {
|
|
|
136
209
|
}
|
|
137
210
|
|
|
138
211
|
updateSelectedItem(visibleItems, direction) {
|
|
139
|
-
if (
|
|
212
|
+
if (visibleItems.length === 0) return;
|
|
213
|
+
|
|
214
|
+
if (this.selectedIndex >= 0 && visibleItems[this.selectedIndex]) {
|
|
140
215
|
this.toggleAriaSelected(visibleItems[this.selectedIndex], false);
|
|
141
216
|
}
|
|
142
217
|
|
|
@@ -150,6 +225,15 @@ export default class extends Controller {
|
|
|
150
225
|
}
|
|
151
226
|
|
|
152
227
|
this.toggleAriaSelected(visibleItems[this.selectedIndex], true);
|
|
228
|
+
|
|
229
|
+
// Focus stays in the input; the highlighted option is exposed via
|
|
230
|
+
// aria-activedescendant (result ids come from generateItemIds).
|
|
231
|
+
if (this.hasInputTarget) {
|
|
232
|
+
this.inputTarget.setAttribute(
|
|
233
|
+
"aria-activedescendant",
|
|
234
|
+
visibleItems[this.selectedIndex].id,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
153
237
|
}
|
|
154
238
|
|
|
155
239
|
toggleAriaSelected(element, isSelected) {
|
|
@@ -159,5 +243,11 @@ export default class extends Controller {
|
|
|
159
243
|
deselectAll() {
|
|
160
244
|
this.itemTargets.forEach((item) => this.toggleAriaSelected(item, false));
|
|
161
245
|
this.selectedIndex = -1;
|
|
246
|
+
|
|
247
|
+
// aria-activedescendant holds an element id; with nothing highlighted it
|
|
248
|
+
// must be removed, not left pointing at a stale option.
|
|
249
|
+
if (this.hasInputTarget) {
|
|
250
|
+
this.inputTarget.removeAttribute("aria-activedescendant");
|
|
251
|
+
}
|
|
162
252
|
}
|
|
163
253
|
}
|
|
@@ -7,16 +7,28 @@ module PhlexKit
|
|
|
7
7
|
class CommandDialogContent < BaseComponent
|
|
8
8
|
SIZES = { xs: "xs", sm: "sm", md: nil, lg: "lg", xl: "xl", full: "full" }.freeze
|
|
9
9
|
|
|
10
|
-
def initialize(size: :md, **attrs)
|
|
10
|
+
def initialize(size: :md, aria_label: "Command palette", **attrs)
|
|
11
11
|
@size = size.to_sym
|
|
12
|
+
@aria_label = aria_label
|
|
12
13
|
@attrs = attrs
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def view_template(&block)
|
|
16
17
|
template(data: { phlex_kit__command_dialog_target: "content" }) do
|
|
17
|
-
|
|
18
|
+
# The keydown action is the focus trap: Tab cycles within the cloned
|
|
19
|
+
# overlay instead of escaping to the page underneath.
|
|
20
|
+
div(data: {
|
|
21
|
+
controller: "phlex-kit--command",
|
|
22
|
+
phlex_kit__command_dialog_instance: true,
|
|
23
|
+
action: "keydown->phlex-kit--command#trapFocus"
|
|
24
|
+
}) do
|
|
18
25
|
backdrop
|
|
19
|
-
div(**mix({
|
|
26
|
+
div(**mix({
|
|
27
|
+
class: panel_classes,
|
|
28
|
+
role: "dialog",
|
|
29
|
+
aria: { modal: "true", label: @aria_label },
|
|
30
|
+
data: { state: "open" }
|
|
31
|
+
}, @attrs), &block)
|
|
20
32
|
end
|
|
21
33
|
end
|
|
22
34
|
end
|
|
@@ -24,7 +36,7 @@ module PhlexKit
|
|
|
24
36
|
private
|
|
25
37
|
|
|
26
38
|
def panel_classes
|
|
27
|
-
[ "pk-command-dialog", SIZES
|
|
39
|
+
[ "pk-command-dialog", fetch_option(SIZES, @size, :size) ].compact.join(" ")
|
|
28
40
|
end
|
|
29
41
|
|
|
30
42
|
def backdrop
|
|
@@ -3,9 +3,14 @@ module PhlexKit
|
|
|
3
3
|
# type, arrows/enter drive selection, esc dismisses. Renders as shadcn's
|
|
4
4
|
# current bordered pill inside the padded palette (input-wrapper >
|
|
5
5
|
# input-group). See command.rb.
|
|
6
|
+
# Pass `list_id:` matching the CommandList id to wire aria-controls
|
|
7
|
+
# statically — otherwise the controller wires it on connect. aria-expanded
|
|
8
|
+
# stays "true" because the list is always visible (filtering hides items,
|
|
9
|
+
# never the listbox itself).
|
|
6
10
|
class CommandInput < BaseComponent
|
|
7
|
-
def initialize(placeholder: "Type a command or search...", **attrs)
|
|
11
|
+
def initialize(placeholder: "Type a command or search...", list_id: nil, **attrs)
|
|
8
12
|
@placeholder = placeholder
|
|
13
|
+
@list_id = list_id
|
|
9
14
|
@attrs = attrs
|
|
10
15
|
end
|
|
11
16
|
|
|
@@ -23,7 +28,7 @@ module PhlexKit
|
|
|
23
28
|
autofocus: true,
|
|
24
29
|
role: "combobox",
|
|
25
30
|
value: "",
|
|
26
|
-
aria: { autocomplete: "list", expanded: "true" },
|
|
31
|
+
aria: { autocomplete: "list", expanded: "true", controls: @list_id },
|
|
27
32
|
data: {
|
|
28
33
|
phlex_kit__command_target: "input",
|
|
29
34
|
action: [
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# One palette result — an <a> (pass `href:`) filtered by `value:`, activated
|
|
3
3
|
# by click or enter. aria-selected is toggled by keyboard navigation.
|
|
4
|
-
#
|
|
4
|
+
# `disabled: true` renders the [data-disabled] styling + aria-disabled and
|
|
5
|
+
# the controller skips it during keyboard navigation (matches
|
|
6
|
+
# ContextMenuItem's pattern). See command.rb.
|
|
5
7
|
class CommandItem < BaseComponent
|
|
6
|
-
def initialize(value:, text: "", href: "#", **attrs)
|
|
8
|
+
def initialize(value:, text: "", href: "#", disabled: false, **attrs)
|
|
7
9
|
@value = value
|
|
8
10
|
@text = text
|
|
9
11
|
@href = href
|
|
12
|
+
@disabled = disabled
|
|
10
13
|
@attrs = attrs
|
|
11
14
|
end
|
|
12
15
|
|
|
@@ -15,7 +18,13 @@ module PhlexKit
|
|
|
15
18
|
class: "pk-command-item",
|
|
16
19
|
href: @href,
|
|
17
20
|
role: "option",
|
|
18
|
-
|
|
21
|
+
aria: { disabled: (@disabled ? "true" : nil) },
|
|
22
|
+
data: {
|
|
23
|
+
phlex_kit__command_target: "item",
|
|
24
|
+
value: @value,
|
|
25
|
+
text: @text,
|
|
26
|
+
disabled: (@disabled ? "true" : nil)
|
|
27
|
+
}
|
|
19
28
|
}, @attrs), &)
|
|
20
29
|
end
|
|
21
30
|
end
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
-
# Container for CommandGroups, with dividers between them.
|
|
2
|
+
# Container for CommandGroups, with dividers between them. Carries the
|
|
3
|
+
# listbox role and an id the controller points the input's aria-controls at
|
|
4
|
+
# (and derives result ids from, for aria-activedescendant). See command.rb.
|
|
3
5
|
class CommandList < BaseComponent
|
|
4
6
|
def initialize(**attrs)
|
|
5
7
|
@attrs = attrs
|
|
6
8
|
end
|
|
7
9
|
|
|
8
10
|
def view_template(&)
|
|
9
|
-
div(**mix({
|
|
11
|
+
div(**mix({
|
|
12
|
+
id: "pk-command-list-#{SecureRandom.hex(4)}",
|
|
13
|
+
class: "pk-command-list",
|
|
14
|
+
role: "listbox",
|
|
15
|
+
data: { phlex_kit__command_target: "list" }
|
|
16
|
+
}, @attrs), &)
|
|
10
17
|
end
|
|
11
18
|
end
|
|
12
19
|
end
|