phlex_kit 0.12.0 → 0.14.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 +4 -4
- data/app/components/phlex_kit/accordion/accordion_content.rb +3 -1
- data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +1 -1
- data/app/components/phlex_kit/accordion/accordion_trigger.rb +1 -1
- data/app/components/phlex_kit/alert/alert.rb +5 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +2 -2
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +16 -6
- data/app/components/phlex_kit/alert_dialog/alert_dialog_media.rb +4 -1
- data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +14 -4
- data/app/components/phlex_kit/attachment/attachment.css +7 -1
- data/app/components/phlex_kit/attachment/scroll_fade_controller.js +4 -3
- data/app/components/phlex_kit/avatar/avatar.css +4 -0
- data/app/components/phlex_kit/breadcrumb/breadcrumb.css +4 -0
- data/app/components/phlex_kit/breadcrumb/breadcrumb.rb +8 -1
- data/app/components/phlex_kit/breadcrumb/breadcrumb_page.rb +1 -1
- data/app/components/phlex_kit/breadcrumb/breadcrumb_separator.rb +1 -1
- data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
- data/app/components/phlex_kit/button_group/button_group_separator.rb +1 -1
- data/app/components/phlex_kit/calendar/calendar.css +5 -0
- data/app/components/phlex_kit/calendar/calendar_controller.js +38 -9
- data/app/components/phlex_kit/calendar/calendar_next.rb +5 -3
- data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -3
- data/app/components/phlex_kit/carousel/carousel.css +7 -0
- data/app/components/phlex_kit/carousel/carousel.rb +8 -4
- data/app/components/phlex_kit/carousel/carousel_controller.js +64 -20
- data/app/components/phlex_kit/carousel/carousel_item.rb +5 -5
- data/app/components/phlex_kit/chart/chart.rb +1 -1
- data/app/components/phlex_kit/chart/chart_controller.js +5 -1
- data/app/components/phlex_kit/checkbox/checkbox.css +16 -0
- data/app/components/phlex_kit/checkbox/checkbox.rb +19 -1
- data/app/components/phlex_kit/clipboard/clipboard.rb +7 -0
- data/app/components/phlex_kit/clipboard/clipboard_controller.js +12 -1
- data/app/components/phlex_kit/clipboard/clipboard_popover.rb +5 -3
- data/app/components/phlex_kit/codeblock/codeblock.rb +2 -2
- data/app/components/phlex_kit/combobox/combobox.rb +1 -1
- data/app/components/phlex_kit/combobox/combobox_clear_button.rb +5 -3
- data/app/components/phlex_kit/combobox/combobox_controller.js +60 -5
- data/app/components/phlex_kit/combobox/combobox_item.rb +7 -4
- data/app/components/phlex_kit/command/command_controller.js +69 -2
- data/app/components/phlex_kit/command/command_dialog_content.rb +15 -6
- data/app/components/phlex_kit/command/command_empty.rb +5 -3
- data/app/components/phlex_kit/command/command_separator.rb +1 -1
- data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +7 -5
- data/app/components/phlex_kit/context_menu/context_menu_content.rb +7 -1
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +42 -1
- data/app/components/phlex_kit/context_menu/context_menu_group.rb +4 -1
- data/app/components/phlex_kit/context_menu/context_menu_item.rb +5 -2
- data/app/components/phlex_kit/context_menu/context_menu_radio_group.rb +4 -1
- data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +7 -5
- data/app/components/phlex_kit/context_menu/context_menu_separator.rb +6 -1
- data/app/components/phlex_kit/context_menu/context_menu_sub_content.rb +4 -1
- data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +12 -9
- data/app/components/phlex_kit/data_table/data_table.css +4 -0
- data/app/components/phlex_kit/data_table/data_table_controller.js +7 -1
- data/app/components/phlex_kit/data_table/data_table_search_controller.js +15 -1
- data/app/components/phlex_kit/data_table/data_table_select_all_checkbox.rb +8 -4
- data/app/components/phlex_kit/data_table/data_table_selection_summary.rb +12 -3
- data/app/components/phlex_kit/date_picker/date_picker_controller.js +3 -5
- data/app/components/phlex_kit/drawer/drawer.css +4 -0
- data/app/components/phlex_kit/drawer/drawer_content.rb +2 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +7 -5
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -5
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_group.rb +4 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +3 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_group.rb +4 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +7 -5
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_separator.rb +6 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_content.rb +4 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +12 -9
- data/app/components/phlex_kit/empty/empty.rb +1 -0
- data/app/components/phlex_kit/field/field.rb +4 -1
- data/app/components/phlex_kit/field/field_error.rb +4 -1
- data/app/components/phlex_kit/form_field/form_field_controller.js +22 -5
- data/app/components/phlex_kit/icon/icon.rb +6 -2
- data/app/components/phlex_kit/input_group/input_group.css +6 -3
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +14 -6
- data/app/components/phlex_kit/input_otp/input_otp_slot.rb +7 -6
- data/app/components/phlex_kit/item/item.css +9 -1
- data/app/components/phlex_kit/item/item_separator.rb +1 -1
- data/app/components/phlex_kit/label/label.css +6 -1
- data/app/components/phlex_kit/link/link.rb +10 -1
- data/app/components/phlex_kit/menubar/menubar.rb +8 -4
- data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +6 -6
- data/app/components/phlex_kit/menubar/menubar_content.rb +5 -1
- data/app/components/phlex_kit/menubar/menubar_controller.js +36 -0
- data/app/components/phlex_kit/menubar/menubar_group.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_item.rb +4 -3
- data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_radio_item.rb +6 -6
- data/app/components/phlex_kit/menubar/menubar_separator.rb +6 -1
- data/app/components/phlex_kit/menubar/menubar_sub_content.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +12 -11
- data/app/components/phlex_kit/menubar/menubar_trigger.rb +7 -4
- data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +5 -0
- data/app/components/phlex_kit/pagination/pagination.css +4 -0
- data/app/components/phlex_kit/progress/progress.css +19 -0
- data/app/components/phlex_kit/progress/progress.rb +29 -13
- data/app/components/phlex_kit/radio_button/radio_button.rb +1 -1
- data/app/components/phlex_kit/resizable/resizable_controller.js +17 -4
- data/app/components/phlex_kit/resizable/resizable_handle.rb +9 -7
- data/app/components/phlex_kit/scroll_area/scroll_area.rb +1 -1
- data/app/components/phlex_kit/select/select.rb +1 -1
- data/app/components/phlex_kit/select/select_content.rb +7 -5
- data/app/components/phlex_kit/select/select_controller.js +10 -0
- data/app/components/phlex_kit/select/select_separator.rb +1 -1
- data/app/components/phlex_kit/sheet/sheet_content.rb +2 -2
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +12 -5
- data/app/components/phlex_kit/sidebar/sidebar_group_action.rb +1 -1
- data/app/components/phlex_kit/sidebar/sidebar_menu_action.rb +1 -1
- data/app/components/phlex_kit/sidebar/sidebar_rail.rb +3 -2
- data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +2 -1
- data/app/components/phlex_kit/switch/switch.rb +5 -2
- data/app/components/phlex_kit/tabs/tabs_content.rb +6 -1
- data/app/components/phlex_kit/tabs/tabs_trigger.rb +3 -1
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +24 -6
- data/app/components/phlex_kit/toast/toast_controller.js +52 -2
- data/app/components/phlex_kit/toast/toast_item.rb +2 -2
- data/app/components/phlex_kit/toast/toaster_controller.js +63 -7
- data/app/components/phlex_kit/toggle/toggle.rb +1 -1
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +18 -4
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +2 -2
- data/app/components/phlex_kit/tooltip/tooltip_controller.js +5 -0
- data/app/components/phlex_kit/typography/typography.css +5 -2
- data/lib/phlex_kit/base_component.rb +9 -0
- data/lib/phlex_kit/version.rb +1 -1
- metadata +1 -1
|
@@ -31,7 +31,7 @@ module PhlexKit
|
|
|
31
31
|
# Canvas content is invisible to AT — announce it as an image with at
|
|
32
32
|
# least a generic name; pass aria: { label: "…" } (or block fallback
|
|
33
33
|
# content) to describe the actual data.
|
|
34
|
-
base[:role] = "img" unless
|
|
34
|
+
base[:role] = "img" unless attr_set?(:role)
|
|
35
35
|
base[:aria] = { label: "Chart" } unless aria_labelled?
|
|
36
36
|
canvas(**mix(base, @attrs), &)
|
|
37
37
|
end
|
|
@@ -29,7 +29,11 @@ export default class extends Controller {
|
|
|
29
29
|
this.disconnectThemeObservers()
|
|
30
30
|
this.chart?.destroy()
|
|
31
31
|
if (!this.chart) {
|
|
32
|
-
|
|
32
|
+
// Dispatch on document, not this.element: disconnect() runs AFTER the
|
|
33
|
+
// canvas has left the DOM, so an event fired on it neither bubbles nor
|
|
34
|
+
// reaches document/window — delegated host listeners would never see it.
|
|
35
|
+
// The canvas rides the detail so the host can still match its instance.
|
|
36
|
+
this.dispatch("disconnect", { target: document, detail: { canvas: this.element } })
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
39
|
|
|
@@ -48,6 +48,22 @@
|
|
|
48
48
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
|
|
49
49
|
}
|
|
50
50
|
.pk-checkbox[aria-invalid="true"]:checked { border-color: var(--pk-brand); }
|
|
51
|
+
/* Invalid + keyboard focus: the always-on invalid ring above would otherwise
|
|
52
|
+
swallow the equal-specificity :focus-visible rule, leaving no visible focus
|
|
53
|
+
change. Keep the red border (invalid stays legible) but switch the ring to
|
|
54
|
+
the standard focus color — the theme-scoped arms outweigh the theme-scoped
|
|
55
|
+
invalid copies below. */
|
|
56
|
+
.pk-checkbox[aria-invalid="true"]:focus-visible,
|
|
57
|
+
:root[data-theme="light"] .pk-checkbox[aria-invalid="true"]:focus-visible {
|
|
58
|
+
border-color: var(--pk-red);
|
|
59
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
60
|
+
}
|
|
61
|
+
@media (prefers-color-scheme: light) {
|
|
62
|
+
:root[data-theme="system"] .pk-checkbox[aria-invalid="true"]:focus-visible {
|
|
63
|
+
border-color: var(--pk-red);
|
|
64
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
51
67
|
.pk-checkbox:disabled { cursor: not-allowed; opacity: .5; }
|
|
52
68
|
:root[data-theme="light"] .pk-checkbox:not(:checked):not(:indeterminate) { background: transparent; }
|
|
53
69
|
:root[data-theme="light"] .pk-checkbox[aria-invalid="true"] {
|
|
@@ -8,11 +8,29 @@ module PhlexKit
|
|
|
8
8
|
# `name:`/`value:`/`checked:`/`**on(...)` pass through via `mix`. Styled by
|
|
9
9
|
# `.pk-checkbox` (checkbox.css).
|
|
10
10
|
class Checkbox < BaseComponent
|
|
11
|
-
|
|
11
|
+
# include_hidden mirrors Rails' check_box (and PhlexKit::Switch): an
|
|
12
|
+
# unchecked box posts nothing, so a paired hidden field carries the
|
|
13
|
+
# unchecked value. Emitted only when a `name:` is present — and never for
|
|
14
|
+
# array-style names ("ids[]"), where an unchecked "0" would inject a
|
|
15
|
+
# bogus element into the collection param (use a single blank hidden for
|
|
16
|
+
# the whole collection instead, as Rails' collection helpers do).
|
|
17
|
+
def initialize(include_hidden: true, unchecked_value: "0", **attrs)
|
|
18
|
+
@include_hidden = include_hidden
|
|
19
|
+
@unchecked_value = unchecked_value
|
|
12
20
|
@attrs = attrs
|
|
13
21
|
end
|
|
14
22
|
|
|
15
23
|
def view_template
|
|
24
|
+
if @include_hidden && @attrs[:name] && !@attrs[:name].to_s.end_with?("[]")
|
|
25
|
+
# Disabled in lockstep with the checkbox (Rails' check_box idiom) —
|
|
26
|
+
# a disabled checkbox must not still post its unchecked value.
|
|
27
|
+
# form: rides along too (Rails' check_box slices name/disabled/form
|
|
28
|
+
# onto the hidden field) — a form-attributed checkbox outside its
|
|
29
|
+
# <form> must post the unchecked value to the same form.
|
|
30
|
+
input(type: "hidden", name: @attrs[:name], value: @unchecked_value,
|
|
31
|
+
disabled: @attrs[:disabled] ? true : nil,
|
|
32
|
+
form: @attrs[:form] || @attrs["form"])
|
|
33
|
+
end
|
|
16
34
|
input(**mix({
|
|
17
35
|
type: :checkbox,
|
|
18
36
|
class: "pk-checkbox",
|
|
@@ -13,6 +13,13 @@ module PhlexKit
|
|
|
13
13
|
div(&block)
|
|
14
14
|
render PhlexKit::ClipboardPopover.new(type: :success) { @success }
|
|
15
15
|
render PhlexKit::ClipboardPopover.new(type: :error) { @error }
|
|
16
|
+
# A persistent (never display:none) sr-only live region: the visual
|
|
17
|
+
# popovers are shown by removing display:none, which re-inserts them
|
|
18
|
+
# into the a11y tree with pre-existing content — a change AT does not
|
|
19
|
+
# announce. This region stays in the tree; the controller writes the
|
|
20
|
+
# confirmation text into it on copy, and THAT mutation announces.
|
|
21
|
+
div(class: "pk-sr-only", role: "status", aria: { live: "polite" },
|
|
22
|
+
data: { phlex_kit__clipboard_target: "liveRegion" })
|
|
16
23
|
end
|
|
17
24
|
end
|
|
18
25
|
end
|
|
@@ -4,7 +4,7 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
4
4
|
// flashes a success/error popover (CSS-positioned, no @floating-ui). Ported from ruby_ui.
|
|
5
5
|
export default class extends Controller {
|
|
6
6
|
// No "trigger" target: the click->#copy action alone wires the trigger.
|
|
7
|
-
static targets = ["source", "successPopover", "errorPopover"]
|
|
7
|
+
static targets = ["source", "successPopover", "errorPopover", "liveRegion"]
|
|
8
8
|
|
|
9
9
|
connect() {
|
|
10
10
|
// Turbo snapshots at turbo:before-cache, BEFORE disconnect — hide the
|
|
@@ -49,6 +49,17 @@ export default class extends Controller {
|
|
|
49
49
|
show(target) {
|
|
50
50
|
this.hideAll()
|
|
51
51
|
target.classList.remove("pk-hidden")
|
|
52
|
+
// Announce via the persistent live region (not the visual popover, which
|
|
53
|
+
// display:none keeps out of the a11y tree). Clear then re-assert on the
|
|
54
|
+
// next frame so an identical consecutive message still reads as a content
|
|
55
|
+
// change — aria-live announces changes, not the current state.
|
|
56
|
+
if (this.hasLiveRegionTarget) {
|
|
57
|
+
const message = target.textContent.trim()
|
|
58
|
+
this.liveRegionTarget.textContent = ""
|
|
59
|
+
requestAnimationFrame(() => {
|
|
60
|
+
if (this.hasLiveRegionTarget) this.liveRegionTarget.textContent = message
|
|
61
|
+
})
|
|
62
|
+
}
|
|
52
63
|
clearTimeout(this.timer)
|
|
53
64
|
this.timer = setTimeout(() => target.classList.add("pk-hidden"), 1500)
|
|
54
65
|
}
|
|
@@ -6,9 +6,11 @@ module PhlexKit
|
|
|
6
6
|
@attrs = attrs
|
|
7
7
|
end
|
|
8
8
|
def view_template(&block)
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
|
|
9
|
+
# Visual-only now (aria-hidden): the announcement rides Clipboard's
|
|
10
|
+
# persistent sr-only role="status" live region — toggling display:none
|
|
11
|
+
# on this element never announced, since AT ignores content that was
|
|
12
|
+
# already present when a region re-enters the tree.
|
|
13
|
+
div(class: "pk-clipboard-popover pk-hidden", aria: { hidden: "true" }, data: { phlex_kit__clipboard_target: fetch_option(TARGETS, @type, :target) }) do
|
|
12
14
|
div(**mix({ class: "pk-clipboard-popover-inner" }, @attrs), &block)
|
|
13
15
|
end
|
|
14
16
|
end
|
|
@@ -15,8 +15,8 @@ module PhlexKit
|
|
|
15
15
|
# Defaults only when the caller didn't supply their own — `mix` would
|
|
16
16
|
# fuse tabindex="0 -1" / role="region article" instead of overriding.
|
|
17
17
|
base = { class: "pk-codeblock", data: { syntax: @syntax } }
|
|
18
|
-
base[:tabindex] = "0" unless
|
|
19
|
-
base[:role] = "region" unless
|
|
18
|
+
base[:tabindex] = "0" unless attr_set?(:tabindex)
|
|
19
|
+
base[:role] = "region" unless attr_set?(:role)
|
|
20
20
|
# A region landmark without a name is an axe violation — default one
|
|
21
21
|
# from the syntax; a caller aria: { label: } replaces it (never fused).
|
|
22
22
|
unless aria_labelled?
|
|
@@ -28,7 +28,7 @@ module PhlexKit
|
|
|
28
28
|
controller: "phlex-kit--combobox",
|
|
29
29
|
phlex_kit__combobox_term_value: @term,
|
|
30
30
|
phlex_kit__combobox_auto_highlight_value: (@auto_highlight ? "true" : nil),
|
|
31
|
-
action: "turbo:morph@window->phlex-kit--combobox#updateTriggerContent click@window->phlex-kit--combobox#onClickOutside"
|
|
31
|
+
action: "turbo:morph@window->phlex-kit--combobox#updateTriggerContent click@window->phlex-kit--combobox#onClickOutside focusout->phlex-kit--combobox#onFocusout"
|
|
32
32
|
}
|
|
33
33
|
}, @attrs)) do
|
|
34
34
|
live_region
|
|
@@ -9,15 +9,17 @@ module PhlexKit
|
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def view_template
|
|
12
|
-
|
|
12
|
+
base = {
|
|
13
13
|
type: :button,
|
|
14
14
|
class: "pk-combobox-clear-button pk-hidden",
|
|
15
|
-
aria: { label: "Clear selection" },
|
|
16
15
|
data: {
|
|
17
16
|
phlex_kit__combobox_target: "clearButton",
|
|
18
17
|
action: "phlex-kit--combobox#clearAll"
|
|
19
18
|
}
|
|
20
|
-
}
|
|
19
|
+
}
|
|
20
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
21
|
+
base[:aria] = { label: "Clear selection" } unless aria_labelled?
|
|
22
|
+
button(**mix(base, @attrs)) do
|
|
21
23
|
render Icon.new(:x, size: 24)
|
|
22
24
|
end
|
|
23
25
|
end
|
|
@@ -42,6 +42,10 @@ export default class extends Controller {
|
|
|
42
42
|
// itemTargetConnected uses must be initialized here.
|
|
43
43
|
this.itemIdCounter = 0
|
|
44
44
|
this.refocusing = false
|
|
45
|
+
// The query the list is currently filtered by. Initialized to "" so a
|
|
46
|
+
// caret/modifier keyup on an empty, unfiltered field reads as unchanged
|
|
47
|
+
// and filterItems skips re-running (which would wipe the highlight).
|
|
48
|
+
this._lastFilter = ""
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
connect() {
|
|
@@ -109,16 +113,20 @@ export default class extends Controller {
|
|
|
109
113
|
}
|
|
110
114
|
|
|
111
115
|
inputChanged(e) {
|
|
116
|
+
// Uncheck "select all" BEFORE updateTriggerContent: syncAriaSelected reads
|
|
117
|
+
// the toggle-all checkbox to set its row's aria-selected, so syncing first
|
|
118
|
+
// left the row marked aria-selected="true" while its checkbox showed
|
|
119
|
+
// unchecked.
|
|
120
|
+
if (this.hasToggleAllTarget && !e.target.checked) {
|
|
121
|
+
this.toggleAllTarget.checked = false
|
|
122
|
+
}
|
|
123
|
+
|
|
112
124
|
this.updateTriggerContent()
|
|
113
125
|
|
|
114
126
|
if (e.target.type == "radio") {
|
|
115
127
|
this.closePopover()
|
|
116
128
|
}
|
|
117
129
|
|
|
118
|
-
if (this.hasToggleAllTarget && !e.target.checked) {
|
|
119
|
-
this.toggleAllTarget.checked = false
|
|
120
|
-
}
|
|
121
|
-
|
|
122
130
|
// Selecting from a chip trigger consumes the query: clear it and reshow
|
|
123
131
|
// the full list for the next pick.
|
|
124
132
|
if (this.hasBadgeInputTarget && this.badgeInputTarget.value !== "") {
|
|
@@ -336,6 +344,15 @@ export default class extends Controller {
|
|
|
336
344
|
if (this.hasPopoverTarget && this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
|
|
337
345
|
this.updateTriggerContent() // reflect the selection into an input trigger
|
|
338
346
|
|
|
347
|
+
// Reset the filter so a REOPEN shows the full list, not the last query's
|
|
348
|
+
// leftover pk-hidden classes. The input trigger keeps showing the
|
|
349
|
+
// selection label (updateTriggerContent, above) but that's display-only —
|
|
350
|
+
// the active query is empty until the user types again; an in-popover or
|
|
351
|
+
// badge search field is cleared to match.
|
|
352
|
+
if (this.hasSearchInputTarget) this.searchInputTarget.value = ""
|
|
353
|
+
if (this.hasBadgeInputTarget) this.badgeInputTarget.value = ""
|
|
354
|
+
this.applyFilter("")
|
|
355
|
+
|
|
339
356
|
if (focusWasInside) {
|
|
340
357
|
const field = this.filterField()
|
|
341
358
|
// Input/badge layouts: back to the filter field (it lives outside the
|
|
@@ -350,10 +367,26 @@ export default class extends Controller {
|
|
|
350
367
|
}
|
|
351
368
|
}
|
|
352
369
|
|
|
370
|
+
// APG (combobox): Tab out of an open combobox closes the popup —
|
|
371
|
+
// dropdown_menu's onFocusout pattern. relatedTarget is null when the
|
|
372
|
+
// window itself blurs; leave the popup alone then. closePopover's
|
|
373
|
+
// focus-restore is a no-op here (focus has already left the popover, so
|
|
374
|
+
// focusWasInside is false — the user's Tab target keeps focus).
|
|
375
|
+
onFocusout(event) {
|
|
376
|
+
if (!this.isOpen()) return
|
|
377
|
+
const to = event.relatedTarget
|
|
378
|
+
if (!to || this.element.contains(to)) return
|
|
379
|
+
this.closePopover()
|
|
380
|
+
}
|
|
381
|
+
|
|
353
382
|
onClickOutside(event) {
|
|
354
383
|
if (!this.isOpen()) return
|
|
355
384
|
if (this.element.contains(event.target)) return
|
|
356
385
|
|
|
386
|
+
// Kit-wide dismiss contract: the combobox is POPOVER-family (shadcn
|
|
387
|
+
// builds it on Popover light-dismiss), so the dismissing outside click
|
|
388
|
+
// deliberately clicks through — no preventDefault. Only the MENU
|
|
389
|
+
// overlays (dropdown/context/menubar/select) swallow theirs.
|
|
357
390
|
this.closePopover()
|
|
358
391
|
}
|
|
359
392
|
|
|
@@ -376,7 +409,25 @@ export default class extends Controller {
|
|
|
376
409
|
const field = (e.target instanceof HTMLInputElement) ? e.target : this.filterField()
|
|
377
410
|
if (!field) return
|
|
378
411
|
|
|
379
|
-
|
|
412
|
+
const term = field.value.toLowerCase()
|
|
413
|
+
// Only re-filter when the query text actually changed. filterItems runs on
|
|
414
|
+
// every keyup, so caret/modifier keys the exclusion list above misses
|
|
415
|
+
// (ArrowLeft/Right/Home/End, releasing Shift) would otherwise re-run
|
|
416
|
+
// applyFilter on the unchanged query and wipe the keyboard highlight
|
|
417
|
+
// (selectedItemIndex, aria-current, aria-activedescendant).
|
|
418
|
+
if (term === this._lastFilter) return
|
|
419
|
+
|
|
420
|
+
// Typing into a CLOSED input/badge trigger must reveal the results — the
|
|
421
|
+
// filtering is otherwise invisible (only click/focusin/Arrow keys opened
|
|
422
|
+
// it). Open without openPopover()'s field.select(), which would select the
|
|
423
|
+
// just-typed text so the next keystroke replaced it. The button layout's
|
|
424
|
+
// search field lives inside the popover, so it can't be typed while closed.
|
|
425
|
+
if (!this.isOpen() && term && (this.hasInputTriggerTarget || this.hasBadgeInputTarget)) {
|
|
426
|
+
this.setExpanded(true)
|
|
427
|
+
this.popoverTarget.showPopover()
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
this.applyFilter(term)
|
|
380
431
|
}
|
|
381
432
|
|
|
382
433
|
// Strips combining diacritical marks after an NFD decomposition, so "é"
|
|
@@ -386,6 +437,10 @@ export default class extends Controller {
|
|
|
386
437
|
}
|
|
387
438
|
|
|
388
439
|
applyFilter(filterTerm) {
|
|
440
|
+
// The query the list is currently filtered by — filterItems reads this to
|
|
441
|
+
// skip re-filtering (and re-wiping the highlight) on caret/modifier keys.
|
|
442
|
+
this._lastFilter = filterTerm
|
|
443
|
+
|
|
389
444
|
if (this.hasToggleAllTarget) {
|
|
390
445
|
if (filterTerm) this.toggleAllTarget.parentElement.classList.add("pk-hidden")
|
|
391
446
|
else this.toggleAllTarget.parentElement.classList.remove("pk-hidden")
|
|
@@ -11,12 +11,15 @@ module PhlexKit
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def view_template(&)
|
|
14
|
-
|
|
14
|
+
base = {
|
|
15
15
|
class: "pk-combobox-item",
|
|
16
|
-
role: "option",
|
|
17
|
-
aria: { selected: "false" },
|
|
18
16
|
data: { phlex_kit__combobox_target: "item" }
|
|
19
|
-
}
|
|
17
|
+
}
|
|
18
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses
|
|
19
|
+
# (a server-rendered initially-selected option sets its own selected).
|
|
20
|
+
base[:role] = "option" unless attr_set?(:role)
|
|
21
|
+
base[:aria] = { selected: "false" } unless aria_key_set?(:selected)
|
|
22
|
+
label(**mix(base, @attrs), &)
|
|
20
23
|
end
|
|
21
24
|
end
|
|
22
25
|
end
|
|
@@ -49,6 +49,7 @@ export default class extends Controller {
|
|
|
49
49
|
// itemTargetConnected uses must be initialized here (mirrors
|
|
50
50
|
// combobox_controller.js).
|
|
51
51
|
this.itemIdCounter = 0;
|
|
52
|
+
this.orderCounter = 0;
|
|
52
53
|
this.searchEntries = [];
|
|
53
54
|
this.selectedIndex = -1;
|
|
54
55
|
}
|
|
@@ -74,7 +75,15 @@ export default class extends Controller {
|
|
|
74
75
|
// Only the cloned dialog overlay grabs focus on connect — an inline
|
|
75
76
|
// palette connecting at page load must not steal it.
|
|
76
77
|
if (this.isDialogClone()) this.inputTarget.focus();
|
|
77
|
-
|
|
78
|
+
// Re-derive item + empty-state visibility from the LIVE input value, not an
|
|
79
|
+
// unconditional hide: a Turbo cache restore of an inline palette can
|
|
80
|
+
// reconnect with an active no-match query still in the input — hiding the
|
|
81
|
+
// empty row then showed the query with neither items NOR a "No results".
|
|
82
|
+
if (this.inputTarget.value) {
|
|
83
|
+
this.filter({ target: this.inputTarget });
|
|
84
|
+
} else {
|
|
85
|
+
this.toggleVisibility(this.emptyTargets, false);
|
|
86
|
+
}
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
// The command-dialog controller clones its <template> content into <body>;
|
|
@@ -83,6 +92,13 @@ export default class extends Controller {
|
|
|
83
92
|
return this.element.hasAttribute("data-phlex-kit--command-dialog-instance");
|
|
84
93
|
}
|
|
85
94
|
|
|
95
|
+
// Clones are appended as direct <body> children; the last stamped clone is
|
|
96
|
+
// the visually topmost overlay across all clone-based overlay families.
|
|
97
|
+
isTopmostClone() {
|
|
98
|
+
const clones = document.body.querySelectorAll(":scope > [data-pk-overlay-clone]");
|
|
99
|
+
return clones.length > 0 && clones[clones.length - 1] === this.element;
|
|
100
|
+
}
|
|
101
|
+
|
|
86
102
|
// ARIA plumbing: aria-controls is wired to the listbox (mirrors
|
|
87
103
|
// select_controller.js); the per-result ids come from itemTargetConnected.
|
|
88
104
|
generateItemIds() {
|
|
@@ -103,6 +119,10 @@ export default class extends Controller {
|
|
|
103
119
|
// arrival (mirrors combobox_controller.js).
|
|
104
120
|
itemTargetConnected(item) {
|
|
105
121
|
if (!item.id) item.id = `${this.listId()}-${this.itemIdCounter++}`;
|
|
122
|
+
// Original position, stamped once — filter() reorders rows by fuzzy
|
|
123
|
+
// score and resetVisibility() restores this order. Survives the
|
|
124
|
+
// disconnect/reconnect a DOM move itself triggers.
|
|
125
|
+
if (!item.dataset.pkOrder) item.dataset.pkOrder = String(this.orderCounter++);
|
|
106
126
|
this.searchEntries.push({
|
|
107
127
|
value: (item.dataset.value || "").toLowerCase(),
|
|
108
128
|
text: (item.dataset.text || "").toLowerCase(),
|
|
@@ -119,7 +139,22 @@ export default class extends Controller {
|
|
|
119
139
|
this.selectedIndex = -1;
|
|
120
140
|
}
|
|
121
141
|
|
|
122
|
-
dismiss() {
|
|
142
|
+
dismiss(e) {
|
|
143
|
+
if (e?.type === "keydown" && this.isDialogClone()) {
|
|
144
|
+
// The backdrop's Escape binding is window-scoped, so it fires even
|
|
145
|
+
// with another overlay (alert dialog, sheet) stacked ON TOP of the
|
|
146
|
+
// palette — those clones are later <body> siblings carrying the same
|
|
147
|
+
// [data-pk-overlay-clone] marker. Only the topmost clone may act on
|
|
148
|
+
// Escape (mirrors alert_dialog_controller.js#topmost).
|
|
149
|
+
if (!this.isTopmostClone()) return;
|
|
150
|
+
// The input's Escape binding is element-scoped and fires BEFORE lower
|
|
151
|
+
// overlays' document/window listeners — stop the keydown here, or by
|
|
152
|
+
// the time they run this clone (and its marker) is already removed and
|
|
153
|
+
// their own topmost check would wrongly elect them (mirrors
|
|
154
|
+
// sheet_content_controller.js's Escape guard).
|
|
155
|
+
e.stopPropagation();
|
|
156
|
+
}
|
|
157
|
+
|
|
123
158
|
// Cloned dialog overlay: tear the clone down and hand focus back.
|
|
124
159
|
if (this.isDialogClone()) {
|
|
125
160
|
// Dispatched BEFORE teardown: the command-dialog controller listens on
|
|
@@ -210,6 +245,9 @@ export default class extends Controller {
|
|
|
210
245
|
results.forEach((result) =>
|
|
211
246
|
this.toggleVisibility([result.item.element], true),
|
|
212
247
|
);
|
|
248
|
+
// cmdk parity: the best match lists first. Within each parent only —
|
|
249
|
+
// groups keep their identity, matches never jump between them.
|
|
250
|
+
this.reorderWithinParents(results.map((r) => r.item.element));
|
|
213
251
|
|
|
214
252
|
this.announceResultCount(results.length);
|
|
215
253
|
this.toggleVisibility(this.emptyTargets, results.length === 0);
|
|
@@ -248,6 +286,35 @@ export default class extends Controller {
|
|
|
248
286
|
this.toggleVisibility(this.groupTargets, true);
|
|
249
287
|
this.toggleVisibility(this.separatorTargets, true);
|
|
250
288
|
this.toggleVisibility(this.emptyTargets, false);
|
|
289
|
+
// Undo any fuzzy-score reordering: back to server-rendered order.
|
|
290
|
+
this.reorderWithinParents(
|
|
291
|
+
[...this.itemTargets].sort((a, b) => Number(a.dataset.pkOrder) - Number(b.dataset.pkOrder)),
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Re-inserts `orderedItems` in the given sequence, each within its own
|
|
296
|
+
// parent, at the position of that parent's first item row — headings,
|
|
297
|
+
// separators and empty-state siblings keep their places. (Items are
|
|
298
|
+
// assumed contiguous within a parent; a separator BETWEEN items of one
|
|
299
|
+
// parent would drift below them.) The moves retrigger item target
|
|
300
|
+
// disconnect/connect, which is safe: ids and pkOrder are stamped once,
|
|
301
|
+
// and filter()/dismiss() have already reset the highlight.
|
|
302
|
+
reorderWithinParents(orderedItems) {
|
|
303
|
+
const itemSet = new Set(this.itemTargets);
|
|
304
|
+
const byParent = new Map();
|
|
305
|
+
orderedItems.forEach((el) => {
|
|
306
|
+
const parent = el.parentElement;
|
|
307
|
+
if (!parent) return;
|
|
308
|
+
if (!byParent.has(parent)) byParent.set(parent, []);
|
|
309
|
+
byParent.get(parent).push(el);
|
|
310
|
+
});
|
|
311
|
+
byParent.forEach((els, parent) => {
|
|
312
|
+
const firstItem = [...parent.children].find((c) => itemSet.has(c));
|
|
313
|
+
const placeholder = document.createComment("");
|
|
314
|
+
parent.insertBefore(placeholder, firstItem || null);
|
|
315
|
+
els.forEach((el) => parent.insertBefore(el, placeholder));
|
|
316
|
+
placeholder.remove();
|
|
317
|
+
});
|
|
251
318
|
}
|
|
252
319
|
|
|
253
320
|
// Upstream builds a Fuse index here; this keeps the same result shape —
|
|
@@ -17,18 +17,27 @@ module PhlexKit
|
|
|
17
17
|
template(data: { phlex_kit__command_dialog_target: "content" }) do
|
|
18
18
|
# The keydown action is the focus trap: Tab cycles within the cloned
|
|
19
19
|
# overlay instead of escaping to the page underneath.
|
|
20
|
+
# data-pk-overlay-clone is the common marker stamped by every
|
|
21
|
+
# clone-based overlay family (see alert_dialog_controller.js#topmost):
|
|
22
|
+
# it lets stacked overlays of DIFFERENT types resolve which one is
|
|
23
|
+
# topmost, so one Escape never closes both layers at once.
|
|
20
24
|
div(data: {
|
|
21
25
|
controller: "phlex-kit--command",
|
|
22
26
|
phlex_kit__command_dialog_instance: true,
|
|
27
|
+
pk_overlay_clone: "",
|
|
23
28
|
action: "keydown->phlex-kit--command#trapFocus"
|
|
24
29
|
}) do
|
|
25
30
|
backdrop
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
panel_attrs = { class: panel_classes, data: { state: "open" } }
|
|
32
|
+
# Defaults only when the caller didn't supply their own — `mix`
|
|
33
|
+
# would fuse role="dialog x" / aria-modal="true false" (same guard
|
|
34
|
+
# as AlertDialogContent/SheetContent/DrawerContent, round 7).
|
|
35
|
+
panel_attrs[:role] = "dialog" unless attr_set?(:role)
|
|
36
|
+
aria = {}
|
|
37
|
+
aria[:modal] = "true" unless aria_key_set?(:modal)
|
|
38
|
+
aria[:label] = @aria_label unless aria_labelled?
|
|
39
|
+
panel_attrs[:aria] = aria unless aria.empty?
|
|
40
|
+
div(**mix(panel_attrs, @attrs), &block)
|
|
32
41
|
end
|
|
33
42
|
end
|
|
34
43
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
-
# "No results" row —
|
|
3
|
-
#
|
|
2
|
+
# "No results" row — revealed by the controller only when a filter matches
|
|
3
|
+
# nothing. Server-rendered with pk-hidden so an inline palette doesn't flash
|
|
4
|
+
# "No results" under its items before Stimulus connects, and doesn't show it
|
|
5
|
+
# permanently with JS disabled. See command.rb.
|
|
4
6
|
class CommandEmpty < BaseComponent
|
|
5
7
|
def initialize(**attrs)
|
|
6
8
|
@attrs = attrs
|
|
@@ -8,7 +10,7 @@ module PhlexKit
|
|
|
8
10
|
|
|
9
11
|
def view_template(&)
|
|
10
12
|
div(**mix({
|
|
11
|
-
class: "pk-command-empty",
|
|
13
|
+
class: "pk-command-empty pk-hidden",
|
|
12
14
|
role: "presentation",
|
|
13
15
|
data: { phlex_kit__command_target: "empty" }
|
|
14
16
|
}, @attrs), &)
|
|
@@ -12,13 +12,15 @@ module PhlexKit
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def view_template(&block)
|
|
15
|
-
|
|
15
|
+
base = {
|
|
16
16
|
class: "pk-context-menu-item pk-context-menu-checkbox-item",
|
|
17
|
-
role: "menuitemcheckbox",
|
|
18
|
-
tabindex: "-1",
|
|
19
|
-
aria: { checked: @checked ? "true" : "false" },
|
|
20
17
|
data: { phlex_kit__context_menu_target: "menuItem" }
|
|
21
|
-
}
|
|
18
|
+
}
|
|
19
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
20
|
+
base[:role] = "menuitemcheckbox" unless attr_set?(:role)
|
|
21
|
+
base[:tabindex] = "-1" unless attr_set?(:tabindex)
|
|
22
|
+
base[:"aria-checked"] = (@checked ? "true" : "false") unless aria_key_set?(:checked)
|
|
23
|
+
label(**mix(base, @attrs)) do
|
|
22
24
|
input(type: :checkbox, class: "pk-context-menu-item-input", name: @name, value: @value, checked: @checked,
|
|
23
25
|
tabindex: "-1", data: { action: "change->phlex-kit--context-menu#syncChecked" })
|
|
24
26
|
span(class: "pk-context-menu-item-indicator", aria: { hidden: "true" }) do
|
|
@@ -2,7 +2,13 @@ module PhlexKit
|
|
|
2
2
|
class ContextMenuContent < BaseComponent
|
|
3
3
|
def initialize(**attrs) = (@attrs = attrs)
|
|
4
4
|
def view_template(&)
|
|
5
|
-
|
|
5
|
+
base = { class: "pk-context-menu-content", data: { phlex_kit__context_menu_target: "content", state: "closed" } }
|
|
6
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
7
|
+
base[:popover] = "manual" unless attr_set?(:popover)
|
|
8
|
+
base[:role] = "menu" unless attr_set?(:role)
|
|
9
|
+
base[:tabindex] = "-1" unless attr_set?(:tabindex)
|
|
10
|
+
base[:aria] = { orientation: "vertical" } unless aria_key_set?(:orientation)
|
|
11
|
+
div(**mix(base, @attrs), &)
|
|
6
12
|
end
|
|
7
13
|
end
|
|
8
14
|
end
|
|
@@ -8,12 +8,39 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
8
8
|
// native [popover=manual] in the top layer (context_menu.css); it is placed
|
|
9
9
|
// in viewport coordinates after showPopover() — a hidden popover has no
|
|
10
10
|
// size — and clamped so it never overflows the viewport (no floating-ui).
|
|
11
|
+
|
|
12
|
+
// One-shot capture listener swallowing the click an outside mousedown is
|
|
13
|
+
// about to produce (see onDocMousedown; same helper in dropdown/menubar —
|
|
14
|
+
// duplicated per controller by design). {once} self-removes; the DOM
|
|
15
|
+
// dedupes re-arming with the same fn. If the gesture's click never fires
|
|
16
|
+
// (e.g. suppressed after a selection drag) the armed swallow eats the next
|
|
17
|
+
// one — rare enough to accept.
|
|
18
|
+
const swallowClick = (ev) => {
|
|
19
|
+
// preventDefault alone suppresses only native default actions; the
|
|
20
|
+
// dismissing outside click would still fire addEventListener handlers on
|
|
21
|
+
// whatever sits under the pointer. stopPropagation (this listener is
|
|
22
|
+
// capture-phase on window, the outermost node, so it runs before any deeper
|
|
23
|
+
// handler) starves those too — the modal-menu contract is that an outside
|
|
24
|
+
// click ONLY dismisses.
|
|
25
|
+
ev.preventDefault()
|
|
26
|
+
ev.stopPropagation()
|
|
27
|
+
}
|
|
28
|
+
function armSwallowClick() {
|
|
29
|
+
window.addEventListener("click", swallowClick, { once: true, capture: true })
|
|
30
|
+
}
|
|
31
|
+
|
|
11
32
|
export default class extends Controller {
|
|
12
33
|
static targets = ["content", "trigger", "menuItem"]
|
|
13
34
|
|
|
14
35
|
connect() {
|
|
15
36
|
this.onDoc = (e) => {
|
|
16
|
-
if (this.contentTarget.contains(e.target))
|
|
37
|
+
if (this.contentTarget.contains(e.target)) {
|
|
38
|
+
// A right-click ON the open menu must not pop the NATIVE context menu
|
|
39
|
+
// on top of it — native menus swallow their own contextmenu. Suppress
|
|
40
|
+
// it and leave the kit menu open (no reposition for an inside click).
|
|
41
|
+
if (e.type === "contextmenu") e.preventDefault()
|
|
42
|
+
return
|
|
43
|
+
}
|
|
17
44
|
// The opening right-click bubbles to document AFTER open() adds this
|
|
18
45
|
// listener — a contextmenu inside our own element must not close what
|
|
19
46
|
// it just opened (it repositions instead). A contextmenu anywhere
|
|
@@ -22,6 +49,17 @@ export default class extends Controller {
|
|
|
22
49
|
if (e.type === "contextmenu" && this.element.contains(e.target)) return
|
|
23
50
|
this.close()
|
|
24
51
|
}
|
|
52
|
+
// Kit-wide dismiss contract (Radix modal-menu parity): a plain click
|
|
53
|
+
// outside only dismisses — swallowed, like dropdown/menubar/select.
|
|
54
|
+
// Armed at MOUSEDOWN: the gesture's focusout close() removes onDoc
|
|
55
|
+
// before the click even fires. Right-button mousedowns are ignored so a
|
|
56
|
+
// contextmenu elsewhere stays click-through (that's how a second kit —
|
|
57
|
+
// or the native — context menu opens in the same gesture).
|
|
58
|
+
this.onDocMousedown = (e) => {
|
|
59
|
+
if (this.contentTarget.contains(e.target)) return
|
|
60
|
+
if (e.button !== 0) return
|
|
61
|
+
armSwallowClick()
|
|
62
|
+
}
|
|
25
63
|
this.onKey = (e) => this.keydown(e)
|
|
26
64
|
// Turbo snapshots BEFORE disconnect — close now or the snapshot keeps a
|
|
27
65
|
// stale data-state="open" (the panel itself can't resurrect, but host
|
|
@@ -47,6 +85,7 @@ export default class extends Controller {
|
|
|
47
85
|
}
|
|
48
86
|
|
|
49
87
|
disconnect() {
|
|
88
|
+
document.removeEventListener("mousedown", this.onDocMousedown)
|
|
50
89
|
document.removeEventListener("click", this.onDoc)
|
|
51
90
|
document.removeEventListener("contextmenu", this.onDoc)
|
|
52
91
|
document.removeEventListener("keydown", this.onKey)
|
|
@@ -95,6 +134,7 @@ export default class extends Controller {
|
|
|
95
134
|
c.style.left = `${x}px`
|
|
96
135
|
c.style.top = `${y}px`
|
|
97
136
|
c.dataset.state = "open"
|
|
137
|
+
document.addEventListener("mousedown", this.onDocMousedown)
|
|
98
138
|
document.addEventListener("click", this.onDoc)
|
|
99
139
|
document.addEventListener("contextmenu", this.onDoc)
|
|
100
140
|
document.addEventListener("keydown", this.onKey)
|
|
@@ -109,6 +149,7 @@ export default class extends Controller {
|
|
|
109
149
|
if (opts?.target?.closest?.('a[href="#"]')) opts.preventDefault()
|
|
110
150
|
if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
|
|
111
151
|
this.contentTarget.dataset.state = "closed"
|
|
152
|
+
document.removeEventListener("mousedown", this.onDocMousedown)
|
|
112
153
|
document.removeEventListener("click", this.onDoc)
|
|
113
154
|
document.removeEventListener("contextmenu", this.onDoc)
|
|
114
155
|
document.removeEventListener("keydown", this.onKey)
|
|
@@ -4,7 +4,10 @@ module PhlexKit
|
|
|
4
4
|
class ContextMenuGroup < BaseComponent
|
|
5
5
|
def initialize(**attrs) = (@attrs = attrs)
|
|
6
6
|
def view_template(&)
|
|
7
|
-
|
|
7
|
+
base = { class: "pk-context-menu-group" }
|
|
8
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
9
|
+
base[:role] = "group" unless attr_set?(:role)
|
|
10
|
+
div(**mix(base, @attrs), &)
|
|
8
11
|
end
|
|
9
12
|
end
|
|
10
13
|
end
|