phlex_kit 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/components/phlex_kit/accordion/accordion_content.rb +3 -1
- data/app/components/phlex_kit/accordion/accordion_default_trigger.rb +1 -1
- data/app/components/phlex_kit/accordion/accordion_trigger.rb +1 -1
- data/app/components/phlex_kit/alert/alert.rb +5 -1
- data/app/components/phlex_kit/alert_dialog/alert_dialog_controller.js +16 -6
- data/app/components/phlex_kit/alert_dialog/alert_dialog_media.rb +4 -1
- data/app/components/phlex_kit/aspect_ratio/aspect_ratio.rb +14 -4
- data/app/components/phlex_kit/attachment/attachment.css +7 -1
- data/app/components/phlex_kit/attachment/scroll_fade_controller.js +4 -3
- data/app/components/phlex_kit/avatar/avatar.css +4 -0
- data/app/components/phlex_kit/breadcrumb/breadcrumb.css +4 -0
- data/app/components/phlex_kit/breadcrumb/breadcrumb.rb +8 -1
- data/app/components/phlex_kit/breadcrumb/breadcrumb_page.rb +1 -1
- data/app/components/phlex_kit/breadcrumb/breadcrumb_separator.rb +1 -1
- data/app/components/phlex_kit/bubble/bubble_content.rb +5 -1
- data/app/components/phlex_kit/button_group/button_group_separator.rb +1 -1
- data/app/components/phlex_kit/calendar/calendar.css +5 -0
- data/app/components/phlex_kit/calendar/calendar_controller.js +26 -8
- data/app/components/phlex_kit/calendar/calendar_next.rb +5 -3
- data/app/components/phlex_kit/calendar/calendar_prev.rb +5 -3
- data/app/components/phlex_kit/carousel/carousel.css +7 -0
- data/app/components/phlex_kit/carousel/carousel.rb +8 -4
- data/app/components/phlex_kit/carousel/carousel_controller.js +64 -20
- data/app/components/phlex_kit/carousel/carousel_item.rb +5 -5
- data/app/components/phlex_kit/chart/chart_controller.js +5 -1
- data/app/components/phlex_kit/checkbox/checkbox.css +16 -0
- data/app/components/phlex_kit/checkbox/checkbox.rb +5 -1
- data/app/components/phlex_kit/clipboard/clipboard.rb +7 -0
- data/app/components/phlex_kit/clipboard/clipboard_controller.js +12 -1
- data/app/components/phlex_kit/clipboard/clipboard_popover.rb +5 -3
- data/app/components/phlex_kit/combobox/combobox.rb +1 -1
- data/app/components/phlex_kit/combobox/combobox_clear_button.rb +5 -3
- data/app/components/phlex_kit/combobox/combobox_controller.js +56 -5
- data/app/components/phlex_kit/combobox/combobox_item.rb +7 -4
- data/app/components/phlex_kit/command/command_controller.js +9 -1
- data/app/components/phlex_kit/command/command_dialog_content.rb +10 -6
- data/app/components/phlex_kit/command/command_empty.rb +5 -3
- data/app/components/phlex_kit/command/command_separator.rb +1 -1
- data/app/components/phlex_kit/context_menu/context_menu_checkbox_item.rb +7 -5
- data/app/components/phlex_kit/context_menu/context_menu_content.rb +7 -1
- data/app/components/phlex_kit/context_menu/context_menu_controller.js +17 -2
- data/app/components/phlex_kit/context_menu/context_menu_group.rb +4 -1
- data/app/components/phlex_kit/context_menu/context_menu_item.rb +5 -2
- data/app/components/phlex_kit/context_menu/context_menu_radio_group.rb +4 -1
- data/app/components/phlex_kit/context_menu/context_menu_radio_item.rb +7 -5
- data/app/components/phlex_kit/context_menu/context_menu_separator.rb +6 -1
- data/app/components/phlex_kit/context_menu/context_menu_sub_content.rb +4 -1
- data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +12 -9
- data/app/components/phlex_kit/data_table/data_table.css +4 -0
- data/app/components/phlex_kit/data_table/data_table_search_controller.js +15 -1
- data/app/components/phlex_kit/data_table/data_table_select_all_checkbox.rb +8 -4
- data/app/components/phlex_kit/drawer/drawer.css +4 -0
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_checkbox_item.rb +7 -5
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_content.rb +4 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +10 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_group.rb +4 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_item.rb +3 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_group.rb +4 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_radio_item.rb +7 -5
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_separator.rb +6 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_content.rb +4 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +12 -9
- data/app/components/phlex_kit/empty/empty.rb +1 -0
- data/app/components/phlex_kit/field/field.rb +4 -1
- data/app/components/phlex_kit/field/field_error.rb +4 -1
- data/app/components/phlex_kit/form_field/form_field_controller.js +22 -5
- data/app/components/phlex_kit/icon/icon.rb +5 -1
- data/app/components/phlex_kit/input_group/input_group.css +6 -3
- data/app/components/phlex_kit/input_otp/input_otp_controller.js +14 -6
- data/app/components/phlex_kit/input_otp/input_otp_slot.rb +7 -6
- data/app/components/phlex_kit/item/item.css +9 -1
- data/app/components/phlex_kit/item/item_separator.rb +1 -1
- data/app/components/phlex_kit/label/label.css +6 -1
- data/app/components/phlex_kit/menubar/menubar.rb +5 -3
- data/app/components/phlex_kit/menubar/menubar_checkbox_item.rb +6 -6
- data/app/components/phlex_kit/menubar/menubar_content.rb +5 -1
- data/app/components/phlex_kit/menubar/menubar_controller.js +11 -1
- data/app/components/phlex_kit/menubar/menubar_group.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_item.rb +4 -3
- data/app/components/phlex_kit/menubar/menubar_menu.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_radio_group.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_radio_item.rb +6 -6
- data/app/components/phlex_kit/menubar/menubar_separator.rb +6 -1
- data/app/components/phlex_kit/menubar/menubar_sub_content.rb +4 -1
- data/app/components/phlex_kit/menubar/menubar_sub_trigger.rb +12 -11
- data/app/components/phlex_kit/menubar/menubar_trigger.rb +7 -4
- data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +5 -0
- data/app/components/phlex_kit/pagination/pagination.css +4 -0
- data/app/components/phlex_kit/radio_button/radio_button.rb +1 -1
- data/app/components/phlex_kit/resizable/resizable_controller.js +17 -4
- data/app/components/phlex_kit/resizable/resizable_handle.rb +9 -7
- data/app/components/phlex_kit/select/select.rb +1 -1
- data/app/components/phlex_kit/select/select_content.rb +7 -5
- data/app/components/phlex_kit/select/select_controller.js +10 -0
- data/app/components/phlex_kit/select/select_separator.rb +1 -1
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +12 -5
- data/app/components/phlex_kit/sidebar/sidebar_group_action.rb +1 -1
- data/app/components/phlex_kit/sidebar/sidebar_menu_action.rb +1 -1
- data/app/components/phlex_kit/sidebar/sidebar_rail.rb +3 -2
- data/app/components/phlex_kit/sidebar/sidebar_trigger.rb +2 -1
- data/app/components/phlex_kit/switch/switch.rb +4 -1
- data/app/components/phlex_kit/tabs/tabs_content.rb +6 -1
- data/app/components/phlex_kit/tabs/tabs_trigger.rb +3 -1
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +24 -6
- data/app/components/phlex_kit/toast/toast_controller.js +46 -1
- data/app/components/phlex_kit/toast/toaster_controller.js +29 -1
- data/app/components/phlex_kit/toggle/toggle.rb +1 -1
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +18 -4
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +2 -2
- data/app/components/phlex_kit/tooltip/tooltip_controller.js +5 -0
- data/app/components/phlex_kit/typography/typography.css +5 -2
- data/lib/phlex_kit/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 60c4cbd0615b995e62c9dee3f19ae2c2f48ff53c5ec7f8294bf757edb7f68a25
|
|
4
|
+
data.tar.gz: b95a0dcc6976d561bb1e8d005eac32607e771386d236735b3e45e4316b0ef4c7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e76f08c320a673fbcda5e136148933c12e26cbc9a9b97f8f253382bf9b9f11f26255de504cb27b67de2b1f0de8b6d40bb1f8e257b1da5c809259c5575c0489da
|
|
7
|
+
data.tar.gz: 130fb551c26417da890ec8da4dc1cc06187ae4dc6916129e3c8a862e3da14c3317e94d6c15ce0d8afa23e353aeacd7449e7d5aaa38ca43049a3a105932d68c30
|
|
@@ -11,7 +11,9 @@ module PhlexKit
|
|
|
11
11
|
def view_template(&)
|
|
12
12
|
# role="region" + aria-labelledby → trigger id (wired by the controller
|
|
13
13
|
# in connect(), which also assigns the ids), per the APG accordion pattern.
|
|
14
|
-
base = { class: "pk-accordion-content",
|
|
14
|
+
base = { class: "pk-accordion-content", data: { phlex_kit__accordion_target: "content", state: @open ? "open" : "closed" } }
|
|
15
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
16
|
+
base[:role] = "region" unless attr_set?(:role)
|
|
15
17
|
base.merge!(style: "height: 0px;", hidden: true) unless @open
|
|
16
18
|
div(**mix(base, @attrs), &)
|
|
17
19
|
end
|
|
@@ -13,7 +13,7 @@ module PhlexKit
|
|
|
13
13
|
# aria-expanded reflects the render-time open state; the controller keeps
|
|
14
14
|
# it in sync and wires aria-controls to the content's id in connect().
|
|
15
15
|
base = {
|
|
16
|
-
type:
|
|
16
|
+
type: :button, class: "pk-accordion-trigger",
|
|
17
17
|
aria_expanded: @open ? "true" : "false", disabled: @disabled,
|
|
18
18
|
data: { phlex_kit__accordion_target: "trigger", action: "click->phlex-kit--accordion#toggle" }
|
|
19
19
|
}
|
|
@@ -12,7 +12,7 @@ module PhlexKit
|
|
|
12
12
|
# aria-expanded reflects the render-time open state; the controller keeps
|
|
13
13
|
# it in sync and wires aria-controls to the content's id in connect().
|
|
14
14
|
base = {
|
|
15
|
-
type:
|
|
15
|
+
type: :button, class: "pk-accordion-trigger",
|
|
16
16
|
aria_expanded: @open ? "true" : "false", disabled: @disabled,
|
|
17
17
|
data: { phlex_kit__accordion_target: "trigger", action: "click->phlex-kit--accordion#toggle" }
|
|
18
18
|
}
|
|
@@ -29,7 +29,11 @@ module PhlexKit
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def view_template(&block)
|
|
32
|
-
|
|
32
|
+
base = { class: classes }
|
|
33
|
+
# Default only when the caller didn't supply their own — `mix` fuses
|
|
34
|
+
# (downgrading to role: "status" is a standard polite-live-region use).
|
|
35
|
+
base[:role] = "alert" unless attr_set?(:role)
|
|
36
|
+
div(**mix(base, @attrs), &block)
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
private
|
|
@@ -95,12 +95,18 @@ export default class extends Controller {
|
|
|
95
95
|
// handle the keyboard, or one Escape would close every layer at once.
|
|
96
96
|
if (!this.#topmost()) return;
|
|
97
97
|
if (event.key === "Escape") {
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
98
|
+
// Yield to any inner overlay that owns this Escape so one keypress
|
|
99
|
+
// doesn't also dismiss the alert dialog underneath: a native <dialog>
|
|
100
|
+
// (its own Escape handling), or an open [popover] menu — Select /
|
|
101
|
+
// DropdownMenu / Combobox, all popover=manual — rendered inside the
|
|
102
|
+
// panel. Two signals make the outcome independent of whether the inner
|
|
103
|
+
// handler fires before or after this document-level one:
|
|
104
|
+
// event.defaultPrevented catches an inner handler (menu, native dialog)
|
|
105
|
+
// that already consumed the key; the live :popover-open check catches a
|
|
106
|
+
// menu still open because this handler ran first.
|
|
107
|
+
if (event.defaultPrevented) return;
|
|
103
108
|
if (event.target.closest("dialog[open]")) return;
|
|
109
|
+
if (this.#panel()?.querySelector("[popover]:popover-open")) return;
|
|
104
110
|
event.preventDefault();
|
|
105
111
|
this.dismiss();
|
|
106
112
|
return;
|
|
@@ -140,8 +146,12 @@ export default class extends Controller {
|
|
|
140
146
|
return clones.length > 0 && clones[clones.length - 1] === this.element;
|
|
141
147
|
}
|
|
142
148
|
|
|
149
|
+
// Select the panel structurally, not by role: AlertDialogContent lets the
|
|
150
|
+
// caller override role: (the default is skipped when attr_set?(:role)), so a
|
|
151
|
+
// role-based lookup would fail to find a custom-role panel and silently
|
|
152
|
+
// break the whole modal contract (focus trap, aria wiring, dismiss).
|
|
143
153
|
#panel() {
|
|
144
|
-
return this.element.querySelector(
|
|
154
|
+
return this.element.querySelector(".pk-alert-dialog-panel");
|
|
145
155
|
}
|
|
146
156
|
|
|
147
157
|
#focusables() {
|
|
@@ -8,7 +8,10 @@ module PhlexKit
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def view_template(&)
|
|
11
|
-
|
|
11
|
+
base = { class: "pk-alert-dialog-media" }
|
|
12
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
13
|
+
base[:aria] = { hidden: "true" } unless aria_key_set?(:hidden)
|
|
14
|
+
div(**mix(base, @attrs), &)
|
|
12
15
|
end
|
|
13
16
|
end
|
|
14
17
|
end
|
|
@@ -16,10 +16,20 @@ module PhlexKit
|
|
|
16
16
|
|
|
17
17
|
def view_template(&block)
|
|
18
18
|
# **mix on the ROOT (kit rule) — caller attrs previously landed on the
|
|
19
|
-
# inner div and could never reach the actual root element.
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
|
|
19
|
+
# inner div and could never reach the actual root element. Emit the
|
|
20
|
+
# generated padding-bottom in the SAME form the caller used for style:,
|
|
21
|
+
# because mix only composes same-typed values — it JOINS two strings
|
|
22
|
+
# (the trailing ";" keeps the fused declaration valid) and MERGES two
|
|
23
|
+
# hashes, but a string base against a caller hash (or vice versa) is
|
|
24
|
+
# replaced wholesale, silently dropping our ratio and collapsing the box.
|
|
25
|
+
caller_style = @attrs[:style] || @attrs["style"]
|
|
26
|
+
generated_style =
|
|
27
|
+
if caller_style.is_a?(Hash)
|
|
28
|
+
{ padding_bottom: "#{padding_bottom}%" }
|
|
29
|
+
else
|
|
30
|
+
"padding-bottom: #{padding_bottom}%;"
|
|
31
|
+
end
|
|
32
|
+
div(**mix({ class: "pk-aspect-ratio", style: generated_style }, @attrs)) do
|
|
23
33
|
div(class: "pk-aspect-ratio-inner", &block)
|
|
24
34
|
end
|
|
25
35
|
end
|
|
@@ -118,6 +118,12 @@
|
|
|
118
118
|
-webkit-text-fill-color: transparent;
|
|
119
119
|
animation: pk-shimmer 2s linear infinite;
|
|
120
120
|
}
|
|
121
|
+
/* Same RTL arm as _tokens.css's .pk-shimmer — the sweep follows reading
|
|
122
|
+
direction. */
|
|
123
|
+
[dir="rtl"] .pk-attachment[data-state="uploading"] .pk-attachment-title,
|
|
124
|
+
[dir="rtl"] .pk-attachment[data-state="processing"] .pk-attachment-title {
|
|
125
|
+
animation-direction: reverse;
|
|
126
|
+
}
|
|
121
127
|
/* Same two fallback arms as _tokens.css's .pk-shimmer (:49-54): never let the
|
|
122
128
|
filename disappear under reduced motion or without relative color syntax
|
|
123
129
|
(Safari <16.4 / Chrome <119). */
|
|
@@ -163,7 +169,7 @@
|
|
|
163
169
|
gap: .5rem;
|
|
164
170
|
overflow-x: auto;
|
|
165
171
|
scroll-snap-type: x mandatory;
|
|
166
|
-
padding:
|
|
172
|
+
padding: 3px; /* room for card rings (the trigger focus ring is 3px) */
|
|
167
173
|
mask-image: linear-gradient(var(--pk-scroll-fade-dir),
|
|
168
174
|
transparent 0, black var(--pk-scroll-fade-size),
|
|
169
175
|
black calc(100% - var(--pk-scroll-fade-size)), transparent 100%);
|
|
@@ -32,9 +32,10 @@ export default class extends Controller {
|
|
|
32
32
|
// Math.abs: in RTL scrollLeft runs 0..negative, so the raw value would
|
|
33
33
|
// read as permanently "at start" — abs tracks the logical inline start.
|
|
34
34
|
const scrolled = Math.abs(el.scrollLeft)
|
|
35
|
-
// <=
|
|
36
|
-
//
|
|
37
|
-
|
|
35
|
+
// <= 3, not <= 1: scroll-snap + the 3px ring padding (bumped from 2px so
|
|
36
|
+
// the 3px card focus ring isn't clipped) leave the group resting at
|
|
37
|
+
// scrollLeft ~3, which must still count as "at start".
|
|
38
|
+
el.toggleAttribute("data-at-start", scrolled <= 3)
|
|
38
39
|
el.toggleAttribute("data-at-end", scrolled + el.clientWidth >= el.scrollWidth - 1)
|
|
39
40
|
} else {
|
|
40
41
|
el.toggleAttribute("data-at-start", el.scrollTop <= 1)
|
|
@@ -111,7 +111,11 @@
|
|
|
111
111
|
font-size: .875rem;
|
|
112
112
|
}
|
|
113
113
|
.pk-avatar-group-count > svg { width: 1rem; height: 1rem; }
|
|
114
|
+
.pk-avatar-group:has(.pk-avatar.xs) .pk-avatar-group-count { width: 1rem; height: 1rem; font-size: .5rem; }
|
|
115
|
+
.pk-avatar-group:has(.pk-avatar.xs) .pk-avatar-group-count > svg { width: .5rem; height: .5rem; }
|
|
114
116
|
.pk-avatar-group:has(.pk-avatar.sm) .pk-avatar-group-count { width: 1.5rem; height: 1.5rem; font-size: .75rem; }
|
|
115
117
|
.pk-avatar-group:has(.pk-avatar.sm) .pk-avatar-group-count > svg { width: .75rem; height: .75rem; }
|
|
116
118
|
.pk-avatar-group:has(.pk-avatar.lg) .pk-avatar-group-count { width: 2.5rem; height: 2.5rem; font-size: 1rem; }
|
|
117
119
|
.pk-avatar-group:has(.pk-avatar.lg) .pk-avatar-group-count > svg { width: 1.25rem; height: 1.25rem; }
|
|
120
|
+
.pk-avatar-group:has(.pk-avatar.xl) .pk-avatar-group-count { width: 5rem; height: 5rem; font-size: 1.875rem; }
|
|
121
|
+
.pk-avatar-group:has(.pk-avatar.xl) .pk-avatar-group-count > svg { width: 2.5rem; height: 2.5rem; }
|
|
@@ -13,3 +13,7 @@
|
|
|
13
13
|
.pk-breadcrumb-separator > svg { width: .875rem; height: .875rem; }
|
|
14
14
|
.pk-breadcrumb-ellipsis { display: flex; height: 1.25rem; width: 1.25rem; align-items: center; justify-content: center; }
|
|
15
15
|
.pk-breadcrumb-ellipsis > svg { width: 1rem; height: 1rem; }
|
|
16
|
+
|
|
17
|
+
/* RTL: the default chevron separator points along the reading direction —
|
|
18
|
+
mirror the icon (custom text/block separators are left alone). */
|
|
19
|
+
[dir="rtl"] .pk-breadcrumb-separator > svg { transform: scaleX(-1); }
|
|
@@ -3,6 +3,13 @@ module PhlexKit
|
|
|
3
3
|
# Breadcrumb > BreadcrumbList > BreadcrumbItem (+ Link/Page/Separator/Ellipsis).
|
|
4
4
|
class Breadcrumb < BaseComponent
|
|
5
5
|
def initialize(**attrs) = (@attrs = attrs)
|
|
6
|
-
|
|
6
|
+
|
|
7
|
+
def view_template(&)
|
|
8
|
+
base = { class: "pk-breadcrumb" }
|
|
9
|
+
# Default only when the caller didn't supply their own accessible name
|
|
10
|
+
# (localization, two breadcrumbs on a page) — `mix` would fuse them.
|
|
11
|
+
base[:aria] = { label: "breadcrumb" } unless aria_labelled?
|
|
12
|
+
nav(**mix(base, @attrs), &)
|
|
13
|
+
end
|
|
7
14
|
end
|
|
8
15
|
end
|
|
@@ -2,7 +2,7 @@ module PhlexKit
|
|
|
2
2
|
class BreadcrumbPage < BaseComponent
|
|
3
3
|
def initialize(**attrs) = (@attrs = attrs)
|
|
4
4
|
def view_template(&)
|
|
5
|
-
span(**mix({ class: "pk-breadcrumb-page", role: "link", aria: { disabled: true, current: "page" } }, @attrs), &)
|
|
5
|
+
span(**mix({ class: "pk-breadcrumb-page", role: "link", aria: { disabled: "true", current: "page" } }, @attrs), &)
|
|
6
6
|
end
|
|
7
7
|
end
|
|
8
8
|
end
|
|
@@ -2,7 +2,7 @@ module PhlexKit
|
|
|
2
2
|
class BreadcrumbSeparator < BaseComponent
|
|
3
3
|
def initialize(**attrs) = (@attrs = attrs)
|
|
4
4
|
def view_template(&block)
|
|
5
|
-
li(**mix({ class: "pk-breadcrumb-separator", role: "presentation", aria: { hidden: true } }, @attrs)) do
|
|
5
|
+
li(**mix({ class: "pk-breadcrumb-separator", role: "presentation", aria: { hidden: "true" } }, @attrs)) do
|
|
6
6
|
if block then yield else render(Icon.new(:chevron_right, size: nil)) end
|
|
7
7
|
end
|
|
8
8
|
end
|
|
@@ -10,7 +10,11 @@ module PhlexKit
|
|
|
10
10
|
@attrs = attrs
|
|
11
11
|
end
|
|
12
12
|
def view_template(&)
|
|
13
|
-
|
|
13
|
+
base = { class: "pk-bubble-content", data: { slot: "bubble-content" } }
|
|
14
|
+
# An interactive bubble inside a <form> must not be an implicit
|
|
15
|
+
# type="submit" (same default PhlexKit::Button ships).
|
|
16
|
+
base[:type] = :button if @as == :button && !attr_set?(:type)
|
|
17
|
+
send(@as, **mix(base, @attrs), &)
|
|
14
18
|
end
|
|
15
19
|
end
|
|
16
20
|
end
|
|
@@ -10,7 +10,7 @@ module PhlexKit
|
|
|
10
10
|
def view_template
|
|
11
11
|
# Purely decorative — aria-hidden only (role="separator" would
|
|
12
12
|
# contradict aria-hidden and confuse AT).
|
|
13
|
-
div(**mix({ class: "pk-button-group-separator", aria: { hidden: true } }, @attrs))
|
|
13
|
+
div(**mix({ class: "pk-button-group-separator", aria: { hidden: "true" } }, @attrs))
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
end
|
|
@@ -166,3 +166,8 @@
|
|
|
166
166
|
border-end-end-radius: var(--pk-cell-radius);
|
|
167
167
|
}
|
|
168
168
|
.pk-calendar-cell:has(> .range-start.range-end) { border-radius: var(--pk-cell-radius); }
|
|
169
|
+
|
|
170
|
+
/* RTL: the nav buttons swap sides via inset-inline-*; mirror the chevrons
|
|
171
|
+
so each arrow keeps pointing outward (toward its travel direction). */
|
|
172
|
+
[dir="rtl"] .pk-calendar-prev svg,
|
|
173
|
+
[dir="rtl"] .pk-calendar-next svg { transform: scaleX(-1); }
|
|
@@ -50,27 +50,39 @@ export default class extends Controller {
|
|
|
50
50
|
|
|
51
51
|
nextMonth(e) {
|
|
52
52
|
e.preventDefault();
|
|
53
|
-
this.
|
|
53
|
+
this.applyViewIso(this.clampViewIso(this.adjustMonth(1)));
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
prevMonth(e) {
|
|
57
57
|
e.preventDefault();
|
|
58
|
-
this.
|
|
58
|
+
this.applyViewIso(this.clampViewIso(this.adjustMonth(-1)));
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
// month/year dropdown caption (native selects)
|
|
61
|
+
// month/year dropdown caption (native selects). When clampViewIso clamps
|
|
62
|
+
// the pick back to the CURRENT view, the value write is a same-value
|
|
63
|
+
// no-op and Stimulus fires no valueChanged — render directly so the
|
|
64
|
+
// dropdown snaps back from the disallowed choice instead of desyncing
|
|
65
|
+
// from the grid.
|
|
62
66
|
setMonth(e) {
|
|
63
67
|
const date = this.viewDate();
|
|
64
68
|
date.setDate(2);
|
|
65
69
|
date.setMonth(Number(e.target.value));
|
|
66
|
-
this.
|
|
70
|
+
this.applyViewIso(this.clampViewIso(this.isoDate(date)));
|
|
67
71
|
}
|
|
68
72
|
|
|
69
73
|
setYear(e) {
|
|
70
74
|
const date = this.viewDate();
|
|
71
75
|
date.setDate(2);
|
|
72
76
|
date.setFullYear(Number(e.target.value));
|
|
73
|
-
this.
|
|
77
|
+
this.applyViewIso(this.clampViewIso(this.isoDate(date)));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
applyViewIso(iso) {
|
|
81
|
+
if (iso === this.viewDateValue) {
|
|
82
|
+
this.updateCalendar();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.viewDateValue = iso;
|
|
74
86
|
}
|
|
75
87
|
|
|
76
88
|
// --- month-navigation bounds (min/max dates + the year dropdown's range) ---
|
|
@@ -184,11 +196,17 @@ export default class extends Controller {
|
|
|
184
196
|
return;
|
|
185
197
|
}
|
|
186
198
|
|
|
187
|
-
// update the viewDateValue to the selected date's month
|
|
199
|
+
// update the viewDateValue to the selected date's month. Arm the
|
|
200
|
+
// double-render guard ONLY when the write actually changes the value:
|
|
201
|
+
// Stimulus skips valueChanged for a same-value attribute write
|
|
202
|
+
// (StringMapObserver compares before invoking), so arming on a
|
|
203
|
+
// same-month selection would leave a stale flag that swallows the next
|
|
204
|
+
// genuine navigation's re-render.
|
|
188
205
|
const newViewDate = new Date(selectedDate);
|
|
189
206
|
newViewDate.setDate(2); // avoid month-length/timezone edges
|
|
190
|
-
|
|
191
|
-
this.
|
|
207
|
+
const newViewIso = this.isoDate(newViewDate);
|
|
208
|
+
this._viewRendered = newViewIso !== this.viewDateValue;
|
|
209
|
+
this.viewDateValue = newViewIso;
|
|
192
210
|
|
|
193
211
|
this.updateCalendar();
|
|
194
212
|
|
|
@@ -6,17 +6,19 @@ module PhlexKit
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def view_template
|
|
9
|
-
|
|
9
|
+
base = {
|
|
10
10
|
type: :button,
|
|
11
11
|
name: "next-month",
|
|
12
12
|
class: "pk-calendar-next",
|
|
13
|
-
aria: { label: "Go to next month" },
|
|
14
13
|
data: {
|
|
15
14
|
action: "click->phlex-kit--calendar#nextMonth",
|
|
16
15
|
# target so the controller can disable it at the max-date/to-year bound
|
|
17
16
|
phlex_kit__calendar_target: "nextButton"
|
|
18
17
|
}
|
|
19
|
-
}
|
|
18
|
+
}
|
|
19
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
20
|
+
base[:aria] = { label: "Go to next month" } unless aria_labelled?
|
|
21
|
+
button(**mix(base, @attrs)) do
|
|
20
22
|
icon
|
|
21
23
|
end
|
|
22
24
|
end
|
|
@@ -6,17 +6,19 @@ module PhlexKit
|
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def view_template
|
|
9
|
-
|
|
9
|
+
base = {
|
|
10
10
|
type: :button,
|
|
11
11
|
name: "previous-month",
|
|
12
12
|
class: "pk-calendar-prev",
|
|
13
|
-
aria: { label: "Go to previous month" },
|
|
14
13
|
data: {
|
|
15
14
|
action: "click->phlex-kit--calendar#prevMonth",
|
|
16
15
|
# target so the controller can disable it at the min-date/from-year bound
|
|
17
16
|
phlex_kit__calendar_target: "prevButton"
|
|
18
17
|
}
|
|
19
|
-
}
|
|
18
|
+
}
|
|
19
|
+
# Default only when the caller didn't supply their own — `mix` fuses.
|
|
20
|
+
base[:aria] = { label: "Go to previous month" } unless aria_labelled?
|
|
21
|
+
button(**mix(base, @attrs)) do
|
|
20
22
|
icon
|
|
21
23
|
end
|
|
22
24
|
end
|
|
@@ -39,3 +39,10 @@
|
|
|
39
39
|
.pk-carousel.is-vertical .pk-carousel-previous { top: -3rem; left: 50%; transform: translateX(-50%) rotate(90deg); }
|
|
40
40
|
.pk-carousel.is-vertical .pk-carousel-next { bottom: -3rem; left: 50%; transform: translateX(-50%) rotate(90deg); }
|
|
41
41
|
.pk-carousel-previous svg, .pk-carousel-next svg { width: 1rem; height: 1rem; }
|
|
42
|
+
|
|
43
|
+
/* RTL (horizontal only): the scroll engine is direction-aware and the
|
|
44
|
+
buttons swap sides via inset-inline-*; mirror the arrow glyphs so they
|
|
45
|
+
point along the actual travel direction. Vertical carousels rotate the
|
|
46
|
+
whole button, which is direction-neutral. */
|
|
47
|
+
[dir="rtl"] .pk-carousel.is-horizontal .pk-carousel-previous svg,
|
|
48
|
+
[dir="rtl"] .pk-carousel.is-horizontal .pk-carousel-next svg { transform: scaleX(-1); }
|
|
@@ -16,10 +16,14 @@ module PhlexKit
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def view_template(&)
|
|
19
|
-
|
|
20
|
-
class: [ "pk-carousel", fetch_option(ORIENTATIONS, @orientation, :orientation) ].join(" ")
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
base = {
|
|
20
|
+
class: [ "pk-carousel", fetch_option(ORIENTATIONS, @orientation, :orientation) ].join(" ")
|
|
21
|
+
}
|
|
22
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses
|
|
23
|
+
# (a localized roledescription would announce both strings).
|
|
24
|
+
base[:role] = "region" unless attr_set?(:role)
|
|
25
|
+
base[:aria] = { roledescription: "carousel" } unless aria_key_set?(:roledescription)
|
|
26
|
+
div(**mix(base, {
|
|
23
27
|
data: {
|
|
24
28
|
controller: "phlex-kit--carousel",
|
|
25
29
|
phlex_kit__carousel_options_value: JSON.generate(default_options.merge(@options)),
|
|
@@ -20,8 +20,8 @@ const OFFSET_EPSILON = 0.5;
|
|
|
20
20
|
// axis from the options value) moves the track directly, so only
|
|
21
21
|
// @hotwired/stimulus is needed. Button disabled state mirrors embla's
|
|
22
22
|
// canScrollNext/canScrollPrev, and pointer drag/swipe (threshold + velocity,
|
|
23
|
-
// rubber-band at the ends
|
|
24
|
-
//
|
|
23
|
+
// rubber-band at the ends, click suppression after a drag) replaces embla's
|
|
24
|
+
// gesture engine.
|
|
25
25
|
export default class extends Controller {
|
|
26
26
|
static values = {
|
|
27
27
|
options: {
|
|
@@ -36,7 +36,14 @@ export default class extends Controller {
|
|
|
36
36
|
this.track = this.viewportTarget.firstElementChild;
|
|
37
37
|
this.drag = null;
|
|
38
38
|
this.suppressClick = false;
|
|
39
|
-
|
|
39
|
+
// connect() re-derives reflected state: a Turbo snapshot can capture
|
|
40
|
+
// mid-drag markup (dragging class kills the transition; the inline
|
|
41
|
+
// transition:none was set for the drag's duration only).
|
|
42
|
+
this.viewportTarget.classList.remove("dragging");
|
|
43
|
+
this.track?.style.removeProperty("transition");
|
|
44
|
+
// _update, not just _applyTransform: a resize can change _maxOffset,
|
|
45
|
+
// which feeds the buttons' disabled state as well as the transform.
|
|
46
|
+
this._onResize = () => this._update();
|
|
40
47
|
this._onPointerDown = this._onPointerDown.bind(this);
|
|
41
48
|
this._onPointerMove = this._onPointerMove.bind(this);
|
|
42
49
|
this._onPointerUp = this._onPointerUp.bind(this);
|
|
@@ -46,7 +53,7 @@ export default class extends Controller {
|
|
|
46
53
|
// window resize misses container-only resizes (flex/grid reflow,
|
|
47
54
|
// sidebar toggles) — observe the viewport itself.
|
|
48
55
|
if (typeof ResizeObserver !== "undefined") {
|
|
49
|
-
this._resizeObserver = new ResizeObserver(() => this.
|
|
56
|
+
this._resizeObserver = new ResizeObserver(() => this._update());
|
|
50
57
|
this._resizeObserver.observe(this.viewportTarget);
|
|
51
58
|
}
|
|
52
59
|
this.viewportTarget.addEventListener("pointerdown", this._onPointerDown);
|
|
@@ -77,18 +84,36 @@ export default class extends Controller {
|
|
|
77
84
|
}
|
|
78
85
|
|
|
79
86
|
scrollNext() {
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
if (
|
|
87
|
+
// Multi-up: the track can hit its max scrollable offset before the index
|
|
88
|
+
// reaches the last slide (_offsetOf clamps). Once there, index still has
|
|
89
|
+
// room to climb but there's nothing left to reveal — advancing anyway
|
|
90
|
+
// produces dead no-op clicks. Non-loop bails (button already disables
|
|
91
|
+
// via _update); loop wraps straight back to the start.
|
|
92
|
+
if (this._offsetOf(this.index) >= this._maxOffset() - OFFSET_EPSILON) {
|
|
93
|
+
if (this.options.loop) this._goTo(0);
|
|
86
94
|
return;
|
|
87
95
|
}
|
|
88
96
|
this._goTo(this.index + 1);
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
scrollPrev() {
|
|
100
|
+
if (this.options.loop) {
|
|
101
|
+
// Mirror of scrollNext's wrap: from the start, jump to the FIRST index
|
|
102
|
+
// that reaches the max offset (not count - 1 — with multi-up layouts
|
|
103
|
+
// every later index clamps to the same offset, so landing on count - 1
|
|
104
|
+
// would make the following Prev presses dead clicks).
|
|
105
|
+
if (this._offsetOf(this.index) <= OFFSET_EPSILON) {
|
|
106
|
+
this._goTo(this._lastReachableIndex());
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// A resize can strand the index in the clamped zone past the last
|
|
110
|
+
// reachable position — step to just before the clamp so Prev moves.
|
|
111
|
+
const last = this._lastReachableIndex();
|
|
112
|
+
if (this.index > last) {
|
|
113
|
+
this._goTo(last - 1);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
92
117
|
this._goTo(this.index - 1);
|
|
93
118
|
}
|
|
94
119
|
|
|
@@ -124,14 +149,18 @@ export default class extends Controller {
|
|
|
124
149
|
|
|
125
150
|
_update() {
|
|
126
151
|
this._applyTransform();
|
|
152
|
+
// Loop mode wraps, so the buttons stay enabled as long as there is any
|
|
153
|
+
// travel to wrap through — with every slide already visible (_maxOffset
|
|
154
|
+
// is 0) a wrap is a permanent no-op, so disable then too.
|
|
155
|
+
const canLoop = this.slides.length > 1 && this._maxOffset() > OFFSET_EPSILON;
|
|
127
156
|
const canNext = this.options.loop
|
|
128
|
-
?
|
|
157
|
+
? canLoop
|
|
129
158
|
// Offset-based, not index-based: with multi-up layouts the track
|
|
130
159
|
// reaches its max scrollable offset (_maxOffset) before this.index
|
|
131
160
|
// reaches the last slide, and _offsetOf clamps to that max — an
|
|
132
161
|
// index-based check left Next enabled for several dead clicks.
|
|
133
162
|
: this._offsetOf(this.index) < this._maxOffset() - OFFSET_EPSILON;
|
|
134
|
-
const canPrev = this.options.loop ?
|
|
163
|
+
const canPrev = this.options.loop ? canLoop : this.index > 0;
|
|
135
164
|
this.nextButtonTargets.forEach((button) => (button.disabled = !canNext));
|
|
136
165
|
this.prevButtonTargets.forEach((button) => (button.disabled = !canPrev));
|
|
137
166
|
}
|
|
@@ -173,16 +202,16 @@ export default class extends Controller {
|
|
|
173
202
|
if (!drag.moved) return;
|
|
174
203
|
|
|
175
204
|
let offset = drag.offset - drag.delta;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
205
|
+
// Rubber-band past the ends — in loop mode too: the translate engine has
|
|
206
|
+
// no cloned slides, so past-the-end is blank space either way (releasing
|
|
207
|
+
// still wraps via scrollNext/scrollPrev when looping).
|
|
208
|
+
const max = this._maxOffset();
|
|
209
|
+
if (offset < 0) offset = offset / 3;
|
|
210
|
+
if (offset > max) offset = max + (offset - max) / 3;
|
|
182
211
|
this._translateTo(offset);
|
|
183
212
|
}
|
|
184
213
|
|
|
185
|
-
_onPointerUp() {
|
|
214
|
+
_onPointerUp(e) {
|
|
186
215
|
const drag = this.drag;
|
|
187
216
|
this.drag = null;
|
|
188
217
|
this.viewportTarget.removeEventListener("pointermove", this._onPointerMove);
|
|
@@ -192,7 +221,10 @@ export default class extends Controller {
|
|
|
192
221
|
|
|
193
222
|
this.track.style.transition = "";
|
|
194
223
|
this.viewportTarget.classList.remove("dragging");
|
|
195
|
-
|
|
224
|
+
// A drag must not activate links in the slide — but a cancelled pointer
|
|
225
|
+
// sequence never fires a click, so arming the flag on pointercancel
|
|
226
|
+
// would silently swallow the next (e.g. keyboard-initiated) click.
|
|
227
|
+
this.suppressClick = e?.type !== "pointercancel";
|
|
196
228
|
|
|
197
229
|
const elapsed = Math.max(performance.now() - drag.startedAt, 1);
|
|
198
230
|
const velocity = Math.abs(drag.delta) / elapsed;
|
|
@@ -257,6 +289,18 @@ export default class extends Controller {
|
|
|
257
289
|
return Math.max(0, trackSize - viewportSize);
|
|
258
290
|
}
|
|
259
291
|
|
|
292
|
+
// First index whose (clamped) offset reaches the max scrollable offset —
|
|
293
|
+
// the last position Next can travel to. count - 1 for single-up layouts;
|
|
294
|
+
// earlier with multi-up layouts, where the trailing slides all clamp to
|
|
295
|
+
// the same offset.
|
|
296
|
+
_lastReachableIndex() {
|
|
297
|
+
const max = this._maxOffset();
|
|
298
|
+
for (let i = 0; i < this.slides.length; i += 1) {
|
|
299
|
+
if (this._offsetOf(i) >= max - OFFSET_EPSILON) return i;
|
|
300
|
+
}
|
|
301
|
+
return Math.max(0, this.slides.length - 1);
|
|
302
|
+
}
|
|
303
|
+
|
|
260
304
|
_slideSize() {
|
|
261
305
|
const slide = this.slides[this.index];
|
|
262
306
|
if (!slide) return 1;
|
|
@@ -7,11 +7,11 @@ module PhlexKit
|
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def view_template(&)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
base = { class: "pk-carousel-item" }
|
|
11
|
+
# Defaults only when the caller didn't supply their own — `mix` fuses.
|
|
12
|
+
base[:role] = "group" unless attr_set?(:role)
|
|
13
|
+
base[:aria] = { roledescription: "slide" } unless aria_key_set?(:roledescription)
|
|
14
|
+
div(**mix(base, @attrs), &)
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
end
|
|
@@ -29,7 +29,11 @@ export default class extends Controller {
|
|
|
29
29
|
this.disconnectThemeObservers()
|
|
30
30
|
this.chart?.destroy()
|
|
31
31
|
if (!this.chart) {
|
|
32
|
-
|
|
32
|
+
// Dispatch on document, not this.element: disconnect() runs AFTER the
|
|
33
|
+
// canvas has left the DOM, so an event fired on it neither bubbles nor
|
|
34
|
+
// reaches document/window — delegated host listeners would never see it.
|
|
35
|
+
// The canvas rides the detail so the host can still match its instance.
|
|
36
|
+
this.dispatch("disconnect", { target: document, detail: { canvas: this.element } })
|
|
33
37
|
}
|
|
34
38
|
}
|
|
35
39
|
|
|
@@ -48,6 +48,22 @@
|
|
|
48
48
|
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-red) 40%, transparent);
|
|
49
49
|
}
|
|
50
50
|
.pk-checkbox[aria-invalid="true"]:checked { border-color: var(--pk-brand); }
|
|
51
|
+
/* Invalid + keyboard focus: the always-on invalid ring above would otherwise
|
|
52
|
+
swallow the equal-specificity :focus-visible rule, leaving no visible focus
|
|
53
|
+
change. Keep the red border (invalid stays legible) but switch the ring to
|
|
54
|
+
the standard focus color — the theme-scoped arms outweigh the theme-scoped
|
|
55
|
+
invalid copies below. */
|
|
56
|
+
.pk-checkbox[aria-invalid="true"]:focus-visible,
|
|
57
|
+
:root[data-theme="light"] .pk-checkbox[aria-invalid="true"]:focus-visible {
|
|
58
|
+
border-color: var(--pk-red);
|
|
59
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
60
|
+
}
|
|
61
|
+
@media (prefers-color-scheme: light) {
|
|
62
|
+
:root[data-theme="system"] .pk-checkbox[aria-invalid="true"]:focus-visible {
|
|
63
|
+
border-color: var(--pk-red);
|
|
64
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
51
67
|
.pk-checkbox:disabled { cursor: not-allowed; opacity: .5; }
|
|
52
68
|
:root[data-theme="light"] .pk-checkbox:not(:checked):not(:indeterminate) { background: transparent; }
|
|
53
69
|
:root[data-theme="light"] .pk-checkbox[aria-invalid="true"] {
|