phlex_kit 0.8.1 → 0.9.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 +11 -0
- data/app/components/phlex_kit/accordion/accordion.css +2 -2
- data/app/components/phlex_kit/accordion/accordion_controller.js +8 -2
- data/app/components/phlex_kit/alert/alert.css +3 -3
- data/app/components/phlex_kit/alert_dialog/alert_dialog.css +3 -3
- data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +12 -8
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +46 -3
- data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +4 -2
- data/app/components/phlex_kit/attachment/attachment.css +1 -1
- data/app/components/phlex_kit/attachment/attachment_action.rb +8 -3
- data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -3
- data/app/components/phlex_kit/attachment/scroll_fade_controller.js +8 -0
- data/app/components/phlex_kit/avatar/avatar.css +16 -5
- data/app/components/phlex_kit/avatar/avatar_controller.js +10 -7
- data/app/components/phlex_kit/badge/badge.css +4 -4
- data/app/components/phlex_kit/breadcrumb/breadcrumb_ellipsis.rb +3 -1
- data/app/components/phlex_kit/bubble/bubble.css +3 -3
- data/app/components/phlex_kit/bubble/bubble.rb +2 -0
- data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
- data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -0
- data/app/components/phlex_kit/button/button.css +6 -6
- data/app/components/phlex_kit/button_group/button_group.css +2 -2
- data/app/components/phlex_kit/button_group/button_group_separator.rb +3 -1
- data/app/components/phlex_kit/calendar/calendar.css +2 -2
- data/app/components/phlex_kit/calendar/calendar.rb +10 -3
- data/app/components/phlex_kit/calendar/calendar_body.rb +3 -0
- data/app/components/phlex_kit/calendar/calendar_controller.js +91 -14
- data/app/components/phlex_kit/calendar/calendar_days.rb +17 -15
- data/app/components/phlex_kit/calendar/calendar_input_controller.js +6 -0
- data/app/components/phlex_kit/calendar/calendar_next.rb +5 -1
- data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -1
- data/app/components/phlex_kit/calendar/calendar_weekdays.rb +1 -1
- data/app/components/phlex_kit/carousel/carousel.rb +11 -1
- data/app/components/phlex_kit/carousel/carousel_controller.js +28 -1
- data/app/components/phlex_kit/chart/chart_controller.js +32 -9
- data/app/components/phlex_kit/checkbox/checkbox.css +2 -1
- data/app/components/phlex_kit/clipboard/clipboard.css +1 -1
- data/app/components/phlex_kit/clipboard/clipboard_controller.js +7 -0
- data/app/components/phlex_kit/codeblock/codeblock.rb +4 -1
- data/app/components/phlex_kit/collapsible/collapsible_content.rb +15 -2
- data/app/components/phlex_kit/collapsible/collapsible_controller.js +11 -2
- data/app/components/phlex_kit/collapsible/collapsible_trigger.rb +17 -2
- data/app/components/phlex_kit/combobox/combobox.css +10 -8
- data/app/components/phlex_kit/combobox/combobox_controller.js +52 -9
- data/app/components/phlex_kit/combobox/combobox_list_group.rb +13 -5
- data/app/components/phlex_kit/command/command.css +4 -4
- data/app/components/phlex_kit/command/command_controller.js +51 -10
- data/app/components/phlex_kit/command/command_dialog.rb +5 -6
- data/app/components/phlex_kit/command/command_dialog_controller.js +26 -8
- data/app/components/phlex_kit/command/command_input.rb +6 -2
- data/app/components/phlex_kit/command/command_item.rb +4 -1
- data/app/components/phlex_kit/context_menu/context_menu.css +6 -6
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +18 -1
- data/app/components/phlex_kit/data_table/data_table.css +1 -1
- data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +11 -12
- data/app/components/phlex_kit/data_table/data_table_column_visibility_controller.js +4 -1
- data/app/components/phlex_kit/data_table/data_table_pagy_adapter.rb +2 -1
- data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +19 -5
- data/app/components/phlex_kit/data_table/data_table_search_controller.js +13 -1
- data/app/components/phlex_kit/date_picker/date_picker.rb +50 -3
- data/app/components/phlex_kit/dialog/dialog.css +4 -4
- data/app/components/phlex_kit/dialog/dialog_controller.js +20 -4
- data/app/components/phlex_kit/drawer/drawer.css +3 -3
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +3 -3
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +5 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +33 -0
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_label.rb +3 -1
- data/app/components/phlex_kit/field/field.css +1 -1
- data/app/components/phlex_kit/field/field_error.rb +5 -6
- data/app/components/phlex_kit/form_field/form_field_controller.js +35 -16
- data/app/components/phlex_kit/form_field/form_field_error.rb +5 -1
- data/app/components/phlex_kit/hover_card/hover_card.css +1 -1
- data/app/components/phlex_kit/hover_card/hover_card_controller.js +29 -5
- data/app/components/phlex_kit/input/input.css +1 -1
- data/app/components/phlex_kit/input_group/input_group.css +9 -7
- data/app/components/phlex_kit/input_group/input_group_addon.rb +5 -1
- data/app/components/phlex_kit/input_otp/input_otp.css +1 -1
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +13 -0
- data/app/components/phlex_kit/input_otp/input_otp_separator.rb +4 -2
- data/app/components/phlex_kit/item/item.css +1 -1
- data/app/components/phlex_kit/item/item_group.rb +4 -2
- data/app/components/phlex_kit/item/item_separator.rb +3 -1
- data/app/components/phlex_kit/marker/marker.css +1 -1
- data/app/components/phlex_kit/masked_input/masked_input_controller.js +10 -4
- data/app/components/phlex_kit/menubar/menubar.css +10 -9
- data/app/components/phlex_kit/menubar/menubar_content.rb +8 -2
- data/app/components/phlex_kit/menubar/menubar_controller.js +70 -7
- data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_sub.rb +6 -1
- data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +4 -1
- data/app/components/phlex_kit/message_scroller/message_scroller.css +1 -1
- data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +46 -2
- data/app/components/phlex_kit/native_select/native_select.css +1 -1
- data/app/components/phlex_kit/navigation_menu/navigation_menu.css +7 -2
- data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +6 -1
- data/app/components/phlex_kit/pagination/pagination.css +2 -2
- data/app/components/phlex_kit/pagination/pagination_ellipsis.rb +3 -1
- data/app/components/phlex_kit/popover/popover.css +1 -1
- data/app/components/phlex_kit/resizable/resizable.css +3 -1
- data/app/components/phlex_kit/resizable/resizable_controller.js +13 -0
- data/app/components/phlex_kit/resizable/resizable_panel.rb +3 -1
- data/app/components/phlex_kit/scroll_area/scroll_area.rb +4 -2
- data/app/components/phlex_kit/select/select.css +3 -3
- data/app/components/phlex_kit/select/select.rb +4 -1
- data/app/components/phlex_kit/select/select_controller.js +25 -0
- data/app/components/phlex_kit/select/select_item.rb +5 -1
- data/app/components/phlex_kit/sheet/sheet.css +3 -3
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -12
- data/app/components/phlex_kit/sidebar/sidebar.css +8 -8
- data/app/components/phlex_kit/sidebar/sidebar_controller.js +98 -1
- data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +24 -5
- data/app/components/phlex_kit/sidebar/sidebar_rail.rb +13 -4
- data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +14 -4
- data/app/components/phlex_kit/slider/slider.css +1 -1
- data/app/components/phlex_kit/slider/slider.rb +3 -1
- data/app/components/phlex_kit/switch/switch.rb +6 -1
- data/app/components/phlex_kit/table/table.css +5 -3
- data/app/components/phlex_kit/tabs/tabs.css +4 -4
- data/app/components/phlex_kit/tabs/tabs_content.rb +6 -3
- data/app/components/phlex_kit/tabs/tabs_controller.js +3 -1
- data/app/components/phlex_kit/theme_toggle/theme_toggle.rb +2 -2
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +45 -9
- data/app/components/phlex_kit/toast/toast.css +32 -14
- data/app/components/phlex_kit/toast/toast_controller.js +24 -4
- data/app/components/phlex_kit/toast/toast_region.rb +13 -3
- data/app/components/phlex_kit/toast/toaster_controller.js +13 -3
- data/app/components/phlex_kit/toggle/toggle.css +4 -4
- data/app/components/phlex_kit/toggle/toggle.rb +6 -2
- data/app/components/phlex_kit/tooltip/tooltip.css +28 -6
- data/app/components/phlex_kit/typography/heading.rb +9 -3
- data/app/components/phlex_kit/typography/typography.css +4 -3
- data/lib/phlex_kit/version.rb +1 -1
- metadata +1 -1
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
.pk-combobox-trigger:disabled,
|
|
42
42
|
.pk-combobox-trigger[aria-disabled="true"] { pointer-events: none; opacity: .5; }
|
|
43
43
|
.pk-combobox-trigger-content { overflow: hidden; text-overflow: ellipsis; }
|
|
44
|
-
.pk-combobox-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-
|
|
44
|
+
.pk-combobox-trigger-icon { flex: none; width: 1rem; height: 1rem; margin-inline-start: .5rem; opacity: .5; }
|
|
45
45
|
|
|
46
46
|
/* Dropdown panel — anchored below the combobox, flips above at the viewport
|
|
47
47
|
bottom. Width: trigger width with a 15rem floor (was JS
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
border-radius: var(--pk-radius);
|
|
59
59
|
background: var(--pk-surface);
|
|
60
60
|
color: var(--pk-text);
|
|
61
|
-
box-shadow: 0 4px 16px
|
|
61
|
+
box-shadow: 0 4px 16px color-mix(in srgb, var(--pk-shadow-color) 40%, transparent);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/* Search row. */
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
border-bottom: 1px solid var(--pk-border);
|
|
70
70
|
color: var(--pk-muted);
|
|
71
71
|
}
|
|
72
|
-
.pk-combobox-search-icon { flex: none; width: 1rem; height: 1rem; margin-
|
|
72
|
+
.pk-combobox-search-icon { flex: none; width: 1rem; height: 1rem; margin-inline-end: .5rem; opacity: .5; }
|
|
73
73
|
.pk-combobox-search-input {
|
|
74
74
|
width: 100%;
|
|
75
75
|
height: 2rem;
|
|
@@ -95,8 +95,10 @@
|
|
|
95
95
|
color: var(--pk-text);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
/* Labelled group — the label renders from the `label` attribute
|
|
99
|
-
|
|
98
|
+
/* Labelled group — the label renders from the `data-label` attribute (the
|
|
99
|
+
invalid bare `label` attribute was dropped; aria-label carries it for AT),
|
|
100
|
+
and the group hides itself (pure CSS) when filtering leaves no visible item
|
|
101
|
+
inside. */
|
|
100
102
|
.pk-combobox-group { display: none; }
|
|
101
103
|
.pk-combobox-group:has(label:not(.pk-hidden)) {
|
|
102
104
|
display: flex;
|
|
@@ -106,7 +108,7 @@
|
|
|
106
108
|
border-bottom: 1px solid var(--pk-border);
|
|
107
109
|
}
|
|
108
110
|
.pk-combobox-group::before {
|
|
109
|
-
content: attr(label);
|
|
111
|
+
content: attr(data-label);
|
|
110
112
|
padding: .375rem .5rem;
|
|
111
113
|
font-size: .75rem;
|
|
112
114
|
font-weight: 500;
|
|
@@ -159,7 +161,7 @@
|
|
|
159
161
|
|
|
160
162
|
/* Trailing ✓, visible while the row's input is checked. */
|
|
161
163
|
.pk-combobox-item-indicator {
|
|
162
|
-
margin-
|
|
164
|
+
margin-inline-start: auto;
|
|
163
165
|
width: 1rem;
|
|
164
166
|
height: 1rem;
|
|
165
167
|
flex: none;
|
|
@@ -314,7 +316,7 @@
|
|
|
314
316
|
align-items: center;
|
|
315
317
|
justify-content: center;
|
|
316
318
|
flex: none;
|
|
317
|
-
margin-
|
|
319
|
+
margin-inline-start: auto;
|
|
318
320
|
border: 0;
|
|
319
321
|
border-radius: .125rem;
|
|
320
322
|
padding: 0;
|
|
@@ -37,23 +37,37 @@ export default class extends Controller {
|
|
|
37
37
|
|
|
38
38
|
selectedItemIndex = null
|
|
39
39
|
|
|
40
|
+
initialize() {
|
|
41
|
+
// Stimulus fires [target]Connected before connect(): anything
|
|
42
|
+
// itemTargetConnected uses must be initialized here.
|
|
43
|
+
this.itemIdCounter = 0
|
|
44
|
+
this.refocusing = false
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
connect() {
|
|
41
48
|
this.generateItemIds()
|
|
42
49
|
this.updateTriggerContent()
|
|
43
50
|
}
|
|
44
51
|
|
|
45
|
-
// ARIA plumbing:
|
|
46
|
-
//
|
|
47
|
-
// and aria-controls is wired to the listbox (mirrors select_controller.js).
|
|
52
|
+
// ARIA plumbing: aria-controls is wired to the listbox (mirrors
|
|
53
|
+
// select_controller.js); the per-option ids come from itemTargetConnected.
|
|
48
54
|
generateItemIds() {
|
|
55
|
+
this.ariaExpandedElements().forEach((el) => el.setAttribute("aria-controls", this.listId()))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
listId() {
|
|
49
59
|
const list = this.hasListTarget ? this.listTarget : (this.hasPopoverTarget ? this.popoverTarget : this.element)
|
|
50
60
|
if (!list.id) list.id = `pk-combobox-list-${Math.random().toString(36).slice(2, 10)}`
|
|
61
|
+
return list.id
|
|
62
|
+
}
|
|
51
63
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
// Every option gets an id derived from the listbox id so the combobox
|
|
65
|
+
// element can point aria-activedescendant at the keyboard highlight. A
|
|
66
|
+
// target callback (not a connect() loop) so options added after connect —
|
|
67
|
+
// e.g. rendered in by the host — get ids too. Counter-based, so a removed
|
|
68
|
+
// option's id is never reissued to a later arrival.
|
|
69
|
+
itemTargetConnected(item) {
|
|
70
|
+
if (!item.id) item.id = `${this.listId()}-${this.itemIdCounter++}`
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
// The elements carrying the open/closed combobox state: the trigger button
|
|
@@ -233,6 +247,14 @@ export default class extends Controller {
|
|
|
233
247
|
}
|
|
234
248
|
|
|
235
249
|
openPopover(event) {
|
|
250
|
+
// The remove-× on a chip and the clear-all button live inside the badge
|
|
251
|
+
// trigger, whose focusin (and click) actions open the popover — clicking
|
|
252
|
+
// them must not reopen it (stopPropagation covers click but focusin still
|
|
253
|
+
// fires when the button takes focus).
|
|
254
|
+
if (event?.target?.closest?.(".pk-combobox-badge-remove, .pk-combobox-clear-button")) return
|
|
255
|
+
// closePopover's focus restore fires the input/badge wrapper's
|
|
256
|
+
// focusin->openPopover action synchronously — don't bounce straight back open.
|
|
257
|
+
if (this.refocusing) return
|
|
236
258
|
if (event) event.preventDefault()
|
|
237
259
|
if (this.isOpen()) return
|
|
238
260
|
|
|
@@ -252,12 +274,31 @@ export default class extends Controller {
|
|
|
252
274
|
}
|
|
253
275
|
|
|
254
276
|
closePopover() {
|
|
277
|
+
// Hiding the popover while focus is inside it (search input, option) drops
|
|
278
|
+
// focus to <body>; hand it back to the trigger. Checked BEFORE hiding, and
|
|
279
|
+
// only when focus would actually be orphaned — a click elsewhere on the
|
|
280
|
+
// page (onClickOutside) must not have its focus stolen.
|
|
281
|
+
const focusWasInside = this.hasPopoverTarget && this.popoverTarget.contains(document.activeElement)
|
|
282
|
+
|
|
255
283
|
this.setExpanded(false)
|
|
256
284
|
// aria-activedescendant holds an element id; on close it must be removed,
|
|
257
285
|
// not left pointing at a hidden option.
|
|
258
286
|
this.clearActiveDescendant()
|
|
259
287
|
if (this.popoverTarget.matches(":popover-open")) this.popoverTarget.hidePopover()
|
|
260
288
|
this.updateTriggerContent() // reflect the selection into an input trigger
|
|
289
|
+
|
|
290
|
+
if (focusWasInside) {
|
|
291
|
+
const field = this.filterField()
|
|
292
|
+
// Input/badge layouts: back to the filter field (it lives outside the
|
|
293
|
+
// popover). Button layout: back to the trigger button (its search input
|
|
294
|
+
// is inside the popover, now hidden).
|
|
295
|
+
const anchor = (field && !this.popoverTarget.contains(field)) ? field : (this.hasTriggerTarget ? this.triggerTarget : null)
|
|
296
|
+
// focus() dispatches focusin synchronously; the flag stops the
|
|
297
|
+
// wrapper's focusin->openPopover action from reopening the panel.
|
|
298
|
+
this.refocusing = true
|
|
299
|
+
anchor?.focus()
|
|
300
|
+
this.refocusing = false
|
|
301
|
+
}
|
|
261
302
|
}
|
|
262
303
|
|
|
263
304
|
onClickOutside(event) {
|
|
@@ -277,7 +318,9 @@ export default class extends Controller {
|
|
|
277
318
|
}
|
|
278
319
|
|
|
279
320
|
filterItems(e) {
|
|
280
|
-
|
|
321
|
+
// Escape's keyup arrives after keydown.esc already closed the popover —
|
|
322
|
+
// re-running the filter then would wipe the highlight state for nothing.
|
|
323
|
+
if (["ArrowDown", "ArrowUp", "Tab", "Enter", "Escape"].includes(e.key)) {
|
|
281
324
|
return
|
|
282
325
|
}
|
|
283
326
|
|
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
|
-
# Labelled option group. Pass `label:`
|
|
3
|
-
#
|
|
4
|
-
#
|
|
2
|
+
# Labelled option group. Pass `label:` — it renders as aria-label (a bare
|
|
3
|
+
# `label` attribute is invalid on <div> and silent to AT) plus data-label,
|
|
4
|
+
# which CSS renders via ::before content(attr(data-label)). The group
|
|
5
|
+
# auto-hides (pure CSS :has) when filtering leaves it with no visible items.
|
|
6
|
+
# See combobox.rb.
|
|
5
7
|
class ComboboxListGroup < BaseComponent
|
|
6
|
-
def initialize(**attrs)
|
|
8
|
+
def initialize(label: nil, **attrs)
|
|
9
|
+
@label = label
|
|
7
10
|
@attrs = attrs
|
|
8
11
|
end
|
|
9
12
|
|
|
10
13
|
def view_template(&)
|
|
11
|
-
div(**mix({
|
|
14
|
+
div(**mix({
|
|
15
|
+
class: "pk-combobox-group",
|
|
16
|
+
role: "group",
|
|
17
|
+
aria: { label: @label },
|
|
18
|
+
data: { label: @label }
|
|
19
|
+
}, @attrs), &)
|
|
12
20
|
end
|
|
13
21
|
end
|
|
14
22
|
end
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
inset: 0;
|
|
20
20
|
z-index: 50;
|
|
21
21
|
pointer-events: auto;
|
|
22
|
-
background:
|
|
22
|
+
background: color-mix(in srgb, var(--pk-overlay) 10%, transparent);
|
|
23
23
|
backdrop-filter: blur(4px);
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
border-radius: .75rem;
|
|
38
38
|
background: var(--pk-surface);
|
|
39
39
|
color: var(--pk-text);
|
|
40
|
-
box-shadow: 0 10px 30px
|
|
40
|
+
box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
|
|
41
41
|
}
|
|
42
42
|
.pk-command-dialog.xs { max-width: 24rem; }
|
|
43
43
|
.pk-command-dialog.sm { max-width: 28rem; }
|
|
@@ -121,7 +121,7 @@
|
|
|
121
121
|
.pk-command-item[data-disabled] { pointer-events: none; opacity: .5; }
|
|
122
122
|
|
|
123
123
|
.pk-command-shortcut {
|
|
124
|
-
margin-
|
|
124
|
+
margin-inline-start: auto;
|
|
125
125
|
font-size: .75rem;
|
|
126
126
|
letter-spacing: .1em;
|
|
127
127
|
color: var(--pk-muted);
|
|
@@ -136,5 +136,5 @@
|
|
|
136
136
|
font-size: .875rem;
|
|
137
137
|
}
|
|
138
138
|
@supports not (backdrop-filter: blur(1px)) {
|
|
139
|
-
.pk-command-overlay { background:
|
|
139
|
+
.pk-command-overlay { background: color-mix(in srgb, var(--pk-overlay) 50%, transparent); }
|
|
140
140
|
}
|
|
@@ -47,11 +47,19 @@ export default class extends Controller {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
this.generateItemIds();
|
|
50
|
-
|
|
50
|
+
// Only the cloned dialog overlay grabs focus on connect — an inline
|
|
51
|
+
// palette connecting at page load must not steal it.
|
|
52
|
+
if (this.isDialogClone()) this.inputTarget.focus();
|
|
51
53
|
this.searchIndex = this.buildSearchIndex();
|
|
52
54
|
this.toggleVisibility(this.emptyTargets, false);
|
|
53
55
|
}
|
|
54
56
|
|
|
57
|
+
// The command-dialog controller clones its <template> content into <body>;
|
|
58
|
+
// the clone's wrapper carries this marker (CommandDialogContent renders it).
|
|
59
|
+
isDialogClone() {
|
|
60
|
+
return this.element.hasAttribute("data-phlex-kit--command-dialog-instance");
|
|
61
|
+
}
|
|
62
|
+
|
|
55
63
|
// ARIA plumbing: every result gets an id derived from the listbox id so the
|
|
56
64
|
// input can point aria-activedescendant at the keyboard highlight, and
|
|
57
65
|
// aria-controls is wired to the listbox (mirrors select_controller.js).
|
|
@@ -67,15 +75,37 @@ export default class extends Controller {
|
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
dismiss() {
|
|
70
|
-
//
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
+
// Cloned dialog overlay: tear the clone down and hand focus back.
|
|
79
|
+
if (this.isDialogClone()) {
|
|
80
|
+
// allow scroll on body
|
|
81
|
+
document.body.style.removeProperty("overflow");
|
|
82
|
+
const previous = this.previouslyFocused;
|
|
83
|
+
// remove the element
|
|
84
|
+
this.element.remove();
|
|
85
|
+
// restore focus to wherever it was before the palette opened
|
|
86
|
+
if (previous instanceof HTMLElement && document.contains(previous)) {
|
|
87
|
+
previous.focus();
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
78
90
|
}
|
|
91
|
+
|
|
92
|
+
// Inline palette: removing the element would delete it permanently.
|
|
93
|
+
// Reset the query instead and let the host react via the dispatched
|
|
94
|
+
// phlex-kit--command:dismiss event.
|
|
95
|
+
if (this.hasInputTarget && this.inputTarget.value !== "") {
|
|
96
|
+
this.inputTarget.value = "";
|
|
97
|
+
this.deselectAll();
|
|
98
|
+
this.resetVisibility();
|
|
99
|
+
this.announceResultCount(null);
|
|
100
|
+
}
|
|
101
|
+
this.dispatch("dismiss");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Guards the items' default href="#": without it an Enter-synthesized
|
|
105
|
+
// click() or a mouse click scrolls to top and appends # to the URL
|
|
106
|
+
// (mirrors dropdown_menu_controller's close() guard).
|
|
107
|
+
onItemClick(event) {
|
|
108
|
+
if (event?.target?.closest?.('a[href="#"]')) event.preventDefault();
|
|
79
109
|
}
|
|
80
110
|
|
|
81
111
|
// Focus trap for the cloned dialog overlay: Tab / Shift+Tab cycle among the
|
|
@@ -173,16 +203,25 @@ export default class extends Controller {
|
|
|
173
203
|
|
|
174
204
|
// Upstream builds a Fuse index here; this keeps the same search() shape —
|
|
175
205
|
// [{ item }] sorted best-match-first — using the fuzzy scorer above.
|
|
206
|
+
// Both data-value and data-text are searchable (an item's `text:` was
|
|
207
|
+
// rendered but never indexed); the better of the two scores wins.
|
|
176
208
|
buildSearchIndex() {
|
|
177
209
|
const items = this.itemTargets.map((el) => ({
|
|
178
210
|
value: (el.dataset.value || "").toLowerCase(),
|
|
211
|
+
text: (el.dataset.text || "").toLowerCase(),
|
|
179
212
|
element: el,
|
|
180
213
|
}));
|
|
181
214
|
return {
|
|
182
215
|
search(query) {
|
|
183
216
|
const q = query.toLowerCase();
|
|
184
217
|
return items
|
|
185
|
-
.map((item) =>
|
|
218
|
+
.map((item) => {
|
|
219
|
+
const scores = [item.value, item.text]
|
|
220
|
+
.filter(Boolean)
|
|
221
|
+
.map((field) => fuzzyScore(q, field))
|
|
222
|
+
.filter((score) => score !== null);
|
|
223
|
+
return { item, score: scores.length ? Math.max(...scores) : null };
|
|
224
|
+
})
|
|
186
225
|
.filter((result) => result.score !== null)
|
|
187
226
|
.sort((a, b) => b.score - a.score);
|
|
188
227
|
},
|
|
@@ -225,6 +264,8 @@ export default class extends Controller {
|
|
|
225
264
|
}
|
|
226
265
|
|
|
227
266
|
this.toggleAriaSelected(visibleItems[this.selectedIndex], true);
|
|
267
|
+
// Keep the highlight visible inside the max-height scrollable list.
|
|
268
|
+
visibleItems[this.selectedIndex].scrollIntoView({ block: "nearest" });
|
|
228
269
|
|
|
229
270
|
// Focus stays in the input; the highlighted option is exposed via
|
|
230
271
|
// aria-activedescendant (result ids come from generateItemIds).
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# Wrapper wiring a CommandDialogTrigger to its CommandDialogContent. The
|
|
3
|
-
#
|
|
4
|
-
#
|
|
3
|
+
# controller tracks its own live clone (sheet pattern) so a second trigger
|
|
4
|
+
# press just refocuses the input — per instance, unlike the old
|
|
5
|
+
# document-scoped outlet selector, which let one dialog adopt another's
|
|
6
|
+
# open palette. See command.rb.
|
|
5
7
|
class CommandDialog < BaseComponent
|
|
6
8
|
def initialize(**attrs)
|
|
7
9
|
@attrs = attrs
|
|
@@ -9,10 +11,7 @@ module PhlexKit
|
|
|
9
11
|
|
|
10
12
|
def view_template(&)
|
|
11
13
|
div(**mix({
|
|
12
|
-
data: {
|
|
13
|
-
controller: "phlex-kit--command-dialog",
|
|
14
|
-
phlex_kit__command_dialog_phlex_kit__command_outlet: "[data-phlex-kit--command-dialog-instance]"
|
|
15
|
-
}
|
|
14
|
+
data: { controller: "phlex-kit--command-dialog" }
|
|
16
15
|
}, @attrs), &)
|
|
17
16
|
end
|
|
18
17
|
end
|
|
@@ -2,18 +2,33 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
2
2
|
|
|
3
3
|
// Ported from ruby_ui's command-dialog controller (Stimulus-only upstream),
|
|
4
4
|
// identifiers renamed ruby-ui → phlex-kit and the Tailwind body scroll-lock
|
|
5
|
-
// class swapped for inline overflow (matching the shipped dialog).
|
|
5
|
+
// class swapped for inline overflow (matching the shipped dialog). Unlike
|
|
6
|
+
// upstream's document-scoped outlet (which let one dialog adopt another's
|
|
7
|
+
// open palette), each instance tracks its own clone — sheet_controller's
|
|
8
|
+
// pattern — and clears it on turbo:before-cache so a cached page never
|
|
9
|
+
// restores a stale overlay + scroll lock.
|
|
6
10
|
// Connects to data-controller="phlex-kit--command-dialog"
|
|
7
11
|
export default class extends Controller {
|
|
8
12
|
static targets = ["content"];
|
|
9
|
-
static outlets = ["phlex-kit--command"];
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
this.
|
|
14
|
+
initialize() {
|
|
15
|
+
this.overlay = null;
|
|
16
|
+
this.clearOverlay = () => {
|
|
17
|
+
if (this.overlay?.isConnected) {
|
|
18
|
+
this.overlay.remove();
|
|
19
|
+
document.body.style.removeProperty("overflow");
|
|
20
|
+
}
|
|
21
|
+
this.overlay = null;
|
|
22
|
+
};
|
|
13
23
|
}
|
|
14
24
|
|
|
15
|
-
|
|
16
|
-
this.
|
|
25
|
+
connect() {
|
|
26
|
+
document.addEventListener("turbo:before-cache", this.clearOverlay);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
disconnect() {
|
|
30
|
+
document.removeEventListener("turbo:before-cache", this.clearOverlay);
|
|
31
|
+
this.clearOverlay();
|
|
17
32
|
}
|
|
18
33
|
|
|
19
34
|
open(e) {
|
|
@@ -25,12 +40,15 @@ export default class extends Controller {
|
|
|
25
40
|
return;
|
|
26
41
|
}
|
|
27
42
|
|
|
28
|
-
|
|
29
|
-
|
|
43
|
+
// Already open (the command controller's dismiss may have removed the
|
|
44
|
+
// clone behind our back — isConnected catches that): just refocus.
|
|
45
|
+
if (this.overlay?.isConnected) {
|
|
46
|
+
this.overlay.querySelector("[data-phlex-kit--command-target='input']")?.focus();
|
|
30
47
|
return;
|
|
31
48
|
}
|
|
32
49
|
|
|
33
50
|
document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
|
|
51
|
+
this.overlay = document.body.lastElementChild;
|
|
34
52
|
// prevent scroll on body
|
|
35
53
|
document.body.style.overflow = "hidden";
|
|
36
54
|
}
|
|
@@ -8,9 +8,13 @@ module PhlexKit
|
|
|
8
8
|
# stays "true" because the list is always visible (filtering hides items,
|
|
9
9
|
# never the listbox itself).
|
|
10
10
|
class CommandInput < BaseComponent
|
|
11
|
-
|
|
11
|
+
# `autofocus:` defaults off so an inline palette doesn't steal page focus
|
|
12
|
+
# on load; the dialog clone needs no autofocus either — the command
|
|
13
|
+
# controller's connect() focuses the input when the clone is inserted.
|
|
14
|
+
def initialize(placeholder: "Type a command or search...", list_id: nil, autofocus: false, **attrs)
|
|
12
15
|
@placeholder = placeholder
|
|
13
16
|
@list_id = list_id
|
|
17
|
+
@autofocus = autofocus
|
|
14
18
|
@attrs = attrs
|
|
15
19
|
end
|
|
16
20
|
|
|
@@ -25,7 +29,7 @@ module PhlexKit
|
|
|
25
29
|
autocomplete: "off",
|
|
26
30
|
autocorrect: "off",
|
|
27
31
|
spellcheck: "false",
|
|
28
|
-
autofocus:
|
|
32
|
+
autofocus: @autofocus,
|
|
29
33
|
role: "combobox",
|
|
30
34
|
value: "",
|
|
31
35
|
aria: { autocomplete: "list", expanded: "true", controls: @list_id },
|
|
@@ -23,7 +23,10 @@ module PhlexKit
|
|
|
23
23
|
phlex_kit__command_target: "item",
|
|
24
24
|
value: @value,
|
|
25
25
|
text: @text,
|
|
26
|
-
disabled: (@disabled ? "true" : nil)
|
|
26
|
+
disabled: (@disabled ? "true" : nil),
|
|
27
|
+
# Guards the default href="#": without it a mouse click (or the
|
|
28
|
+
# Enter-synthesized click()) scrolls to top and appends # to the URL.
|
|
29
|
+
action: "click->phlex-kit--command#onItemClick"
|
|
27
30
|
}
|
|
28
31
|
}, @attrs), &)
|
|
29
32
|
end
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
.pk-context-menu-content {
|
|
10
10
|
position: fixed; inset: auto; margin: 0; min-width: 8rem; overflow: visible;
|
|
11
11
|
border: 1px solid var(--pk-border); border-radius: calc(var(--pk-radius) - 2px); background: var(--pk-surface);
|
|
12
|
-
color: var(--pk-text); padding: .25rem; box-shadow: 0 8px 24px
|
|
12
|
+
color: var(--pk-text); padding: .25rem; box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
|
|
13
13
|
}
|
|
14
14
|
.pk-context-menu-item {
|
|
15
15
|
position: relative; display: flex; align-items: center; gap: .5rem;
|
|
@@ -20,15 +20,15 @@
|
|
|
20
20
|
.pk-context-menu-item > svg { flex: none; width: 1rem; height: 1rem; color: var(--pk-muted); }
|
|
21
21
|
.pk-context-menu-item:hover, .pk-context-menu-item:focus { background: var(--pk-accent); color: var(--pk-text); }
|
|
22
22
|
.pk-context-menu-item[data-disabled="true"] { pointer-events: none; opacity: .5; }
|
|
23
|
-
.pk-context-menu-item.inset { padding-
|
|
23
|
+
.pk-context-menu-item.inset { padding-inline-start: 2rem; }
|
|
24
24
|
.pk-context-menu-check {
|
|
25
25
|
display: inline-flex; align-items: center; justify-content: center;
|
|
26
26
|
flex: none; width: 1rem; height: 1rem;
|
|
27
27
|
}
|
|
28
28
|
.pk-context-menu-check svg { width: .875rem; height: .875rem; }
|
|
29
|
-
.pk-context-menu-shortcut { margin-
|
|
29
|
+
.pk-context-menu-shortcut { margin-inline-start: auto; font-size: .75rem; letter-spacing: .1em; color: var(--pk-muted); }
|
|
30
30
|
.pk-context-menu-label { padding: .375rem .5rem; font-size: .875rem; font-weight: 600; color: var(--pk-text); }
|
|
31
|
-
.pk-context-menu-label.inset { padding-
|
|
31
|
+
.pk-context-menu-label.inset { padding-inline-start: 2rem; }
|
|
32
32
|
.pk-context-menu-separator { height: 1px; margin: .25rem -.25rem; background: var(--pk-border); }
|
|
33
33
|
.pk-context-menu-group { display: contents; }
|
|
34
34
|
|
|
@@ -71,7 +71,7 @@
|
|
|
71
71
|
The row is its own scoped anchor; the panel flips to the other side at
|
|
72
72
|
the viewport right edge. */
|
|
73
73
|
.pk-context-menu-sub { anchor-name: --pk-context-menu-sub; anchor-scope: --pk-context-menu-sub; }
|
|
74
|
-
.pk-context-menu-sub-chevron { margin-
|
|
74
|
+
.pk-context-menu-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
|
|
75
75
|
.pk-context-menu-sub-content {
|
|
76
76
|
position: fixed;
|
|
77
77
|
position-anchor: --pk-context-menu-sub;
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
background: var(--pk-surface);
|
|
93
93
|
color: var(--pk-text);
|
|
94
94
|
padding: .25rem;
|
|
95
|
-
box-shadow: 0 8px 24px
|
|
95
|
+
box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent);
|
|
96
96
|
}
|
|
97
97
|
.pk-context-menu-sub:hover > .pk-context-menu-sub-content,
|
|
98
98
|
.pk-context-menu-sub:focus-within > .pk-context-menu-sub-content { display: block; }
|
|
@@ -12,12 +12,22 @@ export default class extends Controller {
|
|
|
12
12
|
static targets = ["content", "trigger", "menuItem"]
|
|
13
13
|
|
|
14
14
|
connect() {
|
|
15
|
-
this.onDoc = (e) => {
|
|
15
|
+
this.onDoc = (e) => {
|
|
16
|
+
if (this.contentTarget.contains(e.target)) return
|
|
17
|
+
// The opening right-click bubbles to document AFTER open() adds this
|
|
18
|
+
// listener — a contextmenu inside our own element must not close what
|
|
19
|
+
// it just opened (it repositions instead). A contextmenu anywhere
|
|
20
|
+
// else closes us, which also covers opening a second kit context
|
|
21
|
+
// menu: its trigger's contextmenu is "outside" for this instance.
|
|
22
|
+
if (e.type === "contextmenu" && this.element.contains(e.target)) return
|
|
23
|
+
this.close()
|
|
24
|
+
}
|
|
16
25
|
this.onKey = (e) => this.keydown(e)
|
|
17
26
|
}
|
|
18
27
|
|
|
19
28
|
disconnect() {
|
|
20
29
|
document.removeEventListener("click", this.onDoc)
|
|
30
|
+
document.removeEventListener("contextmenu", this.onDoc)
|
|
21
31
|
document.removeEventListener("keydown", this.onKey)
|
|
22
32
|
}
|
|
23
33
|
|
|
@@ -38,14 +48,21 @@ export default class extends Controller {
|
|
|
38
48
|
c.style.top = `${y}px`
|
|
39
49
|
c.dataset.state = "open"
|
|
40
50
|
document.addEventListener("click", this.onDoc)
|
|
51
|
+
document.addEventListener("contextmenu", this.onDoc)
|
|
41
52
|
document.addEventListener("keydown", this.onKey)
|
|
42
53
|
this.items()[0]?.focus()
|
|
43
54
|
}
|
|
44
55
|
|
|
45
56
|
close(opts = {}) {
|
|
57
|
+
// Menu rows default to href="#"; without this an Enter/click on a
|
|
58
|
+
// non-link row scrolls to top and appends # to the URL. (`opts` is the
|
|
59
|
+
// click event when close runs as the item's data-action; Enter/Space
|
|
60
|
+
// arrive here too, via keydown()'s synthesized click.)
|
|
61
|
+
if (opts?.target?.closest?.('a[href="#"]')) opts.preventDefault()
|
|
46
62
|
if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
|
|
47
63
|
this.contentTarget.dataset.state = "closed"
|
|
48
64
|
document.removeEventListener("click", this.onDoc)
|
|
65
|
+
document.removeEventListener("contextmenu", this.onDoc)
|
|
49
66
|
document.removeEventListener("keydown", this.onKey)
|
|
50
67
|
if (opts.refocus === true) this.anchor().focus()
|
|
51
68
|
}
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
/* "Columns" dropdown rows. */
|
|
24
24
|
.pk-data-table-column-toggle { position: relative; }
|
|
25
|
-
.pk-data-table-column-toggle-icon { width: 1rem; height: 1rem; margin-
|
|
25
|
+
.pk-data-table-column-toggle-icon { width: 1rem; height: 1rem; margin-inline-start: .25rem; }
|
|
26
26
|
.pk-data-table-column-option {
|
|
27
27
|
display: flex;
|
|
28
28
|
align-items: center;
|
|
@@ -22,18 +22,17 @@ module PhlexKit
|
|
|
22
22
|
end
|
|
23
23
|
render DropdownMenuContent.new do
|
|
24
24
|
@columns.each do |col|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
end
|
|
25
|
+
# A real menu row (role="menuitemcheckbox" + aria-checked, arrow-
|
|
26
|
+
# navigable) — plain <label>s directly inside role="menu" are
|
|
27
|
+
# invalid ARIA. The change event bubbles from the hidden checkbox
|
|
28
|
+
# to this row, where our toggle action picks it up.
|
|
29
|
+
render DropdownMenuCheckboxItem.new(
|
|
30
|
+
checked: true,
|
|
31
|
+
data: {
|
|
32
|
+
column_key: col[:key].to_s,
|
|
33
|
+
action: "change->phlex-kit--data-table-column-visibility#toggle"
|
|
34
|
+
}
|
|
35
|
+
) { plain col[:label] }
|
|
37
36
|
end
|
|
38
37
|
end
|
|
39
38
|
end
|
|
@@ -7,8 +7,11 @@ import { Controller } from "@hotwired/stimulus";
|
|
|
7
7
|
// Connects to data-controller="phlex-kit--data-table-column-visibility"
|
|
8
8
|
export default class extends Controller {
|
|
9
9
|
toggle(event) {
|
|
10
|
-
|
|
10
|
+
// The change event originates on the checkbox, but data-column-key lives
|
|
11
|
+
// on the menu row (DropdownMenuCheckboxItem) — look up through ancestors.
|
|
12
|
+
const key = event.target.closest("[data-column-key]")?.dataset.columnKey;
|
|
11
13
|
const visible = event.target.checked;
|
|
14
|
+
if (!key) return;
|
|
12
15
|
const root = this.element.closest('[data-controller~="phlex-kit--data-table"]');
|
|
13
16
|
if (!root) return;
|
|
14
17
|
root
|