phlex_kit 0.9.1 → 0.11.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/assets/stylesheets/phlex_kit/_tokens.css +8 -0
- data/app/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
- data/app/components/phlex_kit/accordion/accordion.css +7 -0
- data/app/components/phlex_kit/alert/alert.rb +1 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +15 -0
- data/app/components/phlex_kit/attachment/attachment.css +6 -3
- data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
- data/app/components/phlex_kit/attachment/attachment_trigger.rb +5 -0
- data/app/components/phlex_kit/attachment/scroll_fade_controller.js +7 -2
- data/app/components/phlex_kit/avatar/avatar.css +2 -2
- data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
- data/app/components/phlex_kit/badge/badge.css +1 -1
- data/app/components/phlex_kit/breadcrumb/breadcrumb.css +0 -4
- data/app/components/phlex_kit/bubble/bubble.css +1 -1
- data/app/components/phlex_kit/bubble/bubble.rb +4 -6
- data/app/components/phlex_kit/bubble/bubble_content.rb +4 -3
- data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
- data/app/components/phlex_kit/button_group/button_group.css +16 -16
- data/app/components/phlex_kit/calendar/calendar.css +10 -2
- data/app/components/phlex_kit/calendar/calendar_controller.js +33 -9
- data/app/components/phlex_kit/carousel/carousel.css +4 -4
- data/app/components/phlex_kit/carousel/carousel.rb +3 -2
- data/app/components/phlex_kit/carousel/carousel_controller.js +23 -2
- data/app/components/phlex_kit/chart/chart.rb +8 -2
- data/app/components/phlex_kit/clipboard/clipboard_controller.js +26 -3
- data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
- data/app/components/phlex_kit/codeblock/codeblock.rb +7 -1
- data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
- data/app/components/phlex_kit/collapsible/collapsible_controller.js +15 -6
- data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +5 -2
- data/app/components/phlex_kit/combobox/combobox.css +3 -3
- data/app/components/phlex_kit/combobox/combobox.rb +3 -3
- data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
- data/app/components/phlex_kit/combobox/combobox_controller.js +32 -10
- data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
- data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
- data/app/components/phlex_kit/combobox/combobox_search_input.rb +3 -1
- data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +7 -1
- data/app/components/phlex_kit/command/command.css +2 -2
- data/app/components/phlex_kit/command/command_controller.js +63 -35
- data/app/components/phlex_kit/command/command_dialog_controller.js +30 -1
- data/app/components/phlex_kit/command/command_group.rb +5 -2
- data/app/components/phlex_kit/command/command_item.rb +4 -0
- data/app/components/phlex_kit/command/command_list.rb +6 -2
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +25 -0
- data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
- data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
- data/app/components/phlex_kit/data_table/data_table.css +0 -10
- data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
- data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
- data/app/components/phlex_kit/date_picker/date_picker.rb +6 -0
- data/app/components/phlex_kit/dialog/dialog.css +5 -3
- data/app/components/phlex_kit/dialog/dialog_close.rb +1 -1
- data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
- data/app/components/phlex_kit/dialog/dialog_controller.js +22 -2
- data/app/components/phlex_kit/drawer/drawer.css +4 -4
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +5 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +27 -0
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
- data/app/components/phlex_kit/empty/empty.css +1 -1
- data/app/components/phlex_kit/field/field.css +2 -1
- data/app/components/phlex_kit/field/field_separator.rb +1 -1
- data/app/components/phlex_kit/form_field/form_field_controller.js +29 -1
- data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
- data/app/components/phlex_kit/hover_card/hover_card.css +4 -2
- data/app/components/phlex_kit/icon/icon.css +9 -0
- data/app/components/phlex_kit/icon/icon.rb +7 -1
- data/app/components/phlex_kit/input_group/input_group.css +0 -1
- data/app/components/phlex_kit/input_otp/input_otp.css +4 -4
- data/app/components/phlex_kit/marker/marker.rb +15 -2
- data/app/components/phlex_kit/masked_input/masked_input_controller.js +35 -15
- data/app/components/phlex_kit/menubar/menubar.rb +3 -1
- data/app/components/phlex_kit/menubar/menubar_controller.js +28 -1
- data/app/components/phlex_kit/menubar/menubar_item.rb +4 -2
- data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -2
- data/app/components/phlex_kit/message/message.rb +5 -0
- data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +12 -5
- data/app/components/phlex_kit/native_select/native_select.css +4 -2
- data/app/components/phlex_kit/native_select/native_select.rb +12 -3
- data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +7 -1
- data/app/components/phlex_kit/pagination/pagination.rb +10 -2
- data/app/components/phlex_kit/pagination/pagination_next.rb +11 -2
- data/app/components/phlex_kit/pagination/pagination_previous.rb +11 -2
- data/app/components/phlex_kit/popover/popover_controller.js +31 -1
- data/app/components/phlex_kit/progress/progress.css +8 -0
- data/app/components/phlex_kit/progress/progress.rb +14 -5
- data/app/components/phlex_kit/resizable/resizable_controller.js +52 -5
- data/app/components/phlex_kit/resizable/resizable_panel.rb +13 -4
- data/app/components/phlex_kit/scroll_area/scroll_area.rb +23 -4
- data/app/components/phlex_kit/select/select.css +5 -3
- data/app/components/phlex_kit/select/select_content.rb +9 -3
- data/app/components/phlex_kit/select/select_controller.js +59 -6
- data/app/components/phlex_kit/select/select_item.rb +5 -1
- data/app/components/phlex_kit/separator/separator.rb +12 -2
- data/app/components/phlex_kit/sheet/sheet.css +4 -2
- data/app/components/phlex_kit/sheet/sheet_close.rb +1 -1
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +4 -0
- data/app/components/phlex_kit/sheet/sheet_controller.js +15 -0
- data/app/components/phlex_kit/sidebar/sidebar.css +1 -1
- data/app/components/phlex_kit/sidebar/sidebar_controller.js +10 -0
- data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
- data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
- data/app/components/phlex_kit/slider/slider.css +15 -0
- data/app/components/phlex_kit/switch/switch.css +5 -0
- data/app/components/phlex_kit/table/table.css +2 -1
- data/app/components/phlex_kit/table/table.rb +5 -2
- data/app/components/phlex_kit/tabs/tabs.css +2 -2
- data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
- data/app/components/phlex_kit/tabs/tabs_controller.js +4 -1
- data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
- data/app/components/phlex_kit/tabs/tabs_trigger.rb +15 -3
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
- data/app/components/phlex_kit/toast/toast.css +3 -2
- data/app/components/phlex_kit/toast/toast_action.rb +4 -1
- data/app/components/phlex_kit/toast/toast_controller.js +13 -5
- data/app/components/phlex_kit/toast/toast_region.rb +19 -9
- data/app/components/phlex_kit/toast/toaster_controller.js +34 -12
- data/app/components/phlex_kit/toggle_group/toggle_group.css +7 -7
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +7 -2
- data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +13 -1
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +9 -1
- data/app/components/phlex_kit/tooltip/tooltip.css +7 -3
- data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
- data/app/components/phlex_kit/typography/heading.rb +13 -5
- data/app/components/phlex_kit/typography/list.rb +22 -0
- data/app/components/phlex_kit/typography/text.rb +7 -1
- data/lib/phlex_kit/base_component.rb +13 -0
- data/lib/phlex_kit/version.rb +1 -1
- metadata +3 -1
|
@@ -12,7 +12,13 @@ module PhlexKit
|
|
|
12
12
|
# tabindex=0 + role=region: the block scrolls (overflow:auto), so it must
|
|
13
13
|
# be keyboard-focusable (WCAG 2.1.1 scrollable-region-focusable). Pass
|
|
14
14
|
# `aria: { label: ... }` to name the region for AT.
|
|
15
|
-
|
|
15
|
+
base = { class: "pk-codeblock", tabindex: "0", role: "region", data: { syntax: @syntax } }
|
|
16
|
+
# A region landmark without a name is an axe violation — default one
|
|
17
|
+
# from the syntax; a caller aria: { label: } replaces it (never fused).
|
|
18
|
+
unless aria_labelled?
|
|
19
|
+
base[:aria] = { label: @syntax ? "#{@syntax} code" : "Code" }
|
|
20
|
+
end
|
|
21
|
+
div(**mix(base, @attrs)) do
|
|
16
22
|
pre do
|
|
17
23
|
if block
|
|
18
24
|
code(&block)
|
|
@@ -7,7 +7,10 @@ module PhlexKit
|
|
|
7
7
|
@attrs = attrs
|
|
8
8
|
end
|
|
9
9
|
def view_template(&)
|
|
10
|
-
|
|
10
|
+
# data-state is the CSS styling hook (chevron rotation) — it must be
|
|
11
|
+
# server-rendered or an open: true collapsible renders a closed-pointing
|
|
12
|
+
# chevron until Stimulus connects (and always, with JS off).
|
|
13
|
+
div(**mix({ class: "pk-collapsible", data: { controller: "phlex-kit--collapsible", phlex_kit__collapsible_open_value: @open, state: @open ? "open" : "closed" } }, @attrs), &)
|
|
11
14
|
end
|
|
12
15
|
end
|
|
13
16
|
end
|
|
@@ -6,10 +6,19 @@ export default class extends Controller {
|
|
|
6
6
|
static values = { open: { type: Boolean, default: false } }
|
|
7
7
|
|
|
8
8
|
connect() {
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// aria-expanded must live on the element AT reads state from — the
|
|
10
|
+
// focused control, not the plain wrapper div. The trigger wrapper renders
|
|
11
|
+
// aria-expanded server-side (it can't reach into its block), so resolve
|
|
12
|
+
// the nested focusable control here, relocate the state onto it, and keep
|
|
13
|
+
// toggling there. Wrapper-only triggers keep the wrapper as carrier.
|
|
14
|
+
if (this.hasTriggerTarget) {
|
|
15
|
+
this.control = this.triggerTarget.querySelector("button, [role='button'], a[href], input") || this.triggerTarget
|
|
16
|
+
if (this.control !== this.triggerTarget) this.triggerTarget.removeAttribute("aria-expanded")
|
|
17
|
+
// The trigger can't know the content's id at render time — wire
|
|
18
|
+
// aria-controls here.
|
|
19
|
+
if (this.hasContentTarget && this.contentTarget.id) {
|
|
20
|
+
this.control.setAttribute("aria-controls", this.contentTarget.id)
|
|
21
|
+
}
|
|
13
22
|
}
|
|
14
23
|
this.openValue ? this.open() : this.close()
|
|
15
24
|
}
|
|
@@ -18,13 +27,13 @@ export default class extends Controller {
|
|
|
18
27
|
|
|
19
28
|
open() {
|
|
20
29
|
if (this.hasContentTarget) { this.contentTarget.classList.remove("pk-hidden"); this.openValue = true }
|
|
21
|
-
if (this.
|
|
30
|
+
if (this.control) this.control.setAttribute("aria-expanded", "true")
|
|
22
31
|
this.element.dataset.state = "open";
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
close() {
|
|
26
35
|
if (this.hasContentTarget) { this.contentTarget.classList.add("pk-hidden"); this.openValue = false }
|
|
27
|
-
if (this.
|
|
36
|
+
if (this.control) this.control.setAttribute("aria-expanded", "false")
|
|
28
37
|
this.element.dataset.state = "closed";
|
|
29
38
|
}
|
|
30
39
|
}
|
|
@@ -4,8 +4,11 @@ module PhlexKit
|
|
|
4
4
|
# control (a <button>, e.g. PhlexKit::Button) inside it for keyboard
|
|
5
5
|
# operability and semantics, the same composition pattern as
|
|
6
6
|
# AttachmentTrigger. Pass `open:` matching the parent Collapsible's so
|
|
7
|
-
# aria-expanded is correct before JS
|
|
8
|
-
#
|
|
7
|
+
# aria-expanded is correct before JS. The wrapper carries the server-rendered
|
|
8
|
+
# aria-expanded only because the component can't reach into its block; on
|
|
9
|
+
# connect the controller relocates it (plus aria-controls) onto the nested
|
|
10
|
+
# focusable control — AT reads state from the focused element, not the
|
|
11
|
+
# wrapper — falling back to the wrapper when no control is nested.
|
|
9
12
|
class CollapsibleTrigger < BaseComponent
|
|
10
13
|
def initialize(open: false, **attrs)
|
|
11
14
|
@open = open
|
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
margin: 0;
|
|
153
153
|
accent-color: var(--pk-brand);
|
|
154
154
|
}
|
|
155
|
-
.pk-combobox-checkbox { border-radius:
|
|
155
|
+
.pk-combobox-checkbox { border-radius: calc(var(--pk-radius) - 8px); }
|
|
156
156
|
.pk-combobox-checkbox:disabled, .pk-combobox-radio:disabled { cursor: not-allowed; opacity: .5; }
|
|
157
157
|
.pk-combobox-checkbox:focus-visible, .pk-combobox-radio:focus-visible {
|
|
158
158
|
outline: none;
|
|
@@ -296,7 +296,7 @@
|
|
|
296
296
|
width: .875rem;
|
|
297
297
|
height: .875rem;
|
|
298
298
|
border: 0;
|
|
299
|
-
border-radius:
|
|
299
|
+
border-radius: calc(var(--pk-radius) - 8px);
|
|
300
300
|
padding: 0;
|
|
301
301
|
background: transparent;
|
|
302
302
|
color: var(--pk-muted);
|
|
@@ -318,7 +318,7 @@
|
|
|
318
318
|
flex: none;
|
|
319
319
|
margin-inline-start: auto;
|
|
320
320
|
border: 0;
|
|
321
|
-
border-radius:
|
|
321
|
+
border-radius: calc(var(--pk-radius) - 8px);
|
|
322
322
|
padding: 0;
|
|
323
323
|
background: transparent;
|
|
324
324
|
color: var(--pk-muted);
|
|
@@ -5,9 +5,9 @@ module PhlexKit
|
|
|
5
5
|
# toggle-all live in the phlex-kit--combobox controller. Like PhlexKit::Select, the
|
|
6
6
|
# @floating-ui/dom + native-popover positioning is replaced with CSS (panel
|
|
7
7
|
# anchored below the trigger, click-outside via window action). Compose
|
|
8
|
-
# Combobox(term:) > trigger + Popover(SearchInput + List(
|
|
9
|
-
# Item(Checkbox|Radio + text +
|
|
10
|
-
#
|
|
8
|
+
# Combobox(term:) > trigger + Popover(SearchInput + List([Item(
|
|
9
|
+
# ToggleAllCheckbox) first +] ListGroup > Item(Checkbox|Radio + text +
|
|
10
|
+
# ItemIndicator) + EmptyState)). Three trigger flavours: ComboboxTrigger (button +
|
|
11
11
|
# popover search), ComboboxInputTrigger (the field is the filter), and
|
|
12
12
|
# ComboboxBadgeTrigger (multi-select chips + ComboboxClearButton) — the
|
|
13
13
|
# latter two shipped unfinished in ruby_ui (no controller support) and are
|
|
@@ -30,7 +30,8 @@ module PhlexKit
|
|
|
30
30
|
"keydown.down->phlex-kit--combobox#keyDownPressed",
|
|
31
31
|
"keydown.up->phlex-kit--combobox#keyUpPressed",
|
|
32
32
|
"keydown.enter->phlex-kit--combobox#keyEnterPressed",
|
|
33
|
-
|
|
33
|
+
# no :prevent — closePopover only swallows Escape while open.
|
|
34
|
+
"keydown.esc->phlex-kit--combobox#closePopover"
|
|
34
35
|
].join(" ")
|
|
35
36
|
}
|
|
36
37
|
}, @attrs)) do
|
|
@@ -49,7 +50,10 @@ module PhlexKit
|
|
|
49
50
|
action: [
|
|
50
51
|
"keyup->phlex-kit--combobox#filterItems",
|
|
51
52
|
"input->phlex-kit--combobox#filterItems",
|
|
52
|
-
"
|
|
53
|
+
# plain keydown — "backspace" is not a Stimulus key filter
|
|
54
|
+
# (using it throws "unknown key filter" on every keystroke and
|
|
55
|
+
# the handler never ran); the method guards on e.key itself.
|
|
56
|
+
"keydown->phlex-kit--combobox#handleBadgeInputBackspace"
|
|
53
57
|
].join(" ")
|
|
54
58
|
}
|
|
55
59
|
)
|
|
@@ -45,6 +45,11 @@ export default class extends Controller {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
connect() {
|
|
48
|
+
// A Turbo snapshot serializes aria-expanded / aria-activedescendant even
|
|
49
|
+
// though :popover-open does not survive the restore — normalize here so
|
|
50
|
+
// a restored page doesn't announce an open listbox over a closed combobox.
|
|
51
|
+
this.setExpanded(false)
|
|
52
|
+
this.clearActiveDescendant()
|
|
48
53
|
this.generateItemIds()
|
|
49
54
|
this.updateTriggerContent()
|
|
50
55
|
}
|
|
@@ -174,6 +179,11 @@ export default class extends Controller {
|
|
|
174
179
|
this.inputTargets.forEach((input) => {
|
|
175
180
|
input.parentElement.setAttribute("aria-selected", input.checked ? "true" : "false")
|
|
176
181
|
})
|
|
182
|
+
// The toggle-all row is an option too (first row inside the listbox) —
|
|
183
|
+
// keep its aria-selected in step with its checkbox.
|
|
184
|
+
if (this.hasToggleAllTarget && this.toggleAllTarget.parentElement.getAttribute("role") === "option") {
|
|
185
|
+
this.toggleAllTarget.parentElement.setAttribute("aria-selected", this.toggleAllTarget.checked ? "true" : "false")
|
|
186
|
+
}
|
|
177
187
|
}
|
|
178
188
|
|
|
179
189
|
renderBadges(checked) {
|
|
@@ -212,6 +222,8 @@ export default class extends Controller {
|
|
|
212
222
|
}
|
|
213
223
|
|
|
214
224
|
handleBadgeInputBackspace(e) {
|
|
225
|
+
// Bound as a bare keydown: "backspace" is not a Stimulus key filter.
|
|
226
|
+
if (e.key !== "Backspace") return
|
|
215
227
|
if (this.badgeInputTarget.value !== "") return
|
|
216
228
|
|
|
217
229
|
const checked = this.checkedInputs()
|
|
@@ -273,7 +285,14 @@ export default class extends Controller {
|
|
|
273
285
|
}
|
|
274
286
|
}
|
|
275
287
|
|
|
276
|
-
closePopover() {
|
|
288
|
+
closePopover(e) {
|
|
289
|
+
// Escape must keep its browser default (e.g. cancelling an enclosing
|
|
290
|
+
// <dialog>) when the popover is already closed — only swallow the key
|
|
291
|
+
// when this close actually consumes it.
|
|
292
|
+
if (e?.type === "keydown") {
|
|
293
|
+
if (!this.isOpen()) return
|
|
294
|
+
e.preventDefault()
|
|
295
|
+
}
|
|
277
296
|
// Hiding the popover while focus is inside it (search input, option) drops
|
|
278
297
|
// focus to <body>; hand it back to the trigger. Checked BEFORE hiding, and
|
|
279
298
|
// only when focus would actually be orphaned — a click elsewhere on the
|
|
@@ -284,7 +303,7 @@ export default class extends Controller {
|
|
|
284
303
|
// aria-activedescendant holds an element id; on close it must be removed,
|
|
285
304
|
// not left pointing at a hidden option.
|
|
286
305
|
this.clearActiveDescendant()
|
|
287
|
-
if (this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
|
|
306
|
+
if (this.hasPopoverTarget && this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
|
|
288
307
|
this.updateTriggerContent() // reflect the selection into an input trigger
|
|
289
308
|
|
|
290
309
|
if (focusWasInside) {
|
|
@@ -398,20 +417,23 @@ export default class extends Controller {
|
|
|
398
417
|
}
|
|
399
418
|
|
|
400
419
|
focusSelectedInput() {
|
|
401
|
-
|
|
402
|
-
|
|
420
|
+
// Walk the option rows themselves, not the inner selection inputs: the
|
|
421
|
+
// toggle-all row is an option without an "input" target and must be
|
|
422
|
+
// arrow-reachable like any other option.
|
|
423
|
+
const visibleItems = this.itemTargets.filter(item => !item.classList.contains("pk-hidden"))
|
|
424
|
+
if (visibleItems.length === 0) return
|
|
403
425
|
|
|
404
|
-
this.wrapSelectedInputIndex(
|
|
426
|
+
this.wrapSelectedInputIndex(visibleItems.length)
|
|
405
427
|
|
|
406
|
-
|
|
428
|
+
visibleItems.forEach((item, index) => {
|
|
407
429
|
if (index == this.selectedItemIndex) {
|
|
408
|
-
|
|
430
|
+
item.ariaCurrent = "true"
|
|
409
431
|
// Focus stays in the filter field; the highlighted option is exposed
|
|
410
432
|
// via aria-activedescendant (option ids come from generateItemIds).
|
|
411
|
-
this.setActiveDescendant(
|
|
412
|
-
|
|
433
|
+
this.setActiveDescendant(item.id)
|
|
434
|
+
item.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' })
|
|
413
435
|
} else {
|
|
414
|
-
|
|
436
|
+
item.ariaCurrent = "false"
|
|
415
437
|
}
|
|
416
438
|
})
|
|
417
439
|
}
|
|
@@ -36,7 +36,10 @@ module PhlexKit
|
|
|
36
36
|
"keydown.down->phlex-kit--combobox#keyDownPressed",
|
|
37
37
|
"keydown.up->phlex-kit--combobox#keyUpPressed",
|
|
38
38
|
"keydown.enter->phlex-kit--combobox#keyEnterPressed",
|
|
39
|
-
|
|
39
|
+
# no :prevent — closePopover only swallows Escape while the
|
|
40
|
+
# popover is open, so a closed field keeps the key's default
|
|
41
|
+
# (e.g. cancelling an enclosing <dialog>).
|
|
42
|
+
"keydown.esc->phlex-kit--combobox#closePopover"
|
|
40
43
|
].join(" ")
|
|
41
44
|
}
|
|
42
45
|
}, @attrs)) do
|
|
@@ -3,13 +3,17 @@ module PhlexKit
|
|
|
3
3
|
# role and an id the controller points the combobox element's aria-controls
|
|
4
4
|
# at (and derives option ids from). See combobox.rb.
|
|
5
5
|
class ComboboxList < BaseComponent
|
|
6
|
-
|
|
6
|
+
# id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
|
|
7
|
+
# caller id with the generated one into an invalid two-token id, breaking
|
|
8
|
+
# aria-controls (the documented list_id: wiring) and derived option ids.
|
|
9
|
+
def initialize(id: nil, **attrs)
|
|
10
|
+
@id = id || "pk-combobox-list-#{SecureRandom.hex(4)}"
|
|
7
11
|
@attrs = attrs
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
def view_template(&)
|
|
11
15
|
div(**mix({
|
|
12
|
-
id:
|
|
16
|
+
id: @id,
|
|
13
17
|
class: "pk-combobox-list",
|
|
14
18
|
role: "listbox",
|
|
15
19
|
data: { phlex_kit__combobox_target: "list" }
|
|
@@ -26,7 +26,9 @@ module PhlexKit
|
|
|
26
26
|
class: "pk-combobox-search-input",
|
|
27
27
|
data: {
|
|
28
28
|
phlex_kit__combobox_target: "searchInput",
|
|
29
|
-
|
|
29
|
+
# `input` covers context-menu paste and IME composition, which
|
|
30
|
+
# fire no keyup/search (mirrors ComboboxInputTrigger).
|
|
31
|
+
action: "keyup->phlex-kit--combobox#filterItems input->phlex-kit--combobox#filterItems search->phlex-kit--combobox#filterItems"
|
|
30
32
|
}
|
|
31
33
|
}, @attrs))
|
|
32
34
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# "Select all" checkbox for a multi-select combobox — wrap it in a
|
|
3
|
-
# PhlexKit::ComboboxItem
|
|
3
|
+
# PhlexKit::ComboboxItem as the FIRST row inside the ComboboxList (the item
|
|
4
|
+
# carries role="option", which is only valid inside the listbox, and arrow-key
|
|
5
|
+
# nav walks the list's rows). Hidden while a filter term is active.
|
|
4
6
|
# See combobox.rb.
|
|
5
7
|
class ComboboxToggleAllCheckbox < BaseComponent
|
|
6
8
|
def initialize(**attrs)
|
|
@@ -11,6 +13,10 @@ module PhlexKit
|
|
|
11
13
|
input(**mix({
|
|
12
14
|
type: :checkbox,
|
|
13
15
|
class: "pk-combobox-checkbox",
|
|
16
|
+
# The option label is the interactive surface (ARIA option); the real
|
|
17
|
+
# input must not be a second tab stop inside it — same as
|
|
18
|
+
# ComboboxCheckbox/ComboboxRadio.
|
|
19
|
+
tabindex: "-1",
|
|
14
20
|
data: {
|
|
15
21
|
phlex_kit__combobox_target: "toggleAll",
|
|
16
22
|
action: "change->phlex-kit--combobox#toggleAllItems"
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
.pk-command {
|
|
9
9
|
background: var(--pk-surface);
|
|
10
10
|
color: var(--pk-text);
|
|
11
|
-
border-radius:
|
|
11
|
+
border-radius: calc(var(--pk-radius) + 2px);
|
|
12
12
|
padding: .25rem;
|
|
13
13
|
}
|
|
14
14
|
.pk-command-dialog-trigger { display: inline-block; }
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
width: 100%;
|
|
35
35
|
max-width: 32rem;
|
|
36
36
|
border: 1px solid var(--pk-border);
|
|
37
|
-
border-radius:
|
|
37
|
+
border-radius: calc(var(--pk-radius) + 2px);
|
|
38
38
|
background: var(--pk-surface);
|
|
39
39
|
color: var(--pk-text);
|
|
40
40
|
box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
|
|
@@ -35,8 +35,16 @@ function fuzzyScore(query, value) {
|
|
|
35
35
|
export default class extends Controller {
|
|
36
36
|
static targets = ["input", "group", "item", "empty", "separator", "list", "liveRegion"];
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
initialize() {
|
|
39
|
+
// Stimulus fires [target]Connected before connect(): anything
|
|
40
|
+
// itemTargetConnected uses must be initialized here (mirrors
|
|
41
|
+
// combobox_controller.js).
|
|
42
|
+
this.itemIdCounter = 0;
|
|
43
|
+
this.searchEntries = [];
|
|
39
44
|
this.selectedIndex = -1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
connect() {
|
|
40
48
|
// Remember what had focus before the palette grabbed it (for a cloned
|
|
41
49
|
// dialog this is the element focused when the overlay was inserted), so
|
|
42
50
|
// dismiss() can hand focus back instead of dropping it on <body>.
|
|
@@ -50,7 +58,6 @@ export default class extends Controller {
|
|
|
50
58
|
// Only the cloned dialog overlay grabs focus on connect — an inline
|
|
51
59
|
// palette connecting at page load must not steal it.
|
|
52
60
|
if (this.isDialogClone()) this.inputTarget.focus();
|
|
53
|
-
this.searchIndex = this.buildSearchIndex();
|
|
54
61
|
this.toggleVisibility(this.emptyTargets, false);
|
|
55
62
|
}
|
|
56
63
|
|
|
@@ -60,23 +67,49 @@ export default class extends Controller {
|
|
|
60
67
|
return this.element.hasAttribute("data-phlex-kit--command-dialog-instance");
|
|
61
68
|
}
|
|
62
69
|
|
|
63
|
-
// ARIA plumbing:
|
|
64
|
-
//
|
|
65
|
-
// aria-controls is wired to the listbox (mirrors select_controller.js).
|
|
70
|
+
// ARIA plumbing: aria-controls is wired to the listbox (mirrors
|
|
71
|
+
// select_controller.js); the per-result ids come from itemTargetConnected.
|
|
66
72
|
generateItemIds() {
|
|
73
|
+
this.inputTarget.setAttribute("aria-controls", this.listId());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
listId() {
|
|
67
77
|
const list = this.hasListTarget ? this.listTarget : this.element;
|
|
68
78
|
if (!list.id) list.id = `pk-command-list-${Math.random().toString(36).slice(2, 10)}`;
|
|
79
|
+
return list.id;
|
|
80
|
+
}
|
|
69
81
|
|
|
70
|
-
|
|
71
|
-
|
|
82
|
+
// Every result gets an id derived from the listbox id so the input can
|
|
83
|
+
// point aria-activedescendant at the keyboard highlight, and a search-index
|
|
84
|
+
// entry so filtering can find it. A target callback (not a connect() loop)
|
|
85
|
+
// so items rendered in by the host after load are id'd and findable too.
|
|
86
|
+
// Counter-based, so a removed item's id is never reissued to a later
|
|
87
|
+
// arrival (mirrors combobox_controller.js).
|
|
88
|
+
itemTargetConnected(item) {
|
|
89
|
+
if (!item.id) item.id = `${this.listId()}-${this.itemIdCounter++}`;
|
|
90
|
+
this.searchEntries.push({
|
|
91
|
+
value: (item.dataset.value || "").toLowerCase(),
|
|
92
|
+
text: (item.dataset.text || "").toLowerCase(),
|
|
93
|
+
element: item,
|
|
72
94
|
});
|
|
95
|
+
}
|
|
73
96
|
|
|
74
|
-
|
|
97
|
+
itemTargetDisconnected(item) {
|
|
98
|
+
this.searchEntries = this.searchEntries.filter(
|
|
99
|
+
(entry) => entry.element !== item,
|
|
100
|
+
);
|
|
101
|
+
// The highlight index is positional — after a removal it points at the
|
|
102
|
+
// wrong (or a missing) row, so Enter would activate the wrong item.
|
|
103
|
+
this.selectedIndex = -1;
|
|
75
104
|
}
|
|
76
105
|
|
|
77
106
|
dismiss() {
|
|
78
107
|
// Cloned dialog overlay: tear the clone down and hand focus back.
|
|
79
108
|
if (this.isDialogClone()) {
|
|
109
|
+
// Dispatched BEFORE teardown: the command-dialog controller listens on
|
|
110
|
+
// the overlay and un-inerts the page here — focus() below would be a
|
|
111
|
+
// silent no-op while previouslyFocused sits under an inert ancestor.
|
|
112
|
+
this.dispatch("dismiss");
|
|
80
113
|
// allow scroll on body
|
|
81
114
|
document.body.style.removeProperty("overflow");
|
|
82
115
|
const previous = this.previouslyFocused;
|
|
@@ -157,7 +190,7 @@ export default class extends Controller {
|
|
|
157
190
|
|
|
158
191
|
this.toggleVisibility(this.itemTargets, false);
|
|
159
192
|
|
|
160
|
-
const results = this.
|
|
193
|
+
const results = this.searchItems(query);
|
|
161
194
|
results.forEach((result) =>
|
|
162
195
|
this.toggleVisibility([result.item.element], true),
|
|
163
196
|
);
|
|
@@ -201,31 +234,24 @@ export default class extends Controller {
|
|
|
201
234
|
this.toggleVisibility(this.emptyTargets, false);
|
|
202
235
|
}
|
|
203
236
|
|
|
204
|
-
// Upstream builds a Fuse index here; this keeps the same
|
|
205
|
-
// [{ item }] sorted best-match-first — using the fuzzy scorer above
|
|
206
|
-
//
|
|
207
|
-
// rendered
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
.filter((score) => score !== null);
|
|
223
|
-
return { item, score: scores.length ? Math.max(...scores) : null };
|
|
224
|
-
})
|
|
225
|
-
.filter((result) => result.score !== null)
|
|
226
|
-
.sort((a, b) => b.score - a.score);
|
|
227
|
-
},
|
|
228
|
-
};
|
|
237
|
+
// Upstream builds a Fuse index here; this keeps the same result shape —
|
|
238
|
+
// [{ item }] sorted best-match-first — using the fuzzy scorer above over
|
|
239
|
+
// the live searchEntries (maintained by itemTargetConnected/Disconnected,
|
|
240
|
+
// so host-rendered items are searchable). Both data-value and data-text are
|
|
241
|
+
// searchable (an item's `text:` was rendered but never indexed); the better
|
|
242
|
+
// of the two scores wins.
|
|
243
|
+
searchItems(query) {
|
|
244
|
+
const q = query.toLowerCase();
|
|
245
|
+
return this.searchEntries
|
|
246
|
+
.map((item) => {
|
|
247
|
+
const scores = [item.value, item.text]
|
|
248
|
+
.filter(Boolean)
|
|
249
|
+
.map((field) => fuzzyScore(q, field))
|
|
250
|
+
.filter((score) => score !== null);
|
|
251
|
+
return { item, score: scores.length ? Math.max(...scores) : null };
|
|
252
|
+
})
|
|
253
|
+
.filter((result) => result.score !== null)
|
|
254
|
+
.sort((a, b) => b.score - a.score);
|
|
229
255
|
}
|
|
230
256
|
|
|
231
257
|
handleKeydown(e) {
|
|
@@ -243,7 +269,9 @@ export default class extends Controller {
|
|
|
243
269
|
this.updateSelectedItem(visibleItems, -1);
|
|
244
270
|
} else if (e.key === "Enter" && this.selectedIndex !== -1) {
|
|
245
271
|
e.preventDefault();
|
|
246
|
-
|
|
272
|
+
// Optional-chained: a stale selectedIndex after dynamic item removal
|
|
273
|
+
// (turbo stream re-render) must not throw.
|
|
274
|
+
visibleItems[this.selectedIndex]?.click();
|
|
247
275
|
}
|
|
248
276
|
}
|
|
249
277
|
|
|
@@ -6,14 +6,27 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
6
6
|
// upstream's document-scoped outlet (which let one dialog adopt another's
|
|
7
7
|
// open palette), each instance tracks its own clone — sheet_controller's
|
|
8
8
|
// pattern — and clears it on turbo:before-cache so a cached page never
|
|
9
|
-
// restores a stale overlay + scroll lock.
|
|
9
|
+
// restores a stale overlay + scroll lock. While the overlay is up, the page
|
|
10
|
+
// behind it is inert-ed per element with prior state saved (the same modal
|
|
11
|
+
// contract as alert_dialog/sheet/sidebar — the helper is duplicated per
|
|
12
|
+
// controller by design); restore runs on dismiss (via the overlay's
|
|
13
|
+
// phlex-kit--command:dismiss event), disconnect, AND turbo:before-cache.
|
|
10
14
|
// Connects to data-controller="phlex-kit--command-dialog"
|
|
11
15
|
export default class extends Controller {
|
|
12
16
|
static targets = ["content"];
|
|
13
17
|
|
|
14
18
|
initialize() {
|
|
15
19
|
this.overlay = null;
|
|
20
|
+
this.inerted = null;
|
|
21
|
+
// The command controller's dismiss() dispatches this on the overlay
|
|
22
|
+
// BEFORE it removes the clone / restores focus — un-inert first so the
|
|
23
|
+
// opener is focusable again.
|
|
24
|
+
this.restoreInert = () => {
|
|
25
|
+
for (const [el, wasInert] of this.inerted || []) el.inert = wasInert;
|
|
26
|
+
this.inerted = null;
|
|
27
|
+
};
|
|
16
28
|
this.clearOverlay = () => {
|
|
29
|
+
this.restoreInert();
|
|
17
30
|
if (this.overlay?.isConnected) {
|
|
18
31
|
this.overlay.remove();
|
|
19
32
|
document.body.style.removeProperty("overflow");
|
|
@@ -49,7 +62,23 @@ export default class extends Controller {
|
|
|
49
62
|
|
|
50
63
|
document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
|
|
51
64
|
this.overlay = document.body.lastElementChild;
|
|
65
|
+
this.overlay.addEventListener("phlex-kit--command:dismiss", this.restoreInert);
|
|
52
66
|
// prevent scroll on body
|
|
53
67
|
document.body.style.overflow = "hidden";
|
|
68
|
+
this.#inertOthers();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Make everything behind the overlay inert (the clone is <body>'s last
|
|
72
|
+
// child, so its siblings are the whole page). Prior inert state is saved
|
|
73
|
+
// per element and restored by restoreInert. The command controller's Tab
|
|
74
|
+
// trap on the clone stays as belt and braces.
|
|
75
|
+
#inertOthers() {
|
|
76
|
+
this.inerted = [];
|
|
77
|
+
for (const el of document.body.children) {
|
|
78
|
+
if (el === this.overlay) continue;
|
|
79
|
+
if (["SCRIPT", "STYLE", "LINK", "TEMPLATE"].includes(el.tagName)) continue;
|
|
80
|
+
this.inerted.push([el, el.inert]);
|
|
81
|
+
el.inert = true;
|
|
82
|
+
}
|
|
54
83
|
}
|
|
55
84
|
}
|
|
@@ -5,6 +5,9 @@ module PhlexKit
|
|
|
5
5
|
class CommandGroup < BaseComponent
|
|
6
6
|
def initialize(title: nil, **attrs)
|
|
7
7
|
@title = title
|
|
8
|
+
# Names the role="group" items wrapper via aria-labelledby → heading id
|
|
9
|
+
# (matches cmdk/shadcn; id pattern mirrors CommandList).
|
|
10
|
+
@heading_id = "pk-command-group-heading-#{SecureRandom.hex(4)}" if title
|
|
8
11
|
@attrs = attrs
|
|
9
12
|
end
|
|
10
13
|
|
|
@@ -22,11 +25,11 @@ module PhlexKit
|
|
|
22
25
|
private
|
|
23
26
|
|
|
24
27
|
def render_header
|
|
25
|
-
div(group_heading: @title) { @title }
|
|
28
|
+
div(id: @heading_id, group_heading: @title) { @title }
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
def render_items(&)
|
|
29
|
-
div(group_items: "", role: "group", &)
|
|
32
|
+
div(group_items: "", role: "group", aria_labelledby: @heading_id, &)
|
|
30
33
|
end
|
|
31
34
|
end
|
|
32
35
|
end
|
|
@@ -18,6 +18,10 @@ module PhlexKit
|
|
|
18
18
|
class: "pk-command-item",
|
|
19
19
|
href: @href,
|
|
20
20
|
role: "option",
|
|
21
|
+
# Options in an aria-activedescendant listbox are not tab stops —
|
|
22
|
+
# keyboard access is arrows/Enter from the input (cmdk parity); a
|
|
23
|
+
# tabbable anchor per row also made disabled items focusable.
|
|
24
|
+
tabindex: "-1",
|
|
21
25
|
aria: { disabled: (@disabled ? "true" : nil) },
|
|
22
26
|
data: {
|
|
23
27
|
phlex_kit__command_target: "item",
|
|
@@ -3,13 +3,17 @@ module PhlexKit
|
|
|
3
3
|
# listbox role and an id the controller points the input's aria-controls at
|
|
4
4
|
# (and derives result ids from, for aria-activedescendant). See command.rb.
|
|
5
5
|
class CommandList < BaseComponent
|
|
6
|
-
|
|
6
|
+
# id: is a named kwarg (not left in **attrs) because `mix` would *merge* a
|
|
7
|
+
# caller id with the generated one into an invalid two-token id, breaking
|
|
8
|
+
# aria-controls (the documented list_id: wiring) and derived result ids.
|
|
9
|
+
def initialize(id: nil, **attrs)
|
|
10
|
+
@id = id || "pk-command-list-#{SecureRandom.hex(4)}"
|
|
7
11
|
@attrs = attrs
|
|
8
12
|
end
|
|
9
13
|
|
|
10
14
|
def view_template(&)
|
|
11
15
|
div(**mix({
|
|
12
|
-
id:
|
|
16
|
+
id: @id,
|
|
13
17
|
class: "pk-command-list",
|
|
14
18
|
role: "listbox",
|
|
15
19
|
data: { phlex_kit__command_target: "list" }
|
|
@@ -23,12 +23,37 @@ export default class extends Controller {
|
|
|
23
23
|
this.close()
|
|
24
24
|
}
|
|
25
25
|
this.onKey = (e) => this.keydown(e)
|
|
26
|
+
// Turbo snapshots BEFORE disconnect — close now or the snapshot keeps a
|
|
27
|
+
// stale data-state="open" (the panel itself can't resurrect, but host
|
|
28
|
+
// CSS keyed on the state hook would misfire after a restore).
|
|
29
|
+
this.onBeforeCache = () => {
|
|
30
|
+
if (this.contentTarget.matches(":popover-open")) this.close()
|
|
31
|
+
}
|
|
32
|
+
document.addEventListener("turbo:before-cache", this.onBeforeCache)
|
|
33
|
+
// Outstanding syncSub rAF handles — cancelled on disconnect (menubar's
|
|
34
|
+
// pattern).
|
|
35
|
+
this.subFrames = new Set()
|
|
26
36
|
}
|
|
27
37
|
|
|
28
38
|
disconnect() {
|
|
29
39
|
document.removeEventListener("click", this.onDoc)
|
|
30
40
|
document.removeEventListener("contextmenu", this.onDoc)
|
|
31
41
|
document.removeEventListener("keydown", this.onKey)
|
|
42
|
+
document.removeEventListener("turbo:before-cache", this.onBeforeCache)
|
|
43
|
+
this.subFrames.forEach((id) => cancelAnimationFrame(id))
|
|
44
|
+
this.subFrames.clear()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Mirrors the CSS-driven submenu state (:hover / :focus-within) onto the
|
|
48
|
+
// sub trigger's aria-expanded — same wiring as dropdown_menu/menubar.
|
|
49
|
+
syncSub(e) {
|
|
50
|
+
const sub = e.currentTarget
|
|
51
|
+
const id = requestAnimationFrame(() => {
|
|
52
|
+
this.subFrames.delete(id)
|
|
53
|
+
sub.querySelector(":scope > [aria-haspopup]")
|
|
54
|
+
?.setAttribute("aria-expanded", sub.matches(":hover, :focus-within"))
|
|
55
|
+
})
|
|
56
|
+
this.subFrames.add(id)
|
|
32
57
|
}
|
|
33
58
|
|
|
34
59
|
open(e) {
|
|
@@ -5,7 +5,9 @@ module PhlexKit
|
|
|
5
5
|
class ContextMenuSub < BaseComponent
|
|
6
6
|
def initialize(**attrs) = (@attrs = attrs)
|
|
7
7
|
def view_template(&)
|
|
8
|
-
|
|
8
|
+
# syncSub mirrors the CSS-driven reveal state onto the sub trigger's
|
|
9
|
+
# aria-expanded (same wiring as DropdownMenuSub / MenubarSub).
|
|
10
|
+
div(**mix({ class: "pk-context-menu-sub", data: { action: "mouseenter->phlex-kit--context-menu#syncSub mouseleave->phlex-kit--context-menu#syncSub focusin->phlex-kit--context-menu#syncSub focusout->phlex-kit--context-menu#syncSub" } }, @attrs), &)
|
|
9
11
|
end
|
|
10
12
|
end
|
|
11
13
|
end
|