phlex_kit 0.10.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/assets/stylesheets/phlex_kit/phlex_kit.css +1 -0
- data/app/components/phlex_kit/accordion/accordion.css +7 -0
- data/app/components/phlex_kit/alert_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 +38 -7
- data/app/components/phlex_kit/attachment/attachment.css +15 -0
- data/app/components/phlex_kit/attachment/attachment_group.rb +6 -1
- data/app/components/phlex_kit/attachment/attachment_trigger.rb +8 -0
- data/app/components/phlex_kit/avatar/avatar_badge.rb +9 -2
- data/app/components/phlex_kit/badge/badge.css +1 -1
- data/app/components/phlex_kit/breadcrumb/breadcrumb.css +1 -0
- data/app/components/phlex_kit/bubble/bubble.rb +4 -6
- data/app/components/phlex_kit/bubble/bubble_reactions.rb +4 -6
- data/app/components/phlex_kit/calendar/calendar_controller.js +56 -15
- data/app/components/phlex_kit/carousel/carousel.rb +3 -2
- data/app/components/phlex_kit/carousel/carousel_controller.js +59 -4
- data/app/components/phlex_kit/chart/chart.rb +10 -2
- data/app/components/phlex_kit/chart/chart_controller.js +37 -8
- data/app/components/phlex_kit/clipboard/clipboard_controller.js +9 -2
- data/app/components/phlex_kit/clipboard/clipboard_trigger.rb +1 -1
- data/app/components/phlex_kit/codeblock/codeblock.rb +11 -1
- data/app/components/phlex_kit/collapsible/collapsible.rb +4 -1
- data/app/components/phlex_kit/combobox/combobox.css +1 -1
- data/app/components/phlex_kit/combobox/combobox_badge_trigger.rb +6 -2
- data/app/components/phlex_kit/combobox/combobox_controller.js +72 -3
- data/app/components/phlex_kit/combobox/combobox_input_trigger.rb +4 -1
- data/app/components/phlex_kit/combobox/combobox_list.rb +6 -2
- data/app/components/phlex_kit/combobox/combobox_toggle_all_checkbox.rb +4 -0
- data/app/components/phlex_kit/command/command_controller.js +23 -2
- data/app/components/phlex_kit/command/command_input.rb +3 -2
- data/app/components/phlex_kit/command/command_item.rb +4 -0
- data/app/components/phlex_kit/command/command_list.rb +6 -2
- data/app/components/phlex_kit/context_menu/context_menu.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 +74 -12
- data/app/components/phlex_kit/context_menu/context_menu_sub.rb +3 -1
- data/app/components/phlex_kit/context_menu/context_menu_sub_trigger.rb +3 -1
- data/app/components/phlex_kit/data_table/data_table.css +1 -11
- data/app/components/phlex_kit/data_table/data_table_column_toggle.rb +4 -1
- data/app/components/phlex_kit/data_table/data_table_controller.js +4 -0
- data/app/components/phlex_kit/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 +19 -2
- data/app/components/phlex_kit/date_picker/date_picker_controller.js +28 -0
- data/app/components/phlex_kit/dialog/dialog.css +3 -1
- data/app/components/phlex_kit/dialog/dialog_content.rb +1 -1
- data/app/components/phlex_kit/dialog/dialog_controller.js +16 -2
- 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 +9 -2
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu.rb +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_controller.js +81 -13
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub.rb +3 -1
- data/app/components/phlex_kit/dropdown_menu/dropdown_menu_sub_trigger.rb +3 -1
- data/app/components/phlex_kit/field/field_separator.rb +1 -1
- data/app/components/phlex_kit/form_field/form_field_controller.js +54 -25
- data/app/components/phlex_kit/form_field/form_field_hint.rb +6 -2
- data/app/components/phlex_kit/hover_card/hover_card.css +10 -2
- data/app/components/phlex_kit/hover_card/hover_card_controller.js +24 -0
- data/app/components/phlex_kit/icon/icon.css +9 -0
- data/app/components/phlex_kit/icon/icon.rb +7 -1
- data/app/components/phlex_kit/input/input.css +0 -1
- data/app/components/phlex_kit/input_group/input_group.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/marker/marker.rb +9 -2
- data/app/components/phlex_kit/masked_input/masked_input_controller.js +20 -14
- data/app/components/phlex_kit/menubar/menubar.css +3 -0
- data/app/components/phlex_kit/menubar/menubar_controller.js +46 -16
- data/app/components/phlex_kit/message/message.rb +5 -0
- data/app/components/phlex_kit/message_scroller/message_scroller_controller.js +3 -0
- data/app/components/phlex_kit/native_select/native_select.css +14 -0
- data/app/components/phlex_kit/native_select/native_select.rb +12 -3
- data/app/components/phlex_kit/navigation_menu/navigation_menu.rb +4 -1
- data/app/components/phlex_kit/navigation_menu/navigation_menu_link.rb +8 -1
- data/app/components/phlex_kit/pagination/pagination.rb +10 -2
- data/app/components/phlex_kit/pagination/pagination_next.rb +3 -1
- data/app/components/phlex_kit/pagination/pagination_previous.rb +3 -1
- data/app/components/phlex_kit/popover/popover_controller.js +42 -1
- data/app/components/phlex_kit/progress/progress.css +8 -0
- data/app/components/phlex_kit/progress/progress.rb +14 -5
- data/app/components/phlex_kit/resizable/resizable_controller.js +3 -0
- data/app/components/phlex_kit/scroll_area/scroll_area.rb +5 -15
- data/app/components/phlex_kit/select/select.css +5 -2
- data/app/components/phlex_kit/select/select_controller.js +49 -4
- data/app/components/phlex_kit/select/select_trigger.rb +4 -1
- data/app/components/phlex_kit/separator/separator.rb +5 -1
- data/app/components/phlex_kit/sheet/sheet.css +4 -2
- data/app/components/phlex_kit/sheet/sheet_content.rb +13 -3
- data/app/components/phlex_kit/sheet/sheet_content_controller.js +40 -0
- data/app/components/phlex_kit/sheet/sheet_controller.js +31 -1
- data/app/components/phlex_kit/sidebar/sidebar_menu_button.rb +9 -1
- data/app/components/phlex_kit/sidebar/sidebar_menu_sub_button.rb +8 -1
- data/app/components/phlex_kit/slider/slider.css +15 -0
- data/app/components/phlex_kit/spinner/spinner.rb +6 -3
- data/app/components/phlex_kit/switch/switch.css +5 -0
- data/app/components/phlex_kit/switch/switch.rb +6 -0
- data/app/components/phlex_kit/table/table.css +2 -1
- data/app/components/phlex_kit/table/table.rb +5 -2
- data/app/components/phlex_kit/tabs/tabs_content.rb +4 -2
- data/app/components/phlex_kit/tabs/tabs_controller.js +8 -2
- data/app/components/phlex_kit/tabs/tabs_list.rb +7 -2
- data/app/components/phlex_kit/tabs/tabs_trigger.rb +11 -2
- data/app/components/phlex_kit/theme_toggle/theme_toggle_controller.js +14 -0
- data/app/components/phlex_kit/toast/toast.css +2 -1
- data/app/components/phlex_kit/toast/toast_action.rb +4 -1
- data/app/components/phlex_kit/toast/toast_close.rb +6 -3
- data/app/components/phlex_kit/toast/toast_controller.js +40 -21
- data/app/components/phlex_kit/toast/toast_item.rb +11 -7
- data/app/components/phlex_kit/toast/toast_region.rb +29 -10
- data/app/components/phlex_kit/toast/toaster_controller.js +17 -7
- data/app/components/phlex_kit/toggle_group/toggle_group.rb +17 -2
- data/app/components/phlex_kit/toggle_group/toggle_group_controller.js +16 -9
- data/app/components/phlex_kit/toggle_group/toggle_group_item.rb +20 -2
- data/app/components/phlex_kit/tooltip/tooltip.css +5 -1
- data/app/components/phlex_kit/tooltip/tooltip_controller.js +7 -1
- data/app/components/phlex_kit/typography/heading.rb +7 -1
- data/app/components/phlex_kit/typography/list.rb +22 -0
- data/app/components/phlex_kit/typography/text.rb +7 -1
- data/app/javascript/phlex_kit/controllers/index.js +2 -0
- data/lib/phlex_kit/base_component.rb +23 -0
- data/lib/phlex_kit/version.rb +1 -1
- data/lib/phlex_kit.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 832d7d74c2bcda4dcddef6ab391ee7afff8c15a3f011b57392dfb0e5d73b9922
|
|
4
|
+
data.tar.gz: 730dd9af5257a9350c551d9b5573d919cd04dd462a19f38d55558aa282d16822
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 545d9af3c5b4ef67ff2793618825b17fcfff6a42fd7a7e97ca2b294d447d71ded63858094dc32f938dcdabd9c39ac4c8696bdf68da4b74cf1692bf62770d8081
|
|
7
|
+
data.tar.gz: 2a98a887feff3cf7a2144baeb9f380738880f2c100e6ed3ecc3ab2a4a1d94038efe06531b725eede6e1e419c4f9a282b109b6cb2db49328f32962819a17417c3
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
@import url("form/form.css");
|
|
45
45
|
@import url("form_field/form_field.css");
|
|
46
46
|
@import url("hover_card/hover_card.css");
|
|
47
|
+
@import url("icon/icon.css");
|
|
47
48
|
@import url("input/input.css");
|
|
48
49
|
@import url("input_group/input_group.css");
|
|
49
50
|
@import url("input_otp/input_otp.css");
|
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
background: none; border: 0; cursor: pointer; color: inherit; transition: color .15s ease, background-color .15s ease, text-decoration-color .15s ease;
|
|
10
10
|
}
|
|
11
11
|
.pk-accordion-trigger:hover { text-decoration: underline; }
|
|
12
|
+
/* background/border are stripped above — reinstate the kit focus ring or
|
|
13
|
+
keyboard focus falls back to the UA outline. */
|
|
14
|
+
.pk-accordion-trigger:focus-visible {
|
|
15
|
+
outline: none;
|
|
16
|
+
border-radius: calc(var(--pk-radius) - 2px);
|
|
17
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pk-ring) 50%, transparent);
|
|
18
|
+
}
|
|
12
19
|
.pk-accordion-trigger-label { flex: 1; }
|
|
13
20
|
.pk-accordion-icon { display: inline-flex; opacity: .5; transition: transform .2s ease; }
|
|
14
21
|
.pk-accordion-icon svg { width: 1rem; height: 1rem; }
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
gap: 1rem;
|
|
23
23
|
width: calc(100% - 2rem);
|
|
24
24
|
max-width: 32rem;
|
|
25
|
-
max-height
|
|
25
|
+
/* Breathing room like dialog.css's max-height — 100vh touched the screen
|
|
26
|
+
edges and overflowed behind mobile browser chrome (dvh tracks it). */
|
|
27
|
+
max-height: calc(100dvh - 2rem);
|
|
26
28
|
overflow-y: auto;
|
|
27
29
|
border: 1px solid var(--pk-border);
|
|
28
30
|
border-radius: var(--pk-radius);
|
|
@@ -20,9 +20,20 @@ module PhlexKit
|
|
|
20
20
|
# mousedown on the overlay is prevented so a stray click can't move
|
|
21
21
|
# focus out of the trap. tabindex="-1" on the panel is the focus
|
|
22
22
|
# fallback when the dialog has no focusable children.
|
|
23
|
-
|
|
23
|
+
# data-pk-overlay-clone is a common marker read across clone-based
|
|
24
|
+
# overlay families (see #topmost in alert_dialog_controller.js) so
|
|
25
|
+
# Escape resolves to whichever overlay is actually topmost, even when
|
|
26
|
+
# a different overlay type (e.g. a Sheet) is nested/stacked on top.
|
|
27
|
+
div(data: { controller: "phlex-kit--alert-dialog", pk_overlay_clone: "" }) do
|
|
24
28
|
div(class: "pk-alert-dialog-overlay", "aria-hidden": "true", data: { action: "mousedown->phlex-kit--alert-dialog#overlayMousedown" })
|
|
25
|
-
|
|
29
|
+
panel_attrs = { class: [ "pk-alert-dialog-panel", fetch_option(SIZES, @size, :size) ].compact.join(" ") }
|
|
30
|
+
# Defaults only when the caller didn't supply their own — `mix`
|
|
31
|
+
# would fuse role="alertdialog dialog" / aria-modal="true false" /
|
|
32
|
+
# tabindex="-1 0" instead of overriding.
|
|
33
|
+
panel_attrs[:role] = "alertdialog" unless @attrs.key?(:role) || @attrs.key?("role")
|
|
34
|
+
panel_attrs[:"aria-modal"] = "true" unless aria_key_set?(:modal)
|
|
35
|
+
panel_attrs[:tabindex] = "-1" unless @attrs.key?(:tabindex) || @attrs.key?("tabindex")
|
|
36
|
+
div(**mix(panel_attrs, @attrs), &block)
|
|
26
37
|
end
|
|
27
38
|
end
|
|
28
39
|
end
|
|
@@ -29,7 +29,17 @@ export default class extends Controller {
|
|
|
29
29
|
this.previousOverflow = document.body.style.overflow;
|
|
30
30
|
document.body.style.overflow = "hidden";
|
|
31
31
|
this.onKeydown = (event) => this.keydown(event);
|
|
32
|
-
|
|
32
|
+
// Turbo snapshots synchronously right after dispatching this event —
|
|
33
|
+
// BEFORE Stimulus disconnect() runs (it fires via MutationObserver, too
|
|
34
|
+
// late). Restore inert + scroll lock here so the snapshot never captures
|
|
35
|
+
// a fully-inerted, scroll-locked page; disconnect()'s later restore
|
|
36
|
+
// becomes a harmless no-op (#restoreInert clears `this.inerted`, and
|
|
37
|
+
// reassigning the same overflow value is idempotent).
|
|
38
|
+
this.beforeCache = () => {
|
|
39
|
+
this.#restoreInert();
|
|
40
|
+
document.body.style.overflow = this.previousOverflow;
|
|
41
|
+
this.element.remove();
|
|
42
|
+
};
|
|
33
43
|
document.addEventListener("keydown", this.onKeydown);
|
|
34
44
|
document.addEventListener("turbo:before-cache", this.beforeCache);
|
|
35
45
|
this.#inertOthers();
|
|
@@ -40,7 +50,16 @@ export default class extends Controller {
|
|
|
40
50
|
}
|
|
41
51
|
|
|
42
52
|
disconnect() {
|
|
43
|
-
if (this.hasContentTarget)
|
|
53
|
+
if (this.hasContentTarget) {
|
|
54
|
+
// The source element (holding the template) can disconnect while a
|
|
55
|
+
// clone it spawned is still live in <body> — e.g. Turbo replacing this
|
|
56
|
+
// region out from under an open dialog. Remove the orphan rather than
|
|
57
|
+
// leaving a modal nothing can dismiss. Idempotent: if the clone
|
|
58
|
+
// already removed itself (Cancel, Escape, its own before-cache),
|
|
59
|
+
// this.clone is disconnected and the isConnected check no-ops.
|
|
60
|
+
if (this.clone?.isConnected) this.clone.remove();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
44
63
|
document.removeEventListener("keydown", this.onKeydown);
|
|
45
64
|
document.removeEventListener("turbo:before-cache", this.beforeCache);
|
|
46
65
|
this.#restoreInert();
|
|
@@ -52,6 +71,9 @@ export default class extends Controller {
|
|
|
52
71
|
// Double-open guard: Enter+click (or a double-click) on the trigger must
|
|
53
72
|
// not stack a second clone over the first.
|
|
54
73
|
if (this.clone?.isConnected) return;
|
|
74
|
+
// open: is one-shot — the reflected value sits in the Turbo snapshot, so
|
|
75
|
+
// a cache-restored reconnect would re-open a dialog the user dismissed.
|
|
76
|
+
this.openValue = false;
|
|
55
77
|
document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML);
|
|
56
78
|
this.clone = document.body.lastElementChild;
|
|
57
79
|
}
|
|
@@ -73,6 +95,12 @@ export default class extends Controller {
|
|
|
73
95
|
// handle the keyboard, or one Escape would close every layer at once.
|
|
74
96
|
if (!this.#topmost()) return;
|
|
75
97
|
if (event.key === "Escape") {
|
|
98
|
+
// A native <dialog> nested inside this panel (e.g. a Dialog opened
|
|
99
|
+
// from within AlertDialogContent) owns its own Escape handling; this
|
|
100
|
+
// document-level listener still sees the keydown since it bubbles all
|
|
101
|
+
// the way up — ignore it so one Escape doesn't also dismiss the alert
|
|
102
|
+
// dialog underneath (mirrors sheet_content_controller's guard).
|
|
103
|
+
if (event.target.closest("dialog[open]")) return;
|
|
76
104
|
event.preventDefault();
|
|
77
105
|
this.dismiss();
|
|
78
106
|
return;
|
|
@@ -101,12 +129,15 @@ export default class extends Controller {
|
|
|
101
129
|
}
|
|
102
130
|
}
|
|
103
131
|
|
|
104
|
-
// Clones are appended as direct <body> children
|
|
105
|
-
//
|
|
106
|
-
//
|
|
132
|
+
// Clones are appended as direct <body> children. Other clone-based overlay
|
|
133
|
+
// families (sheet/drawer content) stamp the same [data-pk-overlay-clone]
|
|
134
|
+
// marker on their own clone root, so this checks z-stacking across overlay
|
|
135
|
+
// TYPES, not just other alert dialogs — a sheet opened from inside an
|
|
136
|
+
// alert dialog (or vice versa) is a later sibling and must absorb Escape
|
|
137
|
+
// first; only the last overlay clone overall may act on it.
|
|
107
138
|
#topmost() {
|
|
108
|
-
const clones = document.body.querySelectorAll(
|
|
109
|
-
return clones.length > 0 && clones[clones.length - 1]
|
|
139
|
+
const clones = document.body.querySelectorAll(":scope > [data-pk-overlay-clone]");
|
|
140
|
+
return clones.length > 0 && clones[clones.length - 1] === this.element;
|
|
110
141
|
}
|
|
111
142
|
|
|
112
143
|
#panel() {
|
|
@@ -118,6 +118,21 @@
|
|
|
118
118
|
-webkit-text-fill-color: transparent;
|
|
119
119
|
animation: pk-shimmer 2s linear infinite;
|
|
120
120
|
}
|
|
121
|
+
/* Same two fallback arms as _tokens.css's .pk-shimmer (:49-54): never let the
|
|
122
|
+
filename disappear under reduced motion or without relative color syntax
|
|
123
|
+
(Safari <16.4 / Chrome <119). */
|
|
124
|
+
@media (prefers-reduced-motion: reduce) {
|
|
125
|
+
.pk-attachment[data-state="uploading"] .pk-attachment-title,
|
|
126
|
+
.pk-attachment[data-state="processing"] .pk-attachment-title {
|
|
127
|
+
animation: none; background-image: none; -webkit-text-fill-color: currentColor;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
@supports not (color: oklch(from white l c h)) {
|
|
131
|
+
.pk-attachment[data-state="uploading"] .pk-attachment-title,
|
|
132
|
+
.pk-attachment[data-state="processing"] .pk-attachment-title {
|
|
133
|
+
animation: none; background-image: none; -webkit-text-fill-color: currentColor;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
121
136
|
.pk-attachment[data-state="error"] { border-color: color-mix(in oklab, var(--pk-red) 40%, transparent); }
|
|
122
137
|
.pk-attachment[data-state="error"] .pk-attachment-media { color: var(--pk-red); background: color-mix(in oklab, var(--pk-red) 12%, transparent); }
|
|
123
138
|
.pk-attachment[data-state="error"] .pk-attachment-description { color: var(--pk-red); }
|
|
@@ -11,9 +11,14 @@ module PhlexKit
|
|
|
11
11
|
div(**mix({
|
|
12
12
|
class: "pk-attachment-group",
|
|
13
13
|
role: "group",
|
|
14
|
+
# at-start + at-end = no edge fades. Server-rendered so a group that
|
|
15
|
+
# FITS shows no false "more content" fade pre-hydration / with JS off;
|
|
16
|
+
# the controller's first update() removes them when there's overflow.
|
|
14
17
|
data: {
|
|
15
18
|
controller: "phlex-kit--scroll-fade",
|
|
16
|
-
phlex_kit__scroll_fade_axis_value: "x"
|
|
19
|
+
phlex_kit__scroll_fade_axis_value: "x",
|
|
20
|
+
at_start: "",
|
|
21
|
+
at_end: ""
|
|
17
22
|
}
|
|
18
23
|
}, @attrs), &)
|
|
19
24
|
end
|
|
@@ -4,8 +4,16 @@ module PhlexKit
|
|
|
4
4
|
# stay independently clickable. Give it an aria-label; pass `as: :a` +
|
|
5
5
|
# `href:` for a link. See attachment.rb.
|
|
6
6
|
class AttachmentTrigger < BaseComponent
|
|
7
|
+
AS_TAGS = %i[button a].freeze
|
|
8
|
+
|
|
7
9
|
def initialize(as: :button, href: nil, **attrs)
|
|
8
10
|
@as = as.to_sym
|
|
11
|
+
unless AS_TAGS.include?(@as)
|
|
12
|
+
raise ArgumentError, "unknown AttachmentTrigger as: #{@as.inspect} (use one of #{AS_TAGS.join(", ")})"
|
|
13
|
+
end
|
|
14
|
+
if @as == :a && href.nil?
|
|
15
|
+
raise ArgumentError, "AttachmentTrigger requires href: when as: :a"
|
|
16
|
+
end
|
|
9
17
|
@href = href
|
|
10
18
|
@attrs = attrs
|
|
11
19
|
end
|
|
@@ -4,12 +4,19 @@ module PhlexKit
|
|
|
4
4
|
# svg glyph (hidden automatically on xs/sm avatars). Recolor via style/class
|
|
5
5
|
# — defaults to the brand fill. See avatar.rb.
|
|
6
6
|
class AvatarBadge < BaseComponent
|
|
7
|
-
|
|
7
|
+
# label: gives the color-only presence dot an accessible alternative
|
|
8
|
+
# (a .pk-sr-only span, like AttachmentAction's). No default — a purely
|
|
9
|
+
# decorative dot stays silent.
|
|
10
|
+
def initialize(label: nil, **attrs)
|
|
11
|
+
@label = label
|
|
8
12
|
@attrs = attrs
|
|
9
13
|
end
|
|
10
14
|
|
|
11
15
|
def view_template(&block)
|
|
12
|
-
span(**mix({ class: "pk-avatar-badge" }, @attrs)
|
|
16
|
+
span(**mix({ class: "pk-avatar-badge" }, @attrs)) do
|
|
17
|
+
span(class: "pk-sr-only") { @label } if @label
|
|
18
|
+
block&.call
|
|
19
|
+
end
|
|
13
20
|
end
|
|
14
21
|
end
|
|
15
22
|
end
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
overflow: hidden;
|
|
16
16
|
white-space: nowrap;
|
|
17
17
|
height: 1.25rem;
|
|
18
|
-
border-radius:
|
|
18
|
+
border-radius: 999px; /* fully-round pill (matches avatar/progress), not a --pk-radius derivative */
|
|
19
19
|
border: 1px solid transparent;
|
|
20
20
|
padding: .125rem .5rem;
|
|
21
21
|
font-size: .75rem;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
display: flex; flex-wrap: wrap; align-items: center;
|
|
4
4
|
gap: .375rem; word-break: break-word;
|
|
5
5
|
font-size: .875rem; color: var(--pk-muted);
|
|
6
|
+
list-style: none; margin: 0; padding: 0;
|
|
6
7
|
}
|
|
7
8
|
.pk-breadcrumb-item { display: inline-flex; align-items: center; gap: .25rem; }
|
|
8
9
|
.pk-breadcrumb-link { color: inherit; text-decoration: none; transition: color .15s ease; }
|
|
@@ -3,13 +3,11 @@ module PhlexKit
|
|
|
3
3
|
# the child BubbleContent via a data-slot selector; align: :end flips it to the
|
|
4
4
|
# trailing edge. Compose Bubble > BubbleContent (+ BubbleReactions).
|
|
5
5
|
class Bubble < BaseComponent
|
|
6
|
-
VARIANTS = %i[default secondary muted tinted outline ghost destructive].freeze
|
|
7
|
-
ALIGNS = %i[start end].freeze
|
|
6
|
+
VARIANTS = %i[default secondary muted tinted outline ghost destructive].to_h { |v| [ v, v ] }.freeze
|
|
7
|
+
ALIGNS = %i[start end].to_h { |v| [ v, v ] }.freeze
|
|
8
8
|
def initialize(variant: :default, align: :start, **attrs)
|
|
9
|
-
@variant = variant.to_sym
|
|
10
|
-
|
|
11
|
-
@align = align.to_sym
|
|
12
|
-
raise KeyError, "unknown Bubble align #{@align}" unless ALIGNS.include?(@align)
|
|
9
|
+
@variant = fetch_option(VARIANTS, variant.to_sym, :variant)
|
|
10
|
+
@align = fetch_option(ALIGNS, align.to_sym, :align)
|
|
13
11
|
@attrs = attrs
|
|
14
12
|
end
|
|
15
13
|
def view_template(&)
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
module PhlexKit
|
|
2
2
|
class BubbleReactions < BaseComponent
|
|
3
|
-
SIDES = %i[top bottom].freeze
|
|
4
|
-
ALIGNS = %i[start end].freeze
|
|
3
|
+
SIDES = %i[top bottom].to_h { |v| [ v, v ] }.freeze
|
|
4
|
+
ALIGNS = %i[start end].to_h { |v| [ v, v ] }.freeze
|
|
5
5
|
def initialize(side: :bottom, align: :end, **attrs)
|
|
6
|
-
@side = side.to_sym
|
|
7
|
-
|
|
8
|
-
@align = align.to_sym
|
|
9
|
-
raise KeyError, "unknown BubbleReactions align #{@align}" unless ALIGNS.include?(@align)
|
|
6
|
+
@side = fetch_option(SIDES, side.to_sym, :side)
|
|
7
|
+
@align = fetch_option(ALIGNS, align.to_sym, :align)
|
|
10
8
|
@attrs = attrs
|
|
11
9
|
end
|
|
12
10
|
def view_template(&)
|
|
@@ -159,7 +159,9 @@ export default class extends Controller {
|
|
|
159
159
|
|
|
160
160
|
toggleMultipleDay(iso) {
|
|
161
161
|
const day = this.isoDate(this.parseDate(iso));
|
|
162
|
-
|
|
162
|
+
// Normalized copy: server-supplied entries may be datetime strings
|
|
163
|
+
// (Time#to_s) — indexOf against the bare isoDate must still match.
|
|
164
|
+
const current = this.normalizeDates(this.selectedDatesValue);
|
|
163
165
|
const index = current.indexOf(day);
|
|
164
166
|
if (index >= 0) {
|
|
165
167
|
current.splice(index, 1);
|
|
@@ -241,7 +243,10 @@ export default class extends Controller {
|
|
|
241
243
|
return this.isoDate(date);
|
|
242
244
|
}
|
|
243
245
|
|
|
244
|
-
|
|
246
|
+
// `focusDay` overrides the day to refocus after re-render: on a keyboard
|
|
247
|
+
// month cross the target day lives in the NEW grid, not the pre-render
|
|
248
|
+
// activeElement, so onKeydown passes it explicitly.
|
|
249
|
+
updateCalendar(focusDay = null) {
|
|
245
250
|
if (this.hasTitleTarget) {
|
|
246
251
|
this.titleTarget.textContent = this.monthAndYear();
|
|
247
252
|
}
|
|
@@ -249,9 +254,11 @@ export default class extends Controller {
|
|
|
249
254
|
this.syncNavButtons();
|
|
250
255
|
// innerHTML replacement drops focus — remember the focused day so
|
|
251
256
|
// keyboard selection/month hops don't strand focus on <body>.
|
|
252
|
-
const focusedDay =
|
|
253
|
-
|
|
254
|
-
|
|
257
|
+
const focusedDay =
|
|
258
|
+
focusDay ||
|
|
259
|
+
(this.calendarTarget.contains(document.activeElement)
|
|
260
|
+
? document.activeElement.dataset?.day
|
|
261
|
+
: null);
|
|
255
262
|
this.calendarTarget.innerHTML = this.calendarHTML();
|
|
256
263
|
this.ensureGridTabStop(focusedDay);
|
|
257
264
|
}
|
|
@@ -280,7 +287,11 @@ export default class extends Controller {
|
|
|
280
287
|
// boundary re-renders the grid on the target month. Enter/Space activate
|
|
281
288
|
// natively (the days are real <button>s).
|
|
282
289
|
onKeydown(e) {
|
|
283
|
-
|
|
290
|
+
// In RTL the day grid mirrors: the physical LEFT arrow advances one day.
|
|
291
|
+
// The week steps (Up/Down) and the PageUp/Down/Home/End jumps are
|
|
292
|
+
// unaffected. Runtime dir check is reliable after a dynamic flip.
|
|
293
|
+
const rtl = getComputedStyle(this.element).direction === "rtl";
|
|
294
|
+
const STEPS = { ArrowLeft: rtl ? 1 : -1, ArrowRight: rtl ? -1 : 1, ArrowUp: -7, ArrowDown: 7 };
|
|
284
295
|
const day = e.target.closest?.("[data-day]");
|
|
285
296
|
if (!day) return;
|
|
286
297
|
|
|
@@ -303,11 +314,29 @@ export default class extends Controller {
|
|
|
303
314
|
}
|
|
304
315
|
e.preventDefault();
|
|
305
316
|
|
|
306
|
-
|
|
307
|
-
if (this.isDateDisabled(iso))
|
|
317
|
+
let iso = this.isoDate(target);
|
|
318
|
+
if (this.isDateDisabled(iso)) {
|
|
319
|
+
// A min/max boundary holds focus in place; an isolated booked date is
|
|
320
|
+
// SKIPPED in the travel direction (APG date-grid) — arrows only, the
|
|
321
|
+
// month/row jumps (PageUp/Home/End) still stay put.
|
|
322
|
+
if (!(e.key in STEPS) || this.outOfRange(target)) return;
|
|
323
|
+
do {
|
|
324
|
+
target.setDate(target.getDate() + STEPS[e.key]);
|
|
325
|
+
if (this.outOfRange(target)) return;
|
|
326
|
+
} while (this.isBooked(target));
|
|
327
|
+
iso = this.isoDate(target);
|
|
328
|
+
}
|
|
308
329
|
|
|
309
330
|
if (target.getMonth() !== this.viewDate().getMonth() || target.getFullYear() !== this.viewDate().getFullYear()) {
|
|
310
|
-
|
|
331
|
+
// Crossing a month re-renders the grid on the target month. Stimulus
|
|
332
|
+
// value-changed callbacks fire via MutationObserver (async), so the grid
|
|
333
|
+
// is NOT re-rendered synchronously after this assignment — querying it
|
|
334
|
+
// below would hit the OLD month, and the target day (outside it) would be
|
|
335
|
+
// missing → early return, stranding focus on <body>. Drive the re-render
|
|
336
|
+
// ourselves and hand it the target day so ensureGridTabStop focuses it.
|
|
337
|
+
this.viewDateValue = iso;
|
|
338
|
+
this.updateCalendar(iso);
|
|
339
|
+
return;
|
|
311
340
|
}
|
|
312
341
|
const next = this.calendarTarget.querySelector(`[data-day="${iso}"]:not([disabled])`);
|
|
313
342
|
if (!next) return;
|
|
@@ -412,7 +441,7 @@ export default class extends Controller {
|
|
|
412
441
|
);
|
|
413
442
|
}
|
|
414
443
|
if (this.modeValue === "multiple") {
|
|
415
|
-
return this.selectedDatesValue.includes(this.isoDate(day));
|
|
444
|
+
return this.normalizeDates(this.selectedDatesValue).includes(this.isoDate(day));
|
|
416
445
|
}
|
|
417
446
|
const selectedDate = this.selectedDate();
|
|
418
447
|
return selectedDate && day.toDateString() === selectedDate.toDateString();
|
|
@@ -428,7 +457,17 @@ export default class extends Controller {
|
|
|
428
457
|
}
|
|
429
458
|
|
|
430
459
|
isBooked(day) {
|
|
431
|
-
return this.disabledDatesValue.includes(this.isoDate(day));
|
|
460
|
+
return this.normalizeDates(this.disabledDatesValue).includes(this.isoDate(day));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// disabled_dates / selected_dates entries may be Time#to_s or datetime ISO
|
|
464
|
+
// strings — every other date input tolerates those via parseDate, so the
|
|
465
|
+
// membership arrays must be normalized the same way (exact string equality
|
|
466
|
+
// silently no-oped for anything but bare yyyy-MM-dd).
|
|
467
|
+
normalizeDates(values) {
|
|
468
|
+
return values
|
|
469
|
+
.map((d) => { const parsed = this.parseDate(d); return parsed ? this.isoDate(parsed) : null; })
|
|
470
|
+
.filter(Boolean);
|
|
432
471
|
}
|
|
433
472
|
|
|
434
473
|
monthAndYear() {
|
|
@@ -575,14 +614,16 @@ export default class extends Controller {
|
|
|
575
614
|
isDateDisabled(date) {
|
|
576
615
|
const candidate = this.parseDate(date);
|
|
577
616
|
if (!candidate) return false;
|
|
617
|
+
return this.outOfRange(candidate) || this.isBooked(candidate);
|
|
618
|
+
}
|
|
578
619
|
|
|
620
|
+
// min/max bounds only (not booked entries) — the keyboard skip needs to
|
|
621
|
+
// distinguish a hard boundary from a hole in the middle of the grid.
|
|
622
|
+
outOfRange(candidate) {
|
|
579
623
|
const minDate = this.minDate();
|
|
580
624
|
if (minDate && this.startOfDay(candidate) < this.startOfDay(minDate)) return true;
|
|
581
|
-
|
|
582
625
|
const maxDate = this.maxDate();
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
return this.disabledDatesValue.includes(this.isoDate(candidate));
|
|
626
|
+
return !!(maxDate && this.startOfDay(candidate) > this.startOfDay(maxDate));
|
|
586
627
|
}
|
|
587
628
|
|
|
588
629
|
parseDate(value) {
|
|
@@ -35,12 +35,13 @@ module PhlexKit
|
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Arrow keys follow the scroll axis: left/right for horizontal, up/down
|
|
38
|
-
# for vertical.
|
|
38
|
+
# for vertical. Horizontal arrows route through keyNext/keyPrev, which
|
|
39
|
+
# flip in RTL (the next slide sits to the physical left there).
|
|
39
40
|
def keyboard_action
|
|
40
41
|
if @orientation == :vertical
|
|
41
42
|
"keydown.down->phlex-kit--carousel#scrollNext:prevent keydown.up->phlex-kit--carousel#scrollPrev:prevent"
|
|
42
43
|
else
|
|
43
|
-
"keydown.right->phlex-kit--carousel#
|
|
44
|
+
"keydown.right->phlex-kit--carousel#keyNext:prevent keydown.left->phlex-kit--carousel#keyPrev:prevent"
|
|
44
45
|
end
|
|
45
46
|
end
|
|
46
47
|
end
|
|
@@ -10,6 +10,11 @@ const DEFAULT_OPTIONS = {
|
|
|
10
10
|
const DRAG_DISTANCE_THRESHOLD = 0.25;
|
|
11
11
|
const DRAG_VELOCITY_THRESHOLD = 0.5;
|
|
12
12
|
|
|
13
|
+
// Subpixel slop when comparing the current offset to the max scrollable
|
|
14
|
+
// offset — layout rounding can leave a fraction of a px short of the true
|
|
15
|
+
// max, which would otherwise read as "can still scroll".
|
|
16
|
+
const OFFSET_EPSILON = 0.5;
|
|
17
|
+
|
|
13
18
|
// Ported from ruby_ui's carousel controller with the embla-carousel dependency
|
|
14
19
|
// removed: a minimal translate-based engine (scrollNext/scrollPrev, loop, x/y
|
|
15
20
|
// axis from the options value) moves the track directly, so only
|
|
@@ -36,6 +41,7 @@ export default class extends Controller {
|
|
|
36
41
|
this._onPointerMove = this._onPointerMove.bind(this);
|
|
37
42
|
this._onPointerUp = this._onPointerUp.bind(this);
|
|
38
43
|
this._onClickCapture = this._onClickCapture.bind(this);
|
|
44
|
+
this._onDragStart = this._onDragStart.bind(this);
|
|
39
45
|
window.addEventListener("resize", this._onResize);
|
|
40
46
|
// window resize misses container-only resizes (flex/grid reflow,
|
|
41
47
|
// sidebar toggles) — observe the viewport itself.
|
|
@@ -45,6 +51,10 @@ export default class extends Controller {
|
|
|
45
51
|
}
|
|
46
52
|
this.viewportTarget.addEventListener("pointerdown", this._onPointerDown);
|
|
47
53
|
this.viewportTarget.addEventListener("click", this._onClickCapture, true);
|
|
54
|
+
// Firefox starts a native image drag on pointerdown-over-<img>, which
|
|
55
|
+
// cancels the pointer sequence mid-swipe; -webkit-user-drag (CSS) is
|
|
56
|
+
// WebKit-only, so suppress it here for every browser.
|
|
57
|
+
this.viewportTarget.addEventListener("dragstart", this._onDragStart);
|
|
48
58
|
this._update();
|
|
49
59
|
}
|
|
50
60
|
|
|
@@ -54,6 +64,7 @@ export default class extends Controller {
|
|
|
54
64
|
this._resizeObserver = null;
|
|
55
65
|
this.viewportTarget.removeEventListener("pointerdown", this._onPointerDown);
|
|
56
66
|
this.viewportTarget.removeEventListener("click", this._onClickCapture, true);
|
|
67
|
+
this.viewportTarget.removeEventListener("dragstart", this._onDragStart);
|
|
57
68
|
// a disconnect mid-drag must drop the move/up listeners and drag state
|
|
58
69
|
this.viewportTarget.removeEventListener("pointermove", this._onPointerMove);
|
|
59
70
|
this.viewportTarget.removeEventListener("pointerup", this._onPointerUp);
|
|
@@ -66,6 +77,14 @@ export default class extends Controller {
|
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
scrollNext() {
|
|
80
|
+
// Non-loop multi-up: the track can hit its max scrollable offset before
|
|
81
|
+
// the index reaches the last slide (_offsetOf clamps). Once there, index
|
|
82
|
+
// still has room to climb but there's nothing left to reveal — bail so
|
|
83
|
+
// neither clicks (button already disables via _update) nor ArrowRight
|
|
84
|
+
// (keyNext, which routes here) produce dead no-op advances.
|
|
85
|
+
if (!this.options.loop && this._offsetOf(this.index) >= this._maxOffset() - OFFSET_EPSILON) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
69
88
|
this._goTo(this.index + 1);
|
|
70
89
|
}
|
|
71
90
|
|
|
@@ -73,6 +92,16 @@ export default class extends Controller {
|
|
|
73
92
|
this._goTo(this.index - 1);
|
|
74
93
|
}
|
|
75
94
|
|
|
95
|
+
// Physical arrow keys (horizontal axis only): in RTL the next slide sits
|
|
96
|
+
// to the physical LEFT, so the right arrow must travel toward "previous".
|
|
97
|
+
keyNext() {
|
|
98
|
+
this._rtl() ? this.scrollPrev() : this.scrollNext();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
keyPrev() {
|
|
102
|
+
this._rtl() ? this.scrollNext() : this.scrollPrev();
|
|
103
|
+
}
|
|
104
|
+
|
|
76
105
|
get slides() {
|
|
77
106
|
return this.track ? Array.from(this.track.children) : [];
|
|
78
107
|
}
|
|
@@ -95,8 +124,13 @@ export default class extends Controller {
|
|
|
95
124
|
|
|
96
125
|
_update() {
|
|
97
126
|
this._applyTransform();
|
|
98
|
-
const
|
|
99
|
-
|
|
127
|
+
const canNext = this.options.loop
|
|
128
|
+
? this.slides.length > 1
|
|
129
|
+
// Offset-based, not index-based: with multi-up layouts the track
|
|
130
|
+
// reaches its max scrollable offset (_maxOffset) before this.index
|
|
131
|
+
// reaches the last slide, and _offsetOf clamps to that max — an
|
|
132
|
+
// index-based check left Next enabled for several dead clicks.
|
|
133
|
+
: this._offsetOf(this.index) < this._maxOffset() - OFFSET_EPSILON;
|
|
100
134
|
const canPrev = this.options.loop ? this.slides.length > 1 : this.index > 0;
|
|
101
135
|
this.nextButtonTargets.forEach((button) => (button.disabled = !canNext));
|
|
102
136
|
this.prevButtonTargets.forEach((button) => (button.disabled = !canPrev));
|
|
@@ -129,6 +163,8 @@ export default class extends Controller {
|
|
|
129
163
|
const drag = this.drag;
|
|
130
164
|
if (!drag) return;
|
|
131
165
|
drag.delta = this._pointerPos(e) - drag.start;
|
|
166
|
+
// Normalize to scroll-axis space: in RTL a rightward drag reveals next.
|
|
167
|
+
if (this._rtl()) drag.delta = -drag.delta;
|
|
132
168
|
if (!drag.moved && Math.abs(drag.delta) > 5) {
|
|
133
169
|
drag.moved = true;
|
|
134
170
|
this.track.style.transition = "none";
|
|
@@ -177,19 +213,38 @@ export default class extends Controller {
|
|
|
177
213
|
e.stopPropagation();
|
|
178
214
|
}
|
|
179
215
|
|
|
216
|
+
// Firefox-only: a pointer drag that starts on an <img> slide fires a
|
|
217
|
+
// native HTML5 dragstart, which cancels the in-flight pointer sequence
|
|
218
|
+
// (pointercancel) and aborts the swipe. Chrome/Safari never fire it here
|
|
219
|
+
// because carousel.css already sets -webkit-user-drag: none.
|
|
220
|
+
_onDragStart(e) {
|
|
221
|
+
if (e.target.closest("img")) {
|
|
222
|
+
e.preventDefault();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
180
226
|
// --- geometry ---
|
|
181
227
|
|
|
182
228
|
_pointerPos(e) {
|
|
183
229
|
return this.options.axis === "y" ? e.clientY : e.clientX;
|
|
184
230
|
}
|
|
185
231
|
|
|
232
|
+
// In RTL the horizontal track lays slides right-to-left: later slides have
|
|
233
|
+
// SMALLER offsetLeft and the track must translate in +x to reveal them.
|
|
234
|
+
// All geometry below works in a direction-neutral "offset along the scroll
|
|
235
|
+
// axis" space; _rtl() flips the signs at the physical boundaries only.
|
|
236
|
+
_rtl() {
|
|
237
|
+
return this.options.axis !== "y" && getComputedStyle(this.element).direction === "rtl";
|
|
238
|
+
}
|
|
239
|
+
|
|
186
240
|
_offsetOf(index) {
|
|
187
241
|
const slide = this.slides[index];
|
|
188
242
|
const first = this.slides[0];
|
|
189
243
|
if (!slide || !first) return 0;
|
|
190
|
-
|
|
244
|
+
let raw = this.options.axis === "y"
|
|
191
245
|
? slide.offsetTop - first.offsetTop
|
|
192
246
|
: slide.offsetLeft - first.offsetLeft;
|
|
247
|
+
if (this._rtl()) raw = -raw;
|
|
193
248
|
// Never scroll past the content edge: with multi-up layouts the last
|
|
194
249
|
// slides' offsets overshoot the max scrollable offset (track − viewport).
|
|
195
250
|
return Math.min(Math.max(0, raw), this._maxOffset());
|
|
@@ -211,6 +266,6 @@ export default class extends Controller {
|
|
|
211
266
|
_translateTo(offset) {
|
|
212
267
|
this.track.style.transform = this.options.axis === "y"
|
|
213
268
|
? `translate3d(0, ${-offset}px, 0)`
|
|
214
|
-
: `translate3d(${-offset}px, 0, 0)`;
|
|
269
|
+
: `translate3d(${this._rtl() ? offset : -offset}px, 0, 0)`;
|
|
215
270
|
}
|
|
216
271
|
}
|
|
@@ -19,13 +19,21 @@ module PhlexKit
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def view_template(&)
|
|
22
|
-
|
|
22
|
+
base = {
|
|
23
23
|
class: "pk-chart",
|
|
24
24
|
data: {
|
|
25
25
|
controller: "phlex-kit--chart",
|
|
26
26
|
phlex_kit__chart_options_value: JSON.generate(@options)
|
|
27
27
|
}
|
|
28
|
-
}
|
|
28
|
+
}
|
|
29
|
+
# Defaults only when the caller didn't supply their own — `mix` would
|
|
30
|
+
# fuse role="img figure" instead of overriding.
|
|
31
|
+
# Canvas content is invisible to AT — announce it as an image with at
|
|
32
|
+
# least a generic name; pass aria: { label: "…" } (or block fallback
|
|
33
|
+
# content) to describe the actual data.
|
|
34
|
+
base[:role] = "img" unless @attrs.key?(:role) || @attrs.key?("role")
|
|
35
|
+
base[:aria] = { label: "Chart" } unless aria_labelled?
|
|
36
|
+
canvas(**mix(base, @attrs), &)
|
|
29
37
|
end
|
|
30
38
|
end
|
|
31
39
|
end
|