phlex_kit 0.11.0 → 0.12.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/alert_dialog/alert_dialog.css +3 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_content.rb +13 -2
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +35 -7
- data/app/components/phlex_kit/attachment/attachment.css +15 -0
- data/app/components/phlex_kit/attachment/attachment_trigger.rb +3 -0
- data/app/components/phlex_kit/breadcrumb/breadcrumb.css +1 -0
- data/app/components/phlex_kit/calendar/calendar_controller.js +23 -6
- data/app/components/phlex_kit/carousel/carousel_controller.js +36 -2
- data/app/components/phlex_kit/chart/chart.rb +6 -4
- data/app/components/phlex_kit/chart/chart_controller.js +37 -8
- data/app/components/phlex_kit/codeblock/codeblock.rb +5 -1
- data/app/components/phlex_kit/combobox/combobox.css +1 -1
- data/app/components/phlex_kit/combobox/combobox_controller.js +57 -2
- data/app/components/phlex_kit/command/command_controller.js +17 -1
- data/app/components/phlex_kit/command/command_input.rb +3 -2
- data/app/components/phlex_kit/context_menu/context_menu.css +3 -0
- data/app/components/phlex_kit/context_menu/context_menu.rb +9 -1
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +49 -12
- data/app/components/phlex_kit/data_table/data_table.css +1 -1
- data/app/components/phlex_kit/data_table/data_table_pagination.rb +4 -1
- data/app/components/phlex_kit/data_table/data_table_per_page_select.rb +8 -3
- data/app/components/phlex_kit/data_table/data_table_search.rb +7 -2
- data/app/components/phlex_kit/date_picker/date_picker.rb +13 -2
- data/app/components/phlex_kit/date_picker/date_picker_controller.js +28 -0
- data/app/components/phlex_kit/dialog/dialog_controller.js +3 -0
- data/app/components/phlex_kit/drawer/drawer.rb +1 -1
- data/app/components/phlex_kit/drawer/drawer_content.rb +10 -3
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.css +4 -0
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +54 -13
- data/app/components/phlex_kit/form_field/form_field_controller.js +32 -24
- data/app/components/phlex_kit/hover_card/hover_card.css +6 -0
- data/app/components/phlex_kit/hover_card/hover_card_controller.js +24 -0
- data/app/components/phlex_kit/input/input.css +0 -1
- data/app/components/phlex_kit/input_otp/input_otp.rb +6 -3
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +24 -2
- data/app/components/phlex_kit/item/item.css +2 -1
- data/app/components/phlex_kit/kbd/kbd.css +2 -1
- data/app/components/phlex_kit/label/label.css +1 -1
- data/app/components/phlex_kit/link/link.rb +1 -0
- data/app/components/phlex_kit/marker/marker.css +2 -1
- data/app/components/phlex_kit/menubar/menubar.css +3 -0
- data/app/components/phlex_kit/menubar/menubar_controller.js +40 -16
- data/app/components/phlex_kit/native_select/native_select.css +14 -0
- data/app/components/phlex_kit/navigation_menu/navigation_menu_link.rb +8 -1
- data/app/components/phlex_kit/popover/popover_controller.js +19 -0
- data/app/components/phlex_kit/scroll_area/scroll_area.rb +1 -14
- data/app/components/phlex_kit/select/select_controller.js +22 -0
- data/app/components/phlex_kit/select/select_trigger.rb +4 -1
- data/app/components/phlex_kit/sheet/sheet_content.rb +13 -3
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +36 -0
- data/app/components/phlex_kit/sheet/sheet_controller.js +17 -2
- data/app/components/phlex_kit/spinner/spinner.rb +6 -3
- data/app/components/phlex_kit/switch/switch.rb +6 -0
- data/app/components/phlex_kit/tabs/tabs_controller.js +8 -2
- data/app/components/phlex_kit/toast/toast_action.rb +1 -1
- data/app/components/phlex_kit/toast/toast_close.rb +6 -3
- data/app/components/phlex_kit/toast/toast_controller.js +32 -16
- data/app/components/phlex_kit/toast/toast_item.rb +11 -7
- data/app/components/phlex_kit/toast/toast_region.rb +12 -3
- data/app/components/phlex_kit/toast/toaster_controller.js +2 -0
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +16 -1
- data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +11 -8
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +15 -5
- data/app/javascript/phlex_kit/controllers/index.js +2 -0
- data/lib/phlex_kit/base_component.rb +13 -3
- data/lib/phlex_kit/version.rb +1 -1
- data/lib/phlex_kit.rb +1 -0
- metadata +2 -1
|
@@ -5,7 +5,15 @@ module PhlexKit
|
|
|
5
5
|
class ContextMenu < BaseComponent
|
|
6
6
|
def initialize(**attrs) = (@attrs = attrs)
|
|
7
7
|
def view_template(&)
|
|
8
|
-
div(**mix({
|
|
8
|
+
div(**mix({
|
|
9
|
+
class: "pk-context-menu",
|
|
10
|
+
data: {
|
|
11
|
+
controller: "phlex-kit--context-menu",
|
|
12
|
+
# focusout: tabbing (or otherwise moving real focus) out of the
|
|
13
|
+
# menu closes the open [popover=manual] panel (menubar's pattern).
|
|
14
|
+
action: "focusout->phlex-kit--context-menu#onFocusout"
|
|
15
|
+
}
|
|
16
|
+
}, @attrs), &)
|
|
9
17
|
end
|
|
10
18
|
end
|
|
11
19
|
end
|
|
@@ -33,6 +33,17 @@ export default class extends Controller {
|
|
|
33
33
|
// Outstanding syncSub rAF handles — cancelled on disconnect (menubar's
|
|
34
34
|
// pattern).
|
|
35
35
|
this.subFrames = new Set()
|
|
36
|
+
// A Turbo snapshot (or a detach/reattach that mimics one) serializes
|
|
37
|
+
// aria-expanded="true" on sub triggers even though no submenu can be
|
|
38
|
+
// open at connect time — sweep every stale marker back to false
|
|
39
|
+
// (menubar's pattern).
|
|
40
|
+
this.element.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"))
|
|
41
|
+
// Same shape for checkbox/radio rows: their `checked` DOM property
|
|
42
|
+
// survives a reconnect but a hand-edited/replayed aria-checked can drift
|
|
43
|
+
// from it — resync every row from its live input.
|
|
44
|
+
this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
|
|
45
|
+
i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked)
|
|
46
|
+
})
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
disconnect() {
|
|
@@ -56,6 +67,18 @@ export default class extends Controller {
|
|
|
56
67
|
this.subFrames.add(id)
|
|
57
68
|
}
|
|
58
69
|
|
|
70
|
+
// Tabbing (or otherwise moving real focus) out of the menu closes it —
|
|
71
|
+
// menubar's onFocusout pattern (menubar_controller.js). relatedTarget is
|
|
72
|
+
// null when the window itself blurs — leave the menu alone then. The
|
|
73
|
+
// trigger + content are both DOM children of this.element even though the
|
|
74
|
+
// content is a top-layer popover, so one containment check covers both.
|
|
75
|
+
onFocusout(e) {
|
|
76
|
+
if (!this.contentTarget.matches(":popover-open")) return
|
|
77
|
+
const to = e.relatedTarget
|
|
78
|
+
if (!to || this.element.contains(to)) return
|
|
79
|
+
this.close()
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
open(e) {
|
|
60
83
|
e.preventDefault()
|
|
61
84
|
let x = e.clientX
|
|
@@ -93,6 +116,13 @@ export default class extends Controller {
|
|
|
93
116
|
}
|
|
94
117
|
|
|
95
118
|
keydown(e) {
|
|
119
|
+
// This listener stays attached document-wide while the menu is open (see
|
|
120
|
+
// open()/close()) so it keeps receiving events even once real focus has
|
|
121
|
+
// left the menu — scope the roving-nav keys to when focus is actually
|
|
122
|
+
// within the widget (this.element, not just contentTarget, so a focused
|
|
123
|
+
// trigger still counts). Escape stays global while the menu is open (APG).
|
|
124
|
+
if (e.key !== "Escape" && !this.element.contains(document.activeElement)) return
|
|
125
|
+
|
|
96
126
|
const items = this.items()
|
|
97
127
|
const index = items.indexOf(document.activeElement)
|
|
98
128
|
switch (e.key) {
|
|
@@ -126,19 +156,26 @@ export default class extends Controller {
|
|
|
126
156
|
}
|
|
127
157
|
break
|
|
128
158
|
case "ArrowRight":
|
|
129
|
-
// On a sub trigger, enter the submenu (focus reveals it via
|
|
130
|
-
// :focus-within, making its rows visible to the roving nav).
|
|
131
|
-
if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
|
|
132
|
-
e.preventDefault()
|
|
133
|
-
this.enterSub(document.activeElement)
|
|
134
|
-
}
|
|
135
|
-
break
|
|
136
159
|
case "ArrowLeft": {
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
160
|
+
// Submenus open inline-end — visually LEFT in RTL — so the enter key
|
|
161
|
+
// follows visual direction: ArrowLeft enters / ArrowRight exits in RTL
|
|
162
|
+
// (and the reverse in LTR). Runtime dir check is reliable after a flip.
|
|
163
|
+
const rtl = getComputedStyle(this.element).direction === "rtl"
|
|
164
|
+
const enterKey = rtl ? "ArrowLeft" : "ArrowRight"
|
|
165
|
+
if (e.key === enterKey) {
|
|
166
|
+
// On a sub trigger, enter the submenu (focus reveals it via
|
|
167
|
+
// :focus-within, making its rows visible to the roving nav).
|
|
168
|
+
if (document.activeElement?.matches(".pk-context-menu-sub-trigger")) {
|
|
169
|
+
e.preventDefault()
|
|
170
|
+
this.enterSub(document.activeElement)
|
|
171
|
+
}
|
|
172
|
+
} else {
|
|
173
|
+
// Inside a submenu, step back to its trigger (closes it on focus-out).
|
|
174
|
+
const sub = document.activeElement?.closest(".pk-context-menu-sub-content")
|
|
175
|
+
if (sub) {
|
|
176
|
+
e.preventDefault()
|
|
177
|
+
sub.closest(".pk-context-menu-sub")?.querySelector(".pk-context-menu-sub-trigger")?.focus()
|
|
178
|
+
}
|
|
142
179
|
}
|
|
143
180
|
break
|
|
144
181
|
}
|
|
@@ -70,7 +70,10 @@ module PhlexKit
|
|
|
70
70
|
def edge_item(disabled:, href:, label:, name:)
|
|
71
71
|
if disabled
|
|
72
72
|
li do
|
|
73
|
-
|
|
73
|
+
# role-less span + aria-label is invalid ARIA (aria-label on a
|
|
74
|
+
# role-less element is not perceivable) — role + aria-disabled
|
|
75
|
+
# mirrors BreadcrumbPage's disabled-control pattern.
|
|
76
|
+
span(class: "pk-data-table-page-disabled", role: "link", aria: { disabled: "true", label: name }) { label }
|
|
74
77
|
end
|
|
75
78
|
else
|
|
76
79
|
render PaginationItem.new(href: href, aria: { label: name }) { label }
|
|
@@ -4,21 +4,26 @@ module PhlexKit
|
|
|
4
4
|
# (search/sort/direction/...) survive via `preserved_params:` hidden inputs,
|
|
5
5
|
# mirroring DataTableSearch. See data_table.rb.
|
|
6
6
|
class DataTablePerPageSelect < BaseComponent
|
|
7
|
-
|
|
7
|
+
# method:/action: are kit-owned (a GET form targeting `path:`) — named
|
|
8
|
+
# kwargs so a caller override lands in @method/@action directly instead
|
|
9
|
+
# of fusing with the generated defaults via `mix` ("get post").
|
|
10
|
+
def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [ 5, 10, 25, 50 ], preserved_params: {}, method: "get", action: nil, **attrs)
|
|
8
11
|
@path = path
|
|
9
12
|
@name = name
|
|
10
13
|
@value = value
|
|
11
14
|
@frame_id = frame_id
|
|
12
15
|
@options = options
|
|
13
16
|
@preserved_params = preserved_params
|
|
17
|
+
@method = method
|
|
18
|
+
@action = action || @path
|
|
14
19
|
@attrs = attrs
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
def view_template
|
|
18
23
|
form_attrs = {
|
|
19
24
|
class: "pk-data-table-per-page",
|
|
20
|
-
action: @
|
|
21
|
-
method:
|
|
25
|
+
action: @action,
|
|
26
|
+
method: @method,
|
|
22
27
|
# Reuse the search controller for its CSP-safe #submitNow action.
|
|
23
28
|
data: { controller: "phlex-kit--data-table-search" }
|
|
24
29
|
}
|
|
@@ -3,7 +3,10 @@ module PhlexKit
|
|
|
3
3
|
# params survive via `preserved_params:` hidden inputs; focus/caret survive the
|
|
4
4
|
# frame swap (see the search controller). See data_table.rb.
|
|
5
5
|
class DataTableSearch < BaseComponent
|
|
6
|
-
|
|
6
|
+
# method:/action: are kit-owned (a GET form targeting `path:`) — named
|
|
7
|
+
# kwargs so a caller override lands in @method/@action directly instead
|
|
8
|
+
# of fusing with the generated defaults via `mix` ("get post").
|
|
9
|
+
def initialize(path:, name: "search", value: nil, frame_id: nil, placeholder: "Search...", debounce: 300, preserved_params: {}, method: "get", action: nil, **attrs)
|
|
7
10
|
@path = path
|
|
8
11
|
@name = name
|
|
9
12
|
@value = value
|
|
@@ -11,11 +14,13 @@ module PhlexKit
|
|
|
11
14
|
@placeholder = placeholder
|
|
12
15
|
@debounce = debounce
|
|
13
16
|
@preserved_params = preserved_params
|
|
17
|
+
@method = method
|
|
18
|
+
@action = action || @path
|
|
14
19
|
@attrs = attrs
|
|
15
20
|
end
|
|
16
21
|
|
|
17
22
|
def view_template
|
|
18
|
-
form(**mix({ class: "pk-data-table-search", method:
|
|
23
|
+
form(**mix({ class: "pk-data-table-search", method: @method, action: @action, data: form_data }, @attrs)) do
|
|
19
24
|
render Input.new(
|
|
20
25
|
type: :search,
|
|
21
26
|
name: @name,
|
|
@@ -5,7 +5,12 @@ module PhlexKit
|
|
|
5
5
|
# controller and the calendar's outlet points at its id, so picking a day
|
|
6
6
|
# writes the formatted date into the field. Upstream's popover_options
|
|
7
7
|
# (hover trigger via @floating-ui) are gone — the kit popover is click-only,
|
|
8
|
-
# CSS-positioned.
|
|
8
|
+
# CSS-positioned. The phlex-kit--date-picker controller on the root div
|
|
9
|
+
# listens for the calendar's change event and closes the popover once a
|
|
10
|
+
# selection is COMPLETE (single: any pick; range: the second end only;
|
|
11
|
+
# multiple: never), returning focus to the input — see
|
|
12
|
+
# date_picker_controller.js and popover_controller.js#close.
|
|
13
|
+
# Tailwind → vanilla `.pk-date-picker*` (date_picker.css).
|
|
9
14
|
class DatePicker < BaseComponent
|
|
10
15
|
def initialize(
|
|
11
16
|
id: nil,
|
|
@@ -45,7 +50,13 @@ module PhlexKit
|
|
|
45
50
|
end
|
|
46
51
|
|
|
47
52
|
def view_template
|
|
48
|
-
div(**mix({
|
|
53
|
+
div(**mix({
|
|
54
|
+
class: "pk-date-picker",
|
|
55
|
+
data: {
|
|
56
|
+
controller: "phlex-kit--date-picker",
|
|
57
|
+
action: "phlex-kit--calendar:change->phlex-kit--date-picker#onCalendarChange"
|
|
58
|
+
}
|
|
59
|
+
}, @attrs)) do
|
|
49
60
|
render Popover.new do
|
|
50
61
|
render PopoverTrigger.new(**trigger_attrs) do
|
|
51
62
|
div(class: "pk-date-picker-field") do
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Bridges the nested Calendar's change event to the wrapping Popover's
|
|
4
|
+
// close(), so picking a date collapses the panel instead of leaving the
|
|
5
|
+
// user to click outside/Escape after every pick (shadcn's DatePicker
|
|
6
|
+
// behavior). Only closes on a COMPLETE selection: single mode closes on
|
|
7
|
+
// any pick, range mode only once the second end (rangeEnd) is set — closing
|
|
8
|
+
// on the first end would strand the user before they can pick the second —
|
|
9
|
+
// and multiple mode never auto-closes since picking more than one date is
|
|
10
|
+
// the entire point. See date_picker.rb for the data-action wiring and
|
|
11
|
+
// popover_controller.js for close()/focusTrigger().
|
|
12
|
+
export default class extends Controller {
|
|
13
|
+
onCalendarChange(e) {
|
|
14
|
+
const { mode, rangeEnd } = e.detail
|
|
15
|
+
if (mode === "multiple") return
|
|
16
|
+
// calendar_controller.js clears rangeEnd via `this.rangeEndValue = null`,
|
|
17
|
+
// which goes through Element#setAttribute — the DOM stringifies a null
|
|
18
|
+
// argument to the literal "null", so a genuinely-unset rangeEnd reads
|
|
19
|
+
// back as that string, not JS null/undefined/"".
|
|
20
|
+
if (mode === "range" && (!rangeEnd || rangeEnd === "null")) return
|
|
21
|
+
this.popoverController()?.close()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
popoverController() {
|
|
25
|
+
const popoverEl = this.element.querySelector(".pk-popover")
|
|
26
|
+
return popoverEl && this.application.getControllerForElementAndIdentifier(popoverEl, "phlex-kit--popover")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -37,6 +37,9 @@ export default class extends Controller {
|
|
|
37
37
|
}
|
|
38
38
|
open(e) {
|
|
39
39
|
e?.preventDefault()
|
|
40
|
+
// open: is one-shot — the reflected value sits in the Turbo snapshot, so
|
|
41
|
+
// a cache-restored reconnect would re-open a dismissed dialog.
|
|
42
|
+
this.openValue = false
|
|
40
43
|
this.dialogTarget.showModal()
|
|
41
44
|
// Save/restore rather than removeProperty: a host page (or another
|
|
42
45
|
// component) may have set body overflow itself.
|
|
@@ -12,7 +12,7 @@ module PhlexKit
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def view_template(&)
|
|
15
|
-
div(**mix({ data: { controller: "phlex-kit--sheet", phlex_kit__sheet_open_value: @open } }, @attrs), &)
|
|
15
|
+
div(**mix({ data: { controller: "phlex-kit--sheet", phlex_kit__sheet_open_value: @open.to_s } }, @attrs), &)
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
end
|
|
@@ -12,9 +12,16 @@ module PhlexKit
|
|
|
12
12
|
|
|
13
13
|
def view_template(&block)
|
|
14
14
|
template(data: { phlex_kit__sheet_target: "content" }) do
|
|
15
|
-
div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown" }) do
|
|
16
|
-
div(class: "pk-drawer-overlay", data: { action: "click->phlex-kit--sheet-content#close" })
|
|
17
|
-
|
|
15
|
+
div(data: { controller: "phlex-kit--sheet-content", action: "keydown->phlex-kit--sheet-content#keydown", pk_overlay_clone: "" }) do
|
|
16
|
+
div(class: "pk-drawer-overlay", data: { action: "click->phlex-kit--sheet-content#close mousedown->phlex-kit--sheet-content#overlayMousedown" })
|
|
17
|
+
panel_attrs = { class: "pk-drawer #{fetch_option(SIDES, @side, :side)}", data: { phlex_kit__sheet_content_target: "panel" } }
|
|
18
|
+
# Defaults only when the caller didn't supply their own — `mix`
|
|
19
|
+
# would fuse role="dialog region" / aria-modal="true false" /
|
|
20
|
+
# tabindex="-1 0" instead of overriding.
|
|
21
|
+
panel_attrs[:role] = "dialog" unless @attrs.key?(:role) || @attrs.key?("role")
|
|
22
|
+
panel_attrs[:aria] = { modal: "true" } unless aria_key_set?(:modal)
|
|
23
|
+
panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
|
|
24
|
+
div(**mix(panel_attrs, @attrs)) do
|
|
18
25
|
div(class: "pk-drawer-handle", aria: { hidden: "true" })
|
|
19
26
|
yield if block
|
|
20
27
|
end
|
|
@@ -116,6 +116,10 @@
|
|
|
116
116
|
the viewport right edge. */
|
|
117
117
|
.pk-dropdown-menu-sub { anchor-name: --pk-dropdown-menu-sub; anchor-scope: --pk-dropdown-menu-sub; }
|
|
118
118
|
.pk-dropdown-menu-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
|
|
119
|
+
/* The chevron points toward where the submenu opens (inline-end). In RTL that
|
|
120
|
+
is visually LEFT — mirror it. Ancestor [dir="rtl"] (not :dir()) so Chrome
|
|
121
|
+
re-styles it on a dynamic dir flip too. */
|
|
122
|
+
[dir="rtl"] .pk-dropdown-menu-sub-chevron { transform: scaleX(-1); }
|
|
119
123
|
.pk-dropdown-menu-sub-content {
|
|
120
124
|
position: fixed;
|
|
121
125
|
position-anchor: --pk-dropdown-menu-sub;
|
|
@@ -18,7 +18,9 @@ module PhlexKit
|
|
|
18
18
|
data: {
|
|
19
19
|
controller: "phlex-kit--dropdown-menu",
|
|
20
20
|
phlex_kit__dropdown_menu_open_value: @open,
|
|
21
|
-
|
|
21
|
+
# focusout: tabbing (or otherwise moving real focus) out of the
|
|
22
|
+
# menu closes the open [popover=manual] panel (menubar's pattern).
|
|
23
|
+
action: "click@window->phlex-kit--dropdown-menu#onClickOutside focusout->phlex-kit--dropdown-menu#onFocusout"
|
|
22
24
|
}
|
|
23
25
|
}, @attrs), &block)
|
|
24
26
|
end
|
|
@@ -22,6 +22,18 @@ export default class extends Controller {
|
|
|
22
22
|
// The visible control is the caller's button/link inside the trigger
|
|
23
23
|
// wrapper — that's what AT reads, so the popup wiring belongs on it.
|
|
24
24
|
this.invoker = this.triggerTarget.querySelector("button, a, [tabindex]") || this.triggerTarget;
|
|
25
|
+
// A Turbo snapshot (or a detach/reattach that mimics one) serializes
|
|
26
|
+
// aria-expanded="true" on sub triggers even though no submenu can be
|
|
27
|
+
// open at connect time — sweep every stale marker back to false
|
|
28
|
+
// (menubar's pattern). Runs before the invoker's own reset below so
|
|
29
|
+
// both are covered by one mental model.
|
|
30
|
+
this.element.querySelectorAll("[aria-expanded='true']").forEach((el) => el.setAttribute("aria-expanded", "false"));
|
|
31
|
+
// Same shape for checkbox/radio rows: their `checked` DOM property
|
|
32
|
+
// survives a reconnect but a hand-edited/replayed aria-checked can drift
|
|
33
|
+
// from it — resync every row from its live input.
|
|
34
|
+
this.element.querySelectorAll('input[type="checkbox"], input[type="radio"]').forEach((i) => {
|
|
35
|
+
i.closest('[role^="menuitem"]')?.setAttribute("aria-checked", i.checked);
|
|
36
|
+
});
|
|
25
37
|
this.invoker.setAttribute("aria-haspopup", "menu");
|
|
26
38
|
this.invoker.setAttribute("aria-expanded", "false");
|
|
27
39
|
// Associate trigger and menu for AT (the dialog controller wires its
|
|
@@ -74,6 +86,18 @@ export default class extends Controller {
|
|
|
74
86
|
this.contentTarget.matches(":popover-open") ? this.close() : this.#open();
|
|
75
87
|
}
|
|
76
88
|
|
|
89
|
+
// Tabbing (or otherwise moving real focus) out of the menu closes it —
|
|
90
|
+
// menubar's onFocusout pattern (menubar_controller.js). relatedTarget is
|
|
91
|
+
// null when the window itself blurs — leave the menu alone then. The
|
|
92
|
+
// trigger + content are both DOM children of this.element even though the
|
|
93
|
+
// content is a top-layer popover, so one containment check covers both.
|
|
94
|
+
onFocusout(e) {
|
|
95
|
+
if (!this.contentTarget.matches(":popover-open")) return;
|
|
96
|
+
const to = e.relatedTarget;
|
|
97
|
+
if (!to || this.element.contains(to)) return;
|
|
98
|
+
this.close();
|
|
99
|
+
}
|
|
100
|
+
|
|
77
101
|
#open() {
|
|
78
102
|
this.openValue = true;
|
|
79
103
|
this.#addEventListeners();
|
|
@@ -102,6 +126,16 @@ export default class extends Controller {
|
|
|
102
126
|
}
|
|
103
127
|
|
|
104
128
|
#handleKeydown(e) {
|
|
129
|
+
// This listener is document-level (see #addEventListeners) so it keeps
|
|
130
|
+
// receiving events even once real focus has left the menu (e.g. a click
|
|
131
|
+
// into an unrelated text field the onFocusout listener didn't catch) —
|
|
132
|
+
// scope the roving-nav keys to when focus is actually within the widget.
|
|
133
|
+
// this.element (not just contentTarget) so ArrowDown still works right
|
|
134
|
+
// after a mouse-driven open, when focus is still on the invoker button
|
|
135
|
+
// (#open() doesn't force focus into the first row). Escape stays global
|
|
136
|
+
// while the menu is open (APG).
|
|
137
|
+
if (e.key !== "Escape" && !this.element.contains(document.activeElement)) return;
|
|
138
|
+
|
|
105
139
|
const items = this.#items();
|
|
106
140
|
if (items.length === 0) return;
|
|
107
141
|
const index = items.indexOf(document.activeElement);
|
|
@@ -138,20 +172,27 @@ export default class extends Controller {
|
|
|
138
172
|
this.invoker.focus();
|
|
139
173
|
break;
|
|
140
174
|
case "ArrowRight":
|
|
141
|
-
// On a sub trigger, enter the submenu (focus reveals it via
|
|
142
|
-
// :focus-within, making its rows visible to the roving nav).
|
|
143
|
-
// Same keyboard grammar as context_menu.
|
|
144
|
-
if (document.activeElement?.matches(".pk-dropdown-menu-sub-trigger")) {
|
|
145
|
-
e.preventDefault();
|
|
146
|
-
this.#enterSub(document.activeElement);
|
|
147
|
-
}
|
|
148
|
-
break;
|
|
149
175
|
case "ArrowLeft": {
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
176
|
+
// Submenus open inline-end — visually LEFT in RTL — so the enter key
|
|
177
|
+
// follows visual direction: ArrowLeft enters / ArrowRight exits in RTL
|
|
178
|
+
// (and the reverse in LTR). Same keyboard grammar as context_menu.
|
|
179
|
+
// Runtime dir check is reliable after a dynamic flip.
|
|
180
|
+
const rtl = getComputedStyle(this.element).direction === "rtl";
|
|
181
|
+
const enterKey = rtl ? "ArrowLeft" : "ArrowRight";
|
|
182
|
+
if (e.key === enterKey) {
|
|
183
|
+
// On a sub trigger, enter the submenu (focus reveals it via
|
|
184
|
+
// :focus-within, making its rows visible to the roving nav).
|
|
185
|
+
if (document.activeElement?.matches(".pk-dropdown-menu-sub-trigger")) {
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
this.#enterSub(document.activeElement);
|
|
188
|
+
}
|
|
189
|
+
} else {
|
|
190
|
+
// Inside a submenu, step back to its trigger (closes it on focus-out).
|
|
191
|
+
const sub = document.activeElement?.closest(".pk-dropdown-menu-sub-content");
|
|
192
|
+
if (sub) {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
sub.closest(".pk-dropdown-menu-sub")?.querySelector(".pk-dropdown-menu-sub-trigger")?.focus();
|
|
195
|
+
}
|
|
155
196
|
}
|
|
156
197
|
break;
|
|
157
198
|
}
|
|
@@ -59,42 +59,50 @@ export default class extends Controller {
|
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
this.shouldValidateValue = true;
|
|
62
|
-
this.#setErrorMessage(
|
|
62
|
+
this.#setErrorMessage();
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
onInput(
|
|
66
|
-
this.#setErrorMessage(
|
|
65
|
+
onInput(_event) {
|
|
66
|
+
this.#setErrorMessage();
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
onChange(
|
|
70
|
-
this.#setErrorMessage(
|
|
69
|
+
onChange(_event) {
|
|
70
|
+
this.#setErrorMessage();
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
#setErrorMessage(event) {
|
|
73
|
+
// A FormField can carry several input targets (radio/checkbox groups, or
|
|
74
|
+
// independent controls like two required checkboxes sharing one error).
|
|
75
|
+
// Recompute across ALL of them on every validity event — deciding from the
|
|
76
|
+
// event's own target alone left a shared error stuck on/off based on
|
|
77
|
+
// whichever control happened to fire last, out of step with the others'
|
|
78
|
+
// own (correctly-updated) aria-invalid.
|
|
79
|
+
#setErrorMessage() {
|
|
82
80
|
if (!this.shouldValidateValue || !this.hasErrorTarget) return;
|
|
83
81
|
|
|
84
|
-
const input = this.#inputFor(event);
|
|
85
82
|
// aria-invalid drives the red ring for LIVE validation too (input.css
|
|
86
|
-
// only matched server-rendered attrs);
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
83
|
+
// only matched server-rendered attrs); each control reflects its own
|
|
84
|
+
// validity regardless of which one fired the event. Radio buttons
|
|
85
|
+
// sharing a `name` share native validity, so they naturally agree here.
|
|
86
|
+
let firstInvalid = null;
|
|
87
|
+
this.inputTargets.forEach((input) => {
|
|
88
|
+
if (input.validity.valid) {
|
|
89
|
+
input.removeAttribute("aria-invalid");
|
|
90
|
+
} else {
|
|
91
|
+
input.setAttribute("aria-invalid", "true");
|
|
92
|
+
firstInvalid ||= input;
|
|
93
|
+
}
|
|
94
|
+
// Clear the shared error's id everywhere first — it's re-added below,
|
|
95
|
+
// only on the one control whose message is currently shown.
|
|
90
96
|
this.#describeBy(input, false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (firstInvalid) {
|
|
100
|
+
this.#describeBy(firstInvalid, true);
|
|
101
|
+
this.errorTarget.textContent = this.#getValidationMessage(firstInvalid);
|
|
102
|
+
this.errorTarget.classList.remove("pk-hidden");
|
|
103
|
+
} else {
|
|
91
104
|
this.errorTarget.textContent = "";
|
|
92
105
|
this.errorTarget.classList.add("pk-hidden");
|
|
93
|
-
} else {
|
|
94
|
-
input.setAttribute("aria-invalid", "true");
|
|
95
|
-
this.#describeBy(input, true);
|
|
96
|
-
this.errorTarget.textContent = this.#getValidationMessage(input);
|
|
97
|
-
this.errorTarget.classList.remove("pk-hidden");
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
108
|
|
|
@@ -15,12 +15,18 @@
|
|
|
15
15
|
position-area: block-end span-inline-end;
|
|
16
16
|
margin: .25rem 0 0;
|
|
17
17
|
position-try-fallbacks: flip-block;
|
|
18
|
+
flex-direction: column; gap: .125rem;
|
|
18
19
|
width: 16rem;
|
|
19
20
|
border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
|
|
20
21
|
border-radius: var(--pk-radius); background: var(--pk-surface);
|
|
21
22
|
color: var(--pk-text); padding: .625rem; font-size: .875rem;
|
|
22
23
|
box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
|
|
23
24
|
}
|
|
25
|
+
/* display must be gated on :popover-open — an unconditional author
|
|
26
|
+
`display: flex` would beat the UA's [popover] { display: none } (bit the
|
|
27
|
+
Basic docs example's inline style, which relied on this instead of the
|
|
28
|
+
component doing it). */
|
|
29
|
+
.pk-hover-card-content:popover-open { display: flex; }
|
|
24
30
|
.pk-hover-card-content.top { position-area: block-start span-inline-end; margin: 0 0 .25rem; }
|
|
25
31
|
/* Gap margins are logical to match the logical position-area (and tooltip's
|
|
26
32
|
pattern) — physical margins landed on the wrong side in RTL. */
|
|
@@ -10,6 +10,29 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
10
10
|
export default class extends Controller {
|
|
11
11
|
static targets = ["trigger", "content"]
|
|
12
12
|
static values = { openDelay: { type: Number, default: 200 }, closeDelay: { type: Number, default: 200 } }
|
|
13
|
+
|
|
14
|
+
connect() {
|
|
15
|
+
// Turbo snapshots BEFORE disconnect, so a stale data-state="open" would
|
|
16
|
+
// otherwise resurrect host CSS keyed on the state hook after a cache
|
|
17
|
+
// restore — close now (popover_controller.js's contentTargetConnected /
|
|
18
|
+
// context_menu_controller.js's turbo:before-cache pattern combined).
|
|
19
|
+
this.onBeforeCache = () => {
|
|
20
|
+
if (this.contentTarget.matches(":popover-open")) {
|
|
21
|
+
clearTimeout(this.t)
|
|
22
|
+
this.#close()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
document.addEventListener("turbo:before-cache", this.onBeforeCache)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
contentTargetConnected(el) {
|
|
29
|
+
// A reconnect (Turbo cache restore, or the detach/reattach it's tested
|
|
30
|
+
// with) can hand us markup whose data-state was serialized while open —
|
|
31
|
+
// :popover-open does not survive the restore, so resync before anything
|
|
32
|
+
// reads the hook.
|
|
33
|
+
el.dataset.state = el.matches(":popover-open") ? "open" : "closed"
|
|
34
|
+
}
|
|
35
|
+
|
|
13
36
|
show() {
|
|
14
37
|
clearTimeout(this.t)
|
|
15
38
|
this.t = setTimeout(() => {
|
|
@@ -25,6 +48,7 @@ export default class extends Controller {
|
|
|
25
48
|
disconnect() {
|
|
26
49
|
clearTimeout(this.t)
|
|
27
50
|
this.#unbindEscape()
|
|
51
|
+
document.removeEventListener("turbo:before-cache", this.onBeforeCache)
|
|
28
52
|
}
|
|
29
53
|
|
|
30
54
|
#close() {
|
|
@@ -15,15 +15,18 @@ module PhlexKit
|
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def view_template(&block)
|
|
18
|
-
|
|
18
|
+
base = {
|
|
19
19
|
class: "pk-input-otp",
|
|
20
20
|
role: "group",
|
|
21
|
-
aria: { label: @label },
|
|
22
21
|
data: {
|
|
23
22
|
controller: "phlex-kit--input-otp",
|
|
24
23
|
phlex_kit__input_otp_length_value: @length
|
|
25
24
|
}
|
|
26
|
-
}
|
|
25
|
+
}
|
|
26
|
+
# Default label only when the caller didn't supply their own — `mix`
|
|
27
|
+
# would fuse aria-label into a two-token value instead of overriding.
|
|
28
|
+
base[:aria] = { label: @label } unless aria_labelled?
|
|
29
|
+
div(**mix(base, @attrs)) do
|
|
27
30
|
input(type: :hidden, name: @name, value: @value, data: { phlex_kit__input_otp_target: "value" }) if @name
|
|
28
31
|
yield if block
|
|
29
32
|
end
|
|
@@ -30,20 +30,32 @@ export default class extends Controller {
|
|
|
30
30
|
const chars = this.valueTarget.value.split("")
|
|
31
31
|
this.slotTargets.forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
|
|
32
32
|
}
|
|
33
|
+
// Track each slot's last-known-good value so a rejected keystroke (see
|
|
34
|
+
// onInput) can restore it instead of leaving the slot blanked.
|
|
35
|
+
this.slotTargets.forEach((slot) => { slot.dataset.otpValue = slot.value })
|
|
33
36
|
this.syncValue()
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
onInput(e) {
|
|
37
40
|
const slot = e.target
|
|
41
|
+
const isNumeric = slot.getAttribute("inputmode") === "numeric"
|
|
42
|
+
const priorValue = slot.dataset.otpValue || ""
|
|
38
43
|
let text = slot.value.replace(/\s/g, "")
|
|
39
44
|
// Typing rejects the same characters paste strips — a letter keyed into
|
|
40
45
|
// an inputmode=numeric slot must not land.
|
|
41
|
-
if (
|
|
46
|
+
if (isNumeric) text = text.replace(/\D/g, "")
|
|
42
47
|
|
|
43
48
|
if (text.length > 1) {
|
|
44
49
|
if (e.data && e.data.length === 1) {
|
|
50
|
+
if (isNumeric && !/\d/.test(e.data)) {
|
|
51
|
+
// Rejected keystroke into an already-filled/selected slot: restore
|
|
52
|
+
// the prior digit rather than blanking it, and don't advance focus.
|
|
53
|
+
slot.value = priorValue
|
|
54
|
+
return
|
|
55
|
+
}
|
|
45
56
|
// one keystroke into an already-filled slot: keep the newest char
|
|
46
57
|
slot.value = e.data
|
|
58
|
+
slot.dataset.otpValue = e.data
|
|
47
59
|
this.focusSlot(this.slotTargets.indexOf(slot) + 1)
|
|
48
60
|
this.syncValue()
|
|
49
61
|
} else {
|
|
@@ -55,7 +67,16 @@ export default class extends Controller {
|
|
|
55
67
|
return
|
|
56
68
|
}
|
|
57
69
|
|
|
70
|
+
if (!text && e.data) {
|
|
71
|
+
// A keystroke was fully rejected by numeric filtering (e.g. a letter
|
|
72
|
+
// typed into a filled/selected slot) — restore the slot's prior value
|
|
73
|
+
// instead of blanking it, and don't advance focus.
|
|
74
|
+
slot.value = priorValue
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
slot.value = text
|
|
79
|
+
slot.dataset.otpValue = text
|
|
59
80
|
if (slot.value) this.focusSlot(this.slotTargets.indexOf(slot) + 1)
|
|
60
81
|
this.syncValue()
|
|
61
82
|
}
|
|
@@ -66,6 +87,7 @@ export default class extends Controller {
|
|
|
66
87
|
e.preventDefault()
|
|
67
88
|
const prev = this.slotTargets[index - 1]
|
|
68
89
|
prev.value = ""
|
|
90
|
+
prev.dataset.otpValue = ""
|
|
69
91
|
prev.focus()
|
|
70
92
|
this.syncValue()
|
|
71
93
|
} else if (e.key === "ArrowLeft" && index > 0) {
|
|
@@ -92,7 +114,7 @@ export default class extends Controller {
|
|
|
92
114
|
fillFrom(slot, text) {
|
|
93
115
|
const chars = text.split("")
|
|
94
116
|
const start = this.slotTargets.indexOf(slot)
|
|
95
|
-
this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) s.value = chars[i] })
|
|
117
|
+
this.slotTargets.slice(start).forEach((s, i) => { if (chars[i] != null) { s.value = chars[i]; s.dataset.otpValue = chars[i] } })
|
|
96
118
|
this.focusSlot(Math.min(start + chars.length, this.slotTargets.length - 1))
|
|
97
119
|
this.syncValue()
|
|
98
120
|
}
|