phlex_kit 0.8.1 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/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
|
@@ -1,28 +1,42 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# Per-page NativeSelect in a GET form that self-submits on change (targeting
|
|
3
|
-
# the table's turbo-frame when `frame_id:` is given).
|
|
3
|
+
# the table's turbo-frame when `frame_id:` is given). Extra filter params
|
|
4
|
+
# (search/sort/direction/...) survive via `preserved_params:` hidden inputs,
|
|
5
|
+
# mirroring DataTableSearch. See data_table.rb.
|
|
4
6
|
class DataTablePerPageSelect < BaseComponent
|
|
5
|
-
def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [ 5, 10, 25, 50 ], **attrs)
|
|
7
|
+
def initialize(path:, name: "per_page", value: nil, frame_id: nil, options: [ 5, 10, 25, 50 ], preserved_params: {}, **attrs)
|
|
6
8
|
@path = path
|
|
7
9
|
@name = name
|
|
8
10
|
@value = value
|
|
9
11
|
@frame_id = frame_id
|
|
10
12
|
@options = options
|
|
13
|
+
@preserved_params = preserved_params
|
|
11
14
|
@attrs = attrs
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
def view_template
|
|
15
|
-
form_attrs = {
|
|
16
|
-
|
|
18
|
+
form_attrs = {
|
|
19
|
+
class: "pk-data-table-per-page",
|
|
20
|
+
action: @path,
|
|
21
|
+
method: "get",
|
|
22
|
+
# Reuse the search controller for its CSP-safe #submitNow action.
|
|
23
|
+
data: { controller: "phlex-kit--data-table-search" }
|
|
24
|
+
}
|
|
25
|
+
form_attrs[:data][:turbo_frame] = @frame_id if @frame_id
|
|
17
26
|
|
|
18
27
|
form(**mix(form_attrs, @attrs)) do
|
|
19
|
-
render NativeSelect.new(name: @name,
|
|
28
|
+
render NativeSelect.new(name: @name, data: { action: "change->phlex-kit--data-table-search#submitNow" }) do
|
|
20
29
|
@options.each do |opt|
|
|
21
30
|
option_attrs = { value: opt.to_s }
|
|
22
31
|
option_attrs[:selected] = true if opt.to_s == @value.to_s
|
|
23
32
|
option(**option_attrs) { plain opt.to_s }
|
|
24
33
|
end
|
|
25
34
|
end
|
|
35
|
+
@preserved_params.each do |k, v|
|
|
36
|
+
next if v.nil? || (v.respond_to?(:empty?) && v.empty?)
|
|
37
|
+
next if k.to_s == @name
|
|
38
|
+
input(type: :hidden, name: k.to_s, value: v.to_s)
|
|
39
|
+
end
|
|
26
40
|
end
|
|
27
41
|
end
|
|
28
42
|
end
|
|
@@ -32,6 +32,13 @@ export default class extends Controller {
|
|
|
32
32
|
this.timer = setTimeout(() => this.element.requestSubmit(), this.delayValue);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// Immediate, undebounced submit — used by change-driven controls like the
|
|
36
|
+
// per-page select (CSP-safe replacement for an inline onchange handler).
|
|
37
|
+
submitNow() {
|
|
38
|
+
clearTimeout(this.timer);
|
|
39
|
+
this.element.requestSubmit();
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
captureBeforeRender() {
|
|
36
43
|
const input = this.input();
|
|
37
44
|
if (!input || document.activeElement !== input) return;
|
|
@@ -62,6 +69,11 @@ export default class extends Controller {
|
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
key() {
|
|
65
|
-
|
|
72
|
+
// The key must survive the Turbo Frame swap (the form element is replaced),
|
|
73
|
+
// so it's derived from stable markup: the form id when the caller set one,
|
|
74
|
+
// else action URL + search-input name — two same-path forms (e.g. search
|
|
75
|
+
// alongside the per-page select) must not share a focus-restore slot.
|
|
76
|
+
if (this.element.id) return `#${this.element.id}`;
|
|
77
|
+
return `${this.element.action || "_"}::${this.input()?.name ?? ""}`;
|
|
66
78
|
}
|
|
67
79
|
}
|
|
@@ -24,10 +24,13 @@ module PhlexKit
|
|
|
24
24
|
@id = id || "date-picker-#{SecureRandom.hex(4)}"
|
|
25
25
|
@name = name
|
|
26
26
|
@label = label
|
|
27
|
-
@value = value || selected_date&.to_s
|
|
28
|
-
@placeholder = placeholder
|
|
29
27
|
@selected_date = selected_date
|
|
30
28
|
@date_format = date_format
|
|
29
|
+
# Seed the input with the same date_format the calendar controller
|
|
30
|
+
# writes — a bare `selected_date.to_s` (ISO) would mismatch the format
|
|
31
|
+
# until the first interaction.
|
|
32
|
+
@value = value || format_selected_date
|
|
33
|
+
@placeholder = placeholder
|
|
31
34
|
@input_attrs = input_attrs
|
|
32
35
|
@calendar_attrs = calendar_attrs
|
|
33
36
|
@trigger_attrs = trigger_attrs
|
|
@@ -45,7 +48,7 @@ module PhlexKit
|
|
|
45
48
|
end
|
|
46
49
|
end
|
|
47
50
|
render PopoverContent.new(**@content_attrs) do
|
|
48
|
-
render Calendar.new(input_id:
|
|
51
|
+
render Calendar.new(input_id: input_selector, selected_date: @selected_date, date_format: @date_format, **@calendar_attrs)
|
|
49
52
|
end
|
|
50
53
|
end
|
|
51
54
|
end
|
|
@@ -53,6 +56,50 @@ module PhlexKit
|
|
|
53
56
|
|
|
54
57
|
private
|
|
55
58
|
|
|
59
|
+
# The outlet takes a CSS selector. "#id" is only valid for CSS-identifier
|
|
60
|
+
# ids — anything else (Rails-style "user:due.date" etc) must go through a
|
|
61
|
+
# quoted attribute selector or querySelector throws.
|
|
62
|
+
def input_selector
|
|
63
|
+
return "##{@id}" if @id.match?(/\A[A-Za-z][\w-]*\z/)
|
|
64
|
+
|
|
65
|
+
%([id="#{@id.gsub(/[\\"]/) { |c| "\\#{c}" }}"])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Mirrors the calendar controller's formatDate tokens (calendar_controller.js)
|
|
69
|
+
# so the server-rendered input matches what JS writes after a pick.
|
|
70
|
+
FORMAT_TOKENS = /yyyy|MMMM|MM|dd|HH|mm|ss|EEEE|PPPP|do/
|
|
71
|
+
|
|
72
|
+
def format_selected_date
|
|
73
|
+
return nil unless @selected_date
|
|
74
|
+
|
|
75
|
+
date = @selected_date.respond_to?(:strftime) ? @selected_date : Date.parse(@selected_date.to_s)
|
|
76
|
+
day = date.day
|
|
77
|
+
suffix = day_suffix(day)
|
|
78
|
+
day_name = Date::DAYNAMES[date.wday]
|
|
79
|
+
month_name = Date::MONTHNAMES[date.month]
|
|
80
|
+
map = {
|
|
81
|
+
"yyyy" => date.year.to_s,
|
|
82
|
+
"MMMM" => month_name,
|
|
83
|
+
"MM" => Kernel.format("%02d", date.month),
|
|
84
|
+
"dd" => Kernel.format("%02d", day),
|
|
85
|
+
"HH" => Kernel.format("%02d", date.respond_to?(:hour) ? date.hour : 0),
|
|
86
|
+
"mm" => Kernel.format("%02d", date.respond_to?(:min) ? date.min : 0),
|
|
87
|
+
"ss" => Kernel.format("%02d", date.respond_to?(:sec) ? date.sec : 0),
|
|
88
|
+
"EEEE" => day_name,
|
|
89
|
+
"do" => "#{day}#{suffix}",
|
|
90
|
+
"PPPP" => "#{day_name}, #{month_name} #{day}#{suffix}, #{date.year}"
|
|
91
|
+
}
|
|
92
|
+
@date_format.gsub(FORMAT_TOKENS) { |token| map[token] }
|
|
93
|
+
rescue Date::Error
|
|
94
|
+
@selected_date.to_s
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def day_suffix(day)
|
|
98
|
+
return "th" if day > 3 && day < 21
|
|
99
|
+
|
|
100
|
+
{ 1 => "st", 2 => "nd", 3 => "rd" }.fetch(day % 10, "th")
|
|
101
|
+
end
|
|
102
|
+
|
|
56
103
|
def trigger_attrs
|
|
57
104
|
mix({ class: "pk-date-picker-trigger" }, @trigger_attrs)
|
|
58
105
|
end
|
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
border-radius: .75rem; background: var(--pk-surface); color: var(--pk-text);
|
|
18
18
|
padding: var(--pk-dialog-spacing);
|
|
19
19
|
font-size: .875rem;
|
|
20
|
-
box-shadow: 0 10px 30px
|
|
20
|
+
box-shadow: 0 10px 30px color-mix(in srgb, var(--pk-shadow-color) 35%, transparent);
|
|
21
21
|
}
|
|
22
22
|
.pk-dialog[open] { display: flex; flex-direction: column; }
|
|
23
|
-
.pk-dialog::backdrop { background:
|
|
23
|
+
.pk-dialog::backdrop { background: color-mix(in srgb, var(--pk-overlay) 10%, transparent); backdrop-filter: blur(4px); }
|
|
24
24
|
@supports not (backdrop-filter: blur(1px)) {
|
|
25
|
-
.pk-dialog::backdrop { background:
|
|
25
|
+
.pk-dialog::backdrop { background: color-mix(in srgb, var(--pk-overlay) 50%, transparent); }
|
|
26
26
|
}
|
|
27
27
|
.pk-dialog.xs { max-width: min(16rem, calc(100% - 2rem)); }
|
|
28
28
|
.pk-dialog.sm { max-width: min(20rem, calc(100% - 2rem)); }
|
|
@@ -51,6 +51,6 @@
|
|
|
51
51
|
}
|
|
52
52
|
.pk-overlay-close:hover { opacity: 1; }
|
|
53
53
|
@media (min-width: 640px) {
|
|
54
|
-
.pk-dialog-header { text-align:
|
|
54
|
+
.pk-dialog-header { text-align: start; }
|
|
55
55
|
.pk-dialog-footer { flex-direction: row; justify-content: flex-end; }
|
|
56
56
|
}
|
|
@@ -24,10 +24,26 @@ export default class extends Controller {
|
|
|
24
24
|
}
|
|
25
25
|
disconnect() {
|
|
26
26
|
this.dialogTarget.removeEventListener("close", this.handleClose)
|
|
27
|
-
|
|
27
|
+
if (this.dialogTarget.open) this.handleClose()
|
|
28
|
+
}
|
|
29
|
+
open(e) {
|
|
30
|
+
e?.preventDefault()
|
|
31
|
+
this.dialogTarget.showModal()
|
|
32
|
+
// Save/restore rather than removeProperty: a host page (or another
|
|
33
|
+
// component) may have set body overflow itself.
|
|
34
|
+
this.previousOverflow = document.body.style.overflow
|
|
35
|
+
document.body.style.overflow = "hidden"
|
|
28
36
|
}
|
|
29
|
-
open(e) { e?.preventDefault(); this.dialogTarget.showModal(); document.body.style.overflow = "hidden" }
|
|
30
37
|
dismiss() { this.dialogTarget.close() }
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
// Only a click on the ::backdrop dismisses. e.target is the <dialog> for
|
|
39
|
+
// backdrop clicks, but ALSO for clicks on the dialog's own padding/flex
|
|
40
|
+
// gaps — disambiguate by testing the click point against the dialog's box.
|
|
41
|
+
backdropClick(e) {
|
|
42
|
+
if (e.target !== this.dialogTarget) return
|
|
43
|
+
if (e.detail === 0) return // synthetic/keyboard click — no coordinates
|
|
44
|
+
const r = this.dialogTarget.getBoundingClientRect()
|
|
45
|
+
const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom
|
|
46
|
+
if (!inside) this.dismiss()
|
|
47
|
+
}
|
|
48
|
+
handleClose = () => { document.body.style.overflow = this.previousOverflow ?? "" }
|
|
33
49
|
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
position: fixed;
|
|
10
10
|
inset: 0;
|
|
11
11
|
z-index: 50;
|
|
12
|
-
background:
|
|
12
|
+
background: color-mix(in srgb, var(--pk-overlay) 10%, transparent);
|
|
13
13
|
backdrop-filter: blur(4px);
|
|
14
14
|
}
|
|
15
15
|
.pk-drawer {
|
|
@@ -87,8 +87,8 @@
|
|
|
87
87
|
padding: 1rem;
|
|
88
88
|
}
|
|
89
89
|
@media (min-width: 768px) {
|
|
90
|
-
.pk-drawer-header { text-align:
|
|
90
|
+
.pk-drawer-header { text-align: start; }
|
|
91
91
|
}
|
|
92
92
|
@supports not (backdrop-filter: blur(1px)) {
|
|
93
|
-
.pk-drawer-overlay { background:
|
|
93
|
+
.pk-drawer-overlay { background: color-mix(in srgb, var(--pk-overlay) 50%, transparent); }
|
|
94
94
|
}
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
background: var(--pk-surface);
|
|
38
38
|
color: var(--pk-text);
|
|
39
39
|
padding: .25rem;
|
|
40
|
-
box-shadow: 0 4px 16px
|
|
40
|
+
box-shadow: 0 4px 16px color-mix(in srgb, var(--pk-shadow-color) 40%, transparent);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
.pk-dropdown-menu-item {
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
|
|
77
77
|
/* Keyboard hint, pushed to the row's end. */
|
|
78
78
|
.pk-dropdown-menu-shortcut {
|
|
79
|
-
margin-
|
|
79
|
+
margin-inline-start: auto;
|
|
80
80
|
font-size: .75rem;
|
|
81
81
|
letter-spacing: .1em;
|
|
82
82
|
color: var(--pk-muted);
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
The row is its own scoped anchor; the panel flips to the other side at
|
|
113
113
|
the viewport right edge. */
|
|
114
114
|
.pk-dropdown-menu-sub { anchor-name: --pk-dropdown-menu-sub; anchor-scope: --pk-dropdown-menu-sub; }
|
|
115
|
-
.pk-dropdown-menu-sub-chevron { margin-
|
|
115
|
+
.pk-dropdown-menu-sub-chevron { margin-inline-start: auto; width: 1rem; height: 1rem; }
|
|
116
116
|
.pk-dropdown-menu-sub-content {
|
|
117
117
|
position: fixed;
|
|
118
118
|
position-anchor: --pk-dropdown-menu-sub;
|
|
@@ -5,7 +5,10 @@ module PhlexKit
|
|
|
5
5
|
# Trigger + Content (+ Item / Label / Separator). Tailwind → vanilla
|
|
6
6
|
# `.pk-dropdown-menu*` (dropdown_menu.css).
|
|
7
7
|
class DropdownMenu < BaseComponent
|
|
8
|
-
|
|
8
|
+
# `open: true` renders the menu open on connect (matching Collapsible's
|
|
9
|
+
# kwarg -> Stimulus value -> connect flow).
|
|
10
|
+
def initialize(open: false, **attrs)
|
|
11
|
+
@open = open
|
|
9
12
|
@attrs = attrs
|
|
10
13
|
end
|
|
11
14
|
|
|
@@ -14,6 +17,7 @@ module PhlexKit
|
|
|
14
17
|
class: "pk-dropdown-menu",
|
|
15
18
|
data: {
|
|
16
19
|
controller: "phlex-kit--dropdown-menu",
|
|
20
|
+
phlex_kit__dropdown_menu_open_value: @open,
|
|
17
21
|
action: "click@window->phlex-kit--dropdown-menu#onClickOutside"
|
|
18
22
|
}
|
|
19
23
|
}, @attrs), &block)
|
|
@@ -21,6 +21,9 @@ export default class extends Controller {
|
|
|
21
21
|
this.invoker = this.triggerTarget.querySelector("button, a, [tabindex]") || this.triggerTarget;
|
|
22
22
|
this.invoker.setAttribute("aria-haspopup", "menu");
|
|
23
23
|
this.invoker.setAttribute("aria-expanded", "false");
|
|
24
|
+
// `open:` on the Ruby side renders the value — start open (same
|
|
25
|
+
// kwarg -> value -> connect flow as Collapsible).
|
|
26
|
+
if (this.openValue) this.#open();
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
disconnect() {
|
|
@@ -33,6 +36,9 @@ export default class extends Controller {
|
|
|
33
36
|
if (!this.contentTarget.matches(":popover-open")) return;
|
|
34
37
|
if (this.element.contains(event.target)) return;
|
|
35
38
|
|
|
39
|
+
// Deliberate (matches Radix's modal dismiss): the outside click ONLY
|
|
40
|
+
// dismisses the menu — it is swallowed rather than also acting on
|
|
41
|
+
// whatever was under the pointer (e.g. navigating a link).
|
|
36
42
|
event.preventDefault();
|
|
37
43
|
this.close();
|
|
38
44
|
}
|
|
@@ -104,9 +110,36 @@ export default class extends Controller {
|
|
|
104
110
|
this.close();
|
|
105
111
|
this.invoker.focus();
|
|
106
112
|
break;
|
|
113
|
+
case "ArrowRight":
|
|
114
|
+
// On a sub trigger, enter the submenu (focus reveals it via
|
|
115
|
+
// :focus-within, making its rows visible to the roving nav).
|
|
116
|
+
// Same keyboard grammar as context_menu.
|
|
117
|
+
if (document.activeElement?.matches(".pk-dropdown-menu-sub-trigger")) {
|
|
118
|
+
e.preventDefault();
|
|
119
|
+
this.#enterSub(document.activeElement);
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
case "ArrowLeft": {
|
|
123
|
+
// Inside a submenu, step back to its trigger (closes it on focus-out).
|
|
124
|
+
const sub = document.activeElement?.closest(".pk-dropdown-menu-sub-content");
|
|
125
|
+
if (sub) {
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
sub.closest(".pk-dropdown-menu-sub")?.querySelector(".pk-dropdown-menu-sub-trigger")?.focus();
|
|
128
|
+
}
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
107
131
|
}
|
|
108
132
|
}
|
|
109
133
|
|
|
134
|
+
#enterSub(trigger) {
|
|
135
|
+
trigger.focus();
|
|
136
|
+
const panel = trigger.closest(".pk-dropdown-menu-sub")?.querySelector(".pk-dropdown-menu-sub-content");
|
|
137
|
+
if (!panel) return;
|
|
138
|
+
const first = [...panel.querySelectorAll('[role^="menuitem"]')]
|
|
139
|
+
.find((el) => !el.closest("[data-disabled]") && el.getClientRects().length > 0);
|
|
140
|
+
first?.focus();
|
|
141
|
+
}
|
|
142
|
+
|
|
110
143
|
// Rows inside a closed submenu are display:none (revealed by CSS on
|
|
111
144
|
// hover/focus-within) — focus() on them silently fails and the roving
|
|
112
145
|
// navigation would jam at the submenu boundary.
|
|
@@ -6,7 +6,9 @@ module PhlexKit
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def view_template(&block)
|
|
9
|
-
|
|
9
|
+
# div, not a heading: role="menu" only allows menuitem/group/separator
|
|
10
|
+
# children, so an <h3> is invalid ARIA there (shadcn uses a div too).
|
|
11
|
+
div(**mix({ class: "pk-dropdown-menu-label" }, @attrs), &block)
|
|
10
12
|
end
|
|
11
13
|
end
|
|
12
14
|
end
|
|
@@ -110,7 +110,7 @@
|
|
|
110
110
|
font-size: .875rem;
|
|
111
111
|
font-weight: 400;
|
|
112
112
|
line-height: 1.5;
|
|
113
|
-
text-align:
|
|
113
|
+
text-align: start;
|
|
114
114
|
}
|
|
115
115
|
.pk-field-description a { text-decoration: underline; text-underline-offset: 4px; color: inherit; }
|
|
116
116
|
.pk-field-description a:hover { color: var(--pk-brand); }
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
# Validation message(s) for a Field, ported from shadcn/ui's FieldError.
|
|
3
3
|
# Pass `errors:` an array of message strings (deduped; one renders plain,
|
|
4
|
-
# several render a list) or give a block
|
|
5
|
-
# matching theirs. Distinct from FormFieldError, which is the Stimulus
|
|
4
|
+
# several render a list) or give a block — both render (block after the
|
|
5
|
+
# list) when both are given. Renders nothing when empty — matching theirs. Distinct from FormFieldError, which is the Stimulus
|
|
6
6
|
# live-validation slot; server-rendered errors belong here. See field.rb.
|
|
7
7
|
class FieldError < BaseComponent
|
|
8
8
|
def initialize(errors: nil, **attrs)
|
|
@@ -14,15 +14,14 @@ module PhlexKit
|
|
|
14
14
|
return if @errors.empty? && !block
|
|
15
15
|
|
|
16
16
|
div(**mix({ class: "pk-field-error", role: "alert", data: { slot: "field-error" } }, @attrs)) do
|
|
17
|
-
if
|
|
18
|
-
yield
|
|
19
|
-
elsif @errors.length == 1
|
|
17
|
+
if @errors.length == 1
|
|
20
18
|
plain @errors.first
|
|
21
|
-
|
|
19
|
+
elsif @errors.length > 1
|
|
22
20
|
ul(class: "pk-field-error-list") do
|
|
23
21
|
@errors.each { |message| li { message } }
|
|
24
22
|
end
|
|
25
23
|
end
|
|
24
|
+
yield if block
|
|
26
25
|
end
|
|
27
26
|
end
|
|
28
27
|
end
|
|
@@ -14,7 +14,7 @@ export default class extends Controller {
|
|
|
14
14
|
|
|
15
15
|
connect() {
|
|
16
16
|
if (this.hasErrorTarget) {
|
|
17
|
-
if (this.errorTarget.textContent) {
|
|
17
|
+
if (this.errorTarget.textContent.trim()) {
|
|
18
18
|
this.shouldValidateValue = true;
|
|
19
19
|
} else {
|
|
20
20
|
this.errorTarget.classList.add("pk-hidden");
|
|
@@ -31,41 +31,60 @@ export default class extends Controller {
|
|
|
31
31
|
error.preventDefault();
|
|
32
32
|
|
|
33
33
|
this.shouldValidateValue = true;
|
|
34
|
-
this.#setErrorMessage();
|
|
34
|
+
this.#setErrorMessage(error);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
onInput() {
|
|
38
|
-
this.#setErrorMessage();
|
|
37
|
+
onInput(event) {
|
|
38
|
+
this.#setErrorMessage(event);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
onChange() {
|
|
42
|
-
this.#setErrorMessage();
|
|
41
|
+
onChange(event) {
|
|
42
|
+
this.#setErrorMessage(event);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
// The event's own control, when it's one of ours — radio/checkbox groups
|
|
46
|
+
// register several input targets and validity/aria-invalid must land on
|
|
47
|
+
// the control that fired, not always the first.
|
|
48
|
+
#inputFor(event) {
|
|
49
|
+
if (event && this.inputTargets.includes(event.target)) return event.target;
|
|
50
|
+
return this.inputTarget;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#setErrorMessage(event) {
|
|
46
54
|
if (!this.shouldValidateValue || !this.hasErrorTarget) return;
|
|
47
55
|
|
|
56
|
+
const input = this.#inputFor(event);
|
|
48
57
|
// aria-invalid drives the red ring for LIVE validation too (input.css
|
|
49
58
|
// only matched server-rendered attrs); aria-describedby ties the message
|
|
50
59
|
// to the control for AT.
|
|
51
|
-
if (
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (this.inputTarget.validity.valid) {
|
|
55
|
-
this.inputTarget.removeAttribute("aria-invalid");
|
|
60
|
+
if (input.validity.valid) {
|
|
61
|
+
input.removeAttribute("aria-invalid");
|
|
62
|
+
this.#describeBy(input, false);
|
|
56
63
|
this.errorTarget.textContent = "";
|
|
57
64
|
this.errorTarget.classList.add("pk-hidden");
|
|
58
65
|
} else {
|
|
59
|
-
|
|
60
|
-
this
|
|
66
|
+
input.setAttribute("aria-invalid", "true");
|
|
67
|
+
this.#describeBy(input, true);
|
|
68
|
+
this.errorTarget.textContent = this.#getValidationMessage(input);
|
|
61
69
|
this.errorTarget.classList.remove("pk-hidden");
|
|
62
70
|
}
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
|
|
73
|
+
// Append/remove the error's id on the control's aria-describedby without
|
|
74
|
+
// clobbering pre-existing tokens (e.g. a hint's id).
|
|
75
|
+
#describeBy(input, present) {
|
|
76
|
+
const id = this.errorTarget.id;
|
|
77
|
+
if (!id) return;
|
|
78
|
+
const tokens = (input.getAttribute("aria-describedby") || "").split(/\s+/).filter((t) => t && t !== id);
|
|
79
|
+
if (present) tokens.push(id);
|
|
80
|
+
if (tokens.length) input.setAttribute("aria-describedby", tokens.join(" "));
|
|
81
|
+
else input.removeAttribute("aria-describedby");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
#getValidationMessage(input) {
|
|
66
85
|
let errorMessage;
|
|
67
86
|
|
|
68
|
-
const { validity, dataset, validationMessage } =
|
|
87
|
+
const { validity, dataset, validationMessage } = input;
|
|
69
88
|
|
|
70
89
|
if (validity.tooLong) errorMessage = dataset.tooLong;
|
|
71
90
|
if (validity.tooShort) errorMessage = dataset.tooShort;
|
|
@@ -5,7 +5,10 @@ module PhlexKit
|
|
|
5
5
|
# with server-rendered model errors — non-empty content arms validation on
|
|
6
6
|
# connect. See form_field.rb.
|
|
7
7
|
class FormFieldError < BaseComponent
|
|
8
|
-
def initialize(**attrs)
|
|
8
|
+
def initialize(id: nil, **attrs)
|
|
9
|
+
# A default id (like SelectContent / CommandList) so the controller can
|
|
10
|
+
# always wire aria-describedby from the control to this message.
|
|
11
|
+
@id = id || "pk-form-field-error-#{SecureRandom.hex(4)}"
|
|
9
12
|
@attrs = attrs
|
|
10
13
|
end
|
|
11
14
|
|
|
@@ -13,6 +16,7 @@ module PhlexKit
|
|
|
13
16
|
# role="alert": the controller swaps this text live on invalid/input —
|
|
14
17
|
# without a live region the announcement never reaches screen readers.
|
|
15
18
|
p(**mix({
|
|
19
|
+
id: @id,
|
|
16
20
|
class: "pk-form-field-error",
|
|
17
21
|
role: "alert",
|
|
18
22
|
data: { phlex_kit__form_field_target: "error" }
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
border: 1px solid color-mix(in oklab, var(--pk-text) 10%, transparent);
|
|
20
20
|
border-radius: var(--pk-radius); background: var(--pk-surface);
|
|
21
21
|
color: var(--pk-text); padding: .625rem; font-size: .875rem;
|
|
22
|
-
box-shadow: 0 8px 24px
|
|
22
|
+
box-shadow: 0 8px 24px color-mix(in srgb, var(--pk-shadow-color) 30%, transparent); outline: none;
|
|
23
23
|
}
|
|
24
24
|
.pk-hover-card-content.top { position-area: block-start span-inline-end; margin: 0 0 .25rem; }
|
|
25
25
|
.pk-hover-card-content.left {
|
|
@@ -4,6 +4,9 @@ import { Controller } from "@hotwired/stimulus"
|
|
|
4
4
|
// with a small open/close delay. The panel is a native [popover=manual]
|
|
5
5
|
// (top layer; manual because the controller owns open/close — hover
|
|
6
6
|
// semantics want no light dismiss), anchor-positioned by hover_card.css.
|
|
7
|
+
// Manual popovers get no built-in Escape dismiss, so a window keydown
|
|
8
|
+
// listener (bound only while open, cf. tooltip_controller.js) closes the
|
|
9
|
+
// card immediately — the keyboard/touch escape hatch for a hover surface.
|
|
7
10
|
export default class extends Controller {
|
|
8
11
|
static targets = ["trigger", "content"]
|
|
9
12
|
static values = { openDelay: { type: Number, default: 200 }, closeDelay: { type: Number, default: 200 } }
|
|
@@ -12,14 +15,35 @@ export default class extends Controller {
|
|
|
12
15
|
this.t = setTimeout(() => {
|
|
13
16
|
if (!this.contentTarget.matches(":popover-open")) this.contentTarget.showPopover()
|
|
14
17
|
this.contentTarget.dataset.state = "open"
|
|
18
|
+
this.#bindEscape()
|
|
15
19
|
}, this.openDelayValue)
|
|
16
20
|
}
|
|
17
21
|
hide() {
|
|
18
22
|
clearTimeout(this.t)
|
|
19
|
-
this.t = setTimeout(() =>
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
this.t = setTimeout(() => this.#close(), this.closeDelayValue)
|
|
24
|
+
}
|
|
25
|
+
disconnect() {
|
|
26
|
+
clearTimeout(this.t)
|
|
27
|
+
this.#unbindEscape()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#close() {
|
|
31
|
+
if (this.contentTarget.matches(":popover-open")) this.contentTarget.hidePopover()
|
|
32
|
+
this.contentTarget.dataset.state = "closed"
|
|
33
|
+
this.#unbindEscape()
|
|
34
|
+
}
|
|
35
|
+
#bindEscape() {
|
|
36
|
+
if (this.onKeydown) return
|
|
37
|
+
this.onKeydown = (e) => {
|
|
38
|
+
if (e.key !== "Escape") return
|
|
39
|
+
clearTimeout(this.t)
|
|
40
|
+
this.#close()
|
|
41
|
+
}
|
|
42
|
+
window.addEventListener("keydown", this.onKeydown)
|
|
43
|
+
}
|
|
44
|
+
#unbindEscape() {
|
|
45
|
+
if (!this.onKeydown) return
|
|
46
|
+
window.removeEventListener("keydown", this.onKeydown)
|
|
47
|
+
this.onKeydown = null
|
|
23
48
|
}
|
|
24
|
-
disconnect() { clearTimeout(this.t) }
|
|
25
49
|
}
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
flex-direction: column;
|
|
25
25
|
align-items: stretch;
|
|
26
26
|
}
|
|
27
|
-
|
|
27
|
+
/* Ring only when the CONTROL is focused (shadcn gates on the control's
|
|
28
|
+
focus-visible) — :focus-within would also light it for addon buttons. */
|
|
29
|
+
.pk-input-group:has(.pk-input:focus-visible, .pk-textarea:focus-visible) {
|
|
28
30
|
border-color: var(--pk-ring);
|
|
29
31
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
30
32
|
}
|
|
@@ -48,8 +50,8 @@
|
|
|
48
50
|
.pk-input-group .pk-input:disabled,
|
|
49
51
|
.pk-input-group .pk-textarea:disabled { background: transparent; }
|
|
50
52
|
/* Tighten the control's near-side padding beside an inline addon. */
|
|
51
|
-
.pk-input-group:has(> .pk-input-group-addon.start) > .pk-input { padding-
|
|
52
|
-
.pk-input-group:has(> .pk-input-group-addon.end) > .pk-input { padding-
|
|
53
|
+
.pk-input-group:has(> .pk-input-group-addon.start) > .pk-input { padding-inline-start: .375rem; }
|
|
54
|
+
.pk-input-group:has(> .pk-input-group-addon.end) > .pk-input { padding-inline-end: .375rem; }
|
|
53
55
|
|
|
54
56
|
.pk-input-group-addon {
|
|
55
57
|
display: flex;
|
|
@@ -64,8 +66,8 @@
|
|
|
64
66
|
font-size: .875rem;
|
|
65
67
|
font-weight: 500;
|
|
66
68
|
}
|
|
67
|
-
.pk-input-group-addon.start { order: -1; padding-
|
|
68
|
-
.pk-input-group-addon.end { order: 99; padding-
|
|
69
|
+
.pk-input-group-addon.start { order: -1; padding-inline-start: .5rem; }
|
|
70
|
+
.pk-input-group-addon.end { order: 99; padding-inline-end: .5rem; }
|
|
69
71
|
.pk-input-group-addon.block-start,
|
|
70
72
|
.pk-input-group-addon.block-end {
|
|
71
73
|
width: 100%;
|
|
@@ -78,8 +80,8 @@
|
|
|
78
80
|
.pk-input-group-addon.block-end.bordered { border-top: 1px solid var(--pk-border); padding-top: .5rem; }
|
|
79
81
|
.pk-input-group-addon svg:not([class*="size-"]) { width: 1rem; height: 1rem; }
|
|
80
82
|
.pk-input-group-addon > kbd { border-radius: calc(var(--pk-radius) - 5px); }
|
|
81
|
-
.pk-input-group-addon.start:has(> .pk-button) { margin-
|
|
82
|
-
.pk-input-group-addon.end:has(> .pk-button) { margin-
|
|
83
|
+
.pk-input-group-addon.start:has(> .pk-button) { margin-inline-start: -0.3rem; }
|
|
84
|
+
.pk-input-group-addon.end:has(> .pk-button) { margin-inline-end: -0.3rem; }
|
|
83
85
|
|
|
84
86
|
.pk-button.pk-input-group-button {
|
|
85
87
|
gap: .25rem;
|
|
@@ -17,7 +17,11 @@ module PhlexKit
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
def view_template(&)
|
|
20
|
-
|
|
20
|
+
# No role: shadcn puts role="group" on its addon, but an unnamed group
|
|
21
|
+
# nested inside the InputGroup's own group is noise for AT — a text or
|
|
22
|
+
# icon addon needs no role, and interactive addon children (buttons)
|
|
23
|
+
# carry their own semantics.
|
|
24
|
+
div(**mix({ class: "pk-input-group-addon #{fetch_option(ALIGNS, @align, :align)}" }, @attrs), &)
|
|
21
25
|
end
|
|
22
26
|
end
|
|
23
27
|
end
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
outline: none;
|
|
17
17
|
transition: border-color .15s ease, box-shadow .15s ease;
|
|
18
18
|
}
|
|
19
|
-
.pk-input-otp-slot:not(:first-child) { margin-
|
|
19
|
+
.pk-input-otp-slot:not(:first-child) { margin-inline-start: -1px; }
|
|
20
20
|
.pk-input-otp-group .pk-input-otp-slot:first-child {
|
|
21
21
|
border-top-left-radius: var(--pk-radius);
|
|
22
22
|
border-bottom-left-radius: var(--pk-radius);
|
|
@@ -10,6 +10,12 @@ export default class extends Controller {
|
|
|
10
10
|
static values = { length: Number }
|
|
11
11
|
|
|
12
12
|
connect() {
|
|
13
|
+
// Slots compose without knowing their position, so `length:` can drift
|
|
14
|
+
// from the actual slot count — flag the mismatch instead of silently
|
|
15
|
+
// truncating/underfilling codes.
|
|
16
|
+
if (this.hasLengthValue && this.lengthValue !== this.slotTargets.length) {
|
|
17
|
+
console.warn(`phlex-kit--input-otp: length is ${this.lengthValue} but ${this.slotTargets.length} slots rendered`, this.element)
|
|
18
|
+
}
|
|
13
19
|
// Slots compose without knowing their position — label them here so AT
|
|
14
20
|
// announces "Digit N of M" instead of anonymous edit fields.
|
|
15
21
|
this.slotTargets.forEach((slot, i) => {
|
|
@@ -17,6 +23,13 @@ export default class extends Controller {
|
|
|
17
23
|
slot.setAttribute("aria-label", `Digit ${i + 1} of ${this.slotTargets.length}`)
|
|
18
24
|
}
|
|
19
25
|
})
|
|
26
|
+
// A server-provided value renders on the hidden input only — distribute
|
|
27
|
+
// it into the empty slots BEFORE syncing, or syncValue would join the
|
|
28
|
+
// empty slots and wipe the value with a spurious change event.
|
|
29
|
+
if (this.hasValueTarget && this.valueTarget.value && this.slotTargets.every((slot) => !slot.value)) {
|
|
30
|
+
const chars = this.valueTarget.value.split("")
|
|
31
|
+
this.slotTargets.forEach((slot, i) => { if (chars[i] != null) slot.value = chars[i] })
|
|
32
|
+
}
|
|
20
33
|
this.syncValue()
|
|
21
34
|
}
|
|
22
35
|
|